Skip to content

Fix external tensor copy validation, dense-to-sparse bounds, and ORT format initializer state - #33643

Open
m00dy (professor-moody) wants to merge 2 commits into
microsoft:mainfrom
professor-moody:fix-ort-format-initializer-and-tensorproto-bounds
Open

m00dy (professor-moody) wants to merge 2 commits into
microsoft:mainfrom
professor-moody:fix-ort-format-initializer-and-tensorproto-bounds

Conversation

@professor-moody

@professor-moody m00dy (professor-moody) commented Oct 27, 2026 •

Copy link
Copy Markdown

Description

This change hardens three initializer and TensorProto conversion paths in onnxruntime/core/framework/tensorprotoutils.cc and onnxruntime/core/graph/graph.cc:

  1. TensorProtoToTensor (onnxruntime/core/framework/tensorprotoutils.cc:2054-2088): Moves the tensor_shape != tensor.Shape(), element-type compatibility, and Tensor::CalculateTensorStorageSize capacity checks ahead of the HasExternalData(tensor_proto) branch so external-data tensors validate the destination Tensor shape, element type, and byte capacity before MakeCpuTensorCopy(ext_tensor, tensor) copies raw bytes into tensor.
  2. DenseTensorToSparseTensorProto (onnxruntime/core/framework/tensorprotoutils.cc:2935-2965): Calls ValidateEmbeddedTensorProtoDataSizeAndShape(dense_proto) when !HasExternalData(dense_proto), rejects sub-byte tensor data types (HasSubElems()), and checks dense_raw_data.size() == n_dense_elements * element_size after UnpackInitializerData so truncated, oversized, or sub-byte packed tensor buffers cannot reach SparsifyGeneric.
  3. Graph::LoadFromOrtFormat in onnxruntime/core/graph/graph.cc: When a repeated dense or sparse initializer name is encountered in an ORT-format flatbuffer and replaced by the latest initializer, erases the stale entry from ortvalue_initializers_ (ortvalue_initializers_.erase(initializer->name()) / ortvalue_initializers_.erase(initializer.name())) so ortvalue_initializers_ stays consistent with name_to_initial_tensor_.

Motivation and Context

  • In TensorProtoToTensor, embedded tensors validate tensor_shape != tensor.Shape() and element-type size compatibility before unpacking, whereas HasExternalData(tensor_proto) previously returned early after MakeCpuTensorCopy(ext_tensor, tensor) without running those checks (defense in depth for direct callers of TensorProtoToTensor).
  • In DenseTensorToSparseTensorProto, SparsifyGeneric iterates n_dense_elements * element_size bytes over dense_raw_data.data(). Without rejecting sub-byte types (HasSubElems(), where ml_data->Size() is 1 byte per packed element) and checking dense_raw_data.size() == n_dense_elements * element_size, a sub-byte or mismatched buffer over-reads dense_raw_data.
  • In Graph::LoadFromOrtFormat, if an earlier initializer with the same name populated ortvalue_initializers_, replacing only name_to_initial_tensor_ left a stale OrtValue in ortvalue_initializers_.

Added unit tests in onnxruntime/test/framework/tensorutils_test.cc:

  • TensorProtoUtilsTest.ExternalTensorCopyPreservesMatchingDestination
  • TensorProtoUtilsTest.ExternalTensorCopyRejectsInsufficientDestinationCapacity (external UINT8 source into a UINT4 destination of the same shape: equal element sizes, 4 versus 2 storage bytes; the destination is left unchanged)
  • TensorProtoUtilsTest.ExternalTensorCopyRejectsWiderSourceElementType (external FLOAT source into a UINT8 destination of the same shape)
  • TensorProtoUtilsTest.DenseSparseConversionPreservesTypedFloatValues
  • TensorProtoUtilsTest.RejectsMismatchedExternalTensorShapeAndOversizedDenseToSparseInput
  • TensorProtoUtilsTest.DenseToSparseRejectsPackedSubByteTypes (well-formed packed INT4, UINT4, INT2 and UINT2 inputs, including one-element tensors whose one-byte buffer also passes the later size check)

Added unit tests in onnxruntime/test/framework/ort_model_only_test.cc (ORT format loaded with kOrtSessionOptionsConfigUseORTModelBytesForInitializers, so can_use_flatbuffer_for_initializers is enabled):

  • OrtModelTest.OrtFormatLargeInitializerIsCachedWhenUsingBytesForInitializers (control: a 160-byte initializer is cached as an OrtValue)
  • OrtModelTest.OrtFormatDuplicateDenseInitializerReplacesCachedValue (replacement by a 16-byte dense initializer)
  • OrtModelTest.OrtFormatDuplicateSparseInitializerReplacesCachedValue (replacement by a sparse initializer with different values)

In each replacement test GetInitializedTensor returns the replacement values and GetOrtValueInitializer reports no cached value.

Each new rejection or replacement test fails when only its corresponding production change is reverted and passes with this change (Linux x86_64 Release build, full unfiltered unit test run).

Note: open PR #33659 also adds tests to onnxruntime/test/framework/ort_model_only_test.cc; the two changes touch disjoint regions of that file (this change at lines 476-481 of the base file, #33659 at lines 443-448) and apply cleanly in either order.

…format initializer state

## Description

This change hardens three initializer and `TensorProto` conversion paths in `onnxruntime/core/framework/tensorprotoutils.cc` and `onnxruntime/core/graph/graph.cc`:

1. **`TensorProtoToTensor` (`onnxruntime/core/framework/tensorprotoutils.cc:2054-2088`)**: Moves the `tensor_shape != tensor.Shape()`, element-type compatibility, and `Tensor::CalculateTensorStorageSize` capacity checks ahead of the `HasExternalData(tensor_proto)` branch so external-data tensors validate the destination `Tensor` shape, element type, and byte capacity before `MakeCpuTensorCopy(ext_tensor, tensor)` copies raw bytes into `tensor`.
2. **`DenseTensorToSparseTensorProto` (`onnxruntime/core/framework/tensorprotoutils.cc:2935-2965`)**: Calls `ValidateEmbeddedTensorProtoDataSizeAndShape(dense_proto)` when `!HasExternalData(dense_proto)`, rejects sub-byte tensor data types (`HasSubElems()`), and checks `dense_raw_data.size() == n_dense_elements * element_size` after `UnpackInitializerData` so truncated, oversized, or sub-byte packed tensor buffers cannot reach `SparsifyGeneric`.
3. **`Graph::LoadFromOrtFormat` in `onnxruntime/core/graph/graph.cc`**: When a repeated dense or sparse initializer name is encountered in an ORT-format flatbuffer and replaced by the latest initializer, erases the stale entry from `ortvalue_initializers_` (`ortvalue_initializers_.erase(initializer->name())` / `ortvalue_initializers_.erase(initializer.name())`) so `ortvalue_initializers_` stays consistent with `name_to_initial_tensor_`.

## Motivation and Context

- In `TensorProtoToTensor`, embedded tensors validate `tensor_shape != tensor.Shape()` and element-type size compatibility before unpacking, whereas `HasExternalData(tensor_proto)` previously returned early after `MakeCpuTensorCopy(ext_tensor, tensor)` without running those checks (defense in depth for direct callers of `TensorProtoToTensor`).
- In `DenseTensorToSparseTensorProto`, `SparsifyGeneric` iterates `n_dense_elements * element_size` bytes over `dense_raw_data.data()`. Without rejecting sub-byte types (`HasSubElems()`, where `ml_data->Size()` is 1 byte per packed element) and checking `dense_raw_data.size() == n_dense_elements * element_size`, a sub-byte or mismatched buffer over-reads `dense_raw_data`.
- In `Graph::LoadFromOrtFormat`, if an earlier initializer with the same name populated `ortvalue_initializers_`, replacing only `name_to_initial_tensor_` left a stale `OrtValue` in `ortvalue_initializers_`.

Added unit tests in `onnxruntime/test/framework/tensorutils_test.cc`:
- `TensorProtoUtilsTest.ExternalTensorCopyPreservesMatchingDestination`
- `TensorProtoUtilsTest.DenseSparseConversionPreservesTypedFloatValues`
- `TensorProtoUtilsTest.RejectsMismatchedExternalTensorShapeAndOversizedDenseToSparseInput`
Copilot AI balanced review requested due to automatic review settings October 27, 2026 15:02
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Targeted regression coverage is still needed for capacity validation, packed-type rejection, and both initializer-cache replacement paths.

3 open issues
What changed in this PR

This PR hardens ONNX Runtime tensor conversion and initializer loading by validating copy bounds and keeping cached initializer values consistent.

Changes:

  • Validates destination shape, element size, and capacity before external tensor copies.
  • Rejects malformed or unsupported packed data before dense-to-sparse conversion.
  • Clears stale cached values when ORT-format initializers are replaced and adds tensor utility tests.
File Description
onnxruntime/​test/​framework/​tensorutils_test.cc Adds successful conversion and invalid-input rejection tests.
onnxruntime/​core/​graph/​graph.cc Invalidates cached values for duplicate initializer replacements.
onnxruntime/​core/​framework/​tensorprotoutils.cc Adds copy-capacity and dense-to-sparse validation.

Review effort: Balanced

Comment on lines +2077 to +2080
size_t source_bytes;
ORT_RETURN_IF_ERROR(Tensor::CalculateTensorStorageSize(source_type, tensor_shape, 0, source_bytes));
ORT_RETURN_IF(source_bytes > tensor.SizeInBytes(),
"TensorProto data exceeds destination tensor capacity");
Comment on lines +2967 to +2968
ORT_RETURN_IF(ml_data->AsPrimitiveDataType()->HasSubElems(),
"Dense-to-sparse conversion does not support sub-byte tensor data types");
ORT_RETURN_IF_ERROR(fbs::utils::LoadInitializerOrtFormat(*fbs_tensor, *initializer, load_options));
auto p = name_to_initial_tensor_.emplace(initializer->name(), initializer);
if (!p.second) {
ORT_IGNORE_RETURN_VALUE(ortvalue_initializers_.erase(initializer->name()));
…arse rejection, and ORT format duplicate initializers
@professor-moody

Copy link
Copy Markdown
Author

All three review comments are addressed in 81d4e45, which adds the requested regression tests. The production changes are unchanged.

Comment Test added What it checks
Capacity and element-size checks in TensorProtoToTensor TensorProtoUtilsTest.ExternalTensorCopyRejectsInsufficientDestinationCapacity External UINT8 source, UINT4 destination, shape {4} on both: equal Size() of 1, 4 versus 2 storage bytes. Asserts the capacity error and that both destination bytes are unchanged.
TensorProtoUtilsTest.ExternalTensorCopyRejectsWiderSourceElementType External FLOAT source, UINT8 destination, shape {4}. Asserts the element-type error and an unchanged destination.
Sub-byte rejection in DenseTensorToSparseTensorProto TensorProtoUtilsTest.DenseToSparseRejectsPackedSubByteTypes Well-formed packed INT4, UINT4, INT2 and UINT2 inputs (each passes ValidateEmbeddedTensorProtoDataSizeAndShape), one-element and multi-element. Asserts the unsupported sub-byte type error.
Duplicate initializer cache invalidation in Graph::LoadFromOrtFormat (both paths) OrtModelTest.OrtFormatDuplicateDenseInitializerReplacesCachedValue, OrtModelTest.OrtFormatDuplicateSparseInitializerReplacesCachedValue ORT format loaded with kOrtSessionOptionsConfigUseORTModelBytesForInitializers. A 160-byte FLOAT initializer is replaced by a 16-byte dense initializer, or by a sparse initializer with different values. Asserts GetInitializedTensor returns the replacement values and GetOrtValueInitializer returns false.
OrtModelTest.OrtFormatLargeInitializerIsCachedWhenUsingBytesForInitializers Control: without a duplicate, the 160-byte initializer is cached, so the replacement tests start from a populated cache.

Each new test was also run with only its guard removed:

Guard removed New test result
Capacity check only ExternalTensorCopyRejectsInsufficientDestinationCapacity fails (the copy succeeds and overwrites the destination); ExternalTensorCopyRejectsWiderSourceElementType still passes
TensorProtoToTensor change reverted both external-copy tests fail
HasSubElems() rejection only DenseToSparseRejectsPackedSubByteTypes fails; the one-element inputs convert successfully
ortvalue_initializers_.erase in the dense loop OrtFormatDuplicateDenseInitializerReplacesCachedValue fails with a stale cached value of shape {40}; the sparse test passes
ortvalue_initializers_.erase in the sparse loop OrtFormatDuplicateSparseInitializerReplacesCachedValue fails with a stale cached value of shape {40}; the dense test passes

Full unit test run (Linux x86_64, Release), no filter: 2537 tests, 2511 passed, 26 skipped, 0 failed.

One whitespace-only alignment fix in an existing test line from the first commit (clang-format).

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants