checkpoint: VT1200 reverse-engineering workspace before board-model work

This commit is contained in:
SDD committed 2026-10-16 01:37:50 +03:00
commit acb92d3e44
325 files changed
+951894

No files matched your search

+339
View File
@@ -0,0 +1,339 @@
# VT1200 code-execution audit notes
**Objective:** run self-compiled TMS34010 code on the VT1200, loaded over the
network, **without modifying the ROMs** (user requirement). So: find a
remotely-triggerable control-flow hijack in the stock firmware.
**Hardware:** v2.2 unit (`SHOW` reports `Firmware Revision V2.2`; power-up
self-test passes), on the LAN. ROM/RAM map: see `MEMORY_MAP.md`.
`reference/dis34-a-v22.txt.gz` is the analyzed disassembly used below
(byte offset = displayed address, see `tools/`).
**Safety:** the terminal stores configuration in NVRAM. `INIT`, menu
diagnostics and any crash risk that config — capture the current setup (SHOW /
setup screen) before sending malformed data.
## Attack surfaces, ranked by attacker control
| Surface | Who initiates | Control | Evidence |
|---|---|---|---|
| **PCF / SNF font parsing** | terminal (font server or TFTP font path) | we serve the font bytes | `pcfReadBitmaps`, `Invalid SNF font file format.` |
| TFTP font file fetch | terminal | we are the TFTP server | `dwt_tftp.c`, `Setting TFTP Font Path`, `.DECW$FONT` |
| BOOTP reply | terminal (broadcast) | we are the BOOTP server | `bootp_proto`, `do_bootp` |
| MOP remote console | host → terminal | command args only | `DWT>>>`, `INIT/TEST/SHOW/HELP` |
| X11 requests | client → terminal | full protocol | X server (port 6000) |
| LAT / TCP | mixed | large | `x_tcp_interface`, `ni_rcbtom` |
## What is already guarded
* **Network receive path** (`ni_rcbtom`, `0x08006E`–`0x080170`): explicit size
checks — `cmpi 0x5EE` (`MAXSIZE_RCVPKT` = 1518) and `cmpi 0x226`
(`SIZE_OF_RCV_BUFS` = 550), each followed by an error + abort.
* **TFTP data path** (`tftp_read`, `0x07DD78`–`0x07DE40`): validates payload
length against buffer capacity (`0x7DDAA` `cmp a11,a9` / `jrle`), then the
`bufsize (%d) too small for %d bytes` error; standard 512-byte block logic
(`cmpi 0x200` → last-block flag at `0x1CB940`).
So the packet layers are not the low-hanging fruit.
## The promising target: the X11-derived font parser
The server is **MIT X11 sample-server derived** — the strings are its
internals: `pcfReadBitmaps`, `mfbValidateGC`, `mfbFaultGlyph`, `AM_FaultGlyph`,
`mfbFreeGlyphCacheSpace`. It parses **PCF**, **SNF** and a DEC font format.
Locations (v2.2 byte offsets):
| Code | Site | String |
|---|---|---|
| PCF bitmap reader | `0x0F09F2`, `0x0F0A2C` | `bad count in pcfReadBitmaps (expected %d, got %d)`, `allocation failure in pcfReadBitmaps (offsets,%d)` |
| PCF font reader | `0x0F177E` | `Failed to read PCF format bitmaps` |
| SNF reader | `0x0ED830` | `Invalid SNF font file format.` |
| metrics validation | `0x0EB288`, `0x0EB32E` | `bad packed/unpacked metrics, glyph=…` |
| glyph data | `0x0ED426` | `Failed to read glyph data (%d at %d) for %s.` |
| font-file open | `0x0ED014`, `0x0ED27A`, `0x0ED37A`, `0x0EDA1A` | `Could not open font file '%s'.` etc. |
**Why this matters:** this is X11R3/R4-era font code, a class with a long
public history of memory-safety bugs, well studied in `libXfont`
(Ilja van Sprundel's 2014–2015 audit: integer overflows in PCF/SNF/BDF table
size arithmetic leading to undersized allocations and heap overflows, plus
missing bounds checks in `pcfReadFont`). The firmware predates all of those
fixes, and the presence of *some* checks (`bad count in pcfReadBitmaps`)
just means it is a version that has a few — not necessarily all.
Suspect constructs already spotted in the font loader: `sub a5,a3` / `sll 0x6,a3`
(`0x0ED396`) and `sll 0x3,a12` (`0x0ED29E`) — table indexing by a value derived
from file data, with no visible bound check at the use site.
## CONFIRMED CANDIDATE: `pcfReadBitmaps` num_chars integer overflow (heap)
**Era source:** `reference/x11/r4/contrib/server/pcf/fonts/lib/pcf/pcfrbitmaps.c`
(X11R4, Dec 1989 — the firmware's error strings match *this* file exactly, e.g.
`allocation failure in pcfReadBitmaps (offsets,%d)`; R5's monolithic
`pcfread.c` does not contain that string).
```c
n = pcfReadInt32(file); /* attacker-controlled */
if (pCS->nChars==0) pCS->nChars = n; /* only cross-check is nChars, not a max */
else if (n != pCS->nChars) { error; return; }
offsets = (CARD32 *)fosAlloc(n*sizeof(CARD32)); /* n*4 — 32-bit, can wrap */
if (!offsets) { "allocation failure ... (offsets,%d)"; return; }
for (i=0;i<n;i++) offsets[i] = pcfReadInt32(file); /* writes n entries */
```
**Firmware (v2.2), `pcfReadBitmaps` at `0x0F09CE`–`0x0F0A5E`:**
```
0x0F09CE move *a9(0x270),a11,1 ; a11 = pCS->nChars
0x0F09D2 jrne ... ; nonzero -> only compare against n
0x0F09D4 move *a13(0x20),*a9(0x270),1 ; nChars==0 -> nChars = n (file value)
...
0x0F0A06 move *a13(0x20),a11,1 ; a11 = n
0x0F0A0A sll 0x2,a11 ; a11 = n*4 <-- NO overflow/range check
0x0F0A12 calla 0x3f703d90 ; fosAlloc(n*4, 1)
0x0F0A18 move a8,*a13(0x40),1 ; pBitOffsets = result
0x0F0A1E jrne ... ; alloc ok?
0x0F0A20 "allocation failure in pcfReadBitmaps (offsets,%d)"
0x0F0A42 sub a10,a10 ; i = 0
0x0F0A44 move *a13(0x20),a11,1 ; n
0x0F0A48 cmp a11,a10 / jrge ; loop while i < n
0x0F0A52 move a10,a7; sll 0x5,a7 ; i*32 bits = i*4 bytes
0x0F0A5A add a11,a7 ; offsets[i]
... ; writes n entries (n*4 bytes)
```
There is **no range check on `n`** between the read and the multiply, exactly as
in the R4 source. So `n >= 0x40000000` makes `n*4` wrap: e.g. `n = 0x40000001`
→ `alloc(4)` → the loop then writes ~4 GiB of file-derived data starting 0 bytes
in → **heap overflow with attacker-influenced content** (the first writes come
from our file; ultimately EOF-filled `0xFFFFFFFF`).
### Ruled out (fixed in this firmware)
* The R4 **stack** bug in `pcfrfont.c` (`TableDesc toc[NUM_FONT_TABLES=8]` with
loop bound `MAX_FONT_TABLES=32`) is **not present**: the firmware's loop is
bounded at 8 (`0x0F0A…`: `cmpi 0x8,a9; jrlt loop`), and its message says
`nTables>NUM_FONT_TABLES`.
### Secondary candidates (not yet audited in the firmware)
* `pcfReadBitmaps` repad path: `padTmp = alloc(padSize)` where `padSize` comes
from one table while the glyph offsets/metrics come from another — a
mismatched-size write if the two disagree.
* `pcfReadProperties` (`allocation failure ... (tmpbuf,%d)`, `(isstring,%d)`) —
property-table parsing.
* `pcfReadMetrics` (`bad char count (%d, expected %d)`, `(pCI,%d,0x%x)`).
## Harness — `tools/fontgen.py` (+ `test_fontgen.py`, 10 tests)
* **PCF**: `pcf_tables()`, `get_num_chars()` / `set_num_chars()` (byte-order aware
— PCF table bodies are MSBFirst when `format & FMT_MS_BYTE` (bit 2) is set;
this bit us once, the 10x20 font reads 223 only big-endian).
* **Baseline**: `minimal_bdf()` → `bdftopcf` → a valid PCF the terminal should
accept, so we can distinguish "parse error" from "never delivered".
* **TFTP** (`TFTPServer`): RFC 1350 octet read server; short-block termination,
error opcode for missing files, never dies on an idle socket (bug fixed).
* **CLI**:
```
python3 tools/fontgen.py baseline out.pcf
python3 tools/fontgen.py mutate base.pcf evil.pcf --num-chars 0x40000001
python3 tools/fontgen.py info some.pcf
python3 tools/fontgen.py serve evil.pcf --host 0.0.0.0 --port 69 --name font.DECW$FONT
```
Artifacts in `reference/fonts/`: `baseline.pcf`, `evil-numchars.pcf`
(`num_chars = 0x40000001`), `fonts.dir`, `system-10x20.pcf` (a real system font).
**Primitive:** `num_chars = 0x40000001` → `n*4` wraps to `4` → `alloc(4)` → the
offsets loop then writes `n` entries → heap overflow.
## Next: reachability
Still to determine before touching the live terminal: how the VT1200's TFTP font
path names the file it requests, and whether it fetches `fonts.dir` first
(strings: `/fonts.dir`, `font name different: name in path file='%s'`).
`serve` logs every request, so the first run against the unit will tell us.
## Font delivery: confirmed protocol (live, v2.2)
Learned by driving the real terminal:
* Setup has **two fields** — `LAT Font Path` and `TFTP Font Path` — with a radio
selecting which is used.
* The **TFTP Font Path is `<host>:<fontdirectory>`**:
* `100.103.255.254` bare → parsed as a *port* (`atoi` stops at the first `.`)
→ font path `:100,ROM` (host empty, no font source at all; `xlsfonts` then
reports no fonts).
* `100.103.255.254:69` → host `100.103.255.254`, directory `69`.
* The terminal then **fetches `<fontdirectory>/fonts.dir`** over TFTP and loads
the font files named inside. Observed RRQs: `"69"`, then `"69/fonts.dir"`.
* Confirmed exchange once routing was fixed:
`RRQ "69"` → `<us> DATA block 1` → `terminal ACK block 1` → `<us> DATA block 2`
→ `terminal ERROR ENOSPACE`. (Serving a *directory* path as if it were a file
makes the terminal abort with TFTP error 3.)
### Two non-obvious gotchas
1. **Routing:** our replies were hijacked into the WireGuard interface
(`ip route get 100.103.100.103` → `dev wg-k-space src 172.24.1.2`), so nothing
reached the terminal even though the server logged the requests. Fix: pin the
terminal to the LAN interface (`ip route add 100.103.100.103/32 dev enp197s0f3u1`)
or drop the tunnel.
2. **The TFTP server must not block:** the terminal can ACK then `ERROR`, so the
server has to abort that transfer on a TFTP ERROR, or it stalls on
retransmits and misses the following `fonts.dir` request.
### Working delivery recipe (confirmed)
1. Setup → Font Path Selection → **TFTP Font Path** = `<our-ip>:<fontdir>` (e.g.
`100.103.255.254:69`) → Apply. Font path becomes `100.103.255.254:69,ROM`.
2. The terminal TFTP-fetches **`<fontdir>/fonts.dir`** from us.
3. A **client `OpenFont`/`ListFontsWithInfo`** triggers the lazy fetch of the
font file named in `fonts.dir`. Observed working:
`RRQ "baseline.pcf"` → `DATA 1` → `ACK 1` → `DATA 2` → `ACK 2`.
4. The server registers our font name
(`-misc-vt1200test-medium-r-normal--8-80-75-75-c-80-iso8859-1` shows in
`xlsfonts`).
### Open issues
* `xlsfonts -l -o` / `ListFontsWithInfo` still reports the font **unmatched**, so
the delivered file is not becoming an openable font — the parser may be
rejecting it (`fonts.dir` name vs the name inside the file, or a required
table). Does not block the exploit (`pcfReadBitmaps` runs during the parse).
* `xset fp rehash` on the terminal returns "bad font path element … Directory
missing fonts.dir" and issues **no** TFTP request, so re-applying via Setup is
currently the only way to re-read a changed `fonts.dir`.
* The terminal sends a trailing `ACK block 3` after a short final block.
### Live results (patched payload)
* Small synthetic font (680 B = 2 TFTP blocks): transfers **cleanly**, name
registered, but `OpenFont`/`ListFontsWithInfo` still reports it unmatched and
`xmessage` can't convert it — so the delivered font is *not* accepted, and no
message is printed.
* Large real font (25,860 B ≈ 51 blocks): the terminal **ACKs block 1 and 2,
then stops**, and ~15 s later sends `ERROR ENOSPACE`. This happens for the
**unpatched** font too, so it is a *transfer/large-file* limit, not our patch.
* `num_chars = 0x40000001` therefore never produced corruption: the whole file
never arrived, and the failure is a clean `ENOSPACE`.
* The terminal prints **no** font error, so we have no black-box oracle for
where the parser bails.
### What that implies
* We can reliably push ≤ ~1 KiB of font data into the parser today.
* Two blockers: (1) our synthetic small font is rejected somewhere before it
becomes an openable font; (2) larger fonts cannot be transferred.
* To fire `pcfReadBitmaps` we need a font that (a) parses up to the BITMAPS
table and (b) fits the transfer window — or fix the large-transfer path.
### Candidate next moves
1. Fix/inspect the large-transfer path: try a fresh TID per transfer (spec
behaviour), a smaller/spec block size, and probe with a ~2 KB valid font to
find the real limit.
2. Make a small font the parser accepts (debug the rejection — likely a table or
property the terminal requires).
3. Get visibility: a minimal MAME driver for the TMS34010 + ROM would let us
watch the parse and choose values that corrupt rather than cleanly fail.
### Update: transfer window and font acceptance
* **Multi-block transfers work.** A 2248-byte real font transferred fully over
5 blocks — but the terminal **paused ~13 s mid-transfer** (busy processing), so
the server needs a generous ACK timeout (we now use 60 s). The earlier 25 KB
failure at 2 blocks was our 15 s timeout expiring during such a pause.
* **Even a real, valid font is not openable.** `decsess.pcf` (a genuine DEC
cursor font) fetched via TFTP shows up in `xlsfonts` as `VT1200PROBE1` but
`OpenFont`/`ListFontsWithInfo` still fails. So the rejection is *not* about our
synthetic font — something about the TFTP font path itself.
* Hypothesis under test: the firmware's own template is `DECW$FONTPATH:*.DECW$FONT;`
— the font files may need a **`.DECW$FONT`** name (and/or a DEC-specific
container format). Test: index lists `probe-uniq.DECW$FONT`.
### DEC's own font container (found in the firmware)
Font-file dispatch at `0x0ED764`:
```
cmpi 0x70636601, a5 ; "pcf\1" -> PCF reader
jrne L76BC60 ; otherwise -> SNF path
...
cmpi 0x1DF5E76, a5 ; SNF magic (NOT the MIT/X11 version 4!)
cmpi 0x54, <field@0x20>
cmpi 0x54, <field@0xb0>
<a9(0xd0)> must be 1
<a9(0xe0)> must be 6
else "Invalid SNF font file format." (0x0ED82E)
```
* Our PCF files carry `0x70636601`, so they take the **PCF path** and are rejected
*silently* — even a genuine DEC cursor font (`decsess.pcf`).
* Our MIT-style SNF is rejected with the message above — the firmware wants a
**DEC container** with magic `0x1DF5E76` and that field layout.
* We now have an **oracle** for the SNF path (the message names the failure) and a
concrete target format to reverse from the code at `0x0ED760`–`0x0ED8C0`.
## Terminal crash (observed on real hardware)
The unit displayed a Level-0 fault screen:
```
LEVEL0 ERROR -> Illegal OPCODE Occurrence - 28
007AA3F0 00200008 00000260 3F9B8EA0
00785110 00785070 00000020
```
* The firmware's own string for this fault is `ILLEGAL OPCODE, PC=%#08x`, so one
of those is the PC.
* Decoding the values as TMS34010 **bit** addresses (byte = `v >> 3`):
| value | physical byte | where |
|---|---|---|
| `007AA3F0` | `0x000F547E` | **RAM** ~0.96 MiB |
| `00200008` | `00040001` | **RAM** 0.25 MiB |
| `00000260` | `0x4C` | RAM |
| `3F9B8EA0` | `0x07F371D4` | ROM off `0x0F71D4` (the only ROM value) |
| `00785110` | `0x000F0A22` | **RAM** ~0.94 MiB |
| `00785070` | `0x000F0A0E` | **RAM** ~0.94 MiB |
| `00000020` | `0x4` | RAM |
* ROM code lives at bit `0x3F200000`+; only one entry is in ROM. **So the CPU
was almost certainly executing out of RAM** — it fetched a non-instruction and
trapped. That is the signature of **control flow redirected out of ROM into
RAM**, i.e. a hijack, reached via memory corruption.
* Circumstances: the terminal **crashed while idle** (a separate session was
running the `latd` (LAT) setup; no command was in flight). An idle crash
implies a *latent* corruption that surfaced on later execution, not a
just-in-time fault.
Prime suspects (both control attacker-supplied bytes into the terminal):
1. **LAT receive path** — see `lat/notes/lat-receive-audit.md`: the demultiplexer
at `0x16C44` walks `nbr_slots` (wire byte 1) with **no length bound**, so
`nbr_slots`/`byte_count` up to 255 give ~66 KB of out-of-bounds slots that are
*acted on* (Data_a delivered; Start/Stop/Reject mutate SCB state).
2. **Font path** — `pcfReadBitmaps` `num_chars*4` overflow (see above), delivered
over TFTP; the terminal retries the font path periodically.
Isolation plan: power-cycle, then (a) run only `latd` with the font path set to
`ROM`, and (b) run only the TFTP font path with `latd` stopped — see which one
reproduces the idle crash.
## Plan
1. **Baseline**: obtain or generate a valid PCF/SNF font the terminal will
accept, and confirm the terminal actually fetches it (proves reachability).
2. **Delivery harness**: a Python TFTP server (RFC 783 — trivial) serving
`.DECW$FONT` files, with the terminal's Font Path pointed at us; or
impersonate the font server.
3. **Find the bug**: compare the firmware's `pcfReadFont`/`pcfReadBitmaps`
against the published libXfont fixes to find the missing check; or fuzz
mutated PCFs and watch for a crash (terminal reset / hang / font error).
4. **Payload**: RAM is writable and executable and we know the map; place code
in RAM and redirect the corrupted control datum (return address or a
font/glyph structure field) to it.
The alternative (faster but needs physical work) is a ROM patch + bootloader —
explicitly ruled out, ROMs to stay original.