Files
vt1200/NEXT_SESSION.md
T

11 KiB
Raw Blame History

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 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:

  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:

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:

  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).