11 KiB
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 to09: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 memcpys 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:
- Terminal on, at the LAT service list.
- Connect to
VT1200(orSOAK). - 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 - 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:
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:
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:
- Send only the type-
0xEslot, at 10 s, and see if that alone kills it. - 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.
- 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
--timingmode toreplay_stream.py:extract_stream.pyalready 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):pcfReadBitmapsnum_chars*4overflow is confirmed and reproduced in-emulator (nis a full 32-bitpcfReadInt32, no bound check, then a loop writingnCARD32s):n=0x10000001→fosAlloc(0x40000004);n=0x40000000→ alloc of 0.- The ~1 KB font limit is the firmware's own
tftp_readbuffer 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 at0xff200000–0xffffffff(MAME's address-map unit is 1 bit formemory_access<32,16,3>).
6. Environment facts and gotchas (hard-won — do not relearn these)
- Terminal: MAC
08:00:2b:16:4b:5a, IP100.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 intowg-k-space. This silently broke TFTP for a long time. - Capture filter matters:
ether host 08:00:2b:16:4b:5adoes not capture multicast — useether proto 0x6004. Half of "we saw no announcements" was this filter, not reality.filter-test2.pcapused 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 withsudo latcp -h. - latd runs service commands via
execvp, not a shell (portservice.cc/serversession.cc:298). So-Cneeds 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 foocan kill your own shell when the pattern appears in your own command line. Usepkill -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:fontdirover TFTP, and the value must change to re-enable Apply;xset fp+/rehashare 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 (only3F9B8EA0in 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.pyintov2.1/,v2.2/. - MAME source/binary:
/home/arti/code/k-space/mame0289(outside the project).