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():
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:
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:
- A standalone minimal reproduction program (
check_null.cpp, not part of the final PoC package) directly loaded the crafted.pte, confirmedop->overload()returnsnullptr, and confirmed calling->c_str()on it produces the exactmember call on null pointer of type 'flatbuffers::String'UBSan error. - 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 backendbuild.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
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:
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
Operatorentry namedtest_opwith nooverloadfield - One
KernelCallinstruction referencing that operator - A registered (in the harness) always-failing kernel named
test_opthat matches this operator without needing an overload suffix
4. Trigger the crash
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
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 (sha256f6b855b9d4bb69df0519e9954627703d88b9ef4da7104bbd52a6ba33239c3919)poc/harness_execute_fuzzer.cppβ the harness used to trigger and reproduce the crash, including fake kernel/backend registration for exercisingMethod::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.