--- license: other tags: - security - fuzzing - onnx - null-pointer-dereference - denial-of-service --- # ONNX core parser: NULL-pointer dereference in FunctionProto shape inference (DoS) **Status:** gated PoC, access restricted for coordinated disclosure via huntr.com (Model File Vulnerability program). Contact protectai-bot / huntr maintainers for access. ## Summary `onnx/onnx` (the core protobuf model parser/checker library that ships with every `pip install onnx`, and that ONNX Runtime, model-surgery tools, and MLOps pipelines call directly on **untrusted, externally-sourced** `.onnx` files) crashes with a NULL-pointer dereference (SIGSEGV) when shape inference is run over a model-local `FunctionProto` whose call site omits a trailing optional input, **if that same (unbound) formal parameter name is also used as one of the function's own output names**. This is reachable via: - `onnx.checker.check_model(model, full_check=True)` — the officially documented, commonly recommended way to fully validate an untrusted ONNX model before use. - `onnx.shape_inference.infer_shapes(model)` — used directly by many downstream tools (ONNX Runtime tooling, `onnx-graphsurgeon`, model optimizers/converters, MLOps ingestion pipelines) to propagate shapes over third-party models. Both entry points crash the host **Python process itself** (native SIGSEGV, not a catchable Python exception) on a **159-byte** crafted `.onnx` file — i.e. any service that loads and validates/shape-infers an attacker-supplied ONNX file goes down. ## Root cause `onnx/shape_inference/implementation.cc`, `ShapeInferenceImplBase::Process(const FunctionProto&, InferenceContext&)`: ```cpp for (int i = 0; i < num_func_inputs; ++i) { const auto& parameter_name = func_proto.input().Get(i); const auto* const type_ptr = (i < num_actual_inputs) ? ctx.getInputType(i) : nullptr; // nullptr is valid, and indicates a missing optional input if (type_ptr != nullptr) { types_cache[i] = *type_ptr; value_types_by_name[parameter_name] = &types_cache[i]; } else { value_types_by_name[parameter_name] = nullptr; // <-- intentional null, by design } } ``` When a function call site provides fewer actual inputs than the function's formal parameter list (a legal, documented way to pass "missing optional inputs" to a function), the missing parameter's name is registered in `value_types_by_name` with an explicit `nullptr` value. This is intentional and, by itself, correct. The bug is that two *consumers* of `value_types_by_name` later dereference that stored pointer **without checking it for null**, whenever the same name is looked up again: 1. `ShapeInferenceImplBase::UpdateType()` (line ~367) → `mergeShapesAndTypes(*inferred_type, iter->second)` → `checkShapesAndTypes(inferred_type, *existing_type)` (line 198, 112) dereferences `existing_type` directly → `TypeProto::value_case()` on a null `this` → SEGV. Triggered when any node *inside* the function body reuses the missing parameter's name as its own output name (so `UpdateType` is invoked again for that name). 2. `ShapeInferenceImplBase::Process(const FunctionProto&, InferenceContext&)` (line 670), the function-output copy-back loop: ```cpp for (int i = 0; i < func_proto.output_size(); ++i) { auto iter = value_types_by_name.find(output_name); if (iter != value_types_by_name.cend()) { ctx.getOutputType(i)->CopyFrom(*(iter->second)); // <-- null deref if iter->second==nullptr } } ``` Triggered directly when the function declares the missing parameter's name as one of its own **outputs** (a pure pass-through, no processing node required at all — this is the PoC below). Both are two call sites of the **same** underlying oversight (introduced in [onnx/onnx#5066](https://github.com/onnx/onnx/pull/5066), merged April 2023, "Fix an issue in function type/shape inference to support optional inputs" — the fix added the intentional-null storage but never audited/guarded its later readers). The bug has been present, unfixed, for 3+ years and reproduces on current `main` (`964b886d46d337fa775c6e4012a90534b28cd3ca`, 2026-07-07). ## PoC `poc_minimal.onnx` (159 bytes) — built with `make_min_poc.py` (included) using the standard `onnx.helper` API: - One model-local function `custom.domain::PassThroughY`, formal inputs `["x", "y"]`, formal outputs `["y"]`, **zero nodes** in its body (pure name pass-through). - One call node in the main graph invokes it with only **one** actual input (`["fx"]`), leaving formal parameter `"y"` unbound. ```python import onnx m = onnx.load("poc_minimal.onnx") onnx.checker.check_model(m, full_check=True) # SIGSEGV # or: onnx.shape_inference.infer_shapes(m) # SIGSEGV ``` Reproduces on a stock `pip install onnx` (tested against onnx 1.22.0, PyPI wheel, no custom build) — see `repro_pip_onnx.txt`. ## ASan stack trace (from-source ASan+AFL build of onnx/onnx main, matching line numbers) See `asan_trace.txt`. Top of stack: ``` AddressSanitizer: SEGV on unknown address 0x000000000010 #0 onnx::TypeProto::MergeImpl(...) onnx-ml.pb.cc:11593 #1 ShapeInferenceImplBase::Process(FunctionProto const&, InferenceContext&) implementation.cc:670 #2 InferShapeForFunctionNodeInternal(...) implementation.cc:879 #3 ShapeInferenceImplBase::ProcessCall(...) implementation.cc:896 #4 ShapeInferenceImplBase::Process(NodeProto&) implementation.cc:489 #5 ShapeInferenceImplBase::Process(GraphProto&) implementation.cc:599 #6 InferShapesImpl(...) implementation.cc:789 #7 onnx::shape_inference::InferShapes(ModelProto&, ...) implementation.cc:824 #8 onnx::checker::check_model(ModelProto const&, ...) checker.cc:1187 ``` ## How this was found Coverage-guided AFL++ fuzzing (`afl-clang-fast++` + `AFL_USE_ASAN=1`) directly against the unmodified onnx/onnx C++ core (`checker::check_model` + `shape_inference::InferShapes`), built from source against a matching protobuf 6.31.1/v31.1 (Abseil-backed) static build, and seeded with a small hand-written corpus of structurally valid ONNX models covering Add/Conv/ If/Loop/local-function/Sequence graphs. AFL found 13 raw crashing inputs across a ~25-minute, single-machine (4-core) campaign; all 13 were manually triaged by true root cause (not just distinct stack hashes) down to **one** underlying bug at two call sites, and then hand-minimized to the 159-byte PoC above (no nodes, no tensors, no shapes — just the function-signature/call-arity mismatch that triggers the null store). ## Dedup / prior-art check - Github-issue-searched onnx/onnx for prior reports referencing `checkShapesAndTypes`, `mergeShapesAndTypes`, `value_types_by_name`, `ProcessCall`, `InferShapeForFunctionNodeInternal`, and "function optional input segfault" — no matching open or closed issue found. - Confirmed distinct from the closest-sounding prior reports: onnx/onnx#5219 (closed; a *different* function — the `shape` **data-propagation** helper, not general type/shape-inference merge), #5989 (schema-level empty input/output mismatch, not function-call arity), #5212 (segfault specific to `data_prop=True` on nested functions). - The introducing PR (#5066, April 2023) is old and merged; the bug has had 3+ years of exposure (including onnx/onnx's own OSS-Fuzz atheris harnesses, live since ~mid-June 2026) without being caught, most likely because triggering it requires a specific, non-trivial `FunctionProto`/optional-input/name-reuse structure that raw-byte-mutation fuzzing rarely reaches without a domain-aware seed — exactly the gap a seeded, coverage-guided native build closes. ## Impact Any process that runs `onnx.checker.check_model(untrusted_model, full_check=True)` or `onnx.shape_inference.infer_shapes(untrusted_model)` over a file it does not fully trust — model marketplaces, CI/CD model-validation gates, MLOps ingestion services, ONNX converters — can be crashed (denial of service) by a 159-byte file with no special privileges. This is a native SIGSEGV, not a Python exception, so it cannot be caught by a `try/except` around the call; it takes down the whole process (and, in naive multi-tenant setups, everything else running in it).