mirror of
https://git.k-space.ee/arti/vt1200.git
synced 2026-10-31 12:05:05 +02:00
192 lines
9.2 KiB
Markdown
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.
|