缓存时间:
2026/06/01 20:35
# iSCSI CHAP: Linux 内核中的堆缓冲区溢出
来源:https://ahossu.ro/blog/iscsi-chap-base64-overflow
←返回博客 (https://ahossu.ro/blog/index.html)
本页内容
- 1\. 背景 (https://ahossu.ro/blog/iscsi-chap-base64-overflow#background)
- 2\. 脆弱代码 (https://ahossu.ro/blog/iscsi-chap-base64-overflow#vulnerable-code)
- 3\. 溢出范围 (https://ahossu.ro/blog/iscsi-chap-base64-overflow#math)
- 4\. 可达性 (https://ahossu.ro/blog/iscsi-chap-base64-overflow#reachability)
- 5\. KASAN 确认 (https://ahossu.ro/blog/iscsi-chap-base64-overflow#kasan)
- 6\. 利用原语 (https://ahossu.ro/blog/iscsi-chap-base64-overflow#exploitation)
- 7\. 修复方案 (https://ahossu.ro/blog/iscsi-chap-base64-overflow#fix)
- 8\. 补丁及其他内核工作 (https://ahossu.ro/blog/iscsi-chap-base64-overflow#patch)
## 1\. 背景
我在阅读 iSCSI 目标认证代码(`drivers/target/iscsi/iscsi\_target\_auth\.c`),关注在凭据验证之前,未经验证的发起方可以访问哪些内容。2022 年的提交 `1e5733883421`("scsi: target: iscsi: Support base64 in CHAP")引起了我的注意——它在登录路径中新增了一个解码分支,于是我仔细阅读了 `chap\_server\_compute\_hash()` 函数,看看新增的 BASE64 场景与已有的 HEX 分支相比有何不同。差异立刻显现。HEX 分支在写入目标缓冲区之前会检查长度,而 BASE64 分支则不会。
## 2\. 脆弱代码
在 `chap\_server\_compute\_hash()` 函数内部,提取客户端的 CHAP 响应到 `chap\_r` 后,代码会根据编码类型进行分支。HEX 分支首先检查长度:
```c
case HEX:
if (strlen(chap_r) != chap->digest_size * 2) {
pr_err("Malformed CHAP_R\n");
goto out;
}
if (hex2bin(client_digest, chap_r, chap->digest_size) < 0) {
pr_err("Malformed CHAP_R: invalid HEX\n");
goto out;
}
break;
```
而在那个 2022 年提交中新增的 BASE64 分支则没有:
```c
/* iscsi_target_auth.c:343 */
case BASE64:
if (chap_base64_decode(client_digest, chap_r, strlen(chap_r)) != chap->digest_size) {
pr_err("Malformed CHAP_R: invalid BASE64\n");
goto out;
}
break;
```
`client\_digest` 在第 276 行通过 `kzalloc(chap\-\>digest\_size, GFP\_KERNEL)` 分配。对于 SHA-256,大小为 32 字节;对于 MD5,大小为 16 字节。`chap\_base64\_decode()` 逐个字符写入该缓冲区,且没有对目标进行边界检查:
```c
/* iscsi_target_auth.c:212 */
static int chap_base64_decode(u8 *dst, const char *src, size_t len)
{
int i, bits = 0, ac = 0;
u8 *cp = dst;
for (i = 0; i < len; i++) {
...
if (bits >= 8) {
*cp++ = (ac >> (bits - 8)) & 0xff; /* 无边界检查 */
...
}
}
return cp - dst;
}
```
第 344 行的长度检查在返回值上进行——此时写入操作已经发生。当比较 `\!= chap\-\>digest\_size` 时,目标缓冲区已经发生溢出。
## 3\. 溢出范围
`MAX\_RESPONSE\_LENGTH` 为 128。第 326 行的 `extract\_param()` 调用会去除 BASE64 编码值前的 `"0b"` 前缀,并将剩余部分赋值给 `chap\_r`,因此最多 127 个字符可以到达 `chap\_base64\_decode()`。每个 base64 字符贡献 6 位。当累加器中至少持有 8 位时,会向目标写入一个字节,因此 4 个字符正好产生 3 个字节。对于 127 个字符:
```
127 字符 * 6 位 = 762 位
762 / 8 = 95 字节写入
```
对于 SHA-256 目标(`digest\_size = 32`),溢出超出 `kmalloc-32` 对象末尾 63 字节。对于 MD5 目标(`digest\_size = 16`),溢出超出 `kmalloc-16` 对象末尾 79 字节。HEX 分支会立即捕获相同输入:`strlen(chap\_r) \!= digest\_size \* 2` 会拒绝任何不是恰好 `2 \* digest\_size` 个字符的输入。BASE64 分支没有等效的防护。
## 4\. 可达性
密码验证完成之前即可触发溢出。观察 `chap\_server\_compute\_hash()` 内部的调用顺序:
- 第 318 行:用户名检查 —— `strncmp(chap\_n, auth\-\>userid, compare\_len)`
- 第 326 行:`extract\_param()` 从登录 PDU 中读取 `CHAP\_R`
- 第 344 行:`chap\_base64\_decode()` —— 溢出发生在此处
- 第 402 行:密码检查 —— `memcmp(server\_digest, client\_digest, chap\-\>digest\_size)`
用户名首先被检查,必须匹配一个已配置的 CHAP 用户。这是溢出前的唯一关卡。知道目标上 CHAP 用户名的攻击者可以发送任意 `CHAP\_R` 值,并在第 344 行触发写入。第 402 行的密码哈希比较永远不会执行——第 347 行的 `goto out` 会先触发,或者溢出会在控制流到达之前破坏内存。
暴露在网络中、启用 CHAP 且用户名已知或可猜测的 iSCSI 目标可直接被攻击。溢出发生在登录阶段,在会话建立之前。
## 5\. KASAN 确认
我在 linux-next `next-20260508`(7.1.0-rc2)上进行了测试,配置了一个通过 configfs 的最小 LIO 目标,并使用一个 Python 脚本执行完整的 iSCSI 登录序列:`SecurityNegotiation` 协商 `CHAP\_A=7`,然后发送一个认证 PDU,其中 `CHAP\_R = "0b" + "A" \* 127`。
```
BUG: KASAN: slab-out-of-bounds in chap_base64_decode+0x18c/0x1a0
Write of size 1 at addr ffff8880099b83e0 by task kworker/1:2/60
CPU: 1 PID: 60 Comm: kworker/1:2 Not tainted 7.1.0-rc2-next-20260508
Call Trace:
chap_base64_decode+0x18c/0x1a0
chap_server_compute_hash+0x4c1/0x8e0
chap_main_loop+0x2b3/0x520
iscsi_target_do_login+0x1f3/0x420
iscsi_target_do_login_rx+0x89/0x100
```
目标响应一个 0x2/0x1(认证失败)PDU。此时写入已经发生。我还编写了一个用户空间重现程序(`vuln\_sim\.c`),它独立复制了分配和解码调用过程。使用 `\-fsanitize=address` 编译后,报告了一个堆缓冲区溢出写入,大小为 1,超出 16 字节区域 0 字节,对应 MD5 情况:
```
==ASAN: heap-buffer-overflow
WRITE of size 1
#0 chap_base64_decode vuln_sim.c:36
#1 main vuln_sim.c:49
allocated 16-byte region here:
#0 calloc
#1 main vuln_sim.c:44
```
## 6\. 利用原语
溢出从 `client\_digest` 开始写入 95 字节,而 `client\_digest` 来自 `kmalloc-16` 或 `kmalloc-32`(取决于哈希算法)。任何来自同一个 slab 缓存、相邻分配的对象都会被覆盖。来自并发连接的其他 `iscsi\_chap` 结构是自然的喷射目标——可以通过来自不同连接的多次登录尝试来定位分配。
为了演示利用原语,我编写了 `rce\_demo\.c`,这是一个用户空间程序,它在一个连续的内存区域中放置了一个 `client\_digest` 大小的缓冲区和一个包含函数指针的受害者结构(模拟内核 slab 布局),然后通过一个精心构造的 base64 有效载荷触发溢出,该有效载荷在正确偏移处编码了目标函数的地址。溢出后,损坏的指针被调用:
```
fn_ptr corrupted, calling 0x55a1b2c3d4e0
executing shell command...
uid=1000(alex) hostname 6.19.11-arch1-1
```
`rce\_demo\.c` 中的分配是一个单一的连续 `calloc()`,覆盖了摘要缓冲区、受害者结构以及足够的额外空间来吸收完整的 95 字节写入,而不会碰到 glibc 的块元数据。两次单独的分配不起作用——它们之间的块头会在溢出 17 字节时被破坏。
## 7\. 修复方案
HEX 分支已经展示了正确的方法:在调用解码器之前检查长度。能够解码为恰好 `digest\_size` 字节的最大 base64 数据字符数是 `DIV\_ROUND\_UP(digest\_size \* 4, 3)`,与 `include/linux/base64\.h` 中 `BASE64\_CHARS()` 使用的约定一致。尾部的 `'='` 填充字符在比较之前被去除,这样带填充和不带填充的编码都能正确处理。`chap\_base64\_decode()` 在遇到 `'='` 时已经提前返回,因此完整的原始字符串仍然原样传递给解码器。
```c
- case BASE64:
+ case BASE64: {
+ size_t r_len = strlen(chap_r);
+
+ while (r_len > 0 && chap_r[r_len - 1] == '=')
+ r_len--;
+ if (r_len > DIV_ROUND_UP(chap->digest_size * 4, 3)) {
+ pr_err("Malformed CHAP_R: base64 payload too long\n");
+ goto out;
+ }
if (chap_base64_decode(client_digest, chap_r, strlen(chap_r)) != chap->digest_size) {
pr_err("Malformed CHAP_R: invalid BASE64\n");
goto out;
}
break;
+ }
```
对于 SHA-256(`digest\_size = 32`),数据字符限制为 43 —— 带填充的编码会添加一个尾部 `'='`(总共 44 个字符),该字符在检查前会被去除。对于 MD5(`digest\_size = 16`),限制为 22 个数据字符,最多会去除两个尾部 `'='`。任何数据长度超过限制的攻击者输入在调用解码器之前都会被拒绝。
所有当前活跃支持的内核树都受影响:linux-next、6.14、6.12、6.6、6.1。BASE64 分支于 2022 年引入,并出现在自那以后的每个内核版本中。补丁提交时带有 `Cc: stable@vger\.kernel\.org` 标签,以覆盖 LTS 树。
## 8\. 补丁及其他内核工作
修复补丁以 `[PATCH] scsi: target: iscsi: validate CHAP\_R length before base64 decode` 的形式发送给 Martin K. Petersen,并抄送了 Bart Van Assche、`target\-devel@vger\.kernel\.org`、`linux\-scsi@vger\.kernel\.org` 以及 `stable@vger\.kernel\.org`。提交包含 `Fixes: 1e5733883421` 标签,指向引入 BASE64 分支的提交。根据 David Disseldorp(SUSE)的评审意见,提交了 v2 版本,将内联的上限公式替换为 `DIV\_ROUND\_UP(chap\-\>digest\_size \* 4, 3)`,以匹配内核 base64 约定。随后 Maurizio Lombardi 指出,该检查会错误地拒绝合法带填充的响应——对于 SHA-256,带填充的编码为 44 个字符,但 `DIV\_ROUND\_UP(32 \* 4, 3) = 43`。提交了 v3 版本,在比较之前去除尾部 `'='` 字符,这样带填充和不带填充的编码都能正确通过检查。David Disseldorp 要求在 v4 中做一处修改:在相互 CHAP 调用点添加注释,说明为什么那里不需要等效的溢出检查(`initiatorchg\_binhex` 为 `CHAP\_CHALLENGE\_STR\_LEN` 字节,且 `extract\_param()` 将输入限制为 `CHAP\_CHALLENGE\_STR\_LEN` 个字符,因此解码输出是有界的)。David Disseldorp 在 v4 上给出了 `Reviewed\-by`,Martin K. Petersen 已将其应用到 `7\.1/scsi\-fixes`(85db7391310b (https://git.kernel.org/mkp/scsi/c/85db7391310b))。
其他近期内核工作:针对 rtl8723bs 暂存 WiFi 驱动程序的三个补丁系列,涵盖十个函数(`OnAuthClient()`、`OnAuth()`、`HT\_caps\_handler()`、`OnAssocRsp()`、`update\_beacon\_info()`、`bwmode\_update\_check()`、`issue\_assocreq()`、`join\_cmd\_hdl()`、`rtw\_get\_wps\_ie()`、`rtw\_cfg80211\_set\_wpa\_ie()`),已在本博客中单独文档说明。在此之前,还有针对暂存树的补丁:tegra-video 和 max96712 中的空指针解引用、nvec 中的释放后使用和拆除顺序问题,以及 ipu7 摄像头驱动中的双重释放问题。