| # Signed Integer Overflow in ExecuTorch Tensor Stride Computation via Unchecked Size Product (.pte) |
|
|
| **Target:** ExecuTorch 1.3.1 (.pte, huntr Model File Vulnerability program) |
| **Severity:** Medium (integer overflow with plausible but unproven downstream memory-corruption chain; DoS confirmed) |
| **CWE:** CWE-190 (Integer Overflow or Wraparound) |
| **Component:** `runtime/core/exec_aten/util/dim_order_util.h` (overflow site) / `runtime/executor/tensor_parser_portable.cpp` (caller) |
| **Authentication Required:** No β only requires a victim application to load an attacker-supplied `.pte` file via the standard, always-used `Program::load_method()` API. |
|
|
| ## Summary |
|
|
| ExecuTorch computes a tensor's memory strides from its `sizes` and `dim_order` fields during ordinary `.pte` tensor deserialization. The stride computation multiplies a running product of dimension sizes with no overflow check. A `.pte` file with a `Tensor` value whose `sizes` contains a large-but-individually-valid dimension (passing the existing "no negative sizes" check) causes this multiplication to overflow a 32-bit signed integer β Undefined Behavior in C++, caught deterministically by UBSan. This is the first finding across a broader security review of this codebase reached through the core `Method::init()` deserialization path (not a metadata-only accessor), and the first integer-overflow (rather than null-pointer-dereference) bug class identified. |
|
|
| huntr's own Model File Vulnerability program explicitly lists **"integer overflows"** as an example of "vulnerabilities in model file parsing leading to memory corruption" β this finding is a direct, verbatim match for that named category. |
|
|
| ## Vulnerability Details |
|
|
| `runtime/executor/tensor_parser_portable.cpp`'s `parseTensor()` validates that no individual size is negative: |
|
|
| ```cpp |
| for (flatbuffers::uoffset_t i = 0; i < dim; i++) { |
| ET_CHECK_OR_RETURN_ERROR( |
| sizes[i] >= 0, |
| InvalidProgram, |
| "Negative size[%zu] %" PRId32, |
| static_cast<size_t>(i), |
| sizes[i]); |
| } |
| ``` |
|
|
| This check does **not** bound the *product* of sizes against the 32-bit signed integer type used for strides. The tensor's strides are subsequently computed via `dim_order_to_stride()` β `dim_order_to_stride_nocheck()` in `runtime/core/exec_aten/util/dim_order_util.h`: |
|
|
| ```cpp |
| template <typename SizesType, typename DimOrderType, typename StridesType> |
| inline void dim_order_to_stride_nocheck( |
| const SizesType* sizes, |
| const DimOrderType* dim_order, |
| const size_t dims, |
| StridesType* strides) { |
| if (dims == 0) return; |
| strides[dim_order[dims - 1]] = 1; |
| for (int32_t i = dims - 2; i >= 0; --i) { |
| if (sizes[dim_order[i + 1]] == 0) { |
| strides[dim_order[i]] = strides[dim_order[i + 1]]; |
| } else { |
| strides[dim_order[i]] = |
| strides[dim_order[i + 1]] * sizes[dim_order[i + 1]]; // <-- unchecked multiplication, line 147 |
| } |
| } |
| } |
| ``` |
|
|
| `StridesType`/`SizesType` default to `int32_t`. This multiplication has **no** overflow check, unlike the analogous computation in `runtime/core/tensor_layout.cpp`'s `calculate_nbytes()` (used by the parallel `.ptd` `TensorLayout` code path), which explicitly calls `c10::mul_overflows()` before trusting the product. The `.pte` `Tensor` stride-computation path has no equivalent protection. |
|
|
| ## Steps to Reproduce |
|
|
| ### Environment |
| Linux x86-64, ExecuTorch 1.3.1 pristine source, clang-16, CMake, Ninja. No authentication, no host access. |
|
|
| ### 1. Build ExecuTorch with sanitizers |
|
|
| Same build as REPORT-01 Step 1. |
|
|
| ### 2. Build the load_method-level harness (`poc/harness_load_method_fuzzer.cpp`, included in this report) |
|
|
| This is a new harness (not used in prior reports) that goes one layer deeper than metadata-only accessors β it calls `Program::load_method()`, exercising `Method::init()` β `parse_values()` β `parseTensor()`, the CORE tensor-deserialization path used every time a `.pte` is loaded: |
|
|
| ```bash |
| export ET_PARENT=/path/to/parent-of-executorch |
| C10_INC="$ET_SRC/runtime/core/portable_type/c10" |
| INCLUDES="-I$ET_PARENT -I$ET_BUILD -I$ET_BUILD/schema/include -I$ET_BUILD/extension/flat_tensor/include -I$ET_BUILD/third-party/flatc_ep/include -I$C10_INC" |
| |
| clang++-16 -std=c++17 -fsanitize=fuzzer,address,undefined -fno-omit-frame-pointer -fno-sanitize-recover=all \ |
| $INCLUDES -DFLATBUFFERS_MAX_ALIGNMENT=1024 -DC10_USING_CUSTOM_GENERATED_MACROS \ |
| -c poc/harness_load_method_fuzzer.cpp -o harness.o |
| |
| clang++-16 -fsanitize=fuzzer,address,undefined -o poc_harness harness.o \ |
| "$ET_BUILD/extension/data_loader/libextension_data_loader.a" \ |
| "$ET_BUILD/libexecutorch_core.a" |
| ``` |
|
|
| ### 3. PoC file |
|
|
| `poc/poc_stride_int_overflow.pte` (24,054 bytes, **included in this report β sha256 `0767af28fc00e63e8ea665beaf5139f7d84332abc435bd8e0de3ebf8d0936e98`**) is a `.pte` file found via coverage-guided fuzzing (within ~10,000 executions) containing a `Tensor` value whose `sizes` array produces a stride-computation overflow. The libFuzzer crash minimizer was unable to reduce this file below its original size while preserving the crash, so the original fuzzer-found file is the canonical reproducer. |
|
|
| ### 4. Trigger the crash |
|
|
| ```bash |
| export ASAN_OPTIONS="abort_on_error=1:symbolize=0" |
| export UBSAN_OPTIONS="halt_on_error=1:print_stacktrace=0" |
| ./poc_harness -timeout=5 -runs=0 poc/poc_stride_int_overflow.pte |
| ``` |
|
|
| ### Expected result (secure behavior) |
| `Program::load_method()` should return a clean `Error::InvalidProgram` when the size product would overflow the stride computation, matching the overflow-checked pattern already used in `calculate_nbytes()` for the parallel `.ptd` code path. |
|
|
| ### Actual result β verified against the pristine, unmodified ExecuTorch 1.3.1 source |
|
|
| ``` |
| Running: poc/poc_stride_int_overflow.pte |
| runtime/core/exec_aten/util/dim_order_util.h:147:37: runtime error: signed integer overflow: 8 * 2113929312 cannot be represented in type 'int' |
| SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior runtime/core/exec_aten/util/dim_order_util.h:147:37 in |
| ==<pid>== ERROR: libFuzzer: deadly signal |
| #0 ... (abort machinery) |
| ... |
| dim_order_to_stride_nocheck (dim_order_util.h:147) |
| dim_order_to_stride (dim_order_util.h:168) |
| deserialization::parseTensor (tensor_parser_portable.cpp:144) |
| ``` |
|
|
| Full symbolized call chain (via `addr2line` against the built binary) confirms: |
| `dim_order_to_stride_nocheck` (dim_order_util.h:147) β `dim_order_to_stride` (dim_order_util.h:168) β `deserialization::parseTensor` (tensor_parser_portable.cpp:144). |
|
|
| **Reproduced 3/3 identical runs against the pristine source** (re-verified live for this report): |
| ``` |
| run 1: signed integer overflow: 8 * 2113929312 cannot be represented in type 'int' |
| run 2: (identical) |
| run 3: (identical) |
| ``` |
|
|
| ## Chain Escalation Attempt (Reported Honestly) |
|
|
| Per standard practice of testing whether a confirmed bug can be escalated to a stronger primitive: a second sanitized build was made with **UBSan disabled** (ASan only), so the signed-integer-overflow multiplication would wrap silently instead of aborting β this lets us observe what actually happens to the corrupted stride value downstream, rather than only knowing it aborts under a sanitizer. |
|
|
| **Result: the identical PoC ran cleanly with zero ASan errors** when UBSan wasn't present to trap the overflow. This means: |
|
|
| - The corrupted/wrapped stride value **is** computed and stored in the tensor's `strides` array. |
| - **Nothing within `Program::load_method()`'s own code path** (`parse_values`/`parseTensor`/`dim_order_to_stride`) subsequently dereferences or uses that stride to compute a memory address. |
| - The stride is only actually *used* for address computation when an **operator executes** and indexes into the tensor's data using it β a step that happens during `Method::execute()`, not `load_method()`. |
| |
| **This report does not claim a proven memory-corruption chain.** Proving one would require a substantially larger harness β kernel registration via `register_kernels()`, a full `Method::execute()` invocation, and a `.pte` whose instruction chain actually invokes an operator against the malformed tensor β none of which was built in this investigation. The finding is reported as a **confirmed integer-overflow DoS** (deterministic crash under sanitizers; silent, unobserved UB in production builds) with a **plausible but unproven** downstream out-of-bounds access risk during subsequent operator execution. |
|
|
| ## Impact |
|
|
| **Who is affected:** Any application calling `Program::load_method()` β the standard, always-used method-loading API β on a `.pte` containing a `Tensor` value with a crafted `sizes` array. This is the core deserialization path, not a rarely-exercised accessor. |
|
|
| **What the attacker can do (proven):** Cause a reliable, deterministic crash (SIGABRT under UBSan) purely by supplying a malformed model file. |
|
|
| **What the attacker might additionally be able to do (unproven, flagged honestly):** If the corrupted/wrapped stride value later feeds into address computation during operator execution, it could plausibly cause an out-of-bounds memory read or write β this was investigated but not demonstrated in this session; see Chain Escalation Attempt above. |
|
|
| **What's at risk:** Availability, confirmed. Integrity/memory-safety during operator execution, plausible but not demonstrated. |
|
|
| **Why Medium, not Critical:** Matches huntr's explicitly-named "integer overflow" example under the memory-corruption category, but only the DoS consequence was proven β claiming a full memory-corruption chain without a working PoC would be overclaiming. |
|
|
| ## Suggested Remediation |
|
|
| ```cpp |
| template <typename SizesType, typename DimOrderType, typename StridesType> |
| inline Error dim_order_to_stride_nocheck_safe( |
| const SizesType* sizes, |
| const DimOrderType* dim_order, |
| const size_t dims, |
| StridesType* strides) { |
| if (dims == 0) return Error::Ok; |
| strides[dim_order[dims - 1]] = 1; |
| for (int32_t i = dims - 2; i >= 0; --i) { |
| if (sizes[dim_order[i + 1]] == 0) { |
| strides[dim_order[i]] = strides[dim_order[i + 1]]; |
| } else { |
| StridesType product; |
| if (c10::mul_overflows(strides[dim_order[i + 1]], sizes[dim_order[i + 1]], &product)) { |
| return Error::InvalidArgument; |
| } |
| strides[dim_order[i]] = product; |
| } |
| } |
| return Error::Ok; |
| } |
| ``` |
|
|
| This mirrors the overflow-checked pattern already correctly implemented in `runtime/core/tensor_layout.cpp`'s `calculate_nbytes()` via `c10::mul_overflows()`. |
|
|
| A regression test should build a `.pte` `Tensor` value whose `sizes` product overflows `INT32_MAX` while each individual size passes the existing non-negative check, asserting `parseTensor()` returns a clean `Error` rather than triggering UB. |
|
|
| ## Files Included in This Report |
|
|
| - `poc/poc_stride_int_overflow.pte` β the 24,054-byte PoC file (sha256 `0767af28fc00e63e8ea665beaf5139f7d84332abc435bd8e0de3ebf8d0936e98`) |
| - `poc/harness_load_method_fuzzer.cpp` β the harness used to trigger and reproduce the crash, calling `Program::load_method()` directly |
|
|
| ## huntr Submission Note |
|
|
| Per the huntr MFV program's submission requirements, this PoC needs to be uploaded to a public HuggingFace repository before filing. `poc/poc_stride_int_overflow.pte` is ready for that upload. |
|
|