--- name: contrib-op-shape-inference-memory-safety description: "Audit and fix out-of-range output writes in ONNX Runtime operator shape-inference functions. Use when reviewing or fixing a contrib (or standard) op TypeAndShapeInference where a getNumOutputs() guard precedes a write to a higher output index - optional trailing outputs make a smaller output count schema-valid, so getOutputType(index) can run one past the declared outputs at Graph::Resolve." --- # Contrib-Op Shape-Inference Output-Index Safety Reusable method for finding and fixing the bug class where an operator's `TypeAndShapeInference` function guards an output write with `getNumOutputs() > N` but then writes an output index **greater than** `N`. For a node that declares fewer outputs, the written index is past the end of the inference context's output vector. > **Scope**: schema-level shape inference in `onnxruntime/core/graph/contrib_ops/*.cc` and > `shape_inference_functions.cc`. This runs once during `Graph::Resolve` (model-load time), > **EP-agnostic** - there is no per-EP (CPU/CUDA/ROCm) kernel duplicate of this code to > chase. Op *kernels* allocate outputs via the bounds-safe `OpKernelContext::Output(index)` > and are a separate concern. ## 1. The pattern ```cpp // onnxruntime/core/graph/contrib_ops/bert_defs.cc (before) propagateElemTypeFromInputToOutput(ctx, 0, 0); if (ctx.getNumOutputs() > 1) { // guard says "> 1" propagateElemTypeFromInputToOutput(ctx, 0, 1); propagateElemTypeFromInputToOutput(ctx, 0, 2); // but writes index 2 } ``` The guard `getNumOutputs() > 1` admits a node with **exactly 2 outputs** (indices 0, 1), yet the body writes index **2**. The implication "`> 1` ⇒ index 2 exists" is false: `> 1` only guarantees indices 0 and 1. ### Why a smaller output count is valid Trailing outputs declared `OpSchema::Optional` **lower `min_output`**. ONNX derives `min_output` = number of required outputs, `max_output` = total declared. The model checker (`checker::check_node`) only enforces `min_output <= N <= max_output`. | Op | Output decls | min / max | A 2-output node? | |---|---|---|---| | `DecoderAttention` | out (req), new_key_cache (Opt), new_value_cache (Opt) | 1 / 3 | passes checker | | `MultiHeadAttention` | out (req), present_key (Opt), present_value (Opt), qk (Opt) | 1 / 4 | passes checker | | `DecoderMaskedMultiHeadAttention` | out (req) + 3 Optional | 1 / 4 | passes checker | So a node with `output=['out','present_key']` is schema-valid, passes the checker, and then reaches the index-2 write. **A passing checker is not a guarantee the index is in range.** ## 2. The sink (why the write is not caught) ```cpp // onnxruntime/core/graph/graph.cc - InferenceContextImpl const TypeProto* getInputType(size_t index) const override { return node_.InputDefs().at(index)->TypeAsProto(); // .at() -> bounds-checked } TypeProto* getOutputType(size_t index) override { return &node_output_types_[index]; // operator[] -> NOT bounds-checked } ``` - `node_output_types_` is sized to `node.OutputDefs().size()` in the `InferenceContextImpl` ctor, so for a 2-output node it has 2 elements; `getOutputType(2)` returns one past the end. - `getInputType` uses `.at()` (would throw on a bad index); `getOutputType` uses raw `operator[]` (no check) - the asymmetry is the root cause. - The call runs at `Graph::Resolve` → `InferAndVerifyTypeMatch` → `RunInferencing`. The surrounding `ORT_TRY/ORT_CATCH(const std::exception&)` only catches *thrown* `fail_shape_inference`; a raw out-of-range `operator[]` does not throw, so the catch does not help. - Because this is schema-level inference, it is **EP-independent** - no CUDA/ROCm copy. ## 3. Audit technique — always sweep siblings Do not stop at the reported function. Grep **every** shape-inference guard and compare its threshold against the **highest output index written before the next guard**. ```bash git grep -n 'getNumOutputs' -- \ onnxruntime/core/graph/contrib_ops/*.cc \ onnxruntime/core/graph/contrib_ops/shape_inference_functions.cc ``` For each `if (ctx.getNumOutputs() > N)` block, find the largest `index` passed to `propagateElemTypeFromInputToOutput(ctx, _, index)` / `updateOutputShape(ctx, index, _)` / `getOutputType(index)` inside it. **Rule: the guard must require strictly more outputs than the highest index written** (write index `k` ⇒ guard must ensure `getNumOutputs() > k`). Correct exemplars already in the tree to copy: | Exemplar | Pattern | Why it is safe | |---|---|---| | `BaseGroupQueryAttention...` | `if (getNumOutputs() >= 3)` then writes idx 2 | guard covers highest index | | `PagedAttention...` | nested `> 1` + inner `if (getNumOutputs() != 3) fail_shape_inference` | fails before any write | | `EmbedLayerNormalizationShapeInference` | `> 2` then writes idx 2 | fixed by PR #28176 (precedent) | | `SkipLayerNormalizationShapeInference` | each idx `k` guarded by `> k` | per-index guard | > **Gotcha — conditional writes can hide a vacuous audit.** A write may sit behind an inner > condition (e.g. `hasInputShape(past_key_index)` before writing index 2). The site is still > a bug, but you can only *observe* it when that inner condition is also satisfied. Keep this > in mind both for the audit and for tests (§5). ## 4. Fix patterns **Point fix (required): raise the guard to cover the highest index written.** ```cpp // before if (ctx.getNumOutputs() > 1) { ... writes idx 2 ... } // after if (ctx.getNumOutputs() > 2) { // both present_key (idx 1) AND present_value (idx 2) ... } ``` Justify the threshold with the op's output semantics. For these attention ops the two trailing outputs - `present_key` (idx 1) and `present_value` (idx 2) for `MultiHeadAttention`, `new_key_cache` / `new_value_cache` for `DecoderAttention` (see the §1 table for each op's exact output names) - are a **both-or-neither pair**: there is no valid configuration that emits one without the other, so requiring all three outputs before populating indices 1 and 2 is behavior-preserving. (`PagedAttention` encodes the same invariant via its nested `!= 3` check.) **Defense-in-depth (recommended): bound the sink** so a future author cannot reintroduce the class. ```cpp // onnxruntime/core/graph/graph.cc - InferenceContextImpl::getOutputType TypeProto* getOutputType(size_t index) override { if (index >= node_output_types_.size()) { fail_type_inference("output index ", index, " is out of range; node has ", node_output_types_.size(), " outputs"); } return &node_output_types_[index]; } ``` This mirrors `getInputType`'s `.at()` and the existing bounds checks in the sibling `DataPropagationContextImpl`. Placing it at the base layer transitively protects the NHWC and quantization wrapper contexts. After the point fix this branch is unreachable through a normal model (the guard already prevents the out-of-range index), so it is pure defense-in-depth. Its failure mode is build-dependent: with exceptions enabled, `fail_type_inference` raises `InferenceError` (a `std::exception`), caught by the existing `ORT_CATCH(const std::exception&)` around `RunInferencing` and surfaced as a clean load-time error; under `ORT_NO_EXCEPTIONS` it is **not** compiled out - ONNX's no-exceptions path prints the message to `std::cerr` and calls `abort()`, a deterministic fail-fast (consistent with `getInputType`'s `.at()`, which likewise terminates under no-exceptions). Either way the result is a controlled failure rather than an out-of-range write. ## 5. Test recipe Tests live in `onnxruntime/test/contrib_ops/*.cc` and are **auto-globbed** into the `onnxruntime_provider_test` target by `cmake/onnxruntime_unittests.cmake` (`test/contrib_ops/*.cc` pattern) - **no cmake edit needed** for a new file. See the `ort-test` skill for the executable taxonomy (`onnxruntime_provider_test` vs `onnxruntime_test_all`). Rules that make the regression test actually guard the fix: 1. **Drive through `Model` + `Graph::Resolve`**, not ONNX's standalone `TestShapeInference`. Only the full resolve path constructs the real `InferenceContextImpl` and hits the `getOutputType` sink described in §2. A standalone ONNX shape-inference helper uses a different context and **bypasses** the sink, so it cannot reproduce the bug. 2. **Negative tests must be NON-VACUOUS** - they must actually enter the write branch on pre-fix source. If a write is gated by an inner condition (§3 gotcha), satisfy it: e.g. for `MultiHeadAttention`/`DecoderMaskedMultiHeadAttention`, supply a **shaped `past_key`** (and `past_sequence_length` / `past_present_share_buffer` as the op requires) so the index-2 block runs. A negative test that only supplies `query` skips the block and passes even on pre-fix source - regression-proof in name only. 3. **Add positive (all-outputs) cases**: a node with every output present must still infer the trailing output types - proves the tightened guard did not over-restrict. 4. **Keep tests throw-free post-fix** so they are valid under `ORT_NO_EXCEPTIONS`. Any case that is *expected* to `fail_shape_inference` (throws) must be excluded with `#ifndef ORT_NO_EXCEPTIONS`. The "2 outputs must not go out of range" case is throw-free after the point fix and is safe in all builds. **Verify the negative test is non-vacuous (sanitizer A/B)** - the most reliable way to prove a negative test enters the previously-out-of-range branch: build the test at the **pre-fix** commit with **AddressSanitizer** and confirm it flags the out-of-range output access; then confirm it is clean after the fix. ```bash # Functional run (any Debug build): cmake --build build/Linux/Debug --target onnxruntime_provider_test -j"$(nproc)" ./build/Linux/Debug/onnxruntime_provider_test \ --gtest_filter='AttentionOptionalOutputsShapeInferenceTest.*' # A/B proof (isolated worktree at the pre-fix commit, CPU-only Debug + sanitizer): git worktree add --detach ../ort-prefix-check ~1 # copy the new test file in, then: python3 tools/ci_build/build.py --build_dir build/asan --config Debug --parallel \ --skip_tests --enable_address_sanitizer --skip_submodule_sync \ --cmake_generator Ninja --target onnxruntime_provider_test # Pre-fix: the negative tests fail (the sanitizer flags the out-of-range output access). # Post-fix (cherry-pick the guard fix): all tests pass, no sanitizer report. ``` ## 6. Process / wording conventions - Run **`lintrunner -a`** before pushing so the `CLANGFORMAT` / Python-format gate passes. See the `ort-lint` skill. - Use **correctness/robustness framing** in code, comments, commit messages, and the PR body - describe the change as fixing an optional-output guard, not as a security fix. This matches repo convention (compare `python-kwargs-setattr-security`) and keeps the PR neutral. ## 7. Audit checklist (per-operator review) When reviewing or hardening any operator implementation or its shape inference: - [ ] Read the op's spec - ONNX standard op page, or for a contrib op its `OpSchema` registration (`.Input/.Output/.Attr`, and `Optional`/`Variadic` markers). A local ONNX checkout has the standard-op spec pages; contrib ops are defined only in ORT. - [ ] Enumerate **all** inputs, attributes, and outputs, noting which are optional and the resulting `min/max` input and output counts. - [ ] Validate every input/attribute before indexing into it, to avoid out-of-range reads (which can cascade into worse failures). Match each output-index write to a guard that guarantees the index is in range (§3 rule). - [ ] Prefer `ORT_RETURN_IF` / `ORT_RETURN_IF_NOT` for validation; use `ORT_ENFORCE` in constructors. In shape inference use `fail_shape_inference` / `fail_type_inference`. - [ ] Use `SafeInt<>` / `narrow<>()` for index and size arithmetic and casts to avoid overflow or truncation that yields a wrong index. See `core/common/safeint.h` and `docs/Coding_Conventions_and_Standards.md`. - [ ] Ensure tests build and pass under **no-exceptions** builds; `#ifndef ORT_NO_EXCEPTIONS` around any case expected to throw. - [ ] Exclude EPs known not to support the op, with a comment explaining why. - [ ] Check whether **other EPs (notably CUDA/ROCm)** implement the same op and whether the same issue exists there. (For *shape inference* specifically, the logic is EP-agnostic and single-source - confirm there is no kernel-side analogue.) ## References - **PR #28176** - "Fix ... in EmbedLayerNormalizationShapeInference": the precedent that fixed the identical `> 1` → `> 2` primitive in one site; the sibling attention sites were missed, motivating the sweep in §3. - **PR #29268** - this fix: guards corrected in `DecoderAttention` / `MultiHeadAttention` / `DecoderMaskedMultiHeadAttention` shape inference, plus the `getOutputType` bounds check and non-vacuous regression tests. - Sibling skill: **`ort-test`** (test executables, `--gtest_filter`, contrib-op test layout); **`ort-lint`** (`lintrunner -a`); **`ort-build`** (build flags, ASan).