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.pngprove 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, thenjauc 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 withreti. A return-PC trampoline gets control after that:entryreads the interrupt frame's saved PC atSP+0x20, stores it, replaces it withret_hook, and tail-jumps to3F7146D0h;ret_hookdraws and returns to the original PC withrets 2(the2skips the temporary a0 slot so the interrupted stack is exactly balanced).ret_hooksaves/restores a0-a5 and ST, anddintis 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;
6001is closed (dwt/evidence/visual/xcheck.log). xdpyinfo -display 192.168.99.2:0is 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=1is 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 readRLONG(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.asmtoggles state every0600h= 1536 display interrupts.- the counter at
0186F000advances about once per emulated frame (10 -> 793 over frames 3524 -> 4227 inblink_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.