白帽故事 · 2026年5月1日

Copy Fail 如何仅用732字节获得Root权限

来自Xint代码研究团队

Copy Fail (CVE-2026-31431) 是Linux内核authencesn加密模板中的一个逻辑缺陷。它允许一个本地非特权用户以确定、可控的方式,向系统上任何可读文件的页面缓存执行4字节写入操作。一个仅732字节的Python脚本就能编辑setuid二进制文件,从而在自2017年以来发布的所有Linux发行版上获取root权限。

内核永远不会将已损坏的页面标记为脏页以供写回,因此磁盘上的文件保持不变,普通的基于磁盘的校验和校验无法检测到这种修改。然而,访问文件时实际读取的是页面缓存,因此内存中损坏的版本会立即在全系统范围内可见。通过损坏setuid二进制文件的页面缓存,本地非特权用户可以利用此漏洞获取root权限。由于页面缓存在宿主机中是共享的,该原语还可用于跨越容器边界。

这项发现借助了AI辅助,但最初是源于Theori研究员Taeyang Lee的洞见,他当时正在研究Linux加密子系统如何与基于页面缓存的数据交互。他使用 Xint Code 将他的研究范围扩展到整个加密子系统,而 Copy Fail 是报告中最关键的发现。

这是两篇系列文章的第一篇:

  • 第1部分 (本文):漏洞本身和本地权限提升

  • 第2部分:Kubernetes容器逃逸

    • *

Copy Fail 有何不同之处

Linux内核过去曾有过备受瞩目的权限提升漏洞。Dirty Cow (CVE-2016-5195) 需要在虚拟内存子系统的写时复制路径中赢得竞态条件。它通常需要多次尝试,有时会导致系统崩溃。Dirty Pipe (CVE-2022-0847) 版本特定,且需要精确地操作管道缓冲区。

Copy Fail 是一个直接的逻辑缺陷。它无需竞态、重试或有崩溃风险的时机窗口就能触发。

可移植性。 完全相同的脚本在每一个测试过的发行版和架构上都能工作,包括Ubuntu、Amazon Linux、RHEL和SUSE。没有针对每个发行版的偏移量调整。无需重新编译。利用代码中也没有版本检查。

小巧。 整个攻击利用程序是一个简短的Python脚本,仅使用标准库模块 (os, socket, zlib)。它需要 Python 3.10+ 以支持 os.splice。没有编译后的负载,无需安装依赖。

隐蔽性。 该写入绕过了普通的VFS写入路径。被损坏的页面永远不会被内核的写回机制标记为脏页。标准的文件完整性工具通过比较磁盘上的校验和会检测不到它,因为磁盘上的文件并未改变。只有内存中的页面缓存被损坏。

跨容器影响。 页面缓存在系统上的所有进程间共享,包括跨容器边界。
Copy Fail 不仅仅是一个本地权限提升漏洞。它是一个容器逃逸原语,也是一个Kubernetes节点攻陷向量(第2部分详解)。


根本原因:位于可写分散/聚集列表(writable scatterlist)中的页面缓存页

AF_ALG 是一种套接字类型,它将内核的加密子系统暴露给非特权的用户空间。用户可以打开一个套接字,绑定到任何AEAD(带关联数据的认证加密)模板,并对任意数据进行加密或解密操作。无需任何权限。

构成此漏洞基础的一个核心原语是 splice():它通过引用传递页面缓存页,在不复制的情况下在文件描述符和管道间传输数据。当用户将一个文件splice到管道,然后再转移到AF_ALG套接字时,该套接字的输入分散/聚集列表持有指向该文件内核缓存页的直接引用。这些页面并未被复制;分散/聚集列表条目指向的是为该文件每一次read()mmap()execve()提供支撑的同一个物理页。

对于AEAD解密,输入是 AAD(关联认证数据) || 密文 || 认证标签。在 algif_aead.c 内部,recvmsg() 将操作设置为原地进行,这意味着同一个分散/聚集列表既作为加密算法的输入,也作为其输出。

AAD和密文数据通过 memcpy_sglist 从输入分散/聚集列表中字节拷贝到输出缓冲区。这是一个真实的拷贝。页面缓存页只被读取。但是,认证标签(输入分散/聚集列表中最后的 authsize 字节)不被拷贝。内核保留指向标签的分散/聚集列表条目,并使用 sg_chain() 将它们链接到输出分散/聚集列表的末尾:

