Add PoCs + AFL harness: ExecuTorch WebGPU delegate header missing length/bounds validation
4fb2132 verified | license: other | |
| tags: | |
| - security | |
| - vulnerability | |
| - fuzzing | |
| - executorch | |
| - model-file | |
| # ExecuTorch WebGPU delegate header: missing length/bounds validation (memory-unsafe .pte model load) | |
| **Status:** gated, access on request (huntr / ProtectAI triage). Do not use against production systems you do not own. | |
| ## Summary | |
| `WebGPUDelegateHeader::parse()` in | |
| `backends/webgpu/runtime/WebGPUDelegateHeader.cpp` (pytorch/executorch, | |
| commit `fa49f8ff28983fb820ca86a31cc0fbd2257c1c5b`, main branch) parses the | |
| 30-byte binary header that precedes the WebGPU delegate's flatbuffer + | |
| constant-data blob inside a `.pte` model file's delegate "processed" data | |
| segment. | |
| Unlike its sibling `VulkanDelegateHeader::parse(data, buffer_size)` (same | |
| repo, `backends/vulkan/runtime/VulkanDelegateHeader.cpp`), the WebGPU | |
| version: | |
| 1. Takes **no `buffer_size` parameter at all** β `parse(const void* data)` β | |
| so it can never check that the buffer it was handed is at least | |
| `kExpectedSize` (30) bytes before reading the magic and the five header | |
| fields at fixed offsets. | |
| 2. `is_valid()` only cross-checks the header fields **against each other** | |
| (`flatbuffer_offset >= header_size`, `bytes_offset >= flatbuffer_offset + | |
| flatbuffer_size`, β¦). It never checks them against the real buffer | |
| length, because `parse()` was never given that length. The Vulkan | |
| sibling has an explicit extra check for exactly this | |
| ("Validate that header offsets do not extend beyond the buffer"). | |
| The sole caller, `WebGPUBackend::init()` | |
| (`backends/webgpu/runtime/WebGPUBackend.cpp`), calls | |
| `WebGPUDelegateHeader::parse(processed->data())` β again passing no size β | |
| and then unconditionally computes | |
| `buffer_start + header->flatbuffer_offset` and | |
| `buffer_start + header->bytes_offset` and dereferences them. Unlike | |
| `VulkanBackend::compileModel()` (the analogous Vulkan code path), it also | |
| never runs the flatbuffer through a `flatbuffers::Verifier` before use β it | |
| goes straight from the unchecked offset to | |
| `vkgraph::VkGraphBufferHasIdentifier(flatbuffer_data)` and | |
| `graph->build(flatbuffer_data, ...)`. The WebGPU backend appears to be an | |
| incomplete port of the Vulkan delegate (same `vkgraph`/`VH00` on-disk | |
| format) that dropped the buffer-size plumbing and the FlatBuffer | |
| verification step along the way. | |
| `processed` is the delegate's serialized blob taken straight from the | |
| `.pte` file (`BackendDelegate::Init()` β | |
| `method.cpp:GetProcessedData()`), so every byte of it, including its | |
| length, is attacker-controlled. `BackendDelegate::Init()` runs for every | |
| delegate listed in a program's execution plan simply while **loading** | |
| the model (`Method::init`), before any tensor is ever executed and before | |
| any real WebGPU/GPU context is required. | |
| This gives two distinct, independently reachable memory-safety bugs from a | |
| single missing check, both triggerable just by loading a crafted `.pte` | |
| file that names a WebGPU/Vulkan-schema delegate: | |
| * **Root cause A β no minimum-length check in `parse()`.** Any delegate | |
| blob shorter than 30 bytes makes `parse()` read past the end of the | |
| buffer while checking the magic (`memcmp`) or decoding the header fields | |
| (`getUInt32LE`/`getUInt16LE`/`getUInt64LE`). A 0-byte blob SEGVs; a 4-byte | |
| blob heap-buffer-overflows. | |
| * **Root cause B β no offset/size validation against the real buffer.** A | |
| header that is internally consistent (passes `is_valid()`) but whose | |
| `flatbuffer_offset` / `bytes_offset` point far past the end of the actual | |
| (short) buffer sails through `parse()` as `Error::Ok`. The caller then | |
| dereferences `buffer_start + header->bytes_offset`, an arbitrary, | |
| attacker-chosen offset, causing an out-of-bounds read (SEGV or | |
| heap-buffer-overflow depending on offset magnitude). | |
| Both were reproduced with ASan+UBSan on the real, unmodified source and | |
| independently rediscovered by an AFL++ persistent-mode campaign | |
| (~2.2M execs, ~4 min to full saturation of a 44/278-edge target β the | |
| header parser is small, so AFL exhausts its state space almost instantly; | |
| no new crash classes appeared after the first ~2000 execs) driving the | |
| harness in `fuzz_webgpu_header.cpp`. | |
| ## Impact | |
| Loading an untrusted/downloaded `.pte` model file that specifies a | |
| WebGPU-schema delegate with a truncated or offset-crafted "processed" blob | |
| causes the ExecuTorch runtime to read out of bounds during model *load* | |
| (no inference required), landing on ASan as heap-buffer-overflow or SEGV. | |
| Depending on heap layout this is a crash (DoS) at minimum; an | |
| out-of-bounds read whose result is subsequently treated as a flatbuffer/ | |
| constant-data pointer is also a plausible route to information disclosure | |
| or further memory corruption once the "flatbuffer" bytes it dereferences | |
| are parsed by the vkgraph flatbuffer reader. This is exactly the "loading | |
| an ExecuTorch model file can cause the runtime to crash / undefined | |
| behavior" bug class ExecuTorch has fixed for cash before (see Dedup | |
| section) β just in a different module (WebGPU delegate header, not | |
| tensor-parser/prim-ops). | |
| ## Dedup / prior-art check | |
| * `pytorch/executorch` GitHub issues/PRs: no hits for | |
| `WebGPUDelegateHeader` (searched 2026-07-06). | |
| * CVE-2025-54950 (critical OOB in "ExecuTorch models loading", fixed in | |
| commit `b6b7a16df5e7852d976d8c34c8a7e9a1b6f7d005`) is unrelated: that fix | |
| only touches `kernels/prim_ops/{et_copy_index.cpp,et_view.cpp, | |
| register_prim_ops.cpp}` (missing argument-count validation on primitive | |
| kernels), nothing in `backends/webgpu` or `backends/vulkan`. | |
| * The flat_tensor/tensor-accessor path (`extension/flat_tensor`, | |
| `runtime/executor/tensor_parser*`) was audited and reported in an earlier | |
| pass of this operation; this finding is in a structurally different | |
| module (delegate/backend metadata header parsing) with a different root | |
| cause (parser given no size vs. tensor bounds-check gap). | |
| * `schema/extended_header.cpp` (the outer `.pte` file header) and | |
| `VulkanDelegateHeader.cpp` were read as comparators; both correctly take | |
| and check a `buffer_size`. WebGPUDelegateHeader is the outlier. | |
| ## Files | |
| * `fuzz_webgpu_header.cpp` β AFL++ persistent-mode harness. Compiles the | |
| real, unmodified `WebGPUDelegateHeader.cpp` and drives | |
| `WebGPUDelegateHeader::parse()` exactly the way | |
| `WebGPUBackend::init()` does (including dereferencing the returned | |
| offsets), on an attacker-sized buffer. | |
| * `poc_A_truncated_header.bin` β 4-byte input. Minimal repro for root | |
| cause A (no min-length check). ASan: heap-buffer-overflow in | |
| `MemcmpInterceptorCommon` from `WebGPUDelegateHeader.cpp:78` | |
| (magic check) / `WebGPUDelegateHeader.cpp:48-49` (`getUInt32LE`). | |
| * `poc_B_oob_offset.bin` β 34-byte input with a well-formed, internally | |
| consistent header whose `bytes_offset` is `0x7FFFFFFF`. Minimal repro | |
| for root cause B. ASan: SEGV when the harness (mirroring | |
| `WebGPUBackend::init`) dereferences `buffer_start + header->bytes_offset`. | |
| * `crashes/` β raw AFL++ crashing testcases from the campaign (5 files, | |
| reduce to the same 2 root causes above). | |
| ## Build / repro | |
| ```sh | |
| git clone --depth 1 https://github.com/pytorch/executorch.git | |
| clang++ -std=c++17 -g -O1 -fsanitize=address,undefined -I. \ | |
| fuzz_webgpu_header.cpp \ | |
| executorch/backends/webgpu/runtime/WebGPUDelegateHeader.cpp \ | |
| executorch/runtime/platform/log.cpp \ | |
| executorch/runtime/platform/platform.cpp \ | |
| executorch/runtime/platform/default/posix.cpp \ | |
| executorch/runtime/platform/abort.cpp \ | |
| -o fuzz_webgpu_header_plain | |
| ./fuzz_webgpu_header_plain poc_A_truncated_header.bin # heap-buffer-overflow | |
| ./fuzz_webgpu_header_plain poc_B_oob_offset.bin # SEGV | |
| ``` | |
| ## Suggested fix | |
| Give `WebGPUDelegateHeader::parse()` the same signature and checks as | |
| `VulkanDelegateHeader::parse(data, buffer_size)`: take `buffer_size`, | |
| reject inputs shorter than `kExpectedSize` before touching any bytes, and | |
| after `is_valid()` passes, verify | |
| `flatbuffer_offset + flatbuffer_size <= buffer_size` and | |
| `bytes_offset + bytes_size <= buffer_size` before returning. Update | |
| `WebGPUBackend::init()` to pass `processed->size()` through. | |