Files
vt1200/NEXT_SESSION.md
T

235 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <seconds>`, accepted range **10–180** (value is a
separate argv: `-m 10`, not `-m10`).
- **Service management:** `latcp -D -a <name>` to delete, `latcp -A -a <name> -C <cmd> -r <rating>` 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).