# 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;inChars 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 `:`**: * `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 `/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"` → ` DATA block 1` → `terminal ACK block 1` → ` 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** = `:` (e.g. `100.103.255.254:69`) → Apply. Font path becomes `100.103.255.254:69,ROM`. 2. The terminal TFTP-fetches **`/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, cmpi 0x54, must be 1 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.