dwt: visual injection experiments via the returning rdebug jump

This commit is contained in:
SDD committed 2026-10-18 03:22:59 +03:00
1 parent 1e5c095b72
commit 05f1895bf5
80 files changed
+3252

No files matched your search

+25
View File
@@ -285,3 +285,28 @@ Files: `dwt/challenge.py`, `dwt/solve.py`, `dwt/notes/CHALLENGE_REVERSE.md`,
`dwt/evidence/oracle-*.log`, `dwt/evidence/VALIDATION.txt`,
`dwt/evidence/live-run{,2}/`, `dwt/evidence/scripts/{oracle_call,live_verify,live_check}.lua`.
`dwt/exploit.py` and `dwt/mop_console.py` were not modified.
## 2026-10-18 — visual injection via the returning rdebug `jump`
**`jump` is a real `call`.** A 30-byte `rets` payload writes markers, returns
`0`, and the same TCP session plus a later connection still answer opcode 7
(`dwt/evidence/visual/step1_ret_test.log`).
* **Static VRAM draw lands but is repainted.** A three-box payload's writes are
visible in an immediate `peek` but frames before/after are identical; a
spinning control shows the same boxes, so the coordinates are right.
* **Resident DI hook:** `RLONG(0xFFFFFEA0) = 00000DB0h`, stub `JA 3F7146D0h`.
Patching the stub works (pre-handler draw is repainted). A return-PC
trampoline (draw after the handler's `reti`) produced a visible blink:
frames 3450 (box on) / 3480 (off) / 3500 (on), TCP answered at 3450 and 3500.
It is **not durable** — only the first entry takes the `reti` path; later
entries take the `jauc 3F579880` full path and stop using the patched PC
(counter sticks at 1). A one-shot post-handler box persists for 600+ frames.
* **Window:** port 6000 is open, but `xdpyinfo` is rejected (“Client is not
authorized”). Opening a window by injection was not attempted; it is an
X-structure project.
Details, addresses, frames and limits: `dwt/notes/VISUAL_INJECTION.md`,
`dwt/evidence/visual/`. ROM md5 unchanged; no driver edits; only
`VT1200_SKIP_LEVEL1=1` (documented divergence) and a private nvram.
+164
View File
@@ -0,0 +1,164 @@
# 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` |