17 KiB
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) andcmpi 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 (0x7DDAAcmp a11,a9/jrle), then thebufsize (%d) too small for %d byteserror; standard 512-byte block logic (cmpi 0x200→ last-block flag at0x1CB940).
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 boundMAX_FONT_TABLES=32) is not present: the firmware's loop is bounded at 8 (0x0F0A…:cmpi 0x8,a9; jrlt loop), and its message saysnTables>NUM_FONT_TABLES.
Secondary candidates (not yet audited in the firmware)
pcfReadBitmapsrepad path:padTmp = alloc(padSize)wherepadSizecomes 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 whenformat & 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 PathandTFTP Font Path— with a radio selecting which is used. - The TFTP Font Path is
<host>:<fontdirectory>:100.103.255.254bare → parsed as a port (atoistops at the first.) → font path:100,ROM(host empty, no font source at all;xlsfontsthen reports no fonts).100.103.255.254:69→ host100.103.255.254, directory69.
- The terminal then fetches
<fontdirectory>/fonts.dirover 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
- 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. - 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 followingfonts.dirrequest.
Working delivery recipe (confirmed)
- Setup → Font Path Selection → TFTP Font Path =
<our-ip>:<fontdir>(e.g.100.103.255.254:69) → Apply. Font path becomes100.103.255.254:69,ROM. - The terminal TFTP-fetches
<fontdir>/fonts.dirfrom us. - A client
OpenFont/ListFontsWithInfotriggers the lazy fetch of the font file named infonts.dir. Observed working:RRQ "baseline.pcf"→DATA 1→ACK 1→DATA 2→ACK 2. - The server registers our font name
(
-misc-vt1200test-medium-r-normal--8-80-75-75-c-80-iso8859-1shows inxlsfonts).
Open issues
xlsfonts -l -o/ListFontsWithInfostill reports the font unmatched, so the delivered file is not becoming an openable font — the parser may be rejecting it (fonts.dirname vs the name inside the file, or a required table). Does not block the exploit (pcfReadBitmapsruns during the parse).xset fp rehashon 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 changedfonts.dir.- The terminal sends a trailing
ACK block 3after a short final block.
Live results (patched payload)
- Small synthetic font (680 B = 2 TFTP blocks): transfers cleanly, name
registered, but
OpenFont/ListFontsWithInfostill reports it unmatched andxmessagecan'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 = 0x40000001therefore never produced corruption: the whole file never arrived, and the failure is a cleanENOSPACE.- 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
pcfReadBitmapswe 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
- 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.
- Make a small font the parser accepts (debug the rejection — likely a table or property the terminal requires).
- 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 inxlsfontsasVT1200PROBE1butOpenFont/ListFontsWithInfostill 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$FONTname (and/or a DEC-specific container format). Test: index listsprobe-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
0x1DF5E76and 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):
- LAT receive path — see
lat/notes/lat-receive-audit.md: the demultiplexer at0x16C44walksnbr_slots(wire byte 1) with no length bound, sonbr_slots/byte_countup to 255 give ~66 KB of out-of-bounds slots that are acted on (Data_a delivered; Start/Stop/Reject mutate SCB state). - Font path —
pcfReadBitmapsnum_chars*4overflow (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
- Baseline: obtain or generate a valid PCF/SNF font the terminal will accept, and confirm the terminal actually fetches it (proves reachability).
- Delivery harness: a Python TFTP server (RFC 783 — trivial) serving
.DECW$FONTfiles, with the terminal's Font Path pointed at us; or impersonate the font server. - Find the bug: compare the firmware's
pcfReadFont/pcfReadBitmapsagainst the published libXfont fixes to find the missing check; or fuzz mutated PCFs and watch for a crash (terminal reset / hang / font error). - 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.