# Heap OOB read (buffer underflow) in ExecuTorch Ethos-U delegate init `vela_bin_validate()` computes a footer pointer without a minimum-size check on the attacker-controlled delegate-blob size. ## Target - **Project:** [pytorch/executorch](https://github.com/pytorch/executorch) - **Commit:** `a6d812a082df57898b8608f56c867140cc9da32c` (2026-07-10) - **Files:** - `backends/arm/runtime/VelaBinStream.cpp` (the vulnerable parser) - `backends/arm/runtime/EthosUBackend.cpp` (the caller / entry point) - **Component:** Arm Ethos-U backend delegate (`EthosUBackend`) - **Vulnerability class:** Heap out-of-bounds read / buffer underflow (CWE-125 / CWE-127) ## Summary A malicious `.pte` (ExecuTorch program) that contains an `EthosUBackend` delegate segment shorter than `sizeof(VelaBinBlock)` (== 32 bytes) triggers an out-of-bounds heap read the moment the delegate is initialized at load time. The delegate blob is opaque `[ubyte]` processed data; the `.pte` flatbuffer program verifier does not length-check it, so the bug is reachable **post-verifier**, at delegate `init()`, before any inference runs. ## Root cause `EthosUBackend::init()` hands the delegate blob straight to `vela_bin_validate(data, size)` with **no minimum-size guard**: `backends/arm/runtime/EthosUBackend.cpp` (lines 83-96): ```cpp Result init( BackendInitContext& context, FreeableBuffer* processed, ArrayRef compile_specs) const override { ET_LOG(Info, "data:%p", processed->data()); const char* data = static_cast(processed->data()); size_t size = processed->size(); // Verify format of vela_bin if (vela_bin_validate(data, size) == false) { // <-- size is attacker-controlled, unchecked ET_LOG(Error, "Malformed vela_bin_stream found"); return Error::InvalidProgram; } ... ``` `vela_bin_validate` then computes the footer pointer by subtracting a fixed 32-byte block size from `data + size`, and later reads from it — regardless of whether `size` is even large enough for that footer to lie inside the buffer: `backends/arm/runtime/VelaBinStream.cpp` (lines 28-52): ```cpp bool vela_bin_validate(const char* data, int size) { const char* foot = data + size - sizeof(VelaBinBlock); // sizeof(VelaBinBlock)==32 // Check 16 byte alignment bool valid = true; if ((uintptr_t)data != next_mul_16((uintptr_t)data)) { ET_LOG(Error, "Vela bin ptr not aligned to 16 bytes: %p", data); valid = false; } if ((uintptr_t)foot != next_mul_16((uintptr_t)foot)) { ET_LOG(Error, "End of vela bin not aligned to 16 bytes: %p", foot); valid = false; // <-- sets flag but does NOT early-return } // Check header and footer blocks are the right format if (strncmp(data, "vela_bin_stream", strlen("vela_bin_stream")) != 0) { // OOB if size < 15 ET_LOG(Error, "Incorrect header in vela_bin_stream"); valid = false; } if (strncmp(foot, "vela_end_stream", strlen("vela_end_stream")) != 0) { // <-- OOB read here ET_LOG(Error, "Incorrect footer in vela_bin_stream"); valid = false; } return valid; } ``` When `size < 32`, `foot = data + size - 32` points **before** the start of the heap buffer. Critically, the function only *accumulates* a `valid = false` flag; it never early-returns. So even though the footer-alignment check fails, execution still reaches line 46, `strncmp(foot, "vela_end_stream", 15)`, which dereferences the underflowed pointer — an out-of-bounds read of up to 15 bytes before the allocation. (The line-42 header `strncmp(data, "vela_bin_stream", 15)` is a second OOB read when `size < 15`. The footer underflow is the cleaner and more general primitive because it fires for any `size < 32`.) ## PoC An ASan harness (`harness.cpp`) compiles the **real, unmodified** `backends/arm/runtime/VelaBinStream.cpp` at commit `a6d812a` and calls `vela_bin_validate(data, size)` exactly as `EthosUBackend::init()` does. The only build flag is `-DET_LOG_ENABLED=0`, which compiles out the `ET_LOG` logging macro — none of those log statements fire before the crash, so the parsing logic (pointer arithmetic + `strncmp`) is byte-for-byte the shipped code. **Attack input (mode 0):** a 16-byte, 16-byte-aligned heap buffer whose first 16 bytes are the valid magic `"vela_bin_stream\0"` (so the header check matches and control reaches the footer check), passed with `size = 16` (`< 32`). `foot = data + 16 - 32 = data - 16` lands in the heap redzone; `strncmp(foot, ...)` reads OOB. **Negative control (mode 1):** a well-formed 64-byte blob (a 32-byte header block + a `"vela_end_stream"` footer block at `data + 32`) returns cleanly (`validate == 1`) with zero ASan reports. ### Build ``` clang++ -g -O0 -fsanitize=address -DET_LOG_ENABLED=0 \ -I \ vela_oob/harness.cpp \ executorch/backends/arm/runtime/VelaBinStream.cpp \ -o vela_harness ``` ### Run ``` ./vela_harness 0 # attack -> ASan heap-buffer-overflow READ ./vela_harness 1 # control -> clean, validate==1, no ASan report ``` ## Captured evidence (verbatim) ``` ===== ATTACK (mode 0) ===== [attack] data=0x7b3d051e0040 size=16 foot=0x7b3d051e0030 (data-16) ================================================================= ==568158==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x7b3d051e0030 at pc 0x560626169de5 bp 0x7fff3794c2c0 sp 0x7fff3794ba68 READ of size 1 at 0x7b3d051e0030 thread T0 #0 0x560626169de4 in strncmp #1 0x56062623945a in executorch::backends::arm::vela_bin_validate(char const*, int) backends/arm/runtime/VelaBinStream.cpp:46:7 #2 0x5606262390ba in main vela_oob/harness.cpp:46:14 #3 0x7f0d06003f76 (libc.so.6+0x29f76) #4 0x7f0d06004026 in __libc_start_main (libc.so.6+0x2a026) #5 0x56062614e380 in _start 0x7b3d051e0030 is located 8 bytes after 24-byte region [0x7b3d051e0010,0x7b3d051e0028) allocated by thread T0 here: #0 0x5606261f3928 in malloc #1 0x7f0d0606be20 (libc.so.6+0x91e20) SUMMARY: AddressSanitizer: heap-buffer-overflow in strncmp ==568158==ABORTING ===== NEGATIVE CONTROL (mode 1) ===== [control] data=0x7ba4887e0020 size=64 foot=0x7ba4887e0040 (data+32) [control] vela_bin_validate returned 1 (clean, no OOB) ``` ## Impact - **Reachability:** load-time, pre-inference. Any consumer that loads an untrusted `.pte` targeting the Ethos-U backend (on-device model loaders, model-serving that accepts user-supplied models, CI pipelines that validate models) hits this path during delegate `init()`. - **Effect:** out-of-bounds heap read of up to 15 bytes immediately before the delegate buffer. In hardened allocators this can crash the process (DoS). Depending on heap layout the read result influences the `valid` return and thus control flow, and can leak adjacent heap contents via observable accept/reject behavior. ## Suggested fix Add a minimum-size guard before computing `foot`: ```cpp bool vela_bin_validate(const char* data, int size) { if (size < (int)(2 * sizeof(VelaBinBlock)) || size < 0) { // need header + footer blocks ET_LOG(Error, "vela_bin too small: %d", size); return false; } const char* foot = data + size - sizeof(VelaBinBlock); ... ``` and/or have `EthosUBackend::init()` reject `processed->size() < 2 * sizeof(VelaBinBlock)` before calling the validator. ## Dedup / prior-art note No CVE or public advisory is known for `vela_bin_validate` / the Ethos-U `VelaBinStream` parser. This is distinct from other ExecuTorch delegate-parsing issues (XNNPACK constant-data / weight bounds, Vulkan constant-id, QNN custom-proto header, WebGPU delegate header), which live in different backends and different parsers. The finding is specific to `backends/arm/runtime/VelaBinStream.cpp:46` (footer) and `:42` (header) and the missing minimum-size check in `EthosUBackend::init()`.