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(byte0x00300000, 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(so0x1E8990,0x1E8A10,0x1E8A90) - a block is
{ u32 size_in_bits; ...; u32 next_bitaddr at +0x20 }; the header is0x40bits (8 bytes), so the returned pointer isblock + 8 bytes mallocreturns0on 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
- Bootstrap: the code itself must live somewhere before
mallocruns. Poke it to a fixed scratch address as today. - Call
malloc(size)from the payload's own process (thecreate_processchild, or the launcher before it starts Xlib). - 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). - 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. - Check for
0, and remember sizes and addresses are bits internally even thoughmalloctakes 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?