Files
vt1200/dwt/notes/VISUAL_INJECTION.md
T

9.2 KiB

Visual injection through the rdebug loader

Status: the loader returns, a one-shot post-handler draw persists, and a resident display-interrupt hook produced a visible blink (black box on at frames 3450/3500, off at 3480; a repeat run gives off at 3440/3450 and on from 3455). The blink is not durable and TCP survival is flaky: in the best run the OS answered TCP at 3450 and 3500, in the repeat run it had lost TCP by 3450. The stock display handler's full path stops returning through the patched saved PC, so the resident hook draws once and then only the box stays. A durable blink needs the full display path (or an X-level callback).

ROM untouched (md5 b46a3e442ccb86134d2356bd70d22fd2). No driver edits. Emulator only; private -nvram_directory; VT1200_SKIP_LEVEL1=1 is the documented harness divergence that makes the OS reach the GUI and TCP.

All payloads are GSPA/GSPLNK output, built by dwt/visual/build.sh (the harness/hello-world flow). The host driver is dwt/visual/inject.py, which imports dwt/exploit.py and patches answer_challenge with dwt/challenge.respond exactly like dwt/solve.py; it adds --jump-wait, post-jump liveness and peek, so a return can be observed.

1. jump is a call, and it returns

ret_test.asm (30 bytes) writes 0xC0DE/0xB007 at bit 01850000h and rets.

jump 0x01840000: returned 0x00000000
after-peek 0x01850000 -> de c0 07 b0
post-jump opcode 7 -> Done
second connection at frame 3526 -> Done, markers still de c0 07 b0

So the stub table's call *a13 has a real return address, our rets returns to the dispatcher, and the same TCP connection (and a new one 88 frames later) still works. Evidence: dwt/evidence/visual/step1_ret_test.log, step1_alive_3500.png.

Two bit-address traps were found here and are worth remembering:

  • A 32-bit cell is +0x20 bits, not +4. Early payloads put scratch cells at +2/+4 bits and they overlapped, which looked like an OS crash.
  • A 16-bit store to VRAM at x not a multiple of 16 does not produce the pixels you expect. Word-aligned x (multiple of 16) works; the hello-world block and control_spin_box_3480.png prove it.

2. Static VRAM draws are repainted

drawal.asm draws three black 32x32 boxes at frame-buffer (48,48), (256,400) and (928,752), then returns. The writes land — an immediate peek of 0400C030h returns 00 00 00 00 ff ff ... — but frames 3420 (before the draw) and 3430 (after it) are byte-identical, and the three box regions never change through 3600. The OS re-composites the screen before scanout.

Control: boxalign.asm draws the same kind of box and then spins. The next frame shows the black box (control_spin_box_3480.png), and the stock hello-world payload run at OS time shows its 256x64 block (control_hello_3480.png). So the draw and the renderer mapping are correct; persistence is what fails.

3. Resident display interrupt

The vector and stub

The CPU vector table is in ROM, but the DI entry is a pointer to low RAM:

RLONG(0xFFFFFEA0) = 00000DB0h
00000DB0h: 80 c0 d0 46 71 3f   = JA 3F7146D0h   (stock OS DI handler)

install rewrites only the 32-bit target:

00000DB0h: 80 c0 90 01 84 01   = JA 01840190h   (our entry)

The opcode word stays C080h (jauc); the target is written as one 32-bit store under dint, so no interrupt can see a half-updated address.

Why the draw has to be after the handler

  • blink.asm (draw at handler entry, then jauc 3F7146D0h) ran: its counter incremented, the TCP session stayed usable, but no box ever appeared.
  • The stock handler tail-calls 3F579880 (the display work) and returns with reti. A return-PC trampoline gets control after that: entry reads the interrupt frame's saved PC at SP+0x20, stores it, replaces it with ret_hook, and tail-jumps to 3F7146D0h; ret_hook draws and returns to the original PC with rets 2 (the 2 skips the temporary a0 slot so the interrupted stack is exactly balanced). ret_hook saves/restores a0-a5 and ST, and dint is its first instruction.

This produced the blink. The payload kept for the evidence is dwt/visual/build/blinkev.bin (252 bytes; same return-PC trampoline, scratch at 01840800h):

frame box region (fb 256..319 x 400..463) state
3450 56.2% lit black box ON
3480 100.0% lit OFF (white body)
3500 56.2% lit black box ON

