mirror of
https://git.k-space.ee/arti/vt1200.git
synced 2026-10-31 12:05:05 +02:00
235 lines
11 KiB
Markdown
235 lines
11 KiB
Markdown
# 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).
|