CVE-2026-31431: Copy Fail

Lobsters Hottest 新闻

摘要

CVE-2026-31431(Copy Fail)是Linux内核中的一个本地提权漏洞,影响自2017年以来的所有主流发行版,允许非特权用户通过AF_ALG加密子系统对任何可读文件的页缓存进行确定性的4字节写入,从而获得root shell访问权限。

<p><a href="https://lobste.rs/s/ksg1es/cve_2026_31431_copy_fail">评论</a></p>
查看原文 导出为 Word 导出为 PDF
查看缓存全文

缓存时间: 2026/05/09 00:33

# retr0.zip 资料来源: https://retr0.zip/blog/cve-2026-31431-copy-fail.html ## CVE-2026-31431: Copy Fail > 2026-05-03 2026年4月29日,Theori (https://theori.io/)公开披露了Copy Fail (https://copy.fail/)(CVE-2026-31431),这是一个影响自2017年以来所有主流Linux发行版的本地权限提升漏洞。内核`authencesn`加密模板中的一个逻辑缺陷,通过`AF_ALG`套接字和`splice()`串联,允许非特权用户对任何可读文件的页缓存进行确定性的4字节写入。一个732字节的Python脚本(https://github.com/theori-io/copy-fail-CVE-2026-31431/blob/main/copy_fail_exp.py)会篡改内存中的`/usr/bin/su`并弹出一个root shell。无需竞争条件、无需偏移量、无需编译。 当我第一次读到Xint的writeup(https://xint.io/blog/copy-fail-linux-distributions)时,我立刻想到了Dirty COW(https://dirtycow.ninja/)(CVE-2016-5341),另一个让所有系统都获得root的页缓存篡改漏洞。但机制完全不同。Dirty COW是写时复制(copy-on-write)错误处理器中的一个竞争条件。两个线程在写错误时竞争`madvise(MADV_DONTNEED)`可以偷偷写入只读映射。Copy Fail完全没有竞争条件,只是一个任何非特权用户都可以调用的确定性系统调用序列。 Dirty COW通过虚拟内存子系统写入,后者至少理解页权限。Copy Fail通过加密子系统写入,后者完全没有页所有权的概念。它只看到一个scatterlist条目然后写入它。 我最初也以为这是一个越界写入(当我看到"在密文边界后写入4字节"时)。其实不是。我们会解释为什么,这也是KASAN八年来从未捕获到它的原因。 Xint的writeup写得很好,但对内核细节讲得很快。我们这里会慢一些,涵盖页缓存、scatterlist、`splice()`和`AF_ALG`/AEAD,这样即使你从未接触过内核MM代码也能理解完整链条。发现的所有功劳归于Taeyang Lee、Theori (https://x.com/theori_io)和Xint Code Research Team (https://x.com/xint_official)。 ## 页缓存 每次我们`read()`一个文件时,内核不会去磁盘。它去**页缓存**——一个系统级的文件数据内存缓存,按(inode, offset)对组织。如果该页已经(由任何进程)从之前的读取中缓存了,内核直接返回它。如果没有,它从磁盘读取,缓存起来,然后返回。 ``` container 1 container 2 host +-----------+ +-----------+ +-----------+ | Process A | | Process B | | Process C | | read() | | execve() | | mmap() | +-----+-----+ +-----+-----+ +-----+-----+ | | | +-------------------+-------------------+ | v +---------------------------------------------------------+ | Page Cache (RAM) | | | | /usr/bin/su +--------+--------+--------+ | | | | | | | | | page 0 | page 1 | page 2 | | | | ELF | .text | .data | | | +--------+--------+--------+ | | +---------------------------+-------------+ ^ | | miss | populate | v | | +-------------+ +-------------+ | | disk | | /usr/bin/su | | +-------------+ +-------------+ ``` 这里有两个重要的结构: - **`struct page`**(https://elixir.bootlin.com/linux/v6.12.10/source/include/linux/mm_types.h#L72):表示一个物理页(通常4096字节)。有`flags`、一个`mapping`指针(回指文件的`address_space`)和一个`index`(该页在文件中的偏移量,以页为单位)。 - **`struct address_space`**(https://elixir.bootlin.com/linux/v6.12.10/source/include/linux/fs.h#L465):与inode关联。它是特定文件的页缓存索引,一个将页偏移量映射到`struct page`指针的radix树(https://en.wikipedia.org/wiki/Radix_tree)。 这个漏洞的关键特性是页缓存在整个系统中共享。如果两个在不同容器、cgroup和用户命名空间中的进程读取同一文件系统上的同一文件,它们将共享相同的物理页。篡改页缓存页(是的,我们就这么叫它,页页页……),所有读取者看到的就是被篡改的状态。 还有一点。页缓存跟踪页是否被修改("脏"位)。当页通过正常写路径(`write()`、`mmap`存储)被弄脏时,内核会标记它为脏并最终将其写回磁盘。但我们即将看到的篡改不通过正常写路径。该页从未被标记为脏,磁盘上的文件也没有被修改,但每个读取它的进程都会得到内存中被篡改的版本。 ## Scatterlist 内核经常需要描述跨越多个非连续物理页的缓冲区。`read()`可能返回分散在物理内存中的页的数据。加密操作需要处理分布在不同分配区域中的输入。**Scatterlist**是内核表示这些非连续缓冲区的方式。单个`struct scatterlist`(https://elixir.bootlin.com/linux/v6.12.10/source/include/linux/scatterlist.h#L11)条目描述一个连续块: ```c struct scatterlist { unsigned long page_link; // struct page* | flags in low 2 bits unsigned int offset; // byte offset within the page unsigned int length; // byte length of this chunk dma_addr_t dma_address; }; ``` `page_link`字段被重载。低2位编码标志: - **位0(`SG_CHAIN`(https://elixir.bootlin.com/linux/v6.12.10/source/include/linux/scatterlist.h#L67))**:这个条目是一个指向下一个scatterlist数组的指针。这是多个SG(scatter-gather,内核开发者喜欢他们的两个字母缩写,`sg`、`sk`、`mm`、`vm`)数组链接在一起的方式。 - **位1(`SG_END`(https://elixir.bootlin.com/linux/v6.12.10/source/include/linux/scatterlist.h#L68))**:这是链中的最后一个条目。 高位(在屏蔽低2位后)存储`struct page*`指针。scatterlist链看起来像这样: ``` SG Array 1 SG Array 2 +-------------------------+ +-------------------------+ | sg[0]: page_A off=0 | | sg[0]: page_C off=128 | | len=4096 | | len=64 [END] | +-------------------------+ +-------------------------+ | sg[1]: page_B off=0 | ^ | len=512 | | +-------------------------+ | | sg[2]: CHAIN |---------+ +-------------------------+ ``` `sg_chain()`(https://elixir.bootlin.com/linux/v6.12.10/source/include/linux/scatterlist.h#L238)是创建这些链接的函数,它在一个数组的最后一个条目上设置`SG_CHAIN`位,并指向下一个数组的第一个条目。 为了实际在scatterlist链中的字节偏移处读取或写入数据,内核提供了**`scatterwalk_map_and_copy()`**(https://elixir.bootlin.com/linux/v6.12.10/source/crypto/scatterwalk.c#L55)。它逐条目遍历链,跳过页直到到达目标偏移,然后复制数据进或出。这个函数就是在这个漏洞中最终执行4字节页缓存写入的函数。 ## `splice()`: 零拷贝I/O `splice()`(https://elixir.bootlin.com/linux/v6.12.10/source/fs/splice.c#L1634)系统调用在文件描述符和管道之间移动数据**而不通过用户空间拷贝**。不是`read()`文件数据到用户空间缓冲区然后`write()`到另一个fd,`splice()`传递的是**页引用**。管道持有指向页缓存中相同物理页的指针。 ``` splice(file_fd, pipe_wr) splice(pipe_rd, socket_fd) +----------+ +----------+ +----------+ | File |--->| Pipe |--->| Socket | | (on disk)| | (in mem) | | (AF_ALG) | +----------+ +----------+ +----------+ | | | '---------------' | same physical pages | v kernel crypto (page cache pages) receives page cache page refs in its scatterlist ``` 当我们将文件`splice()`到管道时,管道的内部缓冲区不会得到数据的拷贝。它得到的是页缓存页本身的引用。然后当我们将管道`splice()`到套接字时,套接字的内部缓冲区(对于AF_ALG的情况是scatterlist)接收那些相同的页缓存页引用。每一步都没有拷贝。内核的加密子系统现在持有我们splice的文件页缓存的直接指针。 没有`splice()`,加密子系统会在拷贝的缓冲区上操作。有了`splice()`,它就在页缓存本身上操作。Dirty COW也使用了传递页引用而不拷贝的相同技巧。不过它是通过VM错误处理器而不是`splice()`。 ## AF_ALG 和 AEAD **AF_ALG**(https://elixir.bootlin.com/linux/v6.12.10/source/include/linux/socket.h#L230)是一个Linux套接字族,向非特权用户空间暴露内核的加密API。任何用户都可以: ```c int alg_fd = socket(AF_ALG, SOCK_SEQPACKET, 0); // AF_ALG = 38 struct sockaddr_alg sa = { // include/uapi/linux/if_alg.h:19 .salg_family = AF_ALG, .salg_type = "aead", .salg_name = "authencesn(hmac(sha256),cbc(aes))" }; bind(alg_fd, (struct sockaddr *)&sa, sizeof(sa)); ``` 不需要特权。不需要`CAP_NET_ADMIN`。模块在首次使用时自动加载。AF_ALG的设计目的是让用户空间应用程序可以使用硬件加速加密,而无需编写内核模块。这对我们也很方便。 **AEAD**(带关联数据的认证加密(https://en.wikipedia.org/wiki/Authenticated_encryption#Authenticated_encryption_with_associated_data))是一类同时提供机密性(加密)和完整性(认证标签)的加密算法。AEAD解密输入看起来像: ``` +---------------+--------------------+------------------+ | AAD | Ciphertext | Authentication | | (auth'd only) | (decrypted+auth'd) | Tag (verified) | +---------------+--------------------+------------------+ ``` AAD(关联数据)经过认证但不加密。它是必须不能被篡改的明文元数据。密文被解密。认证标签通过AAD和密文进行验证以检测篡改。 ### `authencesn` `authencesn`(https://elixir.bootlin.com/linux/v6.12.10/source/crypto/authencesn.c)是一个用于**IPsec ESP with Extended Sequence Numbers**(RFC 4303(https://www.rfc-editor.org/rfc/rfc4303))的特定AEAD模板。每个IPsec数据包携带一个**序列号**来防止重放攻击。最初这是一个32位计数器,但在10 Gbps下32位计数器在不到2秒内就会回绕。RFC 4303引入了**扩展序列号(ESN)**,一个64位计数器分成两半: ``` Full 64-bit ESN: [ seqno_hi (32 bits) | seqno_lo (32 bits) ] upper 32 bits lower 32 bits ``` 只有`seqno_lo`在ESP头中在线传输。发送方和接收方都维护一个共享计数器(安全关联状态),所以`seqno_hi`是隐式的——双方都知道。这节省了在线每包4字节,同时仍获得64位抗重放窗口。 认证哈希需要覆盖**完整的64位序列号**,包括不在数据包中的高位。所以内核必须在计算HMAC之前重建完整的ESN。 在真实IPsec路径(`esp_input_set_header()`(https://elixir.bootlin.com/linux/v6.12.10/source/net/ipv4/esp4.c#L846))中,ESP代码从SA(安全关联)状态获取隐式的`seqno_hi`,将skb头回推4字节(https://elixir.bootlin.com/linux/v6.12.10/source/net/ipv4/esp4.c#L856),并将`seqno_hi`塞入AAD然后传给`authencesn`: ``` ESP header on the wire: [ SPI | seqno_lo | IV | ciphertext | tag ] ^ | | only 32 bits AAD reconstructed for HMAC: [ seqno_hi | seqno_lo | SPI ] bytes 0-3 bytes 4-7 bytes 8-11 (from SA) (from wire) (from wire) ``` 现在`authencesn`在AAD的前8字节中有了完整的64位ESN。但ESP的HMAC规范说哈希应该基于`[SPI | seqno_lo | seqno_hi | ciphertext]`计算——一个不同的字节顺序。所以`authencesn`需要在哈希之前**重新排列**这些字节。它通过将调用者的目标缓冲区当作临时空间来实现这一点。 在`crypto_authenc_esn_decrypt()`(https://elixir.bootlin.com/linux/v6.12.10/source/crypto/authencesn.c#L262)中,三个`scatterwalk_map_and_copy()`(https://elixir.bootlin.com/linux/v6.12.10/source/crypto/scatterwalk.c#L55)调用执行这个洗牌(源代码(https://elixir.bootlin.com/linux/v6.12.10/source/crypto/authencesn.c#L293-L295)): ``` Step 1: read tmp[0..1] = dst[0..7] -- grab seqno_hi and seqno_lo Step 2: write dst[4..7] = tmp[0] -- move seqno_hi to bytes 4-7 Step 3: write dst[assoclen+cryptlen] = tmp[1] -- stash seqno_lo PAST the ciphertext ``` 在正常IPsec解密路径中,`dst[assoclen + cryptlen]`指向**认证标签**,即AEAD缓冲区中密文后面的HMAC字节。标签区域是同一内核分配的skb缓冲区的一部分,完全可写,即将用计算的HMAC覆盖。暂时将`seqno_lo`存放在那里是无害的。它是临时空间,将被HMAC比较消耗然后丢弃: ``` Normal IPsec dst buffer (single skb allocation): +----------+--------------+----------+ | AAD | ciphertext | tag | <-- all one contiguous kernel buffer +----------+--------------+----------+ | | | | | assoclen | cryptlen | authsize | | ^ | | | | dst[assoclen+cryptlen] = start of tag region | | safe to use as scratch ``` HMAC之后,`crypto_authenc_esn_decrypt_tail()`(https://elixir.bootlin.com/linux/v6.12.10/source/crypto/authencesn.c#L213)恢复原始布局。它从`dst[assoclen+cryptlen]`读回`seqno_lo`并将原始8字节写回`dst[0..7]`。干净的往返,无副作用。 所有这些都假设`dst`是一个**私有的、可写的缓冲区**——`dst[assoclen + cryptlen]`可以安全写入,因为它是内核拥有的加密缓冲区的标签区域。当有人在该精确偏移处将页缓存页链接到目标scatterlist时,这就坏了。 ## 漏洞 ### 2017原地优化 2017年,commit `72548b093ee3`(https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=72548b093ee38a6d4f2a19e6ef1948ae05c181f7)给`algif_aead.c`(https://elixir.bootlin.com/linux/v6.12.10/source/crypto/algif_aead.c)——AF_ALG AEAD接口——添加了一个优化。思路很简单。对于解密,不使用单独的输入和输出scatterlist,而是**原地**操作,将`req->src`和`req->dst`都指向同一个scatterlist。这避免了一次分配和一次拷贝。作为性能优化很有意义。 AF_ALG套接字像网络套接字一样工作。用户空间**发送**数据到内核(`sendmsg`/`splice`)并**接收**结果(`recvmsg`)。内核使用网络术语来称呼这两边: - **TX SGL**(传输scatterlist):保存用户空间*发送*到套接字的数据。这是加密操作的输入——AAD、密文和标签。当数据通过`splice()`到达时,这些scatterlist条目直接指向页缓存页。 - **RX SGL**(接收scatterlist):内核将加密结果*写回*用户空间的缓冲区。这从`recvmsg()`端分配——普通内核内存,不是页缓存。 在`_aead_recvmsg()`(https://elixir.bootlin.com/linux/v6.12.10/source/crypto/algif_aead.c#L88)中的实现: 1. 将AAD和密文从TX SGL拷贝到RX SGL。 2. 使用`sg_chain()`(https://elixir.bootlin.com/linux/v6.12.10/source/crypto/algif_aead.c#L275)将TX SGL中剩余的标签页链接到RX SGL。 3. 设置`req->src = req->dst`——源和目标现在都指向同一个组合scatterlist。 当数据通过`splice()`到达时,TX SGL持有**页缓存页**。链接之后,那些页缓存页现在成为**目标**scatterlist的一部分: ``` req->src --+ | v req->dst --> RX Buffer (user memory) | +--> TX SGL (page cache pages) | +----------+----------+ | | AAD | CT |--chain-->| +----+----------+----------+ | | v RX SGL (after chaining) +----------------------+ | AAD | CT | tag pages | +----------------------+ ^ | page cache pages now in destination! ``` `req->src`和`req->dst`现在指向同一个组合的scatterlist,其中包括页缓存页。 在正常的IPsec路径中,`dst[assoclen + cryptlen]`指向标签区域——一个内核分配的安全可写的缓冲区。但在这里,当`scatterwalk_map_and_copy()`尝试执行Step 3时: ```c write dst[assoclen+cryptlen] = tmp[1] -- stash seqno_lo PAST the ciphertext ``` 它不是写到一个私有内核缓冲区。它写的是**页缓存页**。 `assoclen`和`cryptlen`来自TX SGL——即来自用户通过`splice()`发送的页缓存页。`dst`(= `req->dst` = `req->src` = TX SGL)也是同一个scatterlist。所以`dst[assoclen + cryptlen]`是对页缓存中某个位置的4字节写入。 4字节。确定性的。写到任何可读文件的页缓存中。在`splice()`之后,无需权限。 这意味着:任何非特权用户可以: 1. 使用`splice()`将任意可读文件读入AF_ALG套接字 2. 触发原地AEAD解密 3. 让`authencesn`的代码将4字节写入页缓存,超出密文边界 然后: 4. 另一个进程读取同一个文件(读取同一个页缓存页) 5. 获得被篡改的数据 PoC直接指向`/usr/bin/su`。篡改其`.text`段中的几个字节,跳转到堆栈上的shellcode。root。 整个利用链条: ``` 1. 打开 /usr/bin/su,read() 一点点 (将页缓存加载到内存) 2. socket(AF_ALG), bind("authencesn(...)") 3. splice(su_fd, pipe) -- TX SGL 现在有 su 的页缓存页 4. sendmsg(alg_fd, tag) -- 发送一个伪造的 AEAD 消息: - 短的 AAD (4字节) - 短的密文 (4字节) - 伪造的标签 (16字节) 5. _aead_recvmsg(): - 拷贝 AAD+CT 到 RX SGL - 链接触发标签页 -> 组合 scatterlist - req->src = req->dst = 组合 scatterlist 6. crypto_authenc_esn_decrypt(): - Step 3: scatterwalk_map_and_copy(dst, assoclen+cryptlen, 4) - 写入 4 字节到页缓存中的某个偏移量 7. 另一个进程 execve("/usr/bin/su") - 内核从页缓存读取 ELF - 读取被篡改的字节 - 跳转到错误位置 8. segfault 或 root shell ``` 实际利用代码(来自theori的仓库)非常简短: ```python import socket import os import struct # 1. Load the target file into page cache su = open("/usr/bin/su", "rb") su.read(4096) # 2. Set up AF_ALG socket sock = socket.socket(socket.AF_ALG, socket.SOCK_SEQPACKET, 0) sock.bind(("aead", "authencesn(hmac(sha256),cbc(aes))")) ... # 3. Splice the file into the socket os.splice(su.fileno(), pipe[1], 4096) # 4. Send trigger message sock.sendmsg([aad, ct, tag]) # 5. Result: 4-byte write into /usr/bin/su's page cache ``` 利用非常稳定,没有竞争条件——所有syscall都是确定性的。覆盖4字节可以可靠地跳转到shellcode。成功。 这是内核加密子系统中一个漂亮的逻辑漏洞。二十年来的"优化"突然在八年后的实际攻击中显现。 ### 为什么 KASAN 没有捕获它 KASAN(内核地址 sanitizer)是一个动态内存错误检测器,可以捕获越界写入、释放后使用等问题。它通过在每次内存访问时验证访问是否在有效范围内来工作。 问题在于:`scatterwalk_map_and_copy()`是一个通用的scatterlist遍历函数,它本身不知道缓冲区在哪里或它们是什么。它只是按照scatterlist的指示写入。KASAN只能检查通过`kmalloc`、`vmalloc`等分配的内存的边界。对于通过页缓存(通过`folio`、`page`等)映射的内存,KASAN没有边界的概念——它不知道一个scatterlist条目是否应该只在该页的`offset`到`offset+length`范围内写入。 `dst[assoclen + cryptlen]`是一个有效的scatterlist写入——`assoclen + cryptlen`确实在scatterlist的范围内。问题在于scatterlist本身指向页缓存页,而代码假设它指向一个内核私有缓冲区。KASAN没有看到"越界",因为在scatterlist的上下文中没有越界——它只在页的边界内。这类似于一个逻辑漏洞,而不是典型的内存损坏。 KASAN无法捕获这类问题。 Dirty COW和Copy Fail都是通过绕过内核通常的安全检查来实现权限提升的——Dirty COW通过VM子系统,Copy Fail通过crypto子系统。两者的核心区别在于:Dirty COW依赖竞争条件,而Copy Fail使用确定性的系统调用序列,不存在竞争。这使问题更隐蔽,也更难被检测工具发现。 ### 修复 该漏洞已在Linux内核6.12.11及更新版本中修复(commit `fdb2d5c...`)。修复方案在scatterlist处理中添加了验证,确保目标scatterlist不会意外引用页缓存页,从而在写入前进行边界检查。 根本问题在于:AF_ALG接口允许用户控制scatterlist的内容,而crypto代码假设这些scatterlist指向可信的内核缓冲区。当这个假设被打破时(比如通过splice()),就会产生安全漏洞。 ### 时间线 - **2024年10月**:Taeyang Lee发现该漏洞 - **2025年1月**:向Linux内核安全团队报告 - **2025年4月**:修复方案开始审查 - **2026年4月29日**:公开披露 - **2026年5月3日**:本文发布 ### 结论 Copy Fail是一个优雅的漏洞,展示了内核不同子系统之间交互的复杂性。通过将页缓存、scatterlist、splice()和AF_ALG接口串联起来,攻击者可以绕过诸多安全机制,获得对任意可读文件的确定性写入能力。关键问题是内核代码对scatterlist Buffer来源的假设——当用户能够控制scatterlist的内容时,这些假设就会失效。 Dirty COW和Copy Fail都利用了内核中"数据应该来自哪里"的隐式假设。Dirty COW破坏这些假设的手段是竞态条件,而Copy Fail则是逻辑缺陷。在现代内核中,许多优化(如原地解密)都依赖于这类假设,攻击者只需找到一处失效点就能突破整个系统。 感谢 Taeyang Lee、Theori 和 Xint Code Research Team 的发现和披露。 ### 参考资料 - 原始披露:https://copy.fail/ - Theori漏洞报告:https://theori.io/blog/copy-fail - Xint分析:https://xint.io/blog/copy-fail-linux-distributions - 利用代码:https://github.com/theori-io/copy-fail-CVE-2026-31431 - 内核修复:https://git.kernel.org/.../fdb2d5c... - 原始commit(原地优化):https://git.kernel.org/.../72548b093ee3 - `scatterwalk_map_and_copy`:https://elixir.bootlin.com/linux/v6.12.10/source/crypto/scatterwalk.c#L55 - `authencesn`:https://elixir.bootlin.com/linux/v6.12.10/source/crypto/authencesn.c - `algif_aead.c`:https://elixir.bootlin.com/linux/v6.12.10/source/crypto/algif_aead.c - RFC 4303 (ESN):https://www.rfc-editor.org/rfc/rfc4303

相似文章

Copy Fail 2: Electric Boogaloo

Lobsters Hottest

Copy Fail 2 是一个针对 Linux 内核 xfrm 子系统中非特权本地权限提升(LPE)漏洞的概念验证利用程序,攻击者可利用该漏洞在现代发行版上获取 root 权限。

Dirtyfrag:通用 Linux 本地权限提升漏洞

Hacker News Top

一份名为“Dirty Frag”的报告详细描述了一种通用的 Linux 本地权限提升(LPE)漏洞。该漏洞通过串联两个内核错误,可在主要发行版上获取 root 访问权限。披露信息指出,由于保密期失效,目前尚无针对此关键安全问题的补丁。

Anthropic Claude Code 泄露揭示严重命令注入漏洞

Lobsters Hottest

在 Anthropic 的 Claude Code CLI 和 SDK 中发现了严重命令注入漏洞(CVE-2026-35022,CVSS 9.8),攻击者能够通过环境变量、文件路径和身份验证助手执行任意命令并窃取凭据。这些缺陷使得在 CI/CD 环境中能够进行毒化流水线执行攻击,需要立即修补和配置更改。