Files
vt1200/dwt/notes/VISUAL_INJECTION.md
T

192 lines
9.2 KiB
Markdown

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