~/posts/web/cve-2024-2961-iconv-php-bucket-lifetimes.md

CVE-2024-2961:iconv 越界与 PHP 对象生命周期

核对状态编码的固定字节越界,沿 PHP 流过滤器区分有效长度、分配请求与所有权,再分析 Zend 空闲链和错误清理如何影响内存解释。

date[31:24]
read[23:16]
8 分钟
cat[15:8]
Web 安全
目录
  1. 0x00四字节指定序列少了一次检查
  2. 0x01从资源参数走到实际转换器
  3. 0x02有效长度、请求尺寸和底层容量不同
  4. 0x03固定低字节如何改变链表解释
  5. 0x04错误返回继续改变对象生命周期
  6. 0x05先验证解析语义,再验证堆轨迹
  7. 0x06修复以发行版包和进程实际加载为准
  8. 0x07官方参考

CVE-2024-2961 把一个很小的写越界带进了更复杂的对象生命周期:glibc 的状态编码转换多写少量固定字节,PHP 流过滤器改变缓冲区的长度、所有权和分配时机,Zend 随后再把空闲块中的数据解释成链表指针。每一层的语义都影响最终结果,字节数小不等于影响小。

分析对象是 Ubuntu 22.04、PHP 8.1.2 与 glibc 2.35-0ubuntu3 的历史环境。下面结合固定版本源码、上游修复和离线模型解释边界;没有重新启动该 PHP 服务、运行旧版漏洞转换器或执行命令载荷。漏洞由 Charles Fol 报告,上游修复于 2024-04-17 公开。

四字节指定序列少了一次检查

ISO-2022-CN-EXT 会先输出字符集指定序列,再输出相应字符数据。glibc 的 SO designation 路径已有空间检查,SS2 与 SS3 designation 分支则缺少同等检查。四字节序列的形态是:

指定序列与固定尾部2 行
分支 序列
SS2 designation 1b 24 2a 48
SS3 designation 1b 24 2b 49 至 1b 24 2b 4d

上游补丁说明具体分支可越界 1、2 或 3 字节,值取自这些固定尾部。公告概述还使用“至多四字节”的宽泛措辞;四字节是完整指定序列长度,不应在这个分支的模型中直接当成任意四字节写入。glibc 修复说明与测试

例如剩余三字节时,SS2 序列的最后一个 48 越过逻辑边界:

SS2 指定序列与逻辑边界
000000001B242A48
  1. in_bounds0x00–0x02
  2. out_of_bounds0x030x48

这里还有一个重要的字符对应关系:上游回归测试把“劄”(U+5284,UTF-8 为 e5 8a 84)用于 SS3 路径。因此,下面的 48 模型表示 SS2 状态,不把这个汉字直接等同于 1b 24 2a 48。

修复在两个遗漏分支的写入之前检查 outptr + 4 > outend,空间不足就返回 __GCONV_FULL_OUTPUT。若容量计数被错误递减,size_t 还可能回绕;64 位的 3 - 4 对应 0xffffffffffffffff。用匹配类型的格式输出计数、返回值和 errno,比把它打印成有符号 -1 更清晰。

从资源参数走到实际转换器

当应用允许调用者控制读取资源,输入可能被解释为流包装器和过滤器组合,而不仅是普通路径:

flowchart TD
  A["读取资源参数"] --> B["流包装器"]
  B --> C["bucket brigade"]
  C --> D["read filters"]
  D --> E["convert.iconv"]
  E --> F["当前进程使用的 iconv 实现"]

这条路径依赖应用输入是否完整到达解析器、包装器和扩展是否可用,以及实际使用的转换器是否受影响。php://filter 可用只证明存在过滤能力,不证明 glibc 写越界可达;一个已修复的进程也可以支持这些正常功能。

进程映射有助于确定加载了哪份 libc 和转换模块,但匿名可写映射、2 MiB 对齐或区域大小不是 Zend 堆的唯一身份证明。调试器中地址稳定,也可能只是 ASLR 设置的结果。地址信息与对象身份应分别验证。

有效长度、请求尺寸和底层容量不同

php_stream_bucket 保存数据指针、有效长度、所有权和引用计数;它没有一个可直接等同于分配器容量的 capacity 字段:

PHP 8.1.2 bucket:相关字段c
char *buf;
size_t buflen;
uint8_t own_buf;
uint8_t is_persistent;
int refcount;

PHP 8.1.2 的 zlib 过滤器确实配置 0x8000 的内部输出缓冲区。但生成输出 bucket 时调用 estrndup(..., bucketlen),后者请求 bucketlen + 1 字节用于结尾 NUL。因而 0x8000 个有效字节不意味着 bucket 数据恰好占一个 0x8000 分配块。

dechunk 先调用 php_stream_bucket_make_writeable。只有 refcount == 1 && own_buf 时直接保留原 bucket;其他情况下,它会复制描述符及数据。随后 php_dechunk 原地移动内容并返回新长度,没有因长度缩短而自动收缩底层分配。

同编码 iconv 的普通输入路径以 buf_len 初始化输出尺寸,再调用 pemalloc。字节内容可以保持不变,但输出对象与分配请求已改变;flush、残留输入、错误和扩容路径还需另行核对。

独占且持有数据的 bucket:示意状态变化5 行
阶段 有效长度 分配或所有权变化
inflate 输出 0x8000 输出数据复制请求为 0x8001,另有内部暂存区
第一次 dechunk 0x100 已有数据块内压缩;独占条件下地址保持
同编码 iconv 0x100 新输出请求 0x100;旧输入按引用计数释放
第二次 dechunk 0x10 有效长度变小,不等于立即更换尺寸等级
再次同编码 iconv 0x10 新请求 0x10;旧 0x100 块可能回到空闲链

这是满足相应输入、引用计数和正常转换条件的状态模型,不是新采集的地址轨迹。把请求大小、尺寸等级和 buflen 合并成一列,会掩盖最关键的分配行为。

固定低字节如何改变链表解释

PHP 8.1.2 的 zend_alloc_sizes.h 将 256 字节等级列为 bin 15,16 字节为 bin 1;257 字节已经进入 320 字节的 bin 16。Zend 小块链表不是 glibc tcache。

简化到已有空闲节点的分配路径:

Zend 小块分配的关键关系c
p = heap->free_slot[bin_num];
heap->free_slot[bin_num] = p->next_free_slot;
return p;

若越界恰好覆盖相邻空闲块内指针的最低字节,固定 48 会把该指针改成:

低字节替换模型python
def replace_low_byte(pointer):
    return (pointer & ~0xff) | 0x48

assert replace_low_byte(0x123400) == 0x123448
assert replace_low_byte(0x1234f0) == 0x123448

两个例子的变化量分别是 +0x48 与 -0xa8。替换最低字节不是统一加上 0x48;只有原低字节为零时,这种加法描述才成立。还需要目标保持映射、落入预期对象范围,且其中数据能被后续分配器当作链指针解释。

因此,受限字节可以改变对象解释,但它没有自动提供任意地址写入。块邻接、尺寸等级、旧数据、指针高字节和之后的申请顺序缺一不可。

错误返回继续改变对象生命周期

PHP iconv 过滤器的输出失败路径会释放 out_buf;外层过滤器随后还会对输入 bucket 减引用。正常的 E2BIG 又可能进入扩容路径,而不是直接失败。应跟随实际分支,不把“iconv 返回错误”当作单一清理动作。

假设输出块为 B、被相邻写入影响的空闲节点为 C、可解释数据位于 A,那么一次特定清理后的逻辑链可能表现为:

flowchart LR
  B["回收后的 B"] --> C["C"]
  C --> A["A 的内部位置"]
  A --> N["按残留数据解释的下一跳"]

这是条件化对象关系,不代表每次失败都形成相同链序。返回失败不会撤销先前写入;只在越界瞬间记录一次链头,还会漏掉后续释放带来的变化。不崩溃也不等于没有内存破坏。

Zend 的自定义分配器还有独立选择条件。use_custom_heap 与 custom_heap.std._malloc / _free / _realloc 配合使用,调试构建另有带调试参数的分支。只改变一个回调,不代表调用路径已经切换;ZEND_MM_CUSTOM、统计、存储和限制相关编译选项也会影响结构。固定的 +0x168 等偏移不属于稳定 ABI。

先验证解析语义,再验证堆轨迹

PHP 的 dechunk 状态机会在长度数字之后跳过扩展内容,直到 CR 或 LF。额外的十六进制字符会继续改变长度,换行字节会改变边界;所以长度行不是透明二进制容器。quoted-printable 可以在后续阶段还原字节,但它也会引入自己的输出对象,过滤顺序始终重要。

本机把 PHP 8.1.2 的 php_dechunk 和状态定义原样取出,放进一个边界受限的 C 测试程序,用 MinGW GCC 的 -std=c11 -O2 -Wall 编译。测试正常 chunk、扩展、单 LF 和非法起始字符,并在一个带扩展的有效输入的全部 19 个位置拆成两段:

解析器与离线模型结果log
PASS: 4 dechunk cases; 19 two-bucket splits; PHP 8.1.2 parser extracted unchanged
PASS: 24 designation boundaries; 256 low-byte substitutions; 2 binary round trips
Zend source: 30 bins; 256 -> bin15; 257 -> bin16; 16 -> bin1
U+5284 UTF-8: e58a84; size_t64 wrap: 0xffffffffffffffff

24 组边界来自六种指定序列和四种剩余容量;两组二进制往返覆盖全部 256 种字节的 quoted-printable 与 raw DEFLATE。这些检查验证解析、算术和编码,不模拟 PHP 服务的 Zend 堆,也没有调用受影响的 glibc。

继续验证真实环境时,应在每个过滤器调用及清理完成后记录 buf、buflen、引用计数、分配请求和对应 free_slot。先证明一条短转换链的对象变化,再解释更长输入的效果。

修复以发行版包和进程实际加载为准

Ubuntu 22.04 的公告将 2.35-0ubuntu3.7 列为已修复版本。发行版可以回移补丁,因此仅看“glibc 2.35”或“低于 2.40”不足以判断状态;这里的 2.35-0ubuntu3 是所分析历史环境的包版本。Ubuntu CVE-2024-2961

处理时分清三件事:

  • 更新提供 libc 与 gconv 模块的受影响包,并确认服务进程已加载修复后的文件;磁盘升级不等于长期运行进程已经换库。
  • 读取接口应接收应用定义的资源标识,再映射到允许文件;不要把完整包装器和过滤表达式当成普通文件名。
  • 将库状态、转换路径可达性和应用影响分别验证。单独关闭某个 URL 选项,不应被当成覆盖所有本地包装器与过滤器的证明。

最终结论不是“任意文件读取都等于代码执行”,而是状态编码错误可以穿过应用层的对象转换。修复真正的越界源头,并消除不必要的资源解释能力,比依赖某次堆布局碰巧失败更可靠。

官方参考

NORMAL~/posts/web/cve-2024-2961-iconv-php-bucket-lifetimes.md§--
0%zh-CN