Verified real ASan crash (Minimal + InternalConsistency), negative control, current HEAD a6d812a
12e8ccf verified | 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 | |
| ```cpp | |
| // 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 | |
| ```sh | |
| 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` | |
| ```sh | |
| 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 | |
| ```sh | |
| 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 `Chain`s): | |
| ``` | |
| [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`. | |