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&):
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:
ShapeInferenceImplBase::UpdateType()(line ~367) βmergeShapesAndTypes(*inferred_type, iter->second)βcheckShapesAndTypes(inferred_type, *existing_type)(line 198, 112) dereferencesexisting_typedirectly βTypeProto::value_case()on a nullthisβ SEGV. Triggered when any node inside the function body reuses the missing parameter's name as its own output name (soUpdateTypeis invoked again for that name).ShapeInferenceImplBase::Process(const FunctionProto&, InferenceContext&)(line 670), the function-output copy-back loop: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, 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.
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
shapedata-propagation helper, not general type/shape-inference merge), #5989 (schema-level empty input/output mismatch, not function-call arity), #5212 (segfault specific todata_prop=Trueon 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).