Files
vt1200/EXPLOIT_NOTES.md
T

17 KiB
Raw Blame History

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).

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.