| # Null Pointer Dereference in ExecuTorch MethodMeta::uses_backend() via Missing ExecutionPlan.delegates (.pte) |
| |
| **Target:** ExecuTorch 1.3.1 (.pte, huntr Model File Vulnerability program) |
| **Severity:** Low-Medium (Denial of Service) |
| **CWE:** CWE-476 (NULL Pointer Dereference) |
| **Component:** `runtime/executor/method_meta.cpp` |
| **Authentication Required:** No β the only requirement is that a victim application loads an attacker-supplied `.pte` file and calls a documented public API. |
|
|
| ## Summary |
|
|
| `MethodMeta::uses_backend()` is a public API intended for applications to check whether a loaded method requires a specific backend before deciding how to dispatch it. It dereferences `ExecutionPlan.delegates` without checking it for null, even though this field is explicitly optional in the schema and the sibling function `num_backends()` three lines below correctly guards the exact same field. A `.pte` file with a well-formed `ExecutionPlan` that simply omits `delegates` passes both of ExecuTorch's verification levels (`InternalConsistency` and `Minimal`) and crashes any process that calls `uses_backend()` on it. |
|
|
| A second, independent null-deref exists in the same function on a different field: `BackendDelegate.id` is also optional, so a `.pte` whose `delegates` vector is present but contains an entry with no `id` triggers the same crash class one line later. |
|
|
| ## Vulnerability Details |
|
|
| `schema/program.fbs` defines `ExecutionPlan.delegates` and `BackendDelegate.id` as ordinary (non-required) fields: |
|
|
| ``` |
| table ExecutionPlan { |
| ... |
| delegates: [BackendDelegate]; |
| ... |
| } |
| table BackendDelegate { |
| id: string; |
| processed: BackendDelegateDataReference; |
| compile_specs: [CompileSpec]; |
| } |
| ``` |
|
|
| `runtime/executor/method_meta.cpp`: |
|
|
| ```cpp |
| bool MethodMeta::uses_backend(const char* backend_name) const { |
| ET_CHECK_MSG(backend_name, "backend name is null"); |
| const auto delegates = s_plan_->delegates(); |
| for (size_t i = 0; i < delegates->size(); i++) { // <-- delegates may be null |
| auto delegate = delegates->Get(i); |
| auto backend_name_len = std::strlen(backend_name); |
| auto delegate_id_len = delegate->id()->size(); // <-- delegate->id() may be null |
| if (backend_name_len == delegate_id_len && |
| std::strncmp(delegate->id()->c_str(), backend_name, backend_name_len) == 0) { |
| return true; |
| } |
| } |
| return false; |
| } |
| |
| size_t MethodMeta::num_backends() const { |
| const auto delegates = s_plan_->delegates(); |
| return delegates ? delegates->size() : 0; // <-- correctly guarded, 3 lines below |
| } |
| ``` |
|
|
| The presence of the correct guard in `num_backends()` proves this is an inconsistency, not an intentional assumption β the maintainers clearly know `delegates` can be absent; the check simply was not applied to `uses_backend()`. |
|
|
| Neither `Program::load()`'s `flatbuffers::Verifier` structural check nor `validate_program()`'s semantic check (which validates `values`/tensor fields, not `ExecutionPlan.delegates`) nor `Program::method_meta()`'s own explicit field checks (`name`, `non_const_buffer_sizes`, `inputs`, `outputs` only) cover this field. A file omitting `delegates` is fully legal per the schema and loads cleanly under **both** verification levels. |
|
|
| ## Steps to Reproduce |
|
|
| ### Environment |
| - Linux x86-64, ExecuTorch 1.3.1 source, clang-16, CMake, Ninja |
| - No authentication, no host access β only the ability to supply a `.pte` file to a process that calls `MethodMeta::uses_backend()` |
|
|
| ### 1. Build ExecuTorch with sanitizers |
|
|
| Same build as REPORT-01/02 Step 1. No additional flags needed β `executorch_core` (which contains `method_meta.cpp`) is part of the default target set. |
|
|
| ### 2. Build the PoC harness (`poc/harness_program_fuzzer.cpp`, included in this report) |
|
|
| ```bash |
| 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_program_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" |
| ``` |
|
|
| The harness calls `Program::load()` under both `InternalConsistency` and `Minimal` verification, then for every loaded method calls the full `MethodMeta` API surface including `uses_backend("XNNPACK")`, `num_backends()`, and `get_backend_name()` β matching how a real application inspects a loaded program before dispatch. |
|
|
| ### 3. Generate the PoC .pte file |
|
|
| ```bash |
| python3 poc/gen_poc.py "$ET_SRC/schema" "$ET_BUILD/third-party/flatc_ep/bin/flatc" |
| ``` |
|
|
| This produces `poc/poc_uses_backend_null_deref.pte` (200 bytes, **included in this report β sha256 `128498fe87ac5be3bfac96e4e1ddd359dba52e37c253da29372ab4d183e47b05`**), a well-formed `.pte` with: |
| - One `ExecutionPlan` named `"forward"`, with empty `values`/`inputs`/`outputs`/`chains`/`operators`, `non_const_buffer_sizes = [0]` |
| - A minimal `constant_segment` (required because this build disables the deprecated `constant_buffer` path via `-DET_ENABLE_DEPRECATED_CONSTANT_BUFFER=0` in `CMakeLists.txt`, even for a program with zero constants) |
| - **`delegates` field entirely omitted** |
|
|
| > **Note on why `constant_segment` is needed even for this trivial PoC:** ExecuTorch 1.3.1's build disables the legacy `constant_buffer` storage path. Without a `constant_segment`, `Program::load()` fails at an earlier, unrelated check (`Error::InvalidProgram`, "relies on the constant_buffer path, which is disabled") before ever reaching `method_meta()`. This is why the PoC includes a `constant_segment` with a single placeholder offset even though the program has no real constant data β this is a `.pte` file structure detail, not part of the vulnerability itself. |
| |
| ### 4. Trigger the crash |
| |
| ```bash |
| 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_uses_backend_null_deref.pte |
| ``` |
| |
| ### Expected result (secure behavior) |
| `MethodMeta::uses_backend("XNNPACK")` should return `false` (no backend used), matching the correct behavior already implemented in the neighboring `num_backends()`. |
| |
| ### Actual result |
| |
| ``` |
| Running: poc/poc_uses_backend_null_deref.pte |
| [dbg] IC program.ok()=1 num_methods=1 |
| [dbg] Minimal program.ok()=1 num_methods=1 |
| runtime/executor/method_meta.cpp:406:37: runtime error: member call on null pointer of type 'flatbuffers::Vector<flatbuffers::Offset<executorch_flatbuffer::BackendDelegate>>' |
| SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior runtime/executor/method_meta.cpp:406:37 in |
| ==<pid>== ERROR: libFuzzer: deadly signal |
| ``` |
| |
| The `[dbg]` lines (from the harness's built-in debug output) confirm the file loads successfully under **both** `Program::Verification::InternalConsistency` and `Program::Verification::Minimal` β proving neither verification level catches this. The crash occurs only once `uses_backend()` is called. |
| |
| **Reproduced 3/3 identical runs** (re-verified live for this report): |
| ``` |
| run 1: IC program.ok()=1 num_methods=1 / Minimal program.ok()=1 num_methods=1 / crash at method_meta.cpp:406:37 |
| run 2: (identical) |
| run 3: (identical) |
| ``` |
| |
| ## Second Independent Crash β Same Function, Different Field |
| |
| Continued fuzzing of the same harness found a second, distinct null-deref inside `uses_backend()` itself: `delegate->id()->size()` at line 409, triggered when `delegates` is non-null but contains a `BackendDelegate` entry whose own `id` field is omitted (also legal per schema β `id: string;` is not required). Confirmed via UBSan: |
| |
| ``` |
| runtime/executor/method_meta.cpp:409:44: runtime error: member call on null pointer of type 'flatbuffers::Vector<char>' |
| ``` |
| |
| This is filed as supporting evidence for the same finding (not a separate report) because both crashes are in the same function with the same missing-validation defect class and the same fix location β the recommended fix below covers both. |
| |
| ## Impact |
| |
| **Who is affected:** Any application that calls `MethodMeta::uses_backend()` β a documented, intended-for-use public API β on a loaded `.pte` program. This is not a corner-case internal function; it exists specifically so applications can query backend requirements before dispatch. |
|
|
| **What the attacker can do:** Cause a deterministic, repeatable crash by supplying a `.pte` file whose `ExecutionPlan` simply omits the optional `delegates` field (or has a `delegates` entry with no `id`). The file loads successfully under both of ExecuTorch's verification levels β there is no existing defense that catches this before the crash. |
|
|
| **What's at risk:** Availability of the process calling `uses_backend()`. In practice, this is likely to be called during normal model-loading/dispatch logic in any application that branches on backend availability, making this a realistic crash path for ordinary usage, not just a corner case an attacker has to work hard to trigger. |
|
|
| **Exploitation complexity:** No interaction beyond the victim loading the file and calling a standard API. Matches huntr MFV's "Denial of Service (DoS) attacks through malformed model files" category. |
|
|
| **Why not Critical:** Controlled null-pointer dereference on a flatbuffers accessor β no memory corruption, no code execution, no data disclosure. |
|
|
| ## Suggested Remediation |
|
|
| In `MethodMeta::uses_backend()` (method_meta.cpp lines 403β417): |
| |
| ```cpp |
| bool MethodMeta::uses_backend(const char* backend_name) const { |
| ET_CHECK_MSG(backend_name, "backend name is null"); |
| const auto delegates = s_plan_->delegates(); |
| if (delegates == nullptr) { |
| return false; |
| } |
| for (size_t i = 0; i < delegates->size(); i++) { |
| auto delegate = delegates->Get(i); |
| if (delegate == nullptr || delegate->id() == nullptr) { |
| continue; |
| } |
| auto backend_name_len = std::strlen(backend_name); |
| auto delegate_id_len = delegate->id()->size(); |
| if (backend_name_len == delegate_id_len && |
| std::strncmp(delegate->id()->c_str(), backend_name, backend_name_len) == 0) { |
| return true; |
| } |
| } |
| return false; |
| } |
| ``` |
| |
| This mirrors the guard pattern already correctly implemented in `num_backends()` immediately below. A regression test should build an `ExecutionPlan` with `delegates` omitted, and separately one with a `BackendDelegate` entry lacking `id`, asserting `uses_backend()` returns `false` cleanly in both cases rather than crashing. |
|
|
| ## Files Included in This Report |
|
|
| - `poc/poc_uses_backend_null_deref.pte` β the 200-byte PoC file (sha256 `128498fe87ac5be3bfac96e4e1ddd359dba52e37c253da29372ab4d183e47b05`) |
| - `poc/gen_poc.py` β deterministic script to regenerate the exact same PoC file from ExecuTorch's own `program.fbs` schema (verified byte-for-byte identical output) |
| - `poc/harness_program_fuzzer.cpp` β the harness used to trigger and reproduce the crash against the real public API, including the debug instrumentation that proves both verification levels accept the file |
|
|
| ## 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_uses_backend_null_deref.pte` is ready for that upload. |
|
|