mirror of
https://git.k-space.ee/arti/vt1200.git
synced 2026-10-30 12:03:54 +02:00
340 lines
17 KiB
Markdown
340 lines
17 KiB
Markdown
# 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.
|