tsk-arasu's picture
Upload folder using huggingface_hub
719d0fc verified
|
Raw
History Blame Contribute Delete
11 kB

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:

  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

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 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

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 (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.