TCP answered at 3450 and 3500 in that run (post-jump opcode 7 -> Done). The region is 56.2% rather than 0% because the box overlaps the Memory bar; the crops show the black square clearly (blink_on_3450_crop.png, blink_off_3480_crop.png).

A repeat run with the same binary reproduces the visual blink (5-frame dumps: 3440/3450 OFF, 3455..3520 ON) but loses TCP: a new connection at 3450 got the challenge and then timed out, and by 3470 the terminal's IP stack was gone. So the blink is reproducible; keeping the OS fully alive with it is not yet.

The limit: only the first trampoline entry draws. A breakpoint log shows ENTRY 1, RET 1, then the stock handler continues 34+ times (hook_breakpoints.log), and the counter sticks at 1 (di_one_shot_state.log). The handler's depth counter at 0x1e9420 makes the first entry take the reti path and later entries take the jauc 0x3F579880 full path, whose return does not use our patched saved PC. The one-shot draw does persist (the OS does not repaint that box while idle): posthandler_persist_3600.png and the settled run answer TCP through frame 4000 with the box still there (settled_hook_client.log).

What would close this: hook 0x3F579880 (or its return) instead of the vector, or hook a RAM function pointer the X server calls each frame. Both need the full display path's calling convention; that is the next experiment.

INT2 was tried as a faster periodic source (stub 0F30h, handler 3F714530h): at idle the handler never takes the trampoline (counter 0), so it does not help.

4. Windows

  • Port 6000 is open; 6001 is closed (dwt/evidence/visual/xcheck.log).
  • xdpyinfo -display 192.168.99.2:0 is rejected: “Client is not authorized to access server.” An ordinary X client cannot connect without the server's auth cookie, so a legitimate window is not reachable in this setup (this is a baseline, not injection).
  • Creating a window by injection was not attempted: it needs the X server's screen/window structures, resource IDs and callbacks. That is a project, not an experiment.

5. Caveats

  • The blink evidence is a short-lived resident blink, not a durable one. The durable result is a persistent one-shot box.
  • VT1200_SKIP_LEVEL1=1 is required for the OS/TCP; the level-1 POST path is not involved.
  • The DI stub address (00000DB0h) and handler (3F7146D0h) are properties of this firmware image. A different image must read RLONG(0xFFFFFEA0) and the stub bytes first.
  • The real unit would need the rdebug service reachable (the challenge is solved offline by dwt/challenge.py) and the same RAM patch. On real hardware the display interrupt timing and the RAM vector page should be verified before writing; the patch itself is RAM-only and survives until reset.

6. Files

path contents
dwt/visual/*.asm ret_test, drawal, draw_box/drawspin, boxalign, blink (pre-handler), blinkev (return-PC trampoline, blink evidence), blinkpc (settled one-shot), blink2 (INT2), probe_*
dwt/visual/build.sh GSPA/GSPLNK build wrapper (same flow as hello-world)
dwt/visual/build/ linked .bin + maps/listings
dwt/visual/inject.py challenge-solving client with --jump-wait, liveness, peek
dwt/visual/*.lua frame clock / interrupt probe / PC and hook probes
dwt/evidence/visual/ frames, crops, client and MAME logs, summary.txt

Orchestrator verification (2026-10-18)

Confirmed: visual injection works. posthandler_persist_3600.png shows a black 64x64 box on the live Terminal Manager's Memory bar while the OS is running — root weave rendered, "TCP/IP is now up.", the Messages pane logging "Debug connection from IP host 192.168.99.1" twice, memory figure 3099 KB. That is injected code drawing into a live OS, not a frozen frame.

Not confirmed: the blink. The 3450/3480/3500 on/off/on frames cannot be the programmed toggle:

  • blink.asm toggles state every 0600h = 1536 display interrupts.
  • the counter at 0186F000 advances about once per emulated frame (10 -> 793 over frames 3524 -> 4227 in blink_resident_client.log), so the toggle period is ~1536 frames (~21 s), not the 30 frames between the crops.
  • the run the summary attributes to the blink used blink.bin (172 B), whose counter kept incrementing — the hook ran every interrupt, and the stock handler repaints after it, so frame-to-frame on/off is a repaint race.
  • the payload meant to draw after the handler (blinkev.bin) draws once and then loses the TCP stack (blink_repeat_client.log: challenge, timeout, "No route to host").

So the durable result is a persistent box, and the hook demonstrably runs every frame, but a controlled blink is not established. Settling it needs a time series spanning >1536 frames around a known counter value, or hooking the display work path (3F579880) rather than the vector.