ONNX 模糊测试指南:基于 OSS-Fuzz 与 Atheris 的 Harness 设计与运行
【免费下载链接】onnxOpen standard for machine learning interoperability项目地址: https://gitcode.com/gh_mirrors/onn/onnx
ONNX(Open Neural Network Exchange)是一个开放的机器学习模型互操作标准,其解析器、形状推断、模型合并与版本转换等核心逻辑直接面对不可信的模型输入。为持续发现崩溃、死锁(hang)与内存安全问题,ONNX 仓库在 onnx/fuzz/ 目录下维护了一套基于 atheris 为核心,结合仓库内的实际 harness 源码与 CI 配置,完整讲解每个 harness 的入口点、输入格式、toggle byte 设计、本地运行方式、种子语料生成方法,以及新增 harness 的标准化流程,帮助读者理解 ONNX 如何对自身最关键的数据解析与图变换路径做防御性验证。
目录结构与整体定位
onnx/fuzz/目录下共有 7 个文件,其中 6 个是可供 OSS-Fuzz 与 CI 直接调用的 Python fuzz target,1 个是种子语料生成脚本。官方 README 用一张表精确刻画了每个 harness 的职责:
| 文件 | 被模糊测试的入口点 | 输入路径 |
|---|---|---|
fuzz_checker.py | checker.check_model | 原始字节 → protobuf 解析器 |
fuzz_compose.py | compose.merge_models | 原始字节(带长度前缀的模型对)以及结构化(toggle byte) |
fuzz_model_loader.py | load_model_from_string+checker.check_model | 原始字节 → protobuf 解析器 |
fuzz_parser.py | parser.parse_model | UTF-8 文本(ONNX 文本格式) |
fuzz_shape_inference.py | shape_inference.infer_shapes | 原始字节以及结构化模型(toggle byte) |
fuzz_version_converter.py | version_converter.convert_version | 原始字节 → protobuf 解析器 |
make_seed_corpus.py | (种子生成器,不是 fuzzer) | 为 OSS-Fuzz 产出种子 zip |
从这张表可以看出,ONNX 的模糊测试覆盖了两类截然不同的输入通道:二进制通道(checker、model_loader、version_converter走 protobuf 反序列化)与文本通道(parser走 ONNX 文本格式的语法解析),而compose与shape_inference则同时覆盖二进制与结构化两条路径。这是 ONNX 对自身攻击面做全面防御的直观体现:既防止 protobuf 解析器本身被恶意字节流击穿,也防止更上层的图算法(如递归子图访问)在合法但病态的模型结构上出问题。
逐个剖析六个 Harness
fuzz_checker.py:模型校验器的直射
fuzz_checker.py是所有 harness 中最短小精悍的一个,完整实现如下(onnx/fuzz/fuzz_checker.py):
import sys import atheris with atheris.instrument_imports(): from onnx import checker def TestOneInput(data): try: checker.check_model(data, full_check=True) except Exception: return def main(): atheris.instrument_all() atheris.Setup(sys.argv, TestOneInput, enable_python_coverage=True) atheris.Fuzz() if __name__ == "__main__": main()它的输入路径是"原始字节 → protobuf 解析器",即checker.check_model直接接收裸字节串,由 ONNX 内部的反序列化逻辑将其解析为ModelProto,再做full_check=True的全面校验。该路径能同时覆盖两层代码:protobuf 字节流解析本身,以及checker内部对模型结构、属性、算子类型等语义约束的验证逻辑(对应 C++ 实现位于 onnx/checker.cc)。full_check=True意味着每次迭代都会执行比默认更严格的检查,包括算子 schema 层面的验证。
fuzz_model_loader.py:加载 + 校验组合拳
fuzz_model_loader.py 与 checker harness 互补:它先调用onnx.load_model_from_string(data)把字节流反序列化为ModelProto,随后主动触碰三个关键的 Python 列表属性(model.graph.node、model.graph.input、model.graph.output),再交给onnx.checker.check_model(model)做整体校验。
其中对len(model.graph.node)等属性的访问并非多余——它强制 pybind11 层把 protobuf 的 RepeatedField 映射为 Python 对象,从而覆盖 C++/Python 绑定代码的路径。在 CI 中该 harness 复用 checker 的种子语料(见下文 CI 章节),因为它与fuzz_checker.py走的是同一条 load+check 主链路。
fuzz_parser.py:文本格式的语法轰炸
fuzz_parser.py 是唯一走文本通道的 harness,其核心只有三行:
text = data.decode("utf-8", "surrogatepass") parser.parse_model(text)注意surrogatepass解码方式:它允许把任意字节序列(包括非法的 UTF-8 序列)无错地转换为 Pythonstr,确保模糊测试的随机字节总能进入 ONNX 文本格式解析器(onnx/parser.py 及底层 C++ 实现)而非在解码阶段被拦下。这正是一个成熟的 fuzz target 该有的行为——把"输入是否合法"的判断完全交给被测代码,而不是在 harness 里提前过滤。
fuzz_shape_inference.py:形状推断 + 递归子图访问
形状推断是 ONNX 中最复杂的图算法之一:它不仅要按算子 schema 逐节点传播形状信息,还要递归进入 If/Loop/Scan 等带子图的算子内部。fuzz_shape_inference.py的核心策略是用 toggle byte 同时驱动两条输入路径:
- 原始字节路径:
onnx.load_model_from_string(data)反序列化 →infer_shapes,覆盖 protobuf 解析器与序列化模型才能构造出的病态结构; - 结构化路径:用
atheris.FuzzedDataProvider从输入字节中逐段抽取参数,通过helper.make_model构造一个包含 If/Loop/Scan 嵌套子图的合法模型→infer_shapes,让递归子图访问器在大多数迭代上都能被真正走到。
结构化路径的构造逻辑在源码中有非常精细的设计(见 onnx/fuzz/fuzz_shape_inference.py 的_build_branch函数):
- 用
Constant节点生成自包含的子图起始张量,避免依赖未声明的外层作用域捕获; - 以概率递归嵌套 If/Loop/Scan(深度上限由
FuzzedDataProvider从输入中抽取,最大 80 层),驱动shape_inference内部的递归下降; - Loop 与 Scan 的 body 子图故意不满足签名约束——因为递归访问器会在签名检查之前就完成下降,即使推断最终失败,递归路径也已经被执行到了;
- 顶层图的 opset 版本由输入随机抽取(7 到 27),从而覆盖不同 opset 的 schema 分发分支。
从源码结构看,这反映了一个重要事实:shape_inference的已知 DoS 风险就存在于递归子图访问路径上(源码注释明确指出_SUBGRAPH_OPS是"the path the known DoS lives on"),因此该 harness 的全部构造逻辑都围绕"如何稳定地递归"来设计,而不是让随机字节碰运气。
fuzz_version_converter.py:跨版本转换的组合爆炸
版本转换器需要把模型从一个 opset 版本升级或降级到目标版本,涉及大量算子适配器(见 onnx/version_converter/adapters/ 下的数十个*_*_*.h文件)。fuzz_version_converter.py 的巧妙之处在于目标版本的选择逻辑:
def _candidate_target_versions(model): current = _default_opset_version(model) latest = onnx.defs.onnx_opset_version() if current is None: return [latest] targets = [] if current > 1: targets.append(current - 1) # 降级一档 if current < latest: targets.append(current + 1) # 升级一档 if latest not in targets and current != latest: targets.append(latest) # 跳到最新 return targets_default_opset_version从model.opset_import中查找默认域(""或ai.onnx)的版本。每次输入都会尝试"当前版本 ± 1"以及"最新版本"三个候选目标,让单次输入即可覆盖相邻版本之间的升级与降级适配器,以及跨越多个版本的转换链。种子语料中还包含大量带拓扑缺口(输出或节点输入无人产生)的模型,专门用于锻炼转换器对未定义名称的处理逻辑。
fuzz_compose.py:模型合并的最复杂输入编排
fuzz_compose.py是全仓库输入编排最复杂的 harness。它同时驱动两个ModelProto和一个io_map进入compose.merge_models,单次迭代即可传递性地覆盖merge_graphs、check_overlapping_names、递归的connect_io子图重写、add_prefix以及合并后的checker.check_model(对应 Python 层实现位于 onnx/compose.py)。
结构化路径中,两个模型由FuzzedDataProvider构建:0 到 4 个随机一元算子(从Relu/Sigmoid/Tanh/Abs/Neg/Identity中抽取)组成的线性图,输入输出名固定为In<tag>/Out<tag>,末尾接一个Identity。源码注释解释了命名的深意:固定名称让派生 io_map(m1 的输出名 → m2 的输入名)在大多数迭代上都能连接成功,从而稳定到达merge_models的重写逻辑;同时两个模型共享同一个 opset,因为merge_models会预先拒绝 opset_import 不匹配的模型对——若各自独立抽取 opset,结构化路径将几乎永远走不到合并逻辑本身。
Toggle Byte 机制:单 Harness 双路径的开关设计
文档与源码中反复出现的 "toggle byte" 是这套 harness 设计中最值得学习的手法:用输入末尾的最后一个字节作为模式开关,其余字节作为真正的模糊测试载荷。
fuzz_shape_inference.py的 toggle byte 定义(源码TestOneInput中toggles = data[-1]之后逐位解析):
| Bit | 含义 |
|---|---|
0x01 | strict_mode=True |
0x02 | check_type=True |
0x04 | 使用结构化模型构造器(If/Loop/Scan 子图)而非原始字节 |
fuzz_compose.py的 toggle byte 定义:
| Bit | 含义 |
|---|---|
0x01 | 传入prefix1/prefix2(走add_prefix名称冲突消解路径) |
0x04 | 结构化:两个模型均由FuzzedDataProvider构建(否则走原始路径:4 字节大端长度前缀把剩余字节切分为 m1 | m2) |
0x08 | 随机 io_map(否则从 m1 的输出名与 m2 的输入名派生) |
设计细节值得细品:
- toggle 放在尾部而非头部:
fuzz_shape_inference.py的原始路径会把完整输入(含 toggle 字节)原样交给load_model_from_string——因为种子模型是完整的序列化 ModelProto,切掉尾部会截断每个种子。libFuzzer 对 toggle 字节的变异是自由的,所以把开关放在尾部既不影响原始字节路径,也不影响结构化路径(结构化路径会把尾部字节切掉后再交给FuzzedDataProvider)。 - 保留位:compose 的
0x02、0x10..0x80与 shape_inference 的0x08..0x80均被注释为"reserved for future toggles",对它们的变异在未被认领前是无害的——这为未来扩展预留了空间。 - 单 harness 双路径:这一设计让一个 fuzz target 同时覆盖 protobuf 解析器路径与递归子图访问器路径,无需为每条路径各维护一个独立的 fuzzer,减少了 OSS-Fuzz 项目中的编译单元与调度开销。
种子语料生成:make_seed_corpus.py
模糊测试从空语料出发往往需要很长时间才能穿越"合法模型"这一深层结构,因此 ONNX 提供了 make_seed_corpus.py 来预置高质量起点。它的命令行接口(第 5 个参数可选,保证旧的 4-zip 调用方式不受影响):
python onnx/fuzz/make_seed_corpus.py \ /tmp/vc_seeds.zip /tmp/parser_seeds.zip /tmp/checker_seeds.zip \ /tmp/shape_inference_seeds.zip [/tmp/compose_seeds.zip] # 5th arg optional各 zip 的种子内容在源码中有明确注释,值得逐一对照:
- version_converter.zip:
Cast 9、Softmax 12/13、Upsample 6/9(含缺输入、缺 scales、合法三种形态)等"缺输入"模型,以及三个带拓扑缺口的模型(identity_13_output_undefined、add_13_output_partial_undefined、add_13_node_input_undefined),专门针对版本转换器的未定义名称处理。 - parser.zip:6 个 ONNX 文本格式种子,均提取自 tests/python/parser_test.py,覆盖基础三算子线性模型、多 opset_import、完整元数据字段、带属性引用的本地函数定义、带 initializer 的 Cast、以及
inf/-inf/nan特殊浮点字面量——最后一个是专门为浮点字面量解析的分支路径准备的。 - checker.zip:6 个跨 opset 版本(13/15/19)的合法序列化模型(Relu/Sigmoid/Tanh/Abs/Cast/Softmax),确保 checker 能触及真实的校验逻辑而非每轮都在 protobuf 解析处死亡。
- shape_inference.zip:6 个完整序列化 ModelProto,覆盖一元链(
linear_relu_sigmoid)、变长输入传播(concat_axis0)、2-D 形状传播(matmul_4x8_8x2)、shape 数据传播(reshape_2x4_to_8x1)以及子图递归(if_then_else、loop_scan_output)。每个种子都是无尾随字节的完整模型,与 toggle 设计严格契合。 - compose.zip:4 个模型对种子,每个都按原始路径打包格式编码(4 字节大端长度前缀 + m1 + m2 + toggle 字节),分别覆盖最小合法合并(toggle
0x00)、三对变长 io_map、含 If 子图的递归connect_io重写、以及同名模型自合并(toggle0x01触发 add_prefix 冲突消解)。
本地运行与 CI 回归检查
本地冒烟运行
Atheris 需要 libFuzzer 插桩的 Python 构建,官方 README 给出的最快途径是使用 OSS-Fuzz 的 Docker 镜像;而快速本地冒烟测试可以直接安装 atheris 后用-runs限制迭代次数:
pip install atheris python onnx/fuzz/fuzz_checker.py -runs=1000 python onnx/fuzz/fuzz_compose.py -runs=1000 python onnx/fuzz/fuzz_parser.py -runs=1000 python onnx/fuzz/fuzz_shape_inference.py -runs=1000 python onnx/fuzz/fuzz_version_converter.py -runs=1000-runs=1000表示只执行 1000 次输入("just loads the harness"的轻量冒烟模式);若不传-runs,libFuzzer 会进入无限循环的持久化模糊测试模式,此时可用-max_total_time=<秒>控制时长。CI 中实际使用的参数是-max_total_time加-timeout=20(单次输入 20 秒超时,防止死锁拖垮整个任务)。
仓库自有 CI 回归检查
.github/workflows/fuzz.yml 是这份 README 的工程化落地,其设计定位在文件头注释里说得很清楚:它刻意不是 OSS-Fuzz 自身持续模糊测试的替代品,而是一个浅层的回归检查——捕获"harness 无法导入""API 漂移""harness 文件丢失"这类问题,因为 OSS-Fuzz 的失败并不会在本仓库 CI 中呈现。
该 workflow 的关键细节:
- 触发条件:PR/push 仅当改动涉及
onnx/**、pyproject.toml、fuzz.yml本身时触发;另有 cron 定时任务(0 6 * * *)每晚执行,以及手动触发入口。 - 运行时长:PR 冒烟测试 60 秒/harness,nightly 300 秒/harness。
- 环境限制:atheris 只为 Linux 和 macOS 发布 wheel,因此该 job 不覆盖 Windows。
- 种子复用:生成 5 个 zip 后逐个解压为目录作为 corpus;
fuzz_model_loader.py没有专属种子,直接复用 checker 的语料(注释明确说明二者走同一 load+check 路径)。 - 失败产物:任何 harness 失败时通过
actions/upload-artifact上传crash-*、timeout-*、oom-*文件,便于事后复现。
设计要点背后的工程哲学
为什么except Exception: return?
这是理解 fuzz harness 的第一课:fuzz target 绝不能在"预期错误"上崩溃,只能在"意外错误"上崩溃。当 fuzzer 投喂随机字节时,protobuf 解析失败、ValidationError、InferenceError、DecodeError等都是预期内的情况。吞掉这些异常,libFuzzer 才能继续寻找真正引发 bug 的输入——真正的 bug 会以内存破坏、死锁、sanitizer 报告(ASAN/UBSAN/MSAN)的形式浮出水面,而这些信号不会被except拦截。
这一惯例不仅体现在 README 中,也写进了代码规范:pyproject.toml 第 281-287 行对onnx/fuzz/**目录做了专门的 lint 豁免——BLE001(宽泛 except 是有意的)、S112(try-except-continue 是逐版本 fuzzing 的正确模式)、S311(fuzz 输入洗牌用非加密随机是合理的)、PLR2004(底层 fuzz 脚手架中魔法数字可接受)。
为什么入口函数叫TestOneInput?
TestOneInput是 atheris 要求的固定入口函数名(atheris 官方用法中指定的回调签名),libFuzzer/atheris 通过它把每次迭代的输入字节传给被测代码。它违反 Python 的 snake_case 命名约定(PEP 8 对应 Ruff 的N802规则),因此pyproject.toml中对onnx/fuzz/**专门抑制了N802,并注释说明"TestOneInput is the required atheris entry-point name"。
每个 harness 的main()结构也完全一致:atheris.instrument_all()对已加载模块做插桩,atheris.Setup(sys.argv, TestOneInput, enable_python_coverage=True)注册回调并开启 Python 覆盖率引导(这能让 libFuzzer 依据 Python 代码覆盖率做定向变异),最后atheris.Fuzz()进入循环。
如何新增一个 Harness
README 给出了标准化的三步流程:
- 创建
onnx/fuzz/fuzz_<name>.py:参照现有 harness 的模式——用with atheris.instrument_imports():包裹被测模块导入,实现TestOneInput(data)(记得except Exception: return),复刻标准main()结构。 - 补充种子语料:如果新 fuzzer 能从种子输入获益,在
make_seed_corpus.py中添加对应种子生成逻辑,并在 OSS-Fuzz 的build.sh中接好输出的 zip。若新增了种子 zip,还需在仓库的 .github/workflows/fuzz.yml 中补充生成与解压步骤,让 CI 回归检查覆盖新 harness。 - 提交 PR:合并后,若新增了种子 zip,同步更新 OSS-Fuzz 侧的
build.sh(该文件通过$SRC/onnx/fuzz/引用本仓库的 harness,确保 harness 与它测试的代码在同一仓库中做版本控制)。
新增 harness 时应遵循的既有约定包括:输入不足时尽早return(如 compose 的len(data) < 2检查)、toggle byte 统一放尾部且预留保留位、结构化路径优先保证能稳定到达被测逻辑的深层路径而非依赖随机字节碰运气。
总结
ONNX 的onnx/fuzz/目录展示了 Python 生态中生产级模糊测试工程的完整形态:用 toggle byte 让单个 harness 同时覆盖二进制与结构化两条输入路径,用种子语料确保 fuzzer 快速穿越深层结构,用"吞掉预期异常"让 libFuzzer 聚焦真正的内存安全与死锁问题,再用仓库自身 CI 兜底 harness 的回归。对希望在自有项目中引入 OSS-Fuzz 或 Atheris 的开发者而言,这份 README 与其配套源码(fuzz harnesses、seed generator、CI workflow、lint 豁免配置)本身就是一套可以直接借鉴的参考实现。
【免费下载链接】onnxOpen standard for machine learning interoperability项目地址: https://gitcode.com/gh_mirrors/onn/onnx
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考