输入 SGL:     AAD  ||  密文  ||  标签
                |        |           ^
                |  拷贝  |           | sg_chain (仍然引用页面缓存页)
                v        v           |
输出 SGL:     AAD  ||  密文  --------+

现在输出分散/聚集列表有两个区域:用户的 recvmsg 缓冲区(包含拷贝的AAD和密文),后面跟着链接的标签页,这些标签页仍然引用着目标文件的原始页面缓存页。内核设置 req->src = req->dst,两者都指向这个合并链表的头部:

req->src ----+
             |
             v
req->dst --> [ AAD  ||  密文  ] --> [ 标签 (页面缓存页) ]
             |                   |   |                        |
             +-- 接收缓冲区 -----+   +-- 从发送SGL链接而来 --+
             |   (用户内存)       |   |   (文件的页面缓存)  |

这种原地设计是漏洞的根本原因。它将页面缓存页置于一个可写的分散/聚集列表中,与合法的写入区域仅以一个偏移量边界相隔。该设计假设每个AEAD算法都会将其写入操作限制在预期的目标区域内,但API中没有任何内容强制执行这一点,也没有将其记录为一项要求。

不幸的是,有一个AEAD算法打破了这一隐式约束。


触发机制:authencesn 的临时写入

内核的AEAD API为解密定义了一个清晰的输出契约:目标缓冲区接收 AAD || 明文,精确为 assoclen + (cryptlen - authsize) 字节。

authencesn 是一个由IPsec使用的AEAD包装器,用于支持扩展序列号。IPsec使用64位序列号,分为高32位(seqno_hi,AAD的字节0–3)和低32位(seqno_lo,字节4–7)。传输格式只携带 seqno_loseqno_hi 是隐含的。为了计算HMAC,authencesn 需要重新排列这些字节:seqno_hi 放在哈希输入的前面,seqno_lo 附加在末尾。

它通过使用调用者的目标缓冲区作为临时空间来执行这种重排。在 crypto_authenc_esn_decrypt() 中:

scatterwalk_map_and_copy(tmp, dst, 0, 8, 0);                           // 读取 AAD 字节 0–7
scatterwalk_map_and_copy(tmp, dst, 4, 4, 1);                           // 用 seqno_hi 覆盖 dst[4..7]
scatterwalk_map_and_copy(tmp + 1, dst, assoclen + cryptlen, 4, 1);     // 在标签之后写入 seqno_lo

前两个调用在AAD区域内重新排列ESN字节,这是一个会被恢复的临时修改。第三个调用在偏移量 assoclen + cryptlen 处(超过AEAD标签的位置)写入4个字节。该算法正在使用它不拥有的内存作为临时便签本。

该位置原始字节永久丢失。代码后续会读取 seqno_lo 以重建AAD,但从未将原始内容写回 dst[assoclen + cryptlen]。无论操作成功与否,该位置都被视为可丢弃的临时空间。

内核中没有其他标准AEAD算法会这样做。GCM、CCM和普通的authenc都将它们的写入操作限制在合法的输出区域内。只有 authencesn 会写入越界。

在AF_ALG的原地路径中,这个写入从输出缓冲区跨越到了链接的页面缓存标签页。scatterwalk_map_and_copy 越过接收缓冲区,通过 kmap_local_page 映射页面缓存页,并将 seqno_lo 直接写入目标文件的内核缓存副本中。随后HMAC计算运行并失败(因为密文是伪造的),所以 recvmsg() 返回错误,但受控的4字节写入依然存在。

关键在于,攻击者控制三件事:

目标文件: 当前用户可读的任何文件。

写入偏移量: 标签区域对应于splice进的文件数据的最后authsize字节。通过选择splice的文件偏移量、长度和assoclen,攻击者可以精确确定文件中页面缓存的哪4个字节被覆盖。

写入值: 4字节的覆盖值(seqno_lo)来自AAD的字节4–7,由攻击者在sendmsg()中构造。


这是如何发生的

