| # Null Pointer Dereference in ExecuTorch Method::execute_instruction() via Missing Operator.overload (.pte) |
| |
| **Target:** ExecuTorch 1.3.1 (.pte, huntr Model File Vulnerability program) |
| **Severity:** Low-Medium (Denial of Service) β CONDITIONAL on `EXECUTORCH_ENABLE_LOGGING=ON` |
| **CWE:** CWE-476 (NULL Pointer Dereference) |
| **Component:** `runtime/executor/method.cpp` |
| **Authentication Required:** No β requires a victim application to execute an attacker-supplied `.pte` file whose method contains an operator that legitimately fails during execution (a routine, expected occurrence). |
| |
| ## Summary |
| |
| `Method::execute_instruction()`'s `KernelCall` failure-logging path calls `op->overload()->c_str()` unconditionally when a kernel fails. `Operator.overload` is an optional flatbuffer field β a `.pte` file with an `Operator` entry that has a `name` but no `overload` triggers a null-pointer dereference the moment that operator's kernel returns any error during normal execution (e.g. a routine input-shape validation failure). This is distinct from all prior findings in this investigation: it is the first bug reached through actual kernel **execution** (`Method::execute()`), not just method loading, and its practical reachability is conditional on a specific (but officially documented and widely used) build configuration flag. |
| |
| ## Vulnerability Details |
| |
| `runtime/executor/method.cpp`'s `Method::execute_instruction()`: |
|
|
| ```cpp |
| case executorch_flatbuffer::InstructionArguments::KernelCall: { |
| ... |
| chain.kernels_[step_state_.instr_idx](context, args); |
| err = context.failure_state(); |
| if (err != Error::Ok) { |
| auto op_index = instruction->instr_args_as_KernelCall()->op_index(); |
| ET_UNUSED auto op = serialization_plan_->operators()->Get(op_index); |
| ET_LOG( |
| Error, |
| "KernelCall failed at instruction %" ET_PRIsize_t ":%" ET_PRIsize_t |
| " in operator %s.%s: 0x%x", |
| step_state_.chain_idx, |
| step_state_.instr_idx, |
| op->name()->c_str(), |
| op->overload()->c_str(), // <-- unguarded, crash site |
| (unsigned int)err); |
| ... |
| ``` |
|
|
| The sibling function `populate_operator_name()` (also in `method.cpp`, used during `Method::init()`'s operator resolution) correctly guards this exact field: |
|
|
| ```cpp |
| const bool has_overload = |
| op->overload() != nullptr && op->overload()->size() > 0; |
| ... |
| has_overload ? op->overload()->c_str() : "", |
| ``` |
|
|
| `execute_instruction()`'s error-logging path independently re-implements similar message construction but omits this guard. |
|
|
| ## An Important Investigative Finding: The Crash Is Masked By Default |
|
|
| During investigation, an initial test of this hypothesis against the default sanitized build configuration (`EXECUTORCH_ENABLE_LOGGING` unset, which defaults to OFF per `CMakeLists.txt`) did **not** crash. Root-cause analysis revealed why: `ET_LOG(...)` expands to `((void)0)` when `ET_LOG_ENABLED=0` β the entire log statement, including the `op->overload()->c_str()` argument expression, is never compiled into the code path at all in that configuration. |
|
|
| This was verified two ways before concluding the bug was real: |
|
|
| 1. A standalone minimal reproduction program (`check_null.cpp`, not part of the final PoC package) directly loaded the crafted `.pte`, confirmed `op->overload()` returns `nullptr`, and confirmed calling `->c_str()` on it produces the exact `member call on null pointer of type 'flatbuffers::String'` UBSan error. |
| 2. The full sanitized library was rebuilt with `-DEXECUTORCH_ENABLE_LOGGING=ON` β a real, non-default, but officially documented CMake option, explicitly set in numerous real upstream build scripts (the XNNPACK delegate tutorial, Vulkan delegate tutorial, Qualcomm backend `build.sh`, Cadence backend build scripts, Arm VGF tutorials, and the Llama example README all set `-DEXECUTORCH_ENABLE_LOGGING=ON`). Against this build, the crash reproduced reliably. |
|
|
| This report is filed with that caveat stated explicitly rather than either hiding it (which would overclaim universal reachability) or omitting the finding (which would underclaim, since `EXECUTORCH_ENABLE_LOGGING=ON` is a genuine, common, documented production configuration). |
|
|
| ## 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 AND logging enabled |
|
|
| ```bash |
| cmake -S executorch -B build \ |
| -DCMAKE_BUILD_TYPE=RelWithDebInfo \ |
| -DCMAKE_C_COMPILER=clang-16 -DCMAKE_CXX_COMPILER=clang++-16 \ |
| -DEXECUTORCH_ENABLE_PROGRAM_VERIFICATION=ON \ |
| -DEXECUTORCH_ENABLE_LOGGING=ON \ |
| -DCMAKE_C_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer -fno-sanitize-recover=all" \ |
| -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer -fno-sanitize-recover=all" \ |
| -DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address,undefined" \ |
| -DEXECUTORCH_BUILD_EXTENSION_FLAT_TENSOR=ON -DEXECUTORCH_BUILD_EXTENSION_DATA_LOADER=ON \ |
| -DEXECUTORCH_BUILD_TESTS=OFF -DEXECUTORCH_BUILD_PYBIND=OFF -DEXECUTORCH_BUILD_XNNPACK=OFF \ |
| -G Ninja |
| ninja -C build executorch_core extension_flat_tensor extension_data_loader |
| ``` |
|
|
| ### 2. Build the execute-level harness (`poc/harness_execute_fuzzer.cpp`, included in this report) |
|
|
| This harness registers fake, deliberately-failing kernels (including one named `test_op` with no overload suffix, and a fake backend) and calls `Program::load_method()` + `Method::execute()` β exercising real kernel invocation, not just metadata: |
|
|
| ```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_execute_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_operator_overload_null.pte` (368 bytes, **included in this report β sha256 `f6b855b9d4bb69df0519e9954627703d88b9ef4da7104bbd52a6ba33239c3919`**) is a hand-crafted `.pte` containing: |
| - One `Operator` entry named `test_op` with **no** `overload` field |
| - One `KernelCall` instruction referencing that operator |
| - A registered (in the harness) always-failing kernel named `test_op` that matches this operator without needing an overload suffix |
|
|
| ### 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_operator_overload_null.pte |
| ``` |
|
|
| ### Expected result (secure behavior) |
| `Method::execute()` should return the kernel's error cleanly (and, if logging, produce a valid log message omitting the missing overload) rather than crashing while constructing the log message. |
|
|
| ### Actual result β verified against the pristine, unmodified ExecuTorch 1.3.1 source (with `EXECUTORCH_ENABLE_LOGGING=ON`) |
|
|
| ``` |
| Running: poc/poc_operator_overload_null.pte |
| runtime/executor/method.cpp:1487:9: runtime error: member call on null pointer of type 'flatbuffers::String' |
| SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior runtime/executor/method.cpp:1487:9 in |
| ==<pid>== ERROR: libFuzzer: deadly signal |
| #0 ... (abort machinery) |
| Method::execute_instruction (method.cpp:1487) |
| Method::execute (method.cpp:1758) |
| ``` |
|
|
| **Reproduced 3/3 identical runs** (re-verified live for this report): |
| ``` |
| run 1: member call on null pointer of type 'flatbuffers::String' |
| run 2: (identical) |
| run 3: (identical) |
| ``` |
|
|
| Root cause was independently corroborated via a standalone minimal C++ program that loads the same `.pte`, confirms `op->overload()` is null, and reproduces the identical crash signature outside of the fuzzing harness entirely. |
|
|
| ## Impact |
|
|
| **Who is affected:** Any application executing a method whose kernel fails at runtime for an operator with no `overload` field β this is triggered by ordinary, expected kernel error handling (shape mismatches, unsupported dtypes, etc.), not an artificial condition β **provided the deployment was built with `EXECUTORCH_ENABLE_LOGGING=ON`**. |
|
|
| **What the attacker can do:** Cause a deterministic crash on operator failure, in builds with logging enabled. |
|
|
| **What's at risk:** Availability only, and only in the logging-enabled configuration. |
|
|
| **Why the conditional framing:** `EXECUTORCH_ENABLE_LOGGING` defaults to OFF in the CMake build (`ET_LOG_ENABLED=0`), which compiles the vulnerable call entirely out of the binary. However, this is not a safety net by design β it's an artifact of the logging macro's no-op expansion β and the flag is explicitly turned ON in numerous official ExecuTorch example/tutorial build scripts for XNNPACK, Vulkan, Qualcomm, Cadence, and Arm backends, plus the Llama example. Deployments following those documented instructions are affected. |
|
|
| ## Suggested Remediation |
|
|
| ```cpp |
| auto op_index = instruction->instr_args_as_KernelCall()->op_index(); |
| ET_UNUSED auto op = serialization_plan_->operators()->Get(op_index); |
| const char* overload_str = |
| (op->overload() != nullptr) ? op->overload()->c_str() : ""; |
| ET_LOG( |
| Error, |
| "KernelCall failed at instruction %" ET_PRIsize_t ":%" ET_PRIsize_t |
| " in operator %s.%s: 0x%x", |
| step_state_.chain_idx, |
| step_state_.instr_idx, |
| op->name()->c_str(), |
| overload_str, |
| (unsigned int)err); |
| ``` |
|
|
| This mirrors the existing, correct pattern already used by `populate_operator_name()` for the same field. |
|
|
| **Design recommendation:** consider a small shared helper for extracting an operator's overload string safely, used by both `populate_operator_name()` and `execute_instruction()`'s failure-logging path, so this field's optionality doesn't need to be independently re-derived (and potentially missed) in future call sites. |
|
|
| A regression test should register a deliberately-failing kernel for an `Operator` entry with no `overload` field, build with `EXECUTORCH_ENABLE_LOGGING=ON`, and assert `Method::execute()` returns a clean `Error` rather than crashing during failure logging. |
|
|
| ## Files Included in This Report |
|
|
| - `poc/poc_operator_overload_null.pte` β the 368-byte PoC file (sha256 `f6b855b9d4bb69df0519e9954627703d88b9ef4da7104bbd52a6ba33239c3919`) |
| - `poc/harness_execute_fuzzer.cpp` β the harness used to trigger and reproduce the crash, including fake kernel/backend registration for exercising `Method::execute()` |
|
|
| ## 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_operator_overload_null.pte` is ready for that upload. Given the conditional build-flag dependency, the submission should state this caveat explicitly per the analysis above. |
|
|