Files
vt1200/dwt/notes/HEAP.md
T

3.7 KiB

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:

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?