2011年,authencesn 被添加到内核 (a5079d084f8b),以支持IPsec ESP的64位扩展序列号。从一开始,该代码就使用调用者的目标分散/聚集列表作为ESN字节重排的临时空间。这在当时是无害的:在旧的AEAD接口下,关联数据位于单独的分散/聚集列表中,并且唯一的调用者是内核内部的xfrm层。其他任何人都不会观察到这些临时的写入。

四年后,2015年,AF_ALG增加了对AEAD的支持 (algif_aead.c),其splice()路径可以将页面缓存页传递到加密分散/聚集列表中。同年,authencesn 被转换为新的AEAD接口 (104880a6b470),引入了会导致写入越过输出边界的 assoclen + cryptlen 偏移量。但在此时,AF_ALG使用的是非原地操作:req->srcreq->dst 是分开的分散/聚集列表。页面缓存页在src(只读)中。临时写入会进入dst(用户的缓冲区)。此时还不可被利用。

然后在2017年,algif_aead.c中添加了一项优化 (72548b093ee3),以启用AEAD操作的原地进行。对于解密,代码将AAD和密文数据从发送SGL复制到接收缓冲区,但使用 sg_chain() 通过引用链接标签页。随后它设置了 req->src = req->dst。来自splice的页面缓存页现在位于可写的目标分散/聚集列表中。authencesndst[assoclen + cryptlen] 处的写入现在会遍历到那些链接的标签页,从而产生了这个漏洞。

没有人将2017年的原地优化与 authencesn 的临时写入或 splice 路径对页面缓存页的使用联系起来。每一次变更孤立地看都是合理的。漏洞存在于这三者的交汇处,并已在近十年间可被悄无声息地利用。


攻击利用过程

默认的攻击路径目标是 /usr/bin/su,这是一个广泛存在于各大Linux发行版上的setuid-root二进制文件,包括本次测试的所有四个发行版。

步骤1:设置套接字。 打开一个AF_ALG套接字,并绑定到 authencesn(hmac(sha256),cbc(aes))。设置一个key。接受一个请求套接字。无需特权;默认情况下,非特权用户可以使用AF_ALG。

步骤2:构造写入。 对于Shellcode负载的每个4字节块,构造一个 sendmsg() + splice() 对。sendmsg 提供AAD:字节4–7携带要写入的4个字节(seqno_lo)。splice 提供目标文件的页面缓存页作为密文和标签。选择AEAD参数(assoclensplice 偏移量、splice 长度),使得 dst[assoclen + cryptlen] 落在 /usr/bin/su.text 段中的目标偏移量上。

步骤3:触发写入。 recv() 触发解密操作。在 authencesn 内部,内核从AAD读取ESN字节,并将 seqno_lo 写入 dst[assoclen + cryptlen]scatterwalk 从输出缓冲区跨越到链接的页面缓存页。有4个字节被写入到 /usr/bin/su 的内核缓存副本中。HMAC基于重排后的数据计算并失败。内核读取 seqno_lo 以恢复AAD,但标签位置上的原始字节永远不会被恢复。recvmsg 返回一个错误。页面缓存被损坏。

步骤4:执行。 所有块写入完成后,调用 execve("/usr/bin/su")。内核从页面缓存加载该二进制文件。页面缓存版本包含注入的Shellcode。由于 su 是setuid-root,Shellcode以UID 0运行。获得root权限。

a = socket.socket(38, 5, 0)  # AF_ALG, SOCK_SEQPACKET
a.bind(("aead", "authencesn(hmac(sha256),cbc(aes))"))
# ... 设置密钥,接受请求套接字 u ...
u.sendmsg([b"A"*4 + payload_chunk], [cmsg_headers], MSG_MORE)
os.splice(target_fd, pipe_wr, offset)
os.splice(pipe_rd, alg_fd, offset)
u.recv(...)  # 触发解密 -> 页面缓存写入

演示

我们在四个发行版上运行了同一个脚本,并在每个发行版上都观察到了root权限的获取。

每个终端都以用户 xint (uid=1001) 开始。下载并执行同一个732字节的利用程序。每个终端最终都进入了一个root shell。

