| # Out-of-Bounds Array Access via Unvalidated FreeCall and MoveCall Indices in ExecuTorch PTE Loading |
|
|
| ## Target |
| pytorch/executorch |
|
|
| ## Vulnerability Type |
| Out-of-Bounds Read/Write (CWE-125, CWE-787) |
|
|
| ## Severity |
| **HIGH** (CVSS 3.1: 8.6 -- AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H) |
|
|
| A crafted .pte model file can contain FreeCall or MoveCall instructions with attacker-controlled indices that are never validated, causing out-of-bounds access on the runtime values array during program execution. |
|
|
| ## Summary |
|
|
| During ExecuTorch method initialization (`Method::init()`), each instruction in the program is validated at load time. KernelCall, DelegateCall, and JumpFalseCall instructions all have their index fields bounds-checked against the values array. However, FreeCall and MoveCall instructions fall through to the `default` case in the init switch statement, which performs **no validation at all**. At execution time, FreeCall directly indexes `values_[]` without bounds checking, and MoveCall passes its indices to `get_value()`/`mutable_value()` which use `ET_CHECK_MSG` (an assertion that aborts the process on debug builds but may be compiled out in release builds). |
|
|
| ## Root Cause |
|
|
| ### File: `runtime/executor/method.cpp`, lines 1063-1065 (init-time) |
|
|
| During `Method::init()`, the instruction setup loop validates indices for KernelCall (line 997-1027), DelegateCall (line 1028-1047), and JumpFalseCall (line 1048-1062). But MoveCall and FreeCall are handled by: |
|
|
| ```cpp |
| default: { |
| chain_instruction_arg_lists[instr_idx] = InstructionArgs(); |
| } break; |
| ``` |
|
|
| No validation of the indices (`move_from`, `move_to`, `value_index`) is performed. |
|
|
| ### File: `runtime/executor/method.cpp`, line 1504 (execution-time, FreeCall) |
|
|
| ```cpp |
| case executorch_flatbuffer::InstructionArguments::FreeCall: { |
| auto free_call = instruction->instr_args_as_FreeCall(); |
| auto t = values_[free_call->value_index()].toTensor(); |
| internal::reset_data_ptr(t); |
| } break; |
| ``` |
|
|
| `values_[free_call->value_index()]` directly indexes the `values_` array using an attacker-controlled `value_index` from the FlatBuffer with **no bounds check**. If `value_index` exceeds `n_value_`, this reads out-of-bounds heap memory, interprets it as an EValue, calls `.toTensor()` on it (type confusion), and then calls `reset_data_ptr()` which writes to whatever the corrupted Tensor pointer points to. |
|
|
| ### File: `runtime/executor/method.cpp`, line 1495 (execution-time, MoveCall) |
|
|
| ```cpp |
| case executorch_flatbuffer::InstructionArguments::MoveCall: { |
| auto move_call = instruction->instr_args_as_MoveCall(); |
| mutable_value(move_call->move_to()) = get_value(move_call->move_from()); |
| } break; |
| ``` |
|
|
| `mutable_value()` and `get_value()` do have `ET_CHECK_MSG` assertions, but these are: |
| 1. Not present in production/release builds when `ET_CHECK_MSG` is compiled as a no-op |
| 2. Even when present, they call `abort()` rather than returning an error, causing a denial-of-service |
|
|
| ### Contrast with properly validated instructions |
|
|
| JumpFalseCall is properly validated at init time (lines 1048-1062): |
| ```cpp |
| case executorch_flatbuffer::InstructionArguments::JumpFalseCall: { |
| auto index = ...->cond_value_index(); |
| ET_CHECK_OR_RETURN_ERROR( |
| index >= 0 && static_cast<size_t>(index) < n_value_, |
| InvalidProgram, |
| "Index %zd negative or >= %" ET_PRIsize_t, |
| static_cast<ssize_t>(index), |
| n_value_); |
| ``` |
|
|
| Neither FreeCall's `value_index` nor MoveCall's `move_from`/`move_to` receive equivalent validation. |
|
|
| ### Validation gap in program_validation.cpp |
| |
| The `validate_program()` function in `runtime/executor/program_validation.cpp` validates Tensor values and TensorList indices, but does NOT validate any instruction indices (FreeCall, MoveCall, KernelCall op_index, DelegateCall delegate_index, etc.). The init-time validation in `method.cpp` is the only line of defense, and it has this gap for FreeCall and MoveCall. |
| |
| ## Exploitation Flow |
| |
| 1. **Attacker crafts a .pte file** containing a FreeCall instruction with `value_index` set to a large value (e.g., 0xFFFFFFFF or any value >= the values array size). |
|
|
| 2. **The program loads successfully** -- FreeCall indices are never checked during init. |
|
|
| 3. **During execution**, `values_[free_call->value_index()]` reads out of bounds: |
| - The read accesses heap memory beyond the `values_` array |
| - The bytes are reinterpreted as an `EValue` struct |
| - `.toTensor()` extracts a `TensorImpl*` pointer from the corrupted EValue |
| - `reset_data_ptr()` writes to the address pointed to by the corrupted TensorImpl's `data_` pointer |
|
|
| 4. **Result**: An attacker who controls both the PTE file and can predict/influence heap layout achieves arbitrary memory write. Even without heap control, this is a reliable crash (denial of service). |
|
|
| ### MoveCall variant |
|
|
| A MoveCall with `move_to` set to a large value would: |
| - In debug builds: trigger `ET_CHECK_MSG` -> `abort()` (DoS) |
| - In release builds (if ET_CHECK_MSG is compiled out): write an EValue to an out-of-bounds location in the values array -> heap corruption |
|
|
| ## Impact |
|
|
| - **Heap Out-of-Bounds Read**: Reading arbitrary heap memory via crafted FreeCall/MoveCall indices |
| - **Heap Corruption / Arbitrary Write**: Writing to attacker-influenced memory locations |
| - **Denial of Service**: Process abort via assertion failure or segfault |
| - **Particularly dangerous on embedded targets**: ExecuTorch runs on mobile/embedded devices with limited memory protections |
|
|
| ## Affected Code Path |
|
|
| ``` |
| Program::load() -> Method::load() -> Method::init() |
| -> [instruction loop, default case -- NO VALIDATION for FreeCall/MoveCall] |
| |
| Method::execute() -> Method::step() -> Method::execute_instruction() |
| -> FreeCall: values_[free_call->value_index()] -- OUT-OF-BOUNDS |
| -> MoveCall: mutable_value(move_call->move_to()) -- ASSERT-ONLY CHECK |
| ``` |
|
|
| ## Remediation |
|
|
| Add init-time bounds validation for FreeCall and MoveCall, matching the pattern used for JumpFalseCall: |
|
|
| ```cpp |
| case executorch_flatbuffer::InstructionArguments::FreeCall: { |
| auto index = static_cast<const executorch_flatbuffer::FreeCall*>( |
| instr_args)->value_index(); |
| ET_CHECK_OR_RETURN_ERROR( |
| index >= 0 && static_cast<size_t>(index) < n_value_, |
| InvalidProgram, |
| "FreeCall value_index %d out of range", |
| index); |
| chain_instruction_arg_lists[instr_idx] = InstructionArgs(); |
| } break; |
| |
| case executorch_flatbuffer::InstructionArguments::MoveCall: { |
| auto mc = static_cast<const executorch_flatbuffer::MoveCall*>(instr_args); |
| ET_CHECK_OR_RETURN_ERROR( |
| mc->move_from() >= 0 && static_cast<size_t>(mc->move_from()) < n_value_ && |
| mc->move_to() >= 0 && static_cast<size_t>(mc->move_to()) < n_value_, |
| InvalidProgram, |
| "MoveCall indices out of range"); |
| chain_instruction_arg_lists[instr_idx] = InstructionArgs(); |
| } break; |
| ``` |
|
|
| Additionally, add a runtime bounds check at the FreeCall execution site (line 1504) as defense-in-depth: |
| ```cpp |
| ET_CHECK_OR_RETURN_ERROR( |
| static_cast<size_t>(free_call->value_index()) < n_value_, |
| Internal, |
| "FreeCall value_index out of bounds"); |
| ``` |
|
|
| ## References |
|
|
| - `runtime/executor/method.cpp:1063-1065` -- Missing init-time validation (default case) |
| - `runtime/executor/method.cpp:1504` -- FreeCall unchecked array access |
| - `runtime/executor/method.cpp:1495` -- MoveCall assert-only check |
| - `runtime/executor/method.cpp:1048-1062` -- JumpFalseCall proper validation (for comparison) |
| - `runtime/executor/program_validation.cpp` -- No instruction index validation |
|
|