| --- |
| 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). |
|
|