Skip to content

Commit 04f7440

Browse files
authored
fix(security): add SafeInt overflow protection in Expand and constant folding output size limit (microsoft#28055)
### Description Harden the constant folding optimizer and the `Expand` CPU kernel against integer overflow attacks from crafted ONNX models. **Problem:** The `Expand::Compute()` kernel performs cumulative dimension multiplications (`input_count *= input_dim`, `output_count *= output_dim`) using raw `int64_t` arithmetic. When triggered during constant folding at `CreateSession()` time via a crafted model with extreme shape values, signed integer overflow can produce corrupted values used for buffer offset calculations and `memcpy` lengths, creating a potential out-of-bounds write. The downstream SafeInt check in the allocator catches overflow only when the total byte count wraps, but carefully chosen dimensions can make the overflowed value appear valid. Additionally, the constant folding optimizer has no output size budget — any deterministic node with constant inputs is eligible for constant folding regardless of output size, enabling memory exhaustion attacks at model load time. ### Key Changes **1. SafeInt-protected arithmetic in `expand.cc`** Wraps all dimension accumulation and offset/length calculations with `SafeInt<int64_t>` or `SafeInt<size_t>` to catch overflow before it can corrupt buffer arithmetic: | Location | Before | After | |---|---|---| | Accumulator loop (L97-98) | `input_count *= input_dim` | `SafeInt<int64_t>(input_count) * input_dim` | | Accumulator loop (L109) | `last_dim_size *= expand_dim_size[...]` | `SafeInt<int64_t>(last_dim_size) * ...` | | copy_byte (L116) | `copy_len * sizeof(T)` | `SafeInt<size_t>(copy_len) * sizeof(T)` | | input_offset (L122) | `i * copy_len` | `SafeInt<int64_t>(i) * copy_len` | | output_offset (L126) | `output_offset += current_count * ...` | `SafeInt<int64_t>(output_offset) + SafeInt<int64_t>(current_count) * ...` | **2. Constant folding output size limit in `constant_folding.cc`** - **Pre-execution check**: `EstimateNodeOutputSizeInBytes()` uses shape inference results with SafeInt-protected arithmetic to estimate total output bytes. Nodes exceeding the limit are skipped. - **Post-execution check**: After `kernel->Compute()`, actual output `SizeInBytes()` is verified against the limit (catches cases where shape inference couldn't determine output size). - **Exception isolation**: `kernel->Compute()` is wrapped in `try/catch` so that SafeInt overflow exceptions from individual nodes skip the node rather than aborting the entire optimization pass. - **Configurable limit**: New session option `optimization.constant_folding_max_output_size_in_bytes` (default: 1 GB, `"0"` to disable). **3. Session option** New key `kOrtSessionOptionsConstantFoldingMaxOutputSizeInBytes` in `onnxruntime_session_options_config_keys.h`. ### Motivation and Context This addresses a security vulnerability where a malicious ONNX model can cause signed integer overflow in the Expand kernel during constant folding at model load time (`CreateSession()`), potentially leading to out-of-bounds memory writes. The constant folding size limit provides defense-in-depth against memory exhaustion attacks from untrusted models. ### Testing - `ConstantFoldingOutputSizeLimit` — Verifies 4 MB Expand is blocked at 1 MB limit, allowed at 8 MB limit. - `ConstantFoldingDefaultLimitBlocksLargeExpand` — Verifies 1 GB ConstantOfShape is blocked at 512 MB limit. - `ConstantFoldingSmallOutputAllowed` — Verifies small Expand (64 bytes) is still folded normally. - `ConstantFoldingExpandOverflowDimsSkipped` — Verifies Expand with `[2^32, 2^32]` dimensions (int64 overflow) is gracefully skipped during constant folding.
1 parent 2979bab commit 04f7440

4 files changed

Lines changed: 443 additions & 9 deletions

File tree

include/onnxruntime/core/session/onnxruntime_session_options_config_keys.h

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,18 @@ static const char* const kOrtSessionOptionsMemoryOptimizerProbeConfig = "optimiz
124124
// Default is an empty string which means no optimizers are disabled.
125125
static const char* const kOrtSessionOptionsDisableSpecifiedOptimizers = "optimization.disable_specified_optimizers";
126126

127+
// Maximum total output size in bytes that the constant folding optimizer is allowed to produce per node.
128+
// Prevents malicious models from causing excessive memory allocation during optimization.
129+
// If the estimated or actual output size of a constant-foldable node exceeds this limit, the node will
130+
// not be constant folded and will instead be executed at runtime.
131+
//
132+
// Option values:
133+
// - A positive integer (as string): Maximum allowed output size in bytes per constant-folded node.
134+
// Default is "1073741824" (1 GB).
135+
// - "0": Disable the size limit (not recommended for untrusted models).
136+
static const char* const kOrtSessionOptionsConstantFoldingMaxOutputSizeInBytes =
137+
"optimization.constant_folding_max_output_size_in_bytes";
138+
127139
// It controls whether to run graph optimizations in loop or not.
128140
//
129141
// "0": disable. Graph Optimization Loop is disabled.

onnxruntime/core/optimizer/constant_folding.cc

Lines changed: 229 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,18 @@
22
// Licensed under the MIT License.
33

44
#include <limits>
5+
#include <string>
56

67
#include "core/optimizer/constant_folding.h"
78
#include "core/optimizer/initializer.h"
89
#include "core/optimizer/utils.h"
910
#include "core/graph/graph_utils.h"
1011
#include "core/optimizer/optimizer_execution_frame.h"
11-
#include "core/optimizer/utils.h"
1212
#include "core/framework/op_kernel.h"
1313
#include "core/framework/tensorprotoutils.h"
14+
#include "core/session/onnxruntime_session_options_config_keys.h"
15+
#include "core/common/safeint.h"
16+
#include "core/common/parse_string.h"
1417

1518
using namespace onnxruntime::common;
1619

@@ -140,6 +143,154 @@ static Status ConstantFoldIfNode(Graph& graph, Node& if_node, const logging::Log
140143
return status;
141144
}
142145

146+
// Default maximum output size per constant-folded node: 1 GB.
147+
// This prevents malicious models from causing excessive memory allocation during optimization.
148+
static constexpr int64_t kDefaultConstantFoldingMaxOutputSizeInBytes = 1024 * 1024 * 1024;
149+
150+
static size_t GetElementSizeForConstantFolding(ONNX_NAMESPACE::TensorProto_DataType elem_type) {
151+
const size_t element_size = utils::GetElementSizeOfTensor(elem_type);
152+
if (element_size != 0) {
153+
return element_size;
154+
}
155+
156+
// String tensors allocate storage for std::string slots even though the payload size is variable.
157+
return elem_type == ONNX_NAMESPACE::TensorProto_DataType_STRING ? sizeof(std::string) : 0;
158+
}
159+
160+
static int64_t EstimateTensorElementCount(const ONNX_NAMESPACE::TensorShapeProto& shape) {
161+
SafeInt<int64_t> num_elements = 1;
162+
for (int i = 0; i < shape.dim_size(); ++i) {
163+
const auto& dim = shape.dim(i);
164+
if (!utils::HasDimValue(dim)) {
165+
return -1; // Symbolic dimension
166+
}
167+
int64_t dim_value = dim.dim_value();
168+
if (dim_value < 0) {
169+
return -1; // Invalid dimension
170+
}
171+
num_elements *= dim_value;
172+
}
173+
174+
return num_elements;
175+
}
176+
177+
static int64_t EstimateTensorSizeInBytes(const NodeArg& node_arg) {
178+
const auto* type_proto = node_arg.TypeAsProto();
179+
if (type_proto == nullptr || !utils::HasTensorType(*type_proto)) {
180+
return -1; // Cannot estimate non-tensor or unknown types
181+
}
182+
183+
const auto* shape = node_arg.Shape();
184+
if (shape == nullptr) {
185+
return -1; // Unknown shape
186+
}
187+
188+
auto elem_type = static_cast<ONNX_NAMESPACE::TensorProto_DataType>(
189+
type_proto->tensor_type().elem_type());
190+
size_t element_size = GetElementSizeForConstantFolding(elem_type);
191+
if (element_size == 0) {
192+
return -1; // Unknown element type
193+
}
194+
195+
int64_t num_elements = EstimateTensorElementCount(*shape);
196+
if (num_elements < 0) {
197+
return -1;
198+
}
199+
200+
return SafeInt<int64_t>(num_elements) * static_cast<int64_t>(element_size);
201+
}
202+
203+
static int64_t EstimateUniqueOutputSizeInBytes(const Node& node) {
204+
const auto& input_defs = node.InputDefs();
205+
if (input_defs.empty() || input_defs[0] == nullptr) {
206+
return -1;
207+
}
208+
209+
const int64_t input_num_elements = input_defs[0]->Shape() != nullptr
210+
? EstimateTensorElementCount(*input_defs[0]->Shape())
211+
: -1;
212+
if (input_num_elements < 0) {
213+
return -1;
214+
}
215+
216+
const auto* input_type_proto = input_defs[0]->TypeAsProto();
217+
if (input_type_proto == nullptr || !utils::HasTensorType(*input_type_proto)) {
218+
return -1;
219+
}
220+
221+
auto input_elem_type = static_cast<ONNX_NAMESPACE::TensorProto_DataType>(
222+
input_type_proto->tensor_type().elem_type());
223+
const size_t input_element_size = GetElementSizeForConstantFolding(input_elem_type);
224+
if (input_element_size == 0) {
225+
return -1;
226+
}
227+
228+
SafeInt<int64_t> total_size = 0;
229+
const auto& output_defs = node.OutputDefs();
230+
for (size_t output_idx = 0; output_idx < output_defs.size(); ++output_idx) {
231+
const auto* output_def = output_defs[output_idx];
232+
if (!output_def->Exists()) {
233+
continue;
234+
}
235+
236+
const size_t element_size = output_idx == 0 ? input_element_size : sizeof(int64_t);
237+
total_size += SafeInt<int64_t>(input_num_elements) * static_cast<int64_t>(element_size);
238+
}
239+
240+
return total_size;
241+
}
242+
243+
static int64_t EstimateIdentityOutputSizeInBytes(const Node& node) {
244+
const auto& input_defs = node.InputDefs();
245+
if (input_defs.empty() || input_defs[0] == nullptr) {
246+
return -1;
247+
}
248+
249+
return EstimateTensorSizeInBytes(*input_defs[0]);
250+
}
251+
252+
// Estimate the total output size in bytes for a node using shape inference results.
253+
// Returns -1 if the output size cannot be estimated (e.g., unknown shapes or types).
254+
static int64_t EstimateNodeOutputSizeInBytes(const Node& node) {
255+
if (node.OpType() == "Identity" && node.Domain().empty()) {
256+
return EstimateIdentityOutputSizeInBytes(node);
257+
}
258+
259+
if (node.OpType() == "Unique" && node.Domain().empty()) {
260+
return EstimateUniqueOutputSizeInBytes(node);
261+
}
262+
263+
SafeInt<int64_t> total_size = 0;
264+
for (const auto* output_def : node.OutputDefs()) {
265+
if (!output_def->Exists()) {
266+
continue;
267+
}
268+
269+
const int64_t output_size = EstimateTensorSizeInBytes(*output_def);
270+
if (output_size < 0) {
271+
return -1;
272+
}
273+
274+
total_size += output_size;
275+
}
276+
277+
return total_size;
278+
}
279+
280+
// Get the configured max output size from session options, or use the default.
281+
static int64_t GetConstantFoldingMaxOutputSize(const ConfigOptions& config_options) {
282+
std::string max_size_str = config_options.GetConfigOrDefault(
283+
kOrtSessionOptionsConstantFoldingMaxOutputSizeInBytes,
284+
std::to_string(kDefaultConstantFoldingMaxOutputSizeInBytes));
285+
286+
int64_t max_size = 0;
287+
if (!TryParseStringWithClassicLocale(max_size_str, max_size) || max_size < 0) {
288+
max_size = kDefaultConstantFoldingMaxOutputSizeInBytes;
289+
}
290+
291+
return max_size;
292+
}
293+
143294
Status ConstantFolding::ApplyImpl(Graph& graph, bool& modified, int graph_level, const logging::Logger& logger) const {
144295
bool have_updated_nodes = false;
145296
GraphViewer graph_viewer(graph);
@@ -151,6 +302,8 @@ Status ConstantFolding::ApplyImpl(Graph& graph, bool& modified, int graph_level,
151302
};
152303
#endif
153304

305+
const int64_t max_output_size = GetConstantFoldingMaxOutputSize(config_options_);
306+
154307
for (NodeIndex i : order) {
155308
auto* node = graph.GetNode(i);
156309
if (!node || !AllowConstantFolding(*node)) {
@@ -233,6 +386,35 @@ Status ConstantFolding::ApplyImpl(Graph& graph, bool& modified, int graph_level,
233386
}
234387
}
235388

389+
// Check if the estimated output size exceeds the configured limit.
390+
// This prevents malicious models from causing excessive memory allocation during constant folding.
391+
if (max_output_size > 0) {
392+
int64_t estimated_size = -1;
393+
try {
394+
estimated_size = EstimateNodeOutputSizeInBytes(*node);
395+
} catch (const std::exception&) {
396+
// SafeInt overflow means the size is astronomically large - definitely skip
397+
LOGS(logger, WARNING) << "Integer overflow while estimating output size of "
398+
<< node->OpType() << " node '" << node->Name()
399+
<< "'. Skipping constant folding for this node.";
400+
continue;
401+
}
402+
403+
if (estimated_size > max_output_size) {
404+
LOGS(logger, WARNING) << "Skipping constant folding for " << node->OpType()
405+
<< " node '" << node->Name()
406+
<< "' because estimated output size (" << estimated_size
407+
<< " bytes) exceeds the limit (" << max_output_size << " bytes).";
408+
continue;
409+
}
410+
if (estimated_size < 0) {
411+
LOGS(logger, INFO) << "Skipping constant folding for " << node->OpType()
412+
<< " node '" << node->Name()
413+
<< "' because output size could not be estimated before execution.";
414+
continue;
415+
}
416+
}
417+
236418
#if !defined(DISABLE_SPARSE_TENSORS)
237419
// Create execution frame for executing constant nodes.
238420
OptimizerExecutionFrame::Info info({node}, constant_inputs, graph.ModelPath(), execution_provider_,
@@ -312,14 +494,59 @@ Status ConstantFolding::ApplyImpl(Graph& graph, bool& modified, int graph_level,
312494
#pragma warning(disable : 6387)
313495
#endif
314496
OpKernelContext op_kernel_context(&frame, kernel.get(), /*stream*/ nullptr, nullptr, logger);
315-
ORT_RETURN_IF_ERROR(kernel->Compute(&op_kernel_context));
497+
498+
// Skip the current node if Compute fails so one bad constant-fold candidate does not abort
499+
// the entire constant folding pass.
500+
Status compute_status = Status::OK();
501+
try {
502+
compute_status = kernel->Compute(&op_kernel_context);
503+
} catch (const std::exception& ex) {
504+
LOGS(logger, WARNING) << "Exception during constant folding of " << node->OpType()
505+
<< " node '" << node->Name() << "': " << ex.what()
506+
<< ". Skipping constant folding for this node.";
507+
continue;
508+
}
509+
510+
if (!compute_status.IsOK()) {
511+
LOGS(logger, WARNING) << "Failure during constant folding of " << node->OpType()
512+
<< " node '" << node->Name() << "': " << compute_status.ErrorMessage()
513+
<< ". Skipping constant folding for this node.";
514+
continue;
515+
}
316516
#ifdef _WIN32
317517
#pragma warning(pop)
318518
#endif
319519

320520
std::vector<OrtValue> fetches;
321521
ORT_RETURN_IF_ERROR(frame.GetOutputs(fetches));
322522

523+
// Post-execution size check: verify actual output sizes don't exceed the limit.
524+
// This catches cases where pre-execution shape inference couldn't determine the output size.
525+
if (max_output_size > 0) {
526+
SafeInt<int64_t> actual_total_size = 0;
527+
bool size_exceeded = false;
528+
try {
529+
for (size_t fetch_idx = 0; fetch_idx < fetches.size(); ++fetch_idx) {
530+
if (fetches[fetch_idx].IsAllocated() && fetches[fetch_idx].IsTensor()) {
531+
const auto& tensor = fetches[fetch_idx].Get<Tensor>();
532+
actual_total_size += tensor.SizeInBytes();
533+
}
534+
}
535+
size_exceeded = actual_total_size > max_output_size;
536+
} catch (const std::exception&) {
537+
// SafeInt overflow means total size is astronomically large
538+
size_exceeded = true;
539+
}
540+
541+
if (size_exceeded) {
542+
LOGS(logger, WARNING) << "Skipping constant folding for " << node->OpType()
543+
<< " node '" << node->Name()
544+
<< "' because actual output size exceeds the limit ("
545+
<< max_output_size << " bytes).";
546+
continue;
547+
}
548+
}
549+
323550
// Go over all output node args and substitute them with the newly computed tensors, which will be
324551
// added to the graph as initializers.
325552
ORT_ENFORCE(fetches.size() == fetch_to_output_idx.size());

onnxruntime/core/providers/cpu/tensor/expand.cc

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,8 @@ Status Expand<T>::Compute(OpKernelContext* context) const {
9494
auto input_dim = input_dims_iter > -1 ? input_dims[input_dims_iter] : 1;
9595
auto output_dim = output_dims[output_dims_iter];
9696

97-
input_count *= input_dim;
98-
output_count *= output_dim;
97+
input_count = SafeInt<int64_t>(input_count) * input_dim;
98+
output_count = SafeInt<int64_t>(output_count) * output_dim;
9999

100100
if (0 == input_count || 0 == output_count) {
101101
return Status::OK();
@@ -106,26 +106,26 @@ Status Expand<T>::Compute(OpKernelContext* context) const {
106106
input_dim_group[onnxruntime::narrow<size_t>(dim_group_start)] = input_count;
107107
output_dim_group[onnxruntime::narrow<size_t>(dim_group_start)] = output_count;
108108
expand_dim_size[onnxruntime::narrow<size_t>(dim_group_start)] = output_count / input_count / last_dim_size;
109-
last_dim_size *= expand_dim_size[onnxruntime::narrow<size_t>(dim_group_start)];
109+
last_dim_size = SafeInt<int64_t>(last_dim_size) * expand_dim_size[onnxruntime::narrow<size_t>(dim_group_start)];
110110
}
111111
}
112112

113113
auto distribute_count = input_dim_group[onnxruntime::narrow<size_t>(dim_group_start)] / input_dim_group[SafeInt<size_t>(max_dims_size) - 1];
114114
std::vector<int64_t> output_offsets(onnxruntime::narrow<size_t>(distribute_count), 0);
115115
int64_t copy_len = input_dim_group[SafeInt<size_t>(max_dims_size) - 1];
116-
auto copy_byte = copy_len * sizeof(T);
116+
size_t copy_byte = SafeInt<size_t>(copy_len) * sizeof(T);
117117

118118
auto distribute_fn =
119119
[&](ptrdiff_t i_start, ptrdiff_t i_end) {
120120
for (auto i = i_start; i < i_end; i++) {
121-
auto input_offset = i * copy_len;
121+
int64_t input_offset = SafeInt<int64_t>(i) * copy_len;
122122
int64_t output_offset = 0;
123123
for (auto j = dim_group_start + 1, remains = input_offset; j < max_dims_size; ++j) {
124124
auto current_count = remains / input_dim_group[onnxruntime::narrow<size_t>(j)];
125-
output_offset += current_count * output_dim_group[onnxruntime::narrow<size_t>(j)];
125+
output_offset = SafeInt<int64_t>(output_offset) + SafeInt<int64_t>(current_count) * output_dim_group[onnxruntime::narrow<size_t>(j)];
126126
remains = remains % input_dim_group[onnxruntime::narrow<size_t>(j)];
127127
} // for j
128-
memcpy(output_data + output_offset, input_data + input_offset, onnxruntime::narrow<size_t>(copy_byte));
128+
memcpy(output_data + output_offset, input_data + input_offset, copy_byte);
129129
output_offsets[onnxruntime::narrow<size_t>(i)] = output_offset;
130130
} // for i
131131
}; // distribute_fn

0 commit comments

Comments
 (0)