diff --git a/dwt/heapmap.py b/dwt/heapmap.py new file mode 100644 index 0000000..8afc4fe --- /dev/null +++ b/dwt/heapmap.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Dump the VT1200 firmware heap free lists over rdebug `peek`. + +The firmware has a real bit-addressed allocator (malloc `0x07EE0246`, free +`0x07EE0488`, realloc near `0x07EE0B84`). It keeps: + + * a free-bytes counter at bit `0x1E8BA0` (32-bit, in **bits**) + * a global free-list head at bit `0x1E8AC0` + * three arenas at `0x1E8930` / `0x1E89B0` / `0x1E8A30`, each with its free-list + head at `+0x60` + +Each free block is a 0x40-bit (8-byte) header: + + +0x00 size in bits (includes the 8-byte header) + +0x20 next block, a **bit** address + +This tool walks them, so payload placement can be checked against what the OS +may actually hand out. Note that the firmware's payload scratch addresses +(bit `0x01800000` = byte `0x00300000`) sit **inside** the largest free block on +a default boot -- see `dwt/notes/HEAP.md`. + +The **global** list is the one that matters for payload placement and is +reliable. The per-arena lists are optional (`--arenas`); following one has +been observed to kill the rdebug process, so each list is read on its own +connection. + + python3 dwt/heapmap.py --host 192.168.99.2 + python3 dwt/heapmap.py --host 192.168.99.2 --at 0x01800000 + python3 dwt/heapmap.py --host 192.168.99.2 --blocks --arenas +""" +from __future__ import annotations + +import argparse +import struct +import sys + +import exploit + +FREE_COUNTER = 0x1E8BA0 +GLOBAL = ("global", 0x1E8AC0) +ARENAS = ( + ("arena0", 0x1E8990), + ("arena1", 0x1E8A10), + ("arena2", 0x1E8A90), +) +RAM_BITS = 0x02000000 # 4 MiB, as bit addresses +HDR_NEXT = 0x20 # bits +HDR_BYTES = 8 + + +def u32(rd: exploit.RDebug, bit: int) -> int: + return struct.unpack("= HDR_BYTES * 8) + print(f"{name:6s}: {len(blocks)} blocks, byte 0x{lo>>3:06X}..0x{hi>>3:06X}, " + f"{usable} bytes usable") + all_blocks += [(name, b, s) for b, s, _ in blocks] + if a.blocks: + for b, s, nxt in blocks: + print(f" bit 0x{b:08X} byte 0x{b>>3:06X} " + f"size {s//8:>9} B end 0x{(b+s)>>3:06X} next 0x{nxt:08X}") + + if a.at is not None: + hits = [(n, b, s) for n, b, s in all_blocks if b <= a.at < b + s] + print(f"check bit 0x{a.at:08X} (byte 0x{a.at>>3:06X}): " + + ("INSIDE a free block" if hits else "outside every free block")) + for n, b, s in hits: + print(f" {n}: block bit 0x{b:08X}, " + f"{(b+s-a.at)//8} bytes of headroom to its end") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main(sys.argv)) + except (exploit.ProtocolError, OSError) as exc: + print(f"error: {exc}", file=sys.stderr) + sys.exit(1) diff --git a/dwt/notes/HEAP.md b/dwt/notes/HEAP.md new file mode 100644 index 0000000..67ba15b --- /dev/null +++ b/dwt/notes/HEAP.md @@ -0,0 +1,87 @@ +# Payload memory and the firmware heap + +Where the injected payloads live, and what the firmware's allocator actually +owns. Measured on a default `VT1200_SKIP_LEVEL1=1` boot, 2026-10-18. + +## Short answer + +* Payloads are poked to **fixed scratch addresses** at bit `0x01800000` + (byte `0x00300000`, 3 MiB) and above — the top of the 4 MiB RAM. +* That region is **not allocated**. On a default boot it lies **inside the + largest free block** of the firmware's heap (`byte 0x12B772..0x400000`, + 2,967,694 bytes). Nothing protects it; the OS may hand it out. +* There **is** a real allocator — `malloc`/`free`/`realloc` — and it is + callable from a payload. `rdebug`'s own handlers already use it. + +## The allocator + +| what | address | +|---|---| +| `malloc` | Ghidra `0x07EE0246` / bit `0x3F701230` | +| `free` | Ghidra `0x07EE0488` / bit `0x3F702440` | +| `realloc` | near Ghidra `0x07EE0B84` (string `realloc: bad address %x not allocated`, bit `0x3F705C20`) | +| `calloc` | present (used by `rdebug_gather_usage_stats` and `remote_debug`) | + +It is a **bit-addressed first-fit** allocator over free lists: + +* free-bytes counter, 32-bit, **in bits**: bit `0x1E8BA0` +* global free-list head: bit `0x1E8AC0` +* three arenas at bit `0x1E8930`, `0x1E89B0`, `0x1E8A30`; each free-list head at + `+0x60` (so `0x1E8990`, `0x1E8A10`, `0x1E8A90`) +* a block is `{ u32 size_in_bits; ...; u32 next_bitaddr at +0x20 }`; the header + is `0x40` bits (8 bytes), so the **returned pointer is `block + 8 bytes`** +* `malloc` returns `0` on failure + +`create_process` (`0x07EE0D48`) mallocs a 0x39-byte PCB and the stack; +`rdebug_display_message` mallocs `len+2`; `jump` mallocs the argument block. +So allocation from our payload's context is already proven by the firmware +itself. + +## Measured boot + +``` +free counter @0x1E8BA0 = 24,795,312 bits = 3,099,414 bytes (3099 KB decimal) +global free list: 9 blocks + [0] byte 0x0F5B5A 196 B + ... + [8] byte 0x12B772 2,967,694 B end byte 0x400000 +``` + +The last block runs to the top of RAM. `0x01800000` (byte `0x300000`) falls +**inside** it — about 1.9 MB from the block's start and 1 MiB below its end. +That is why the payloads work, and why they are not safe indefinitely: the +allocator splits this block from its start, so roughly 1.9 MB of allocation +from it would reach the payload. + +The Memory bar's `3099 KB` is this counter in decimal kilobytes, a useful +cross-check. + +**The per-arena lists are not safe to follow.** Reading them has twice killed +the terminal's network stack (`TimeoutError`, then `No route to host`), while +the global list reads cleanly every time. `dwt/heapmap.py` therefore reads the +global list by default and treats `--arenas` as an experiment; do the same, and +treat `peek` of an unvalidated pointer as able to take the machine down. + +## Safe pattern for a payload + +1. **Bootstrap**: the code itself must live somewhere before `malloc` runs. + Poke it to a fixed scratch address as today. +2. Call `malloc(size)` from the payload's own process (the `create_process` + child, or the launcher before it starts Xlib). +3. Copy any long-lived buffers — or the payload itself — into the returned + block, keep the pointer in a **callee-saved register** or a fixed marker, and + never write before `ptr-8` (the header). +4. One allocation at startup is safest. The allocator's lock is a per-process + byte at `[@0x1B3900]+0x1C1`; assume cooperative scheduling, do not allocate + from an interrupt handler, and do not race it from two payloads. +5. Check for `0`, and remember sizes and addresses are **bits** internally even + though `malloc` takes bytes. + +## Tool + +`dwt/heapmap.py` walks the free lists over `peek`: + +```sh +python3 dwt/heapmap.py --host 192.168.99.2 --blocks +python3 dwt/heapmap.py --host 192.168.99.2 --at 0x01800000 # is my payload safe? +```