发行版 内核版本
Ubuntu 24.04 LTS 6.17.0-1007-aws
Amazon Linux 2023 6.18.8-9.213.amzn2023
RHEL 10.1 6.12.0-124.45.1.el10_1
SUSE 16 6.12.0-160000.9-default

这些是我们直接测试过的四个发行版与内核组合,涵盖了内核主线6.12、6.17和6.18。


修复方案

补丁 (a664bf3d603d) 将 algif_aead.c 回退到非原地操作,完全移除了2017年的原地优化。Fixes: 标签指向 72548b093ee3——引入原地设计的那个提交——确认了将页面缓存页链接到可写目标分散/聚集列表是根本原因。

易受攻击的代码设置了 req->src = req->dst,两者都指向一个合并的分散/聚集列表,其中来自 splice() 的页面缓存页被链接到了可写目标中。该修复将它们分离开:

// 修复前: src 和 dst 是同一个分散/聚集列表 (原地)
aead_request_set_crypt(&areq->cra_u.aead_req, rsgl_src,              // 接收 SGL
                       areq->first_rsgl.sgl.sgt.sgl, used, ctx->iv); // 接收 SGL (相同)

// 修复后: src 是发送 SGL,dst 是接收 SGL (非原地)
aead_request_set_crypt(&areq->cra_u.aead_req, tsgl_src,              // 发送 SGL (可能包含页面缓存页)
                       areq->first_rsgl.sgl.sgt.sgl, used, ctx->iv); // 接收 SGL (不同,用户缓冲区)

现在 req->src 指向发送SGL(其中可能包含来自 splice 的页面缓存页)。req->dst 指向接收SGL(用户的 recvmsg 缓冲区)。只有AAD从 src 复制到 dst。将页面缓存标签页链接到可写目标分散/聚集列表的整个 sg_chain 机制被移除。

提交信息概括了这一点:"在 algif_aead 中使用原地操作没有好处,因为源和目标来自不同的映射。"


缓解措施

为内核打补丁。 此修复 将AF_ALG AEAD恢复为非原地操作,消除了可写分散/聚集列表中的页面缓存页。

更新发行版的内核软件包。 主要发行版应通过常规的内核软件包更新来推送此修复。

对于立即缓解,可以通过seccomp阻止AF_ALG套接字创建,或通过将algif_aead模块列入黑名单:

echo "install algif_aead /bin/false" > /etc/modprobe.d/disable-algif-aead.conf
rmmod algif_aead 2>/dev/null

关于容器逃逸的影响,请见第2部分。


协调披露时间线

日期 事件
[2026-03-23] 向Linux内核安全团队报告漏洞
[2026-03-24] 收到初步确认
[2026-03-25] 补丁被提出并接受审查
[2026-04-01] 补丁提交到主线内核
[2026-04-22] 分配 CVE-2026-31431
[2026-04-29] 公开披露(本文)

我们发现漏洞的过程

Taeyang Lee早期的 kernelCTF工作 已经勾勒出了AF_ALG的攻击面。他意识到AF_ALG + splice 创建了一条路径,使得非特权的用户空间可以直接将页面缓存页送入加密子系统,并怀疑分散/聚集列表页面的来源可能是未被充分探索的漏洞来源。

与此同时,其他Theori研究人员正在运行Xint Code,并在内核代码(包括Android驱动程序和XNU)中发现了关键漏洞。我们正寻求将这项工作扩展到Linux,而加密子系统鉴于我们对其内部知识的掌握,成为了一个自然的起点。

Xint Code支持一个"操作员提示"功能,它(可选地)允许人类操作员提供额外的上下文来指导自动化扫描。在本例中,操作员提示相当简单:

"这是 Linux 的 crypto/ 子系统。请检查所有从用户空间系统调用可达的代码路径。请注意一个关键观察:splice() 可以将只读文件(包括 setuid 二进制文件)的页面缓存引用传递给 crypto 的 TX 散射列表。"

大约一小时后,扫描完成,Copy Fail 是严重性最高的输出。

Xint Code 对 CVE-2026-31431 的扫描结果

注意:该扫描还识别了其他高严重性漏洞,包括另一个权限提升漏洞。这些其他漏洞仍在负责任的披露流程中。


原文:https://lost-number.bearblog.dev/recovering-files-from-beyond-the-grave-using-photorec/