[CISCN 2017]kernel-babydriver
作为一个刚刚涉足 kernelpwn 的萌新,由于大量未知的基础知识以及对 kernelpen 工具的陌生,一时不知道该从哪里学起,也不清楚整个 kernelpwn 大概是走什么样子的学习路线。既然如此,那就从题目开始熟悉 kernelpwn
上一篇文章我们通过 kernel-babydriver 这个题目初步了解整个题目提权的过程,这次就记录一下如何利用漏洞
checksec 检查 babydriver.ko:

cat boot.sh

- 没有 KASLR:
-append参数中没有kaslr。这表示内核基地址是固定的,你不需要通过泄露内核基地址 - 没有 SMEP/SMAP:参数中没有
+smep或+smap。这表示如果你能劫持执行流,内核可以直接跳转到用户态执行你的 Shellcode)。 - 内存极小:
-m 64M,这在内核调试中很常见。 - 单核:
-smp cores=1。
IDA 静态分析题目:
babyioctl:
1 | __int64 __fastcall babyioctl(file *filp, unsigned int command, unsigned __int64 arg) |
kfree 掉device_buf,kmalloc用户指定大小的堆块。
1 | _fentry__(filp, command); |
__fentry__是编译器在启用-mfentry编译选项后,自动在每个函数的最开头插入的一个调用指令。
- 它的存在就像是在函数的大门口放了一个哨兵。在函数执行实际逻辑,甚至是保存栈帧寄存器 push rbp之前,程序会先跳转到 fentry 运行。
filp与command表明该钩子被放置在一个处理文件操作或驱动通信的函数中:
- filp (struct file *): 指向当前打开文件的结构体指针。它包含了文件的状态、权限以及对应的驱动程序信息
- command (unsigned int): 通常代表 IOCTL 命令码。用户态程序通过 ioctl() 系统调用向内核发送特定的指令,内核根据这个 command 来决定执行什么操作。
1 | __int64 __fastcall babyioctl(file *filp, unsigned int command, unsigned __int64 arg) |
在 64 位 Linux 内核调用约定中,前三个参数分别放在 rdi, rsi, rdx 寄存器中。arg 就是第三个参数,也就是你从用户态传进来的长度,它存在 rdx 里。v4 = v3; 就等同于 v4 = arg;。你传入的大小最终交给了 v4
1 | if ( command == 65537 ) // 如果用户态发来的命令是 0x10001,就执行大括号里的逻辑。 |
kfree函数是内核态释放内存的标准函数,相当于用户态的free()。它会将内存块交还给内核的 SLUB 分配器。它释放了全局结构体中保存的那个旧缓冲区的指针babydev_struct.device_buf。- 此时,device_buf 变成了一个悬空指针,存在 UAF 漏洞。
_kmalloc函数是内核态分配连续物理内存的函数,相当于用户态的malloc()。v4 作为分配内存的大小参数,前面提到 v4=v3,这意味着新分配的内存大小完全由用户决定- 参数 37748928是分配标志位,转为十六进制是 0x24000C0:这是 kmalloc 第二个参数
gfp_t flags。 这个具体的数值代表内核在分配这块内存时,允许进程睡眠等待(如果内存紧张的话),并且允许进行文件系统操作和 I/O 操作来置换内存。
总结来说就是内核按照用户指定的大小(v4)分配出一块新的堆内存,并将新地址重新赋给了全局指针 babydev_struct.device_buf。
1 | else |
- 在 Linux 内核中,负数返回值代表错误码 Errno。22 对应的是宏 EINVAL ,即非法参数。
- 当用户态程序调用 ioctl 传递了一个驱动程序不支持的 command 时,内核会返回这个错误。此时,用户态收到的 ioctl 返回值通常是 -1,而全局变量 errno 会被设置为 22。
babywrite
1 | ssize_t __fastcall babywrite(file *filp, const char *buffer, size_t length, loff_t *offset) |
从用户的buf里面写入到device_buf,大小要小于堆块的大小。
1 | ssize_t __fastcall babywrite(file *filp, const char *buffer, size_t length, loff_t *offset) |
- filp:文件结构体指针(代表你打开的这个设备文件)。
- buffer:用户态传进来的数据指针(你要写入的数据)。
- length:用户态请求写入的字节数。实际上 v4 就等于 length
- offset:文件偏移量。
1 | if ( !babydev_struct.device_buf ) |
空指针检查:检查全局结构体 babydev_struct 中的 device_buf 是否为 NULL。
1 | if ( babydev_struct.device_buf_len > v4 ) |
长度边界检查:检查当前全局缓冲区分配的长度是否大于用户请求写入的长度 v4 。
copy_from_user():这是 Linux 内核中最核心的函数之一,它的作用是安全地将数据从用户空间 buffer 拷贝到内核空间。底层汇编实际执行的是 copy_from_user(babydev_struct.device_buf, buffer, v4)。- 返回成功:返回实际写入的字节数 v6(即传入的
length)。
babyread
1 | ssize_t __fastcall babyread(file *filp, char *buffer, size_t length, loff_t *offset) |
将device_buf读入到用户指定的buf。
babyrelease
1 | int __fastcall babyrelease(inode *inode, file *filp) |
kfree 掉指定的 device_buf,但没有置0,UAF 漏洞。
漏洞利用:
了解 thread_info:
在用户态二进制 PWN 中,最终目标为通过执行 system 或者 execve 获取 shell。但是在内核 PWN 中,最终的目标是提权。那么应该如何进行提权呢?首先需要了解 Linux 内核的相关机制。在旧版的 Linux 内核当中有个 thread_info 结构体:
1 | struct thread_info { |
在该结构体当中有一个 task 指针,指向另一个 task_struct 结构体:
task_struct:
1 | struct task_struct { |
该结构体描述了进程的所有信息,其中包含了指向权限凭证的指针,而当中有存在 cred 结构体:
cred_struct:
1 | struct cred { |
cred_struct 用于保存进程的权限信息(如 UID, GID 等)。
内核获取 thread_info 地址的代码:
1 | /* 相关宏定义 */ |
因此在旧版的内核当中,当一个进程进入内核态以后,在当前栈顶偏移为 0x4000 或 0x8000 的位置(由编译内核时的配置决定),可以获取 thread_info 地址,从而得到 task_struct 的地址,进而得到 cred 的地址。由于在 cred 结构体当中保存着当前进程的权限信息,因此在内核 PWN 当中的最终目标是修改 cred 结构体。
EXP 思路:
本题有一个较为简单的方法,由于 cred 结构体的大小事 0xa8,当创建一个新进程时,内核会在堆中申请 0xa8 长度的堆内存放 cred 结构体。因此利用思路如下:
利用 babyioctl 申请一个 0xa8 字节的堆,赋值给 babydev_struct.buf,再释放,然后创建一个新进程,这样释放的 0xa8 大小的堆会分配给新进程的 cred 结构体。由于 UAF 漏洞,cred 结构体的内容是可控的,只要把控制权限改为 0,即 root 的 UID,就可以使创建的新进程获取到 root 权限,达到提权的目的。
定义变量:
1 |
- DEV_PATH:我们要攻击的设备路径。
- BABY_IOCTL_ALLOC:这就是我们之前分析出触发堆块重新分配的魔法命令号 65537。
- CRED_SIZE:目标内核版本中 cred 结构体的大小。在旧版内核中,当创建一个新进程时,内核会在堆中申请 0xa8 长度的堆存放 cred 结构体。
- SPRAY_CHILDREN:定义将要 fork 多少个子进程。
1 | static int fd2 = -1; |
定义一个全局变量 fd2。这是因为后面提权操作是在子进程中调用的函数里执行的,设为全局变量方便直接向这个悬空文件描述符(悬挂指针)写入数据。
函数辅助区:
1 | static void die(const char *msg) |
标准的错误处理函数,打印错误信息并直接退出。
1 | tatic void root_shell(void) |
调用 execl(“/bin/sh”, “sh”, NULL); 替换当前进程镜像,弹出一个 Root Shell。
UAF 篡改 cred_struct:
1 | static void overwrite_cred(void) |
- 定义一个大小为 80 字节(20 * 4 字节)的数组,并全部初始化为 0。因为 cred 结构体的前半部分全是各种 ID,只要把 UID、GID 等字段修改为 0(root 的 UID),就能获得 root 权限。
- cred 结构体的第一个成员是
atomic_t usage;(引用计数)。如果这里也被写成了 0,当进程结束时内核去释放这个 cred,会因为引用计数异常引发内核崩溃。所以必须保留它为 1。 - cred 结构体在各种 ID(uid, gid 等)后面,跟着 5 个 kernel_cap_t 类型的特权能力掩码。这里循环把 payload[10] 到 payload[19] 填充为全 1(即十六进制的 0xffffffff),意图是开启进程的所有 Capabilities
由于 cred 结构体后面还有 security、user_ 等指针。如果把指针也覆盖了,内核一旦解引用就会立刻崩溃。因此,write 操作只覆盖前 80 个字节。_
- 由于 UAF 漏洞,fd2 指向的那块内存实际上已经被分配给当前进程作为 cred 结构体了。通过 write,我们将精心构造的 payload 直接覆盖在当前进程的 cred 内存上。
攻击执行流 -Main 函数
1 | int main(void) |
- 打开两次设备。由于驱动程序的缺陷,fd1 和 fd2 在底层共享了同一个全局结构体指针。
- 通过 fd1 调用 ioctl,传入魔法命令 0x10001 和目标大小 0xa8。驱动会分配一块 0xa8 大小的堆,赋值给全局指针 babydev_struct.buf。
- 关闭 fd1。驱动触发 release,释放了这块 0xa8 的内存。但是 fd2 依然认为这块内存是合法的设备缓冲区。UAF 状态成立。
- 循环调用 fork() 创建子进程。因为每当创建一个新进程时,内核就会在堆中申请一块 0xa8 长度的内存存放新进程的 cred 结构体。由于内核 Slab 分配器的后进先出 LIFO 特性,刚才释放的那块 0xa8 内存,极大概率会被分配给其中一个子进程作为 cred。
总 EXP:
1 |
|
更新: 2026-05-10 09:07:55
原文: https://www.yuque.com/idcm/wnemg9/glrgwhl99gf422y8