--- 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.