缓存时间:
2026/09/26 23:30
# 如何在我发现 Copy Fail 之前找到价值 113,337 美元的 AF_ALG Linux 本地权限提升漏洞
来源:https://idnsec.com/research/linux-local-privilege-escalation-with-af-alg/
2025 年,我在 Linux 内核中发现了一个 AF_ALG 漏洞,该漏洞允许普通用户将权限提升至 root。本文回顾了我在 2026 年 Copy Fail 引发广泛关注之前发现 CVE-2025-39964 以及开发漏洞利用程序的过程。
**编辑部(IDNSEC):** Muhammad Alifa Ramdhan 在 STAR Labs 进行研究时发现了 CVE-2025-39964。他的同事 Bing-Jhong Billy Jheng 也因协助完成漏洞利用链而获得认可。Ramdhan 撰写了这篇文章供 IDNSEC 发表。该漏洞已通过负责任披露流程提交给 Linux 内核维护者,并作为 Google kernelCTF 提交成果,获得了 **113,337.00 美元** 奖励。
AF_ALG 是 Linux 内核的一个特性,它提供了用于加密和解密的 API 接口。用户空间程序通过 API 与内核交互,内核则执行请求的操作。
该漏洞发生在 AF_ALG 处理来自用户空间程序输入数据的过程中。通过理解该机制的工作原理,可以利用越界访问将普通用户权限提升至 root。同一漏洞也可用于逃逸 Docker 容器并在宿主机上获取 root 权限。经查明,漏洞代码自 2011 年左右就已存在于 Linux 中。
## Linux 内核攻击面
读者可能还记得 2026 年披露的 Copy Fail (https://copy.fail/),该漏洞后来与 DirtyFrag 共同获得了 2026 年 Pwnie Awards (https://risky.biz/risky-bulletin-pwnie-awers-2026-winners/) 的最佳权限提升漏洞奖项。Copy Fail 同样使用 AF_ALG,但本文讨论的漏洞有所不同。Copy Fail 是 AEAD 路径中的直线逻辑缺陷,而 CVE-2025-39964 则是共享 AF_ALG 套接字的写入线程之间的竞争条件。我于 2025 年 9 月左右在审查相关源码时发现了这一竞争条件,早于 Copy Fail 的披露时间。
我从事漏洞研究工作,花费大量时间在操作系统(包括 Linux 内核)中寻找漏洞。当时的目标是利用该发现参与 Google kernelCTF 项目——该项目奖励能够针对最新稳定版 Linux 内核演示本地权限提升(LPE)漏洞利用的研究人员。
要开发 Linux 内核 LPE 漏洞利用程序,研究人员首先需要审计构成内核攻击面的代码和子系统。例如,著名的 Dirty COW 漏洞位于 `mm` 子系统,而 Dirty Pipe 则发现于 `fs/pipe` 中。
在寻找下一个值得审计的攻击面时,我发现了 AF_ALG (https://docs.kernel.org/crypto/userspace-if.html)。其文档中最吸引我的一点是:AF_ALG 可以通过套接字 API 从未提权的用户空间直接访问。从内核漏洞利用的角度来看,这颇具吸引力,因为无需任何权限或特殊配置即可触及内核代码。更让我感兴趣的是,据我所知,此前没有任何 kernelCTF 提交成果以 AF_ALG 漏洞作为入口点。
## 与 AF_ALG 交互
用户空间程序通过调用 `socket(2)` (https://man7.org/linux/man-pages/man2/socket.2.html) 获取 AF_ALG 套接字文件描述符,并通过 `bind(2)` 选择算法。以下示例通过 AF_ALG API 选择 AES-CBC 算法:
```c
int tfmfd = socket(AF_ALG, SOCK_SEQPACKET, 0);
struct sockaddr_alg sa = {
.salg_family = AF_ALG,
.salg_type = "skcipher", /* 对称密钥密码 */
.salg_name = "cbc(aes)", /* CBC 模式的 AES */
};
bind(tfmfd, (struct sockaddr *)&sa, sizeof(sa));
```
对于 AES-CBC,内核还需要一个 AES 密钥,可通过 `setsockopt()` 设置:
```c
unsigned char key[32] = {0}; /* 256 位密钥 */
setsockopt(tfmfd, SOL_ALG, ALG_SET_KEY, key, sizeof(key));
```
设置密钥后,程序调用 `accept()`,返回另一个可用于执行加密或解密操作的文件描述符:
```c
int opfd = accept(tfmfd, NULL, 0);
```
程序通过向 `opfd` 调用 `sendmsg()` 来执行加密或解密操作。在消息头的控制字段中,可以提供初始向量(IV)和请求的操作类型,以及待处理的数据字节:
```c
char cbuf[CMSG_SPACE(sizeof(__u32)) +
CMSG_SPACE(sizeof(struct af_alg_iv) + 16)] = {0};
struct iovec iov = {
.iov_base = buf,
.iov_len = 0x1000,
};
struct msghdr msgh = {
.msg_iov = &iov,
.msg_iovlen = 1,
.msg_control = cbuf,
.msg_controllen = sizeof(cbuf),
};
struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msgh);
cmsg->cmsg_level = SOL_ALG;
cmsg->cmsg_type = ALG_SET_OP;
cmsg->cmsg_len = CMSG_LEN(sizeof(__u32));
*(__u32 *)CMSG_DATA(cmsg) = ALG_OP_ENCRYPT;
cmsg = CMSG_NXTHDR(&msgh, cmsg);
cmsg->cmsg_level = SOL_ALG;
cmsg->cmsg_type = ALG_SET_IV;
cmsg->cmsg_len = CMSG_LEN(sizeof(struct af_alg_iv) + 16);
struct af_alg_iv *alg_iv = (void *)CMSG_DATA(cmsg);
alg_iv->ivlen = 16;
memset(alg_iv->iv, 0x01, 16);
ssize_t n = sendmsg(opfd, &msgh, MSG_MORE);
```
首次 `sendmsg()` 调用初始化 IV,使用 `ALG_OP_ENCRYPT` 选择加密操作,并提供 `0x1000` 字节供内核处理。`MSG_MORE` 标志告知内核输入尚未完成,程序可以再次调用 `sendmsg()` 向先前输入追加更多数据。当后续 `sendmsg()` 调用省略 `MSG_MORE` 时,内核停止等待额外输入。此时程序可在 `opfd` 上调用 `read()`、`recv()` 或 `recvmsg()` 请求加密并获取结果。
## AF_ALG 内部机制
我以 Linux 内核 v6.12.44 作为参考进行分析,因为这是进行漏洞挖掘和漏洞利用开发的版本。下文讨论的源码可在 `crypto/af_alg.c` (https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/tree/crypto/af_alg.c?h=v6.12.44)、`crypto/algif_skcipher.c` (https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/tree/crypto/algif_skcipher.c?h=v6.12.44) 和 `include/crypto/if_alg.h` (https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/tree/include/crypto/if_alg.h?h=v6.12.44) 中找到。
在深入源码前,需要理解一个重要关键点:对 AF_ALG 调用 `sendmsg()` 不会立即执行加密。内核首先将用户提供的数据收集到 TX 散列列表中。只有当用户调用 `recvmsg()` 时,内核才会创建加密请求,并将先前收集的数据作为输入。
该流程可简化为:
**AF_ALG 数据流**
对于 `skcipher` 算法,初始 `sendmsg()` 处理函数是 `skcipher_sendmsg()`。它获取所选密码的 IV 大小,并将整个输入处理过程转发给 `af_alg_sendmsg()`:
```c
unsigned int ivsize = crypto_skcipher_ivsize(tfm);
return af_alg_sendmsg(sock, msg, size, ivsize);
```
这意味着接收用户输入、分配缓冲区以及在多次 `sendmsg()` 调用间保持状态的大部分机制都位于此函数中:
```c
int af_alg_sendmsg(struct socket *sock, struct msghdr *msg,
size_t size, unsigned int ivsize)
```
参数 `msg` 包含用户数据和控制消息,`size` 是输入长度,`ivsize` 是所选密码要求的 IV 大小。无论密钥是 AES-128、AES-192 还是 AES-256,AES-CBC 均使用 16 字节 IV。
函数开始处,AF_ALG 通过 `af_alg_cmsg_send()` 处理控制消息。这是内核获取 `ALG_OP_ENCRYPT`、`ALG_OP_DECRYPT`、IV 以及 AEAD 关联数据长度等信息的地方。验证这些元数据后,AF_ALG 开始将用户数据放入套接字拥有的缓冲区中。
## `opfd` 关联的上下文
每个 `opfd` 都有一个 `struct af_alg_ctx` 类型的上下文。即使程序多次调用 `sendmsg()`,该上下文仍会保留数据和操作状态。
对象关系可简化为:
**`opfd` 对象关系**
`struct af_alg_ctx` 有许多字段,但理解此漏洞仅需关注少数关键字段:
| 字段 | 用途 |
|-----------|------------------------------------------------------|
| `tsgl_list` | 包含用户输入的 TX 散列列表的链表 |
| `used` | 内核当前存储的输入字节总数 |
| `more` | 表示用户将通过 `MSG_MORE` 发送更多数据 |
| `merge` | 表示最后一页有空余空间,下次输入可追加到此页 |
| `init` | 表示操作元数据已初始化 |
| `enc` | 选择操作是加密还是解密 |
这些并非在 `sendmsg()` 返回后消失的局部变量。只要 `opfd` 处于使用状态,它们就保留在共享上下文中。因此,一次 `sendmsg()` 调用的结果会影响下一次调用的处理方式。
## 输入数据的存储方式
用户数据并非存储在一个连续的大缓冲区中。AF_ALG 将其分散在多个内存页中,每个部分由一个 `struct scatterlist` 表示。
例如,在 4 KB 页的系统上,10 KB 输入可存储如下:
**跨内存页拆分输入**
每个散列列表条目记录页码、该页内的起始偏移量以及数据长度。内核无需物理连续的 10 KB 区域,因为 Crypto API 可以消耗位置列表。
散列列表条目在 `struct af_alg_tsgl` 对象中分组:
```c
struct af_alg_tsgl {
struct list_head list;
unsigned int cur;
struct scatterlist sg[];
};
```
字段 `sg` 是散列列表数组,而 `cur` 记录已填充的条目数。如果 `cur` 为 3,则有效条目为 `sg[0]`、`sg[1]` 和 `sg[2]`。因此当前使用的最后一个散列列表可通过以下方式获取:
```c
sg = sgl->sg + sgl->cur - 1;
```
正常情况下,当 `cur = 3` 时,该表达式返回 `sg[2]`。这一简单计算成为漏洞的重要组成部分。
单个 `struct af_alg_tsgl` 只能容纳有限数量的条目,数量由 `MAX_SGL_ENTS` 决定。当最后一个对象的数组已满时,`af_alg_alloc_tsgl()` 会分配一个新的 `af_alg_tsgl`,将其 `cur` 初始化为 0,并追加到 `ctx->tsgl_list` 中。
因此,上下文保留的数据大致如下:
**tsgl_list 结构**
## `af_alg_sendmsg()` 内部处理
处理控制消息后,`af_alg_sendmsg()` 在需要从用户复制输入时循环执行。主要有两条路径:
如果最后一页已满或没有可用页面,内核会分配新页面。然后通过 `memcpy_from_msg()` 复制数据,初始化散列列表条目,增加 `sgl->cur`,并将成功复制的字节数添加到 `ctx->used` 中。
如果最后一页未满,AF_ALG 不需要立即分配新页面。剩余空间可以容纳下次 `sendmsg()` 调用的数据。此条件记录在 `ctx->merge` 中。
假设用户向容量为 4 KB 的页面发送仅 2 KB 数据:
| 数据 | 大小 |
|---------------|-----------|
| 已复制数据 | 2048 字节 |
| 剩余空间 | 2048 字节 |
| 总计 `PAGE_SIZE` | 4096 字节 |
由于仍有空间,`ctx->merge` 变为 true。下一次 `sendmsg()` 调用进入此分支:
```c
if (ctx->merge) {
sgl = list_entry(ctx->tsgl_list.prev,
struct af_alg_tsgl, list);
sg = sgl->sg + sgl->cur - 1;
/* 将数据追加到最后一页 */
}
```
在此上下文中,`ctx->merge = true` 意味着下一次数据可追加到最后一个散列列表条目。
复制后,AF_ALG 在 `ctx->more` 中记录此次调用是否使用了 `MSG_MORE`。值为 true 表示用户尚未完成此操作的输入。后续的 `sendmsg()` 调用可继续相同输入,而无需重新发送所有元数据。
正常的 `af_alg_sendmsg()` 流程可总结为:
**正常 af_alg_sendmsg 流程**
这就是 AF_ALG 如何在多次调用间接受输入,而无需为每次小量添加分配新页面。
合并分支假设最后一个 `af_alg_tsgl` 至少有一个条目可供追加。我将条件写为:
```
ctx->merge == true => last_sgl->cur > 0
```
如果此条件成立,`sgl->cur - 1` 是安全的,因为 `cur` 不可能为零。我想测试当两个线程在同一个 `opfd` 上调用 `sendmsg()` 时,此条件是否仍然成立。
## 两个线程并发调用 `sendmsg()`
`af_alg_sendmsg()` 在修改上下文之前调用 `lock_sock()`。乍看之下,这似乎序列化了多个 `sendmsg()` 调用。然而,当发送缓冲区已满时,函数可能进入 `af_alg_wait_for_wmem()` 并等待空间可用。
在 `af_alg_wait_for_wmem()` 内部,等待通过 `sk_wait_event()` 宏执行:
```c
if (sk_wait_event(sk, &timeout, af_alg_writable(sk), &wait)) {
err = 0;
break;
}
```
仅阅读该函数时,套接字解锁和重新锁定并不直接可见。它们位于 `sk_wait_event()` (https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/tree/include/net/sock.h?h=v6.12.44) 的实现中。简化为相关操作,该宏执行:
```c
release_sock(__sk);
/* 当条件为 false 时执行 wait_woken() */
lock_sock(__sk);
/* 再次评估条件 */
```
当线程休眠等待发送缓冲区变为可写状态时,套接字锁被有意释放。这样另一个操作才能消费数据并释放空间。当线程唤醒时,它在 `af_alg_sendmsg()` 继续执行前重新获取锁。
结果,两个线程可能都在同一个 `opfd` 上有未完成的 `sendmsg()` 调用。由于套接字锁仍然保护上下文,它们不会同时修改上下文。然而,第二个线程可能在唤醒时发现上下文状态与其等待前看到的不同。
我使用了以下初始条件:
```
last_sgl->cur = MAX_SGL_ENTS - 1
ctx->merge = false
send buffer = full
```
然后两个线程调用 `sendmsg()` 并在 `af_alg_wait_for_wmem()` 中阻塞。在缓冲数据被释放后,第一个线程唤醒。最后一个 `af_alg_tsgl` 仍有一个空闲条目,因此该线程使用了它。我使复制的输入短于 `PAGE_SIZE`,使最后一页部分为空并将 `ctx->merge` 设为 true。
在下图中,`tail.cur` 表示最后一个 `af_alg_tsgl` 的 `cur` 字段。该字段未直接存储在 `af_alg_ctx` 中,最终对象通过 `ctx->tsgl_list` 的最后一个条目访问。
**两个线程的交错执行**
线程 A 完成后,但线程 B 继续之前,状态为:
```
last_sgl->cur = MAX_SGL_ENTS
ctx->merge = true
```
第二个线程仍在等待路径上。一旦缓冲区空间可用,它在可写内存检查后恢复。由于最后一个 `af_alg_tsgl` 现已满,`af_alg_alloc_tsgl()` 分配新对象并追加到 `tsgl_list`。
新对象初始状态如下:
对于第二个线程,我提供了一个无效的用户空间地址,使 `memcpy_from_msg()` 失败。新分配的页面被释放,函数通过错误路径退出。然而,新的 `af_alg_tsgl` 仍然是最终的列表条目,而第一个线程创建的 `ctx->merge` 值仍为 true。
上下文首次达到此状态:
```
ctx->merge = true
last_sgl->cur = 0
```
这是先前认为不可能的状态。
在下一次 `sendmsg()` 调用中,`ctx->merge` 使内核检索最后一个散列列表条目:
```c
sg = sgl->sg + sgl->cur - 1;
```
由于 `sgl->cur` 为零,计算变为:
```c
sg = sgl->sg + 0 - 1
= &sgl->sg[-1]
```
该指针不再指向