近期又参与了一些实验室的工作,主要都是在 fuzz 动态链接库。想做一些更底层的内容却无从下手,只能先从 CTF kernel 题做起了。
Linux Kernel Pwn 系列博客预计包括:
在 Kernel - How2Kernel 0x00: Environment and Basic LPE 中了解了内核提权的基本思路、调试方法后,可以正式地尝试一下内核中的 ROP,即用 ROP 重写之前的提权 shellcode,掌握更完整的内核漏洞利用流程。博客中也会包含 ret2dir 等有趣的技巧,顺带介绍 曾经 对利用帮助巨大的 pt_regs
结构体。
随着保护技术的发展与完善,文中的一些技巧已经很难直接套用到较新版本内核的利用中,不过思路还是值得学习并动手实践的。
内核态与用户态
首先回过头来思考内核漏洞利用的目标,之所以能够实现提权是因为操作系统本身也不过是一种软件,是位于物理内存中的代码和数据,在分级保护域(保护环,Rings)的设计思路下 CPU 在执行操作系统内核代码时拥有高权限环境(Ring 0),而在运行用户代码时通常运行在低权限环境(Ring 3)。
CPU 处在的保护环通常通过 CS 段寄存器的最低 2 位判断,00
表明处在 Ring 0,11
表明处在 Ring 3。
还有更高的权限吗?事实上在启动引导过程中需要高于 Ring 0 的权限来完成一些底层的系统管理任务,如电源管理,但是在启动后就不需要再调整设置了,因此就有通常被称为 ring -2 的 SMM Mode 等特权级别。
用户态与内核态经常需要切换:
用户态进入内核态
如前所述,用户态的能力是受限的,在下述情况下就会进入内核态:
- 系统调用:当用户程序需要访问操作系统内核的功能时(如文件操作、网络通信等),就需要通过系统调用来请求内核提供服务。这时会从用户态切换到内核态,待内核完成服务后再切换回用户态。
- 中断:当硬件设备需要操作系统处理某些事件时(如键盘输入、磁盘读写完成等),会产生中断。此时 CPU 会立即停止当前任务,切换到内核态来处理中断。
- 异常:当程序运行出现异常情况时(如除零错误、缺页异常等),CPU 会切换到内核态进行相应的异常处理。
状态切换的关键步骤包括:
- 设置 gs 寄存器的值:
- GS 是一个重要的段寄存器,在用户态 GS 通常指向用户程序的 TLS;在内核态 GS 需要指向内核的 per-CPU 数据结构。
- 通常在进入内核态(例如系统调用的入口)使用
swapgs
指令,交换 GS 寄存器的值和一个特定的 MSR(Model Specific Register)中的值(通常是 IA32_KERNEL_GS_BASE
,地址 0xC0000102
)。
- 通常只能在 Ring 0 下进行 MSR 寄存器的操作与
swapgs
指令的调用。
- 切换栈顶指针:
- 将用户态栈顶指针记录到 CPU 独占变量区(per-CPU),并从中取出内核栈顶存入
RSP
。
- 保存用户态寄存器信息:
- 将用户态的寄存器值依次压入内核栈。
- 这些寄存器值按照特定的顺序压栈,在内核栈底构成
pt_regs
结构体。
- 在内核态完成相应操作。
在需要劫持控制流的 Kernel PWN ROP 中,通常要提前记录用户态的 CS、SS、RSP、rflags 寄存器,以便后续完成提权并回到用户态:
关于代码板子可以参考博客 Pwn-Cheatsheet,或者我的 Nixos 配置文件
内核态回到用户态
在内核态完成相应的操作后就会返回用户态继续执行用户空间代码:
swapgs
切换回用户态 GS 的值;
iretq + ret_addr + cs + rflags + sp + ss
回到用户态地址。
用 ROP 链的形式写出来就是:
当然也可以直接复用内核代码中返回用户态的 gadget,相关代码在 arch/x86/entry/entry_64.S
的 swapgs_restore_regs_and_return_to_usermode
函数中:
即只需要在栈上布置好:
这样也可以成功返回用户态并执行 ret_addr
处的代码。
Chal-0x03: QWB-2018-core
是一道很经典的内核题,尽管从现在角度来看 4.15.8
版本的内核非常古老,但还是集成了 KASLR、KPTI、SMEP / SMAP 等基本保护,大致上可以通过 CPU 的标志位来判断:cat /proc/cpuinfo | grep flags
对于这道题,博客中将会逐步开启保护,介绍 Kernel ROP 和基本的保护绕过技巧。
如何开始?
- 解包文件系统:
- 修改
init
和 start.sh
启动脚本完成利用的准备工作,使题目能够正常启动并简化调试流程(每次只需要编译 exp 到指定文件,避免反复打包):
- 重新打包题目:
分析
接下来把 rootfs/core.ko
内核模块拖进 ida,就是常规的逆向分析流程了, 相比于用户态的虚拟机题,内核 pwn 还是很少在逆向上设置门槛 。
关于内核模块中实现的系统调用,可以到 init_module
函数中找 core_fops
结构体,其中就列出了所有实现的接口,一般比较需要细看的就是 ioctl,其实就是一个「菜单」,通过 ioctl(fd, cmd, args)
的形式进行菜单功能调用。
进入 core_ioctl
函数立刻就可以发现全局变量 off 非常可疑,因为它被用作缓冲区偏移量的同时也是用户可控的,并且没有任何检查:
core_read
函数中从内核栈上拷贝 0x40 字节到用户态缓冲区中,提供了越界读:
来到最后一个接口,core_copy_func
里面有一个整数溢出导致的栈溢出:
接下来就可以写出利用板子,包括状态保存、地址泄漏、漏洞触发(其中找偏移有很多种方法,笔者通常用 gdb 加载有符号的 vmlinux 来看):
笔者通常使用 ROPgadget 导出到 nvim 中找 gadget,但是 ROPgadget 在默认情况下找不到 iretq
,之前尝试过 rp++,找到的 gadget 也有问题,可以用 objdump -M intel -d vmlinux
来找相关 gadget
此后在板子的基础上讨论基本 ROP 链的构造与 SMEP / SMAP / KPTI 保护的绕过:
常规 ROP
对于最原始的情况,提权只需要布置好 commit_creds(prepare_kernel_cred(NULL))
的 ROP 链即可。因为 kernel 中的 gadgets 非常充足,设置参数 rdi 为上一个函数的返回值 rax 也不是什么难事:
但是布置上述 ROP 链发现提权失败了:Segmentation fault
,这是因为 Linux 4.15 中就引入了内核页表隔离 KPTI 机制,并且反向移植到了 4.14.11,4.9.75,4.4.110 上。
KPTI - 内核页表隔离
KPTI 不支持运行过程中开启或关闭,可以在 cmdline 中增加 kpti=1
或 nopti
来控制是否启用。
顾名思义,内核页表隔离就是 隔离了内核态页表和用户态页表(什么废话) 。Linux 中使用四级页表,而最上层的 PGD 页全局目录就存储在 CR3 寄存器中,要实现虚拟地址到真实地址的映射就依赖于 CR3 的值,因此 KPTI 就在此之上实现了两套页表用来隔离用户态空间和内核态空间:
这种隔离带来了如下限制:
- 在内核态中映射了 完整用户空间和内核空间 ,但是 用户空间 的所有页顶级表项都被标记了 NX 不可执行 ,因此 ret2usr 不再可行;
- 在用户态的这套页表中,只有 极少量的内核空间映射 ,包括中断处理、系统调用入口等必要的地址。
这两套页表的切换也很直接:
- 两张表各占 4k,紧挨着存放在连续的内存空间中,并且起始地址对齐到页;
- 内核页表在低地址,用户页表在高地址;
- 由 1,2 就可以发现切换页表时只需要反转
CR3[12]
的二进制位。
设置 CR3 切换页表
因此在开启 KPTI 的情况下,可以再观察内核代码中返回用户态的 gadget,相关代码在 arch/x86/entry/entry_64.S
的 swapgs_restore_regs_and_return_to_usermode
函数中,切换的代码简化为:
因此可以构造出如下 ROP 链:
signal 系统调用劫持 SEGV 信号
可以注意到上面的报错是 SEGV 而不是 kernel panic,这是因为程序已经结束 ROP,用 iretq
返回用户态时,因为 CR3 还保留着内核态的那套页表,导致用户空间代码没有执行权限:
- 用户态踩到没有执行权限的内存;
- 触发异常,进入内核态处理;
- 发送段错误信号,回到用户态;
- 用户态收到 SEGV 信号,终止程序的运行;
因为此时在内核态是正常的异常处理流程,并且结束后还会成功切换页表回到用户态,因此只需要能 hook 掉 SEGV 信号的处理流程,让程序执行想要的用户态代码即可:
于是就可以绕过 KPTI 保护成功提权了。
ret2usr 与 SMEP / SMAP 保护
ret2usr 的攻击手段随着 KPTI 的出现已经消声觅迹了,不过还是可以结合看看 SMEP / SMAP 保护的基本原理与绕过。
- SMEP 即 Supervisor Mode Execution Protection,禁止执行用户空间代码;
- SMAP 即 Supervisor Mode Access Protection,禁止访问用户空间代码;
那在 SMAP 开启后内核该怎么执行 copy to / from user 函数呢?
可以参考到源码:一路跟入 copy_from_user 能发现最终调用到 stac,这会设置 RFLAGS.AC
标志位,可以暂时关闭 SMAP,在结束之后再调用 clac 重置标志位来重新开启 SMAP。
Reference - How does the linux kernel temporarily disable x86 smap in copy from user
直接的 ret2usr 写起来非常简单,省去了找 ROP 链的麻烦:
对于没有开启 SMEP / SMAP / KPTI 保护的情况下,直接在内核态下跳转到上述函数地址就能实现提权,因为内核态保留了对用户空间的完整映射。但是对于开启了 SMEP / SMAP 的情况,内核态下访问用户空间会直接引起 kernel panic。
而 CR4 寄存器的 CR4[20]
和 CR4[21]
分别标识了 SMEP / SMAP 的开启和关闭,可以找到 kernel 中的 gadget:
- 方法 1 - 直接将
0x6f0
存入 CR4 寄存器;
- 方法 2 - 通过运算将 CR4 的相应位置清零。
但是在开启 KPTI 后,若在内核态下在调用到用户态的代码,就相当于在内核态下执行没有执行权限的内存地址,会直接导致 kernel panic,因此 ret2usr 已经过时。
pt_regs
可以进一步考虑内核栈的结构,即是否有用户可控的数据会被布置到内核栈的某个地方?关注系统调用在 kernel 中的入口函数 entry_SYSCALL_64,就能发现 call do_syscall_64
之前会先在内核栈上布置 pt_regs
结构体,其定义为:
即系统调用的入口处会在 内核栈底 保存用户态的一系列寄存器,构成 pt_regs
结构体,这就给漏洞利用带来了便利。
在内核版本 5.13 之前 pt_regs
结构体和栈顶的偏移值基本是固定的(因为内核栈只有一个 page),通常可以借助 add rsp, val ; ret
的 gadget 劫持一处函数指针就能实现进一步 ROP 利用。
但是,在 5.13 及之后的 do_syscall_64
函数入口处,新增了一行 add_random_kstack_offset();
,来源于 2021 年 的一个 commit,效果是在栈底的 pt_regs
之上放了一个不超过 0x3FF 的偏移,使得利用的稳定性大幅下降。
Chal-0x04: kgadget
这是一道标准的 ret2dir 例题,其中也需要借助 pt_regs
来完成栈迁移。
题目的 kgadget.ko 中只有一个 ioctl 是有用的,里面先对 rdx 解引用,再清空了栈上部分内容(其实这里就是 pt_regs
结构体的区域),最后将控制流跳转到 rdx 解引用得到的地址:
这里就存在两个问题:
pt_regs
还剩下哪些东西可以用?
先随便给一个合法地址,断点下到 kgadget_base + 0x19A
,给每个寄存器打上标记后触发 ioctl:
这时候看栈底就能找到一长串的标记值:
即只留下了 r8、r9 两个寄存器可用,这时候就可以布置 pop rsp ; ret
来完成栈迁移。
题目开启了 smep / smap 保护,ROP 链和 RDX 参数该怎么布置?
在当前情况下,RDX 需要是指向存放内核代码段(gadget)地址的指针,内核中并不直接存在这种能够利用的函数指针,用户态的数据也由于保护的存在用不了,这时候就可以想到 ret2dir 技术。
RET2DIR + Physmap Spray
该技术最初于 2014 年提出,被用于绕过 SMEP / SMAP 等隔离保护,攻击的点在于内核态和用户态的虚拟地址可能会被映射到同一块物理地址上,而通过虚拟地址隔离实现的保护就此可以被绕过。
Ret2dir 中的 dir 就是指内核内存空间中的直接映射区,关注 linux 官方文档中的 Linux Kernel Memory Map 也可以注意到这一块位于 0xffff888000000000 - 0xffffc87fffffffff
的 Direct Mapping Area:
开启 KASLR 后,CONFIG_RANDOMIZE_MEMORY
选项会使得物理内存的直接映射区域(direct mapping area)、vmalloc / ioremap 空间以及虚拟内存映射区域的基地址在引导时被随机化偏移:
- 直接映射区域地址变化
- 内存区域顺序保持不变
- 为避免内存地址重叠,开启 KASAN 时会禁用 KASLR
对于 DMA,这段长达 64T 的虚拟内存空间直接映射了所有内存空间,即从 page_offset_base
到 page_offset_base + MAX_MEMORY_SIZE
的内存直接对应了整个物理地址空间。
这也就意味着除了直接 Direct Mapping Area 以外的所有虚拟内存空间在 Direct Mapping Area 内都必然存在一个对应的内存页,也就是其他虚拟内存对应的物理地址在 Direct Mapping Area 的映射,两块虚拟内存页对应着同一块物理内存页。
然而,这些映射关系并不要求所有映射到同一物理页的虚拟页具有相同的访问权限。例如:
- Linux Kernel Text 段:内核的代码段通常具有 r-x(可读、可执行)权限。这确保了内核代码可以被执行,但不允许修改,以保护内核的完整性。
- Direct Map 区域:在 Linux 内核中,直接映射区(Direct Mapping Area)用于将物理内存直接映射到内核的虚拟地址空间中。在这个区域中,对应于内核文本段的物理页可能仅具有 r__(只读)权限。这意味着即使这些物理页在直接映射区中被访问,它们也只能被读取,不能被修改或执行。
当然也可以用 USMA 等方法重新映射新的内存页绕过这种限制
上述设计也是 ret2dir 攻击手段的基本原理,通常借助的是用户态 mmap 出来的内存可以在 Direct Mapping Area 找到的性质:
- 利用 mmap 在用户空间大量喷射内存;
- 由于 kmalloc 得到的堆块通常也来源于这块直接映射区,就可以通过泄漏内核堆拿到相关地址;
- 基于泄漏得到的处于 Direct Mapping Area 的堆地址进行内存搜索,就可以稳定拿到用户态喷射的内存;
在大部分情况下并没有内存搜索的能力,这时候就可以采用 Physmap Spray
的手段布置大量同样的 payload,后随机选取一块合适的位于 Direct Mapping Area 的地址以求命中。
回到题目上,可以先验证上述喷射思路,先 mmap 大量内存并填满标记,到调试器中从 0xffff888000000000 ~ 0xffffc87fffffffff
的直接映射区中找到用户态喷射的内存映射,代码如下:
调试时按照 SPRAY_NUM * PAGE_SIZE
的步长依次查看直接映射区的数据,发现上面的地址是可行的。
现在这道题只剩最后一个问题,该布置什么样的 ROP 链才能稳定实现利用?毕竟目前只能执行一次 call rbp
,内核中也不存在提权 one_gadget
。
可以考虑如下构造:
- R8、R9 布置好栈迁移的链子,即
R8 = &(pop rsp ; ret)
,R9 = 0xffff888000000000 + SPRAY_NUM * PAGE_SIZE * 2
;
- 喷射的 payload 中,必然要包含大量
add rsp 0xC0 ; ret
的 gadget,其中 0xC0 来源于调试找到栈上 pt_regs
中 R8 的偏移;
- 光栈迁移还不够,最后还是要写提权的 ROP 链;
- 但是若命中了
add rsp 0xC0 ; ret
,也不能确保最终控制流会落在提权 ROP 的开始处,就需要往里面塞 0xC0 字节的 ret
来确保利用稳定。
于是就构造出如下 payload:
References
- PWN.0x00 Linux Kernel Pwn I:Basic Exploit to Kernel Pwn in CTF . arttnba3
- 系列 - Digging Into Kernel . wings
- index : kernel/git/torvalds/linux.git . Linux