内核提权案例分析一
1726215822930452 二进制安全 124浏览 · 2025-03-28 04:40

修改cred结构体

1 内核会通过进程的 task_struct 结构体中的 cred 指针来索引 cred 结构体,然后根据 cred 的内容来判断一个进程拥有的权限,如果 cred 结构体成员中的 uid-fsgid 都为 0,那一般就会认为进程具有 root 权限。


直接修改 cred 结构体的内容

修改 task_struct 结构体中的 cred 指针指向一个满足要求的 cred

1 进程的 task_struct 结构体中会存放指向 cred 的指针,因此我们可以:

利用 task_struct 结构体中的comm字段来定位,comm 用来标记可执行文件的名字,且 comm 其实在 cred 的正下方:

然而,在进程名字并不特殊的情况下,内核中可能会有多个同样的字符串,这会影响搜索的正确性与效率。因此,我们可以使用 prctl 设置进程的 comm 为一个特殊的字符串,然后再开始定位 comm。

定位当前进程 task_struct 结构体的地址

根据 cred 指针相对于 task_struct 结构体的偏移计算得出 cred 指针存储的地址

获取 cred 具体的地址(或者直接修改这个指针)在具体修改时,我们可以使用如下的两种方式

修改 cred 指针为内核镜像中已有的 init_cred 的地址。这种方法适合于我们能够直接修改 cred 指针以及知道 init_cred 地址的情况。

伪造一个 cred,然后修改 cred 指针指向该地址即可。这种方式比较麻烦,一般并不使用。

1 修改cred结构体,(已过时)UAF 使用同样堆块:如果我们在进程初始化时能控制 cred 结构体的位置,并且我们可以在初始化后修改该部分的内容,那么我们就可以很容易地达到提权的目的。这里给出一个典型的例子

但是,此种方法在较新版本内核中已不再可行,我们已无法直接分配到 cred_jar 中的 object,这是因为 cred_jar 在创建时设置了 SLAB_ACCOUNT 标记(从v4.5开始),在 CONFIG_MEMCG_KMEM=y 时(默认开启)cred_jar 不会再与相同大小的 kmalloc-192 进行合并


a申请一块与 cred 结构体大小一样的堆块

b释放该堆块

c fork 出新进程,恰好使用刚刚释放的堆块

d此时,修改 cred 结构体特定内存,从而提权

commit_creds函数

commit_creds(&init_cred)

1 commit_creds() 函数被用以将一个新的 cred 设为当前进程 task_struct 的 real_cred 与 cred 字段,因此若是我们能够劫持内核执行流调用该函数并传入一个具有 root 权限的 cred,则能直接完成对当前进程的提权工作:

在内核初始化过程当中会以 root 权限启动 init 进程,其 cred 结构体为静态定义init_cred,由此不难想到的是我们可以通过 commit_creds(&init_cred) 来完成提权的工作:

commit_creds(prepare_kernel_cred())

1 在内核当中提供了 prepare_kernel_cred() 函数用以拷贝指定进程的 cred 结构体,当我们传入的参数为 NULL 时,该函数会拷贝 init_cred 并返回一个有着 root 权限的 cred:

然后在内核空间中调用 commit_creds(prepare_kernel_cred(NULL)),则也能直接完成提权的工作:
image.png
图片加载失败



[!NOTE]

不过自从内核版本 v6.2 起,prepare_kernel_cred(NULL)不再拷贝 init_cred,而是将其视为一个运行时错误并返回 NULL,这使得这种提权方法无法再应用于 v6.2 及更高版本的内核:


Kernel ROP

1 内核态的 ROP 与用户态的 ROP 一般无二,只不过利用的 gadget 变成了内核中的 gadget,所需要构造执行的 ropchain 由 system("/bin/sh") 变为了 commit_creds(&init_cred) 或 commit_creds(prepare_kernel_cred(NULL)),当我们成功地在内核中执行这样的代码后,当前线程的 cred 结构体便变为 init 进程的 cred 的拷贝,我们也就获得了 root 权限,此时在用户态起一个 shell 便能获得 root shell
image.png
图片加载失败


2 状态保存:通常情况下,我们的 exploit 需要进入到内核当中完成提权,而我们最终仍然需要着陆回用户态以获得一个 root 权限的 shell,因此在我们的 exploit 进入内核态之前我们需要手动模拟用户态进入内核态的准备工作——保存各寄存器的值到内核栈上,以便于后续着陆回用户态。通常情况下使用如下函数保存各寄存器值到我们自己定义的变量中,以便于构造 rop 链:通用的 kernel pwn 板子。方便起见,使用了内联汇编,编译时需要指定参数:-masm=intel

1 返回用户态 :由内核态返回用户态只需要:

swapgs 指令恢复用户态 GS 寄存器

sysretq 或者 iretq 恢复到用户空间(注意:这两者的操作不相同)

1

例题:CISCN2017_babydriver

1分析提供的启动脚本,和内核模块:

babydriver_init 内核模块初始化函数注册了一个字符设备驱动:/dev/babydev 后续用户态通过操作设备节点 /dev/babydev 触发内核函数:看一下fops中定义了哪些操作函数:file_operations结构体 : file_operations 结构体是 Linux 内核中用于定义字符设备文件操作的核心数据结构。它充当用户态系统调用(如 openreadwriteioctlclose 等)与内核驱动实现之间的接口1. 核心作用

典型的驱动初始化示例:

查看babydriver中的fops结构体,其中babyrelease的偏移为0,所以开始没有显示出来:

image.png
图片加载失败


加载内核之后看的更清楚,这函数的地址是在加载babydriver.ko内核模块的时候初始化的:

image.png
图片加载失败


babyread 、babywrite 、babyioctl 、babyopen、babyrelease这5个函数。另外babyrelease函数在关闭打开的 /dev/babydev 时回自动调用:

这里babyread 和 babywrite 编译后最终还是调用到syscall_read、syscall_write系统调用。最后触发fops中的回调函数来调用内核的babywrite、babyread函数。ioctl和close同理,只是需要对打开的"/dev/babydev"设备操作即可:

image.png
图片加载失败


漏洞点 :babyrelease释放了全局变量指向的空间,但是没有清0,如果打开两个"/dev/babydev"设备再关闭掉一个就会造成uaf漏洞。close(fd)会调用到:

image.png
图片加载失败


桥梁功能:将用户对设备文件的操作(如 /dev/mydevice)映射到内核模块中具体的函数实现。

动态绑定:驱动开发者通过填充该结构体的成员函数指针,自定义设备的行为(例如:读取传感器数据、控制硬件等)。

1 利用思路:可以用 uaf 劫持内核的控制流,执行内核gadget来修改 smep 属性,然后用 ret2usr 提权 ==> 系统根据 cr4 寄存器的第 20 位判断是否开启 SMEP 保护,通常可以向 CR4 寄存器中写入 0x6f0 来关闭 SMEP。把第21位覆盖为0即可
image.png
图片加载失败


2 如何利用uaf来劫持程序的控制流?该方法利用的是两个结构体,为tty_struct 、tty_operations:

其中有着许多的函数指针,所以说对于我们构造rop来说就非常有用。创建结构体:当open("/dev/ptmx", O_RDWR)时会创建一个tty_struct结构体。struct tty_struct结构体大小为0x2e0打开tty设备会创建该结构体,我们可以创建ptmx设备实现struct tty_struct结构体的创建。ptmx设备是tty设备的一种,当使用open函数打开时,通过系统调用进入内核,创建新的文件结构体,最终创建struct tty_struct结构体。将该结构体中的ops指针指向伪造的const struct tty_operations结构体,实现在对该设备进行操作时调用相应的函数指针时,实现程序流的控制。对设备进行write操作,修改const struct tty_operations结构体的write函数指针实现控制流的劫持。

1 调试分析如何劫持控制流:先加载完符号打上断点
image.png
图片加载失败
打开fd1、fd2两个"/dev/babydev"文件,调用到内核模块babydriver.ko中的babyopen函数的调用链如下:

1. chrdev_open 的作用chrdev_open 在内核源码中 fs/char_dev.c 定义,属于字符设备框架的一部分。它的核心作用如下:

a 关联设备号与驱动:根据设备号找到对应的字符设备驱动。

b 调用驱动的 .open 函数:若驱动在 file_operations 中注册了 .open 回调,则调用它。

c 管理模块引用计数:防止驱动模块在设备被使用时被卸载。

image.png
图片加载失败


ioctl close完毕之后的babydev_struct全局变量:

image.png
图片加载失败


最后打开文件fd3,open("/dev/ptmx",O_RDWR|O_NOCTTY),此时,内核不会调用babydriver中的babyopen(因为此时操作的不是"/dev/babydev"设备),而是ptmx_open这个内核函数(这里打开不同的设备,会调用到不同的内核驱动函数)

image.png
图片加载失败


步入,这里调用到tty_init_dev函数 该函数的返回值是struct tty_struct * 类型。明显就是来分配 tty_struct结构体的,此时fd2的babydev_struct.device_buf 与 fd3的tty_struct结构体成功重合(uaf实现):

image.png
图片加载失败


查看此时的 tty_struct 结构体:

成功让babydev_struct.device_buf指向 tty_struct结构体

image.png
图片加载失败


后续 babyread(fd2, fake_tty_struct, 24);babywrite(fd2, fake_tty_struct, 4*8);就可以直接对 tty_struct结构体进行操作:

调用 babyread(fd2, fake_tty_struct, 24):

image.png
图片加载失败


读出后的栈:

image.png
图片加载失败


再向第4个位置添加 伪造在用户态栈上的tty_operations结构体地址:

image.png
图片加载失败


最后利用 babywrite(fd2, fake_tty_struct, 4*8),将伪造的前4个字节 fake_tty_struct结构体写入到 tty_struct结构体中,从而覆盖掉原来的 ops指针:

image.png
图片加载失败


最后就是调用到 tty_operations结构体中的 write函数指针 ,调用write(fd3, "evil", 4):

在n_tty_write函数中调用到 其中的write函数指针,此时的rdi寄存器的值为 前面申请的tty_struct结构体的首地址 ,

image.png
图片加载失败


1 上面的方法可以控制执行流,但是只能执行write指针上的一条指令,即使有ret来衔接也无法做到ROP,因为我们无法控制内核栈上的值。所以此时最好的方法就是进行栈迁移,目前唯一可以控制大量写入数据的地址就是rax指向的tty_operations结构体 ,所以可以将栈指针迁移到用户栈上伪造的tty_operations结构体,即利用rax寄存器向rsp传值,必须是一条连续的指令完成栈迁移,否则后续无法衔接。可利用的gadget:mov rsp, rax;dec ebx; ret 。这里是 jmp 0xffffffff8181bf7e 衔接到的ret指令,用ROP_gadget和ropper不一定能搜出来
image.png
图片加载失败
结构体伪造如下,:


1 exp调试分析如下:直接到最后控制执行流的位置
image.png
图片加载失败
修改cr4寄存器,关闭semp保护
image.png
图片加载失败
返回到用户空间执行指令(ret2usr),但是此时任然处于内核态(此时不能调用syscall指令):
image.png
图片加载失败
commit_creds(prepare_kernel_cred())提权:看一眼我们静态编译的exp脚本的privilege_escalate函数的反汇编:
image.png
图片加载失败
image.png
图片加载失败
填权完成后,这个进程就是root权限,此时再来起一个shell,回到用户空间执行system("/bin/sh"):swapgs切换gs寄存器,iretq将栈上存放的值返还给个寄存器
image.png
图片加载失败
成功返回到用户态,衔接到root_shell,最后执行system("/bin/sh"):
image.png
图片加载失败
最后成功提权拿到flag:
image.png
图片加载失败


2 为什么要返回到用户态再执行system("/bin/sh")函数,不能直接提权结束后衔接到用户段上的root_shell函数吗?尝试提权后直接调用用户态的root_shell函数:
image.png
图片加载失败
此时再getuid函数中,会再次调用syscall,会不会 "再次"进入内核?
image.png
图片加载失败
si步入,进入到entry_SYSCALL_64函数,执行swapgs指令,交换gs寄存器的值:
image.png
图片加载失败
发现,交换之前是内核态的gs寄存器,但是交换之后编程用户态的值了(负负得正),后续执行指令就会报错:
image.png
图片加载失败
image.png
图片加载失败
在提权结束后,先swapgs,再直接衔接到用户态的root_shell函数,可以成功起一个shell。但是最好还是先回到用户态再执行root_shell。如果不进入内核,直接执行privilege_escalate函数?肯定不行,因为privilege_escalate调用的是内核的函数commit_creds、prepare_kernel_cred,用户态不可直接访问内核态的数据、执行内核态的代码。用户态只能通过syscall进入内核,再执行内核的函数(如果手动编译一个syscall来执行,虽然能进入内核态,但是到了内核后执行流就不受我们控制了(因为syscall之后会进入entry_SYSCALL_64处理,然后切换内核GS、内核栈等)。所以需要劫持内核的执行流,然后在内核态的情况下执行用户段上的函数 privilege_escalate(ret2usr),最后执行内核的函数commit_creds、prepare_kernel_cred。

例题:qwb_2018_core

1查看启动脚本,将core.cpio解包,其中gen_cpio.sh是一个快速打包脚本:

kptr_restrict 参数该参数控制是否允许普通用户通过 /proc/kallsyms/proc/modules 等接口查看内核符号(函数/变量)的内存地址。修改 /proc/sys/kernel/kptr_restrict 需要 root 权限

参数值的含义

0:所有用户均可查看内核符号的地址(默认值,安全性最低)。

1:普通用户无法查看内核符号的地址,但 root 用户仍可查看。

2:无论用户权限如何,均禁止查看内核符号地址(安全性最高)。

dmesg_restrict 参数该参数控制普通用户是否能通过 dmesg 命令或 /dev/kmsg 接口读取内核日志(如硬件事件、驱动错误、内核启动信息等)。

参数值的含义

0所有用户均可查看完整内核日志(默认值,安全性较低)。

1:仅 root 用户 可以查看内核日志,普通用户无权限。



换用 /tmp/kallsyms 来获得 commit_creds 和prepare_kernel_cred两个函数的地址:(因为开启了kaslr,每次内核启动时这两个函数的地址都是不确定的,所以需要在exp中进行读取)

image.png
图片加载失败


提供的内核模块开启了 Canary保护:

image.png
图片加载失败


1 分析提供的 core.ko 内核模块:init_module在初始化的时候创建一个新的条目 /proc/core :
image.png
图片加载失败
core_fops结构体定义这三个回调函数:core_write , core_ioctl , core_release
image.png
图片加载失败
ioctl函数定义了三个行为:0x6677889C操作 和 0x6677889B操作 搭配可以泄漏canary
image.png
图片加载失败
image.png
图片加载失败
core_write 搭配 0x6677889A中的core_copy_func 可以照成 内核栈溢出,向内核栈上写入ROP:
image.png
图片加载失败
image.png
图片加载失败


2 如何过nokaslr:先从 /tmp/kallsyms 中读取 commit_cred 函数,当前加载的绝对地址commit_cred_addr,再减去当前vmlinux加载的基地址,即可得出commit_cred函数的偏移 -->commit_cred_offset(一个固定值,内核再次启动都不会变化)。在未开启kaslr是vmlinux加载的地址默认是 raw_vmlinux_base = 0xffffffff81000000 :
image.png
图片加载失败
所以 内核加载的偏移 kernel_offset = commit_cred_addr - commit_cred_offset - raw_vmlinux_base (commit_cred_addr - commit_cred_offset 是内核当前加载的基地址,或者说 commit_cred_offset + raw_vmlinux_base 是未开启kaslr时commit_creds函数的地址):
image.png
图片加载失败
后续利用的gadget的地址:直接用ROPgadget、ropper查到的地址 加上 前面算出的 kernel_offset 即可得出此时内核加载时gadget的绝对地址。

1 泄漏内核栈的canary:先将off 改大,看内核栈的布局
image.png
图片加载失败
v5是读取的起始地址,canary就挨在其结束的位置,所以要将off设置为v5的大小,让其指向数组结尾处,第一个读出的就是canary:
image.png
图片加载失败
随后通过core_read读到用户空间上,第一个读出的就是canary:
image.png
图片加载失败


2 内核栈溢出:先利用core_write将构造的ROP写到name上,再用core_ioctl中的0x6677889A操作进行栈溢出,core_copy_func中的if条件用负数绕过
image.png
图片加载失败
在core_copy_func函数中的栈布局,第9个位置是canary,第11个位置是返回地址。所以直接用canary覆盖前10个位置即可:
image.png
图片加载失败
image.png
图片加载失败
最后,提权结束返回用户态起shell:

3exp如下:


0 条评论
某人
表情
可输入 255

没有评论