# VT1200 — next session handoff Written 2026-10-15, end of session. Everything below is reproducible from the repo. Read this first, then `EXPLOIT_NOTES.md` (full protocol/address record) and `lat/notes/lat-receive-audit.md` (receive-path audit). --- ## 1. Where we are **Goal:** run custom, self-compiled code on the real VT1200 over the network, **with the ROMs untouched** (exploit route only). **This session's result:** the crash is **not** caused by the session data stream. - We extracted the exact bytes the terminal received in the capture where it died (27,529 bytes, 136 slots) and replayed them at the terminal. - It **survived** — twice over, in fact (the replay produced **two circuits**, so the terminal got the data ~2.4×, and still no fault). So the trigger is something *other* than the byte stream. The one protocol-level difference we can see is now the top suspect: > **latd's periodic LAT service announcements (message type `0xA`, multicast to > `09:00:2b:00:00:0f`)** — 91 of them, one per ~10 s, for the entire crashing session. > Our data-only replay contained **zero** of them. This matters because the audit's **Finding 3** says exactly that path is unguarded: type `0xA` → dispatcher `0x11634` → `0x118C0` → LAD update (`0x10660`/`0x103CA`), which `memcpy`s with **announcement-derived lengths and no confirmed upper bound**. --- ## 2. The test that is armed and waiting The soak is set up; it just needs a terminal connected to it and ~10–15 minutes. **Services** (latd, node `SID`): | service | command | note | |---|---|---| | `VT1200` | `lat/probe/heartbeat.sh` | **use this** — prints `alive HH:MM:SS` every 20 s | | `SOAK` | `lat/probe/heartbeat.sh` | backup name, same thing | | `SID` | the data-stream replayer | replays the 27 KB capture, then exits | | `REPLAY` | the data-stream replayer | same | **latd** is configured for the crash-session conditions: `Multicast Timer (sec): 10` (verified empirically: gaps of exactly 10.0 s), so latd's own live announcements now arrive at the same rate as in the crashing capture. **Procedure:** 1. Terminal on, at the LAT service list. 2. Connect to **`VT1200`** (or `SOAK`). 3. Watch the screen 10–15 min. You should see a line every 20 s: ``` alive 23:20:05 -- soak running alive 23:20:25 tick 1 ``` 4. **Text keeps ticking** → terminal alive (soak continues). **Text freezes** → real fault. Announcements are implicated → go to §5.1. **No crash after the full soak** → announcements exonerated → go to §5.2. We no longer have to judge by eye: a live terminal sends LAT **keepalives every ~20 s**, so death is also visible on the wire (see `lat/local/rep-*.pcap` captures). --- ## 3. The lesson that cost us time (read this before judging "it's broken") **A blank terminal window does NOT mean a crash.** We picked `sleep 3600` as the soak service, which prints nothing — and an idle LAT session window is *legitimately* blank: titlebar only, empty inside. It looked identical to a dead terminal and caused a needless power cycle. Evidence it was benign: the connect handshake matched the healthy original session frame-for-frame. ``` original (worked) our "blank" VT cmd=0x6 (connect) yes yes service name slot \x01\x1f\xff\x06VT1200\x00\x00 identical host port reply ty=0xf ... /dev/pts/8 ty=0xf ... /dev/pts/0 then H ty=0x0 bc=1 b'@' (login text) (nothing -- sleep is silent) ``` **Rule: always use an output-producing service for a soak test.** `heartbeat.sh` exists for exactly this. And check *both* the screen and the wire keepalives before concluding anything. --- ## 4. Tools built this session (all in `lat/probe/`, all tested) | file | what it does | |---|---| | `extract_stream.py` | self-contained libpcap + LAT parser → the exact host→VT1200 byte stream. Also `--stats`. | | `replay_stream.py` | replays a stream to stdout; usable as a **latd service command**. `--limit N` sends the first N bytes (for bisecting). | | `announce_replay.py` | re-sends the captured **service announcements verbatim** at a chosen interval. `--dry-run`, `--interval`, `--loops`, `--forever`. | | `heartbeat.sh` | the soak service: prints a timestamp every 20 s. | | `test_replay.py`, `test_announce_replay.py` | tests for the above (11 tests, green). | Handy invocations: ```sh cd /home/arti/code/k-space/vt1200/lat/probe # re-extract the crashing stream and look at it python3 extract_stream.py ../local/filter-test2.pcap -o ../local/crash-stream.bin --stats # replay the announcements only (verbatim, 10 s apart, looping) sudo python3 announce_replay.py ../local/filter-test2.pcap --interval 10 --forever --dry-run # arm a service that replays the data stream (bisect with --limit) sudo /home/arti/code/k-space/vt1200/lat/local/build/latcp -A -a REPLAY \ -C 'python3 /home/arti/code/k-space/vt1200/lat/probe/replay_stream.py /home/arti/code/k-space/vt1200/lat/local/crash-stream.bin' \ -r 100 ``` --- ## 5. Next steps, in order ### 5.1 If the soak crashes the terminal (announcements confirmed) Shrink the trigger to a minimal frame. The announcement frame carries several slots; the interesting one is **type `0xE`** (the service advertisement): ``` LATD for Linux \x01\x0e \x06VT1200 \x14login via OSC filter \x01\x01\x00\x00 ``` Note the 2-byte field at payload offsets **14–15**, which **changes on every announcement** (`\x01\x0e` → `\x01\x0f` → … → `\x02\x27`). Its source is `lat/code/latd/server.cc:221`: ```c announce->incarnation = --multicast_incarnation; ``` latd **decrements the node incarnation on every announcement**, so the terminal sees the node's identity change every 10 s — a strong candidate for a receiver that re-allocates/replaces its directory entry each time (the "unbounded" theme of Finding A/3). Bisect plan: 1. Send **only** the type-`0xE` slot, at 10 s, and see if that alone kills it. 2. Then vary one field at a time — **incarnation first**, then the length fields — until a single minimal frame reproduces it. That is the exploit primitive. 3. If it is the incarnation, the "trigger" is trivially `latcp -m 1`-style announcement spam — a **network-only, ROM-untouched** trigger, which is exactly the goal. ### 5.2 If the soak does NOT crash it (announcements exonerated) The remaining difference is **pacing**. The original delivered ~2 KB bursts every ~2.3 s over ~527 s; our replay blasted everything in ~59 s. - Add a `--timing` mode to `replay_stream.py`: `extract_stream.py` already records each slot's timestamp (`events`), so emit `(delay, length)` pairs and sleep accordingly for a faithful 527 s replay. - Cheaper first probe: `--chunk 255 --pace 2.3` (a 2 KB burst every 2.3 s). ### 5.3 Font path (parallel thread, still blocked) - Our PCFs register but **never open** (`OpenFont`/`ListFontsWithInfo` → "unmatched"). - DEC "SNF" container entry checks still fail (`Invalid SNF font file format.`) — file→buffer offset mapping unverified. - **New from the MAME harness (see `harness/mame/FINDINGS.md`):** - `pcfReadBitmaps` `num_chars*4` overflow is **confirmed and reproduced in-emulator** (`n` is a full 32-bit `pcfReadInt32`, no bound check, then a loop writing `n` CARD32s): `n=0x10000001` → `fosAlloc(0x40000004)`; `n=0x40000000` → alloc of **0**. - The ~1 KB font limit is the **firmware's own `tftp_read` buffer check**, not a disk error: `cmp a11,a9 / jrle` → `tftp_read: bufsize (%d) too small for %d bytes`, TFTP error code 3. The 516-byte value is the datagram buffer; ~1 KB is the file-accumulation buffer. - ROM is mapped at **bit `0x3f200000`**, mirrored at `0xff200000–0xffffffff` (MAME's address-map unit is 1 bit for `memory_access<32,16,3>`). --- ## 6. Environment facts and gotchas (hard-won — do not relearn these) - **Terminal:** MAC `08:00:2b:16:4b:5a`, IP `100.103.100.103`, v2.2, X server on TCP 6000. - **Route:** either kill the WireGuard tunnel or `ip route add 100.103.100.103/32 dev enp197s0f3u1`, or replies get hijacked into `wg-k-space`. This silently broke TFTP for a long time. - **Capture filter matters:** `ether host 08:00:2b:16:4b:5a` **does not capture multicast** — use `ether proto 0x6004`. Half of "we saw no announcements" was this filter, not reality. `filter-test2.pcap` used the broad filter and *did* capture them. - **latcp needs root** (the socket is root-owned): `sudo latcp -d`. - **latd start:** `cd lat/local/build && sudo ./latcp -s -d -v -v -i enp197s0f3u1 > ../latd.log 2>&1 &` (it execs `./latd -d -v -v -i enp197s0f3u1`). Stop with `sudo latcp -h`. - **latd runs service commands via `execvp`, not a shell** (`portservice.cc`/`serversession.cc:298`). So `-C` needs a real executable path with a shebang — not a shell one-liner. - **Multicast timer:** `latcp -m `, accepted range **10–180** (value is a separate argv: `-m 10`, not `-m10`). - **Service management:** `latcp -D -a ` to delete, `latcp -A -a -C -r ` to add. - **`pkill -f foo` can kill your own shell** when the pattern appears in your own command line. Use `pkill -f "[f]oo"`. - **Access control:** the terminal's LAT service list is populated from **announcements**, not from latd's actual service table. If a service doesn't appear, it isn't being advertised — registering it with latcp is not enough. - **Font path delivery:** must be `host:fontdir` over TFTP, and the value must **change** to re-enable Apply; `xset fp+`/`rehash` are rejected; only a power cycle re-reads the index. The terminal needs a ~60 s ACK timeout (it pauses ~13 s mid-transfer). - **NVRAM corruption is acceptable** (setup saved; NVRAM reset jumper exists). - **Crash signature to look for:** `LEVEL0 ERROR -> Illegal OPCODE Occurrence - 28`, with values mostly in RAM ~1 MiB (only `3F9B8EA0` in ROM) → execution out of RAM. --- ## 7. Current process state at end of session Left running deliberately (kill or keep as you prefer): | process | note | |---|---| | `latd` (pid varies) | multicast timer 10 s; services SID/REPLAY/IDLE/VT1200/SOAK | | `tcpdump -w lat/local/ann-run1.pcap` | broad filter, `timeout 5400` — **may still be recording** | | `harness/serve.py 100.103.255.254 69` | TFTP font server (you asked to leave it running) | | `announce_replay.py` | **stopped** | Captures present: `filter-test2.pcap` (the crash, 73 KB), `replay-run1.pcap` (survived data replay, 106 KB), `ann-run1.pcap` (announcement soak, in progress), plus older ones. ## 8. Key file map - `EXPLOIT_NOTES.md` — full record: protocol, addresses, ruled-out items, crash decode. - `MEMORY_MAP.md` — confirmed ROM/RAM/vector map. - `lat/notes/lat-receive-audit.md` — receive-path audit (Finding 3 = announcements/LAD). - `lat/local/HANDOFF.md` — earlier latd setup notes. - `harness/mame/FINDINGS.md` — MAME harness results (font parser, ROM mapping). - `lat/probe/` — the tools in §4. - `reference/dis34-a-v22.txt.gz` — full analyzed disassembly (byte offset = displayed address). - ROM images rebuilt by `reconstruct.py` into `v2.1/`, `v2.2/`. - MAME source/binary: `/home/arti/code/k-space/mame0289` (outside the project).