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 分支则缺少同等检查。四字节序列的形态是:
| 分支 | 序列 |
|---|---|
| SS2 designation | 1b 24 2a 48 |
| SS3 designation | 1b 24 2b 49 至 1b 24 2b 4d |
上游补丁说明具体分支可越界 1、2 或 3 字节,值取自这些固定尾部。公告概述还使用“至多四字节”的宽泛措辞;四字节是完整指定序列长度,不应在这个分支的模型中直接当成任意四字节写入。glibc 修复说明与测试
例如剩余三字节时,SS2 序列的最后一个 48 越过逻辑边界:
in_bounds0x00–0x02out_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 字段:
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、残留输入、错误和扩容路径还需另行核对。
| 阶段 | 有效长度 | 分配或所有权变化 |
|---|---|---|
| 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。
简化到已有空闲节点的分配路径:
p = heap->free_slot[bin_num];
heap->free_slot[bin_num] = p->next_free_slot;
return p;若越界恰好覆盖相邻空闲块内指针的最低字节,固定 48 会把该指针改成:
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 个位置拆成两段:
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: 0xffffffffffffffff24 组边界来自六种指定序列和四种剩余容量;两组二进制往返覆盖全部 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 选项,不应被当成覆盖所有本地包装器与过滤器的证明。
最终结论不是“任意文件读取都等于代码执行”,而是状态编码错误可以穿过应用层的对象转换。修复真正的越界源头,并消除不必要的资源解释能力,比依赖某次堆布局碰巧失败更可靠。