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:
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)
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)
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:
- Not present in production/release builds when
ET_CHECK_MSGis compiled as a no-op - 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):
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
Attacker crafts a .pte file containing a FreeCall instruction with
value_indexset to a large value (e.g., 0xFFFFFFFF or any value >= the values array size).The program loads successfully -- FreeCall indices are never checked during init.
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
EValuestruct .toTensor()extracts aTensorImpl*pointer from the corrupted EValuereset_data_ptr()writes to the address pointed to by the corrupted TensorImpl'sdata_pointer
- The read accesses heap memory beyond the
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:
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:
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 accessruntime/executor/method.cpp:1495-- MoveCall assert-only checkruntime/executor/method.cpp:1048-1062-- JumpFalseCall proper validation (for comparison)runtime/executor/program_validation.cpp-- No instruction index validation