mirror of
https://git.k-space.ee/arti/vt1200.git
synced 2026-10-30 12:03:54 +02:00
dwt: map the firmware heap -- payload scratch sits inside the largest free block; document malloc/free and add heapmap.py
This commit is contained in:
1 parent
a94e99579f
commit
dcca6bee4d
2 files changed
+226
No files matched your search
+139
@@ -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("<I", rd.peek(bit, 4))[0]
|
||||
|
||||
|
||||
def walk(rd: exploit.RDebug, head: int, limit: int = 400):
|
||||
p = u32(rd, head)
|
||||
out, seen = [], set()
|
||||
while p and len(out) < limit and p not in seen and 8 <= p < RAM_BITS:
|
||||
seen.add(p)
|
||||
sz = u32(rd, p)
|
||||
nxt = u32(rd, p + HDR_NEXT)
|
||||
out.append((p, sz, nxt))
|
||||
if not (8 <= nxt < RAM_BITS):
|
||||
break
|
||||
p = nxt
|
||||
return out
|
||||
|
||||
|
||||
def read_list(host, port, timeout, name, head):
|
||||
"""Read one list on its own connection; a bad list must not abort the rest."""
|
||||
rd = exploit.RDebug(host, port, timeout, False)
|
||||
rd.connect()
|
||||
try:
|
||||
return walk(rd, head)
|
||||
finally:
|
||||
rd.close()
|
||||
|
||||
|
||||
def main(argv):
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--host", default="192.168.99.2")
|
||||
ap.add_argument("--port", type=int, default=771)
|
||||
ap.add_argument("--timeout", type=float, default=8.0)
|
||||
ap.add_argument("--at", type=lambda s: int(s, 0), default=None,
|
||||
help="bit address to check (e.g. a payload scratch address)")
|
||||
ap.add_argument("--blocks", action="store_true", help="list every block")
|
||||
ap.add_argument("--arenas", action="store_true",
|
||||
help="also try the per-arena lists (can kill the rdebug process)")
|
||||
a = ap.parse_args(argv[1:])
|
||||
|
||||
rd = exploit.RDebug(a.host, a.port, a.timeout, False)
|
||||
rd.connect()
|
||||
try:
|
||||
free_bits = u32(rd, FREE_COUNTER)
|
||||
finally:
|
||||
rd.close()
|
||||
print(f"free counter: {free_bits} bits = {free_bits // 8} bytes "
|
||||
f"({free_bits // 8000} KB decimal)")
|
||||
|
||||
lists = (GLOBAL,) + (ARENAS if a.arenas else ())
|
||||
all_blocks = []
|
||||
for name, head in lists:
|
||||
try:
|
||||
blocks = read_list(a.host, a.port, a.timeout, name, head)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"{name:6s}: unreadable ({type(exc).__name__}: {exc})")
|
||||
continue
|
||||
if not blocks:
|
||||
print(f"{name:6s}: empty")
|
||||
continue
|
||||
lo = min(b for b, _, _ in blocks)
|
||||
hi = max(b + s for b, s, _ in blocks)
|
||||
usable = sum(s // 8 - HDR_BYTES for _, s, _ in blocks
|
||||
if s >= 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)
|
||||
@@ -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?
|
||||
```
|
||||
Reference in new issue
Block a user