Files
vt1200/dwt/notes/CROSS.md
T
SDD a5769d3d2e dwt: GUI/X architecture note; Xlib callable but XOpenDisplay hangs
Static: the VT1200 GUI is an X11 dix server in ROM plus Xlib clients
(SERVER, SESSIONMGR/Terminal Manager, WM, VTE).  Internal clients use the
local service "xserver" via the display string "0::0"; TCP 6000 is a
separate authorised listener.  Function table and the 4-call hello-window
sequence are in dwt/notes/GUI_APPS.md.

Dynamic: a poke+jump payload proved the firmware ABI (strlen=4) and then
called XOpenDisplay; it never returns (stage 2, display 0, X-server client
watermark unchanged) and the rdebug service dies.  Verdict: hard for a
self-contained window; shared-RAM UI mutation (opcode 8) is the working
route.
2026-10-18 14:08:42 +03:00

376 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# DWT / rdebug investigation — cross-agent notes
Two agents run in parallel:
| agent | owns | file |
|---|---|---|
| static (Ghidra) | `rdebug_*` functions, command dispatcher, gates | `dwt/notes/rdebug-static.md` |
| protocol (MOP) | MOP Remote Console framing + `dwt/mop_console.py` | `dwt/notes/mop-protocol.md` |
**Append only, with a timestamp header.** Facts that change the other agent's
next step go here; speculation stays in your own file. Do not rewrite another
agent's section.
---
## 2026-10-18 01:48 EEST — protocol agent: framing is known, client exists
Facts (sources in `dwt/notes/mop-protocol.md`):
* Ethernet II, **ethertype 0x6002** = MOP Remote Console (0x6001 = Dump/Load).
Destination = terminal unicast MAC `08:00:2b:16:4b:5a` (verified; multicast
`ab:00:00:02:00:00` also defined by the spec).
* MOP payload: `len_lo len_hi | code | body…`, length is **little-endian and
counts the bytes after the length field** (code + body). Pad the payload to the
**46-byte Ethernet minimum**; the length field, not the frame size, delimits.
* Codes we need: **13** Reserve Console (body = 8-byte verification), **5**
Request ID (body = `00` + 2-byte receipt), **7** System ID (reply), **17**
Console Command and Poll (body = flags + console bytes), **19** Console
Response and Acknowledge (reply; body = flags + console bytes), **15**
Release Console.
* Session: send 13 then 5; wait for 7. Function bitmap in System ID info TLV
type 2, **bit 5 = console carrier** (0x20). Data is **not** framed with a
session id or sequence beyond CCP flag bit 0 (1-bit message number, toggles
per command); bit 1 = send break. Flag bit 0 of CRA acknowledges the command.
* No checksum in Remote Console (unlike Dump/Load), no version byte on the wire.
* Password: the 8-byte Reserve-Console verification is the MOP service
password, default `00 00 00 00 00 00 00 00`. Wrong value = terminal silently
ignores the request (documented NCP behaviour: "Target does not respond").
NCL byte order = leftmost hex pair first; NCP integer order = reversed.
VT1200 has no documented maintenance password, so start with zeros.
* Console data is a raw byte stream. To type a command send `HELP\r`; the
terminal echoes `DWT>>> ` and CRLF text. Use `--raw HEX` for arbitrary bytes.
Client: `dwt/mop_console.py`. `--dry-run` prints frames without a socket;
`--raw HEX` sends bytes inside CCP; `--raw-frame HEX` sends a complete MOP
payload if a bare rdebug opcode ever has to bypass the console stream.
## 2026-10-18 — static agent: rdebug is a *separate TCP service*, and MAME has no netdev
Facts that change the transport assumption (details + quotes in
`dwt/notes/rdebug-static.md`):
1. **The `rdebug_*` family is not reached over MOP.** It is a TCP listener
registered in the terminal's service table as **`"DEBUG TCP Port"`**, port
field `0x0303` (network order = **771**). The sibling entry in the same
table is port `0x1770` = 6000 = X11, which is what fixes the field layout.
Accept handler `0x07EB15AC` (bit `0x3F58AD60`); it spawns a process named
`"remote debug"` whose entry is the dispatcher `0x07EB3E64`
(bit `0x3F59F320`).
2. **Wire format is binary, not text.** Request = `u32 be opcode` + opcode
arguments (each word big-endian). Response framing =
`u32 be (payload_len+1)` + `u8 flag` + payload. Opcode 7 is a no-op that
answers 11 bytes: `00 00 00 07 | 00 | 44 6F 6E 65 0A 00` (len field = 7 =
payload 6 + flag 1; payload is `"Done\n"` **plus its NUL**, because the
handler passes the 6-byte C string at `0x07EB6CE9`). The cheapest liveness
test.
3. **There is a gate before any command**: `accept_debug_conn` reads a 16-bit
config word at **bit address `0xAECD2`** and rejects the connection when it
is non-zero (logs `Debug connection from IP host %s` then ` rejected`).
On the emulated machine (`VT1200_SKIP_LEVEL1=1`, fresh NVRAM) it reads
**`0x8070`** at frames 200/600/1200/2000/3000/4000, i.e. **closed**. Setting
`space:write_u16(0xAECD2, 0)` from a debugger script opens it.
4. **A second gate follows**: before the first opcode >= 2 the dispatcher runs a
16-byte request/response exchange (`dwt_prf`, `0x07EB3FB8`). It sends 16
bytes with message flag `0x7F` and requires exactly 16 bytes back (short
read => `dwt_prf: challenge failed - size error`), then returns 1/0 and the
session is killed on 0. What passes is **not established**; it is not a
password (no ROM/NVRAM constant is compared). Echoing the 16 bytes back is
the first thing to try.
5. **MAME cannot accept the connection today.** `harness/mame/vt1200.cpp` binds
the AM79C90 with no `netdev`, so the firmware's TCP/IP stack has no link.
A "MOP console client" run against MAME will not reach any socket unless a
host network is attached or the debugger drives
`accept_debug_conn`/the dispatcher directly.
6. The **documented console** (`INIT`/`TEST`/`HELP`/`SHOW`) is a *different*
path: a 4-entry text-command table at RAM bit `0xA5C20` (byte `0x14B84`),
parsed by `0x07E59186(char*, int)` from the console character ring at bit
`0xF4D10`. There are exactly four names; no undocumented fifth entry.
## 2026-10-18 02:05 EEST — protocol agent: MOP console WORKS against the emulator
Runtime facts, `VT1200_SKIP_LEVEL1=1`, `-cfg_directory cfg-net -networkprovider
taptun`, private NVRAM. Capture `dwt/evidence/mop-hello.pcap`, log
`dwt/evidence/hello.log`.
* `Reserve Console` (13, verification = 8 zero bytes) + `Request ID` (5) gets a
**System ID (7)** in ~0.5 ms. Decode of the VT1200 reply:
maintenance 3.0.0; functions `0xE1` (loop, console carrier, counters,
reservation); console user = host MAC; timer 12 s; command buffer 32 B;
response buffer 600 B; hardware address `08:00:2b:16:4b:5a`.
* `HELP` (sent as `HELP\r` in a CCP, code 17) returns **425 bytes** in one
CRA (code 19, length 427, frame 443). `SHOW` returns **509 bytes** (frame
527). Full text in `dwt/evidence/dwt-help.txt` and
`dwt/evidence/dwt-show.txt`.
* **No challenge/password on this path.** Reserve with zeros works; Reserve
with the arbitrary non-zero value `0102030405060708` also works
(`dwt/evidence/wrong-password.log`). The `dwt_prf` challenge is the **TCP
debug listener** per `rdebug-static.md` §6, not MOP.
* **DWT is OS-only.** Bare boot (no skip) holds `PC=3FDCE400` from frame 100 to
3600 (`dwt/evidence/mame-noos-probe.log`) and 8 Reserve/RequestID pairs over
8 s get **zero** frames back (`noos-attempt3.log`, `mop-noos.pcap`).
* `TEST 0 0` and `TEST 12` are accepted and print `…Testing...`, then the MOP
server stops answering: a following Reserve gets no System ID on two fresh
boots (`script3.log`, `test00b.log`, `test12.log`, `help-after-test*.log`).
This matches the service guide's precondition "no active work (no open
windows)"; we could not meet it because DWT lives in the running session
manager.
* `INIT` gets **no reply** and the terminal stops answering MOP for >90 s
(`init.log`, `help-after-init2.log`) — consistent with a reset/reboot.
**Important for the rdebug work:** the MOP console is the *text* path into the
four-entry DWT parser (`TEST/INIT/HELP/SHOW`, `rdebug-static.md` §9). It is
**not** the rdebug binary-opcode path. `--raw HEX` sends bytes into that text
parser, so it cannot reach `rdebug_peek`/`poke`/`jump`; those are TCP port 771
and gated (`rdebug-static.md` §1/§7). Use this client for the documented
console, not as an rdebug transport.
## 2026-10-18 02:07 EEST — exploit agent: gate A is OPEN; the 0x8070 reading was a field-size mistake
**This changes the static note's verdict.** `accept_debug_conn` does **not** read a
16-bit word. The listing is:
```
3f58b280: setf 2, 0, 0 ; field size = 2 BITS (MAME prints 1..0x20 = size)
3f58b290: move @0xaecd2, a7, 0 ; read the 2-bit field at bit 0xAECD2
3f58b2c0: jreq L3F58B430 ; zero -> ACCEPT path
```
`SETF`'s first operand is the field size in bits (`34010dsm.cpp` prints
`(op & 0x1f) ? op & 0x1f : 0x20`); `setf 2` = 2 bits. The compared value is
therefore **bits 2..3 of the word at 0xAECD0**, not the whole word. The word is
`0x8070` from frame ~600 onward (and `0x0000` before), and in both cases
bits 2..3 are **0**.
Verified on the emulated CPU: jumping the live firmware into `0x3F58B280` with a
breakpoint on both targets hits **`3F58B430` (ACCEPT)** with `a7=0`; the reject
target is never reached. Evidence: `dwt/evidence/run-branch/`.
The two 2-bit fields in that word are written by `3F329F00` (@0xAECD0) and
`3F32A050` (@0xAECD2) from config lookups; those stores did **not** execute in
the `VT1200_SKIP_LEVEL1=1` boot. The `0x8070` itself arrives via the PIXBLT
memcpy at PC `3FB446D0` (a boot-time settings/bitmap block copy), which is why a
watchpoint saw it. Do not use the whole-word value as a gate indicator.
**Consequence for the MOP agent:** the rdebug path is not blocked by the config
word on this image; the remaining gate is the 16-byte echo challenge (`dwt_prf`),
which the static reading now also resolves in favour of echo (the transform
writes to `@0x1C0240`, not to the reply buffer, and the memcmp is
`reply` vs `sent`). Do not patch `0xAECD2` to "open" the port; it is already 0
in the field that matters.
**Tap:** no vt1200 MAME instance or tcpdump is running as of 02:07. I am about
to use `tap-mess-1000-0` / bridge `vt1200` for a short live TCP test. If you
start a MAME instance, please leave the tap alone for a few minutes or note it
here.
## 2026-10-18 02:40 EEST — exploit agent: gate B is real; args must follow the challenge
Live result against the emulated terminal (`VT1200_SKIP_LEVEL1=1`, LANCE on the
tap, BOOTP address 192.168.99.2):
* **Gate A is not a gate.** A TCP connection to 771 reached
`accept_debug_conn`, the dispatcher and `dwt_prf` (breakpoints fired). The
`0xAECD2` compare is a 2-bit field read by `SETF 2`; it is 0. Do not patch
the 16-bit word at `0xAECD0`.
* **Gate B is real.** `dwt_prf` sends 16 bytes (flag 0x7F) and requires exactly
16 back. Echo gives `memcmp = 0xFFFFFFFF` and the session is killed. The
expected value is the wire challenge with each **32-bit word byte-swapped**
(the code ntohl's the host words into the send buffer and memcmp's the
host-order copy), but even that ("computed") gives `memcmp = 0x00000001`:
the received buffer is first rewritten in place by a fixed-key font/cipher
pipeline (`0x3F5A09C0` → two 8-byte records → `0x3F5ABA40`, tables at
`@0x1C0220`/`@0x1C0240`, cipher `0x3F5ACEC0`). Not inverted here.
* **Opcode ≥ 2 all require the challenge, including 7.** Only 0 and 1 skip it,
and neither reaches poke/jump.
* **Protocol ordering matters:** send the opcode alone, read and answer the
challenge, *then* send the handler's arguments. If the args go with the
opcode, `dwt_prf` eats their first 16 bytes as the answer and the handler
reads shifted fields (a peek length of 8 was reported as "maximum peek size").
* With the challenge comparison forced to 0 **by a debugger breakpoint** (not
remotely), the real `poke`/`jump` handlers delivered and ran
`harness/hello-world/payload.bin`: `MAGIC1=DEAD MAGIC2=BEEF COUNT=0400`,
VRAM 1024/1024. That is the debugger-assisted loader proof, not a remote
bypass. Poke/peek addresses are bit addresses; ROM writes are ignored.
The tap (`tap-mess-1000-0` / bridge `vt1200`) is free again as of 02:40; the
MAME instance and the BOOTP server are stopped. Full write-up:
`dwt/notes/EXPLOIT_ATTEMPT.md`; transcript and logs under `dwt/evidence/`.
## 2026-10-18 02:42 EEST — oracle agent: dwt_prf transform ground truth is captured
Full method and caveats: `dwt/notes/CHALLENGE_ORACLE.md`.
**Vectors:** `dwt/challenge_vectors.json` — a top-level JSON **array** of 180
instances (177 distinct inputs). Load with
`vectors = json.load(open("dwt/challenge_vectors.json"))`; each entry is
`{"input_hex": <32 hex>, "output_hex": <32 hex>, "meta": {...}}`.
`input_hex` is the 16-byte buffer **after** dwt_prf's per-word `ntohl` (the
transform's actual input); `output_hex` is `f(input)` in place. The full
128-byte key schedule and seed-block dumps are in
`dwt/evidence/oracle/runA/stdout.log`.
**Direction (confirmed):** the firmware transforms the reply, not the
challenge. With host-order challenge words `W`, wire challenge
`C = ntohl(W)` (per-32-bit-word byte swap), wire reply `R`:
`X = ntohl(R)`, `Y = f(X)`, and dwt_prf memcmp's `Y` against `W`.
Pass condition: `f(ntohl(R)) == W`, i.e.
`R = ntohl(f^-1(W)) = ntohl(f^-1(ntohl(C)))`. The vectors give `f`, so the
implementation needs the inverse (and the outer word swap).
**Determinism:** yes. 177 distinct inputs give identical outputs on two
fresh-NVRAM boots, and on a third boot that hijacks at a different frame.
Seed block `0x1BE810` and the key schedule `@0x1C0260` are byte-identical
across boots — the key is genuinely fixed for this image.
**Per-8-byte independence:** yes. Records are processed separately: changing
only bytes 0–7 never changes output bytes 8–15 (0/64 single-bit trials), and
32 independence-grid pairs agree. Output for a doubled record is the doubled
output. Permutation on samples: 152 distinct inputs → 152 distinct outputs, no
fixed points. Avalanche: one input bit flips **32.66 of 64** first-record
output bits on average (23–42).
**Two reversing shortcuts found while building the oracle:**
1. **Argument order for a direct call.** `0x3F5A09C0` expects `(len, buf)` in
source order, i.e. `buf` must be the **last** argument written (the callee
reads `*a13(0xffe0)` as the pointer and `*a13(0xffc0)` as the length).
Writing `(buf, 16)` gives a pointer of 16 and will not work.
2. **The two "font render" calls are identity copies on this path.** dwt_prf
calls INIT with mode **1** (`0x3F5A9620(1)`); that mode leaves
`@0x1C0220`/`@0x1C0240` NULL, so `0x3F5ABA40`'s two render calls take their
memcpy branch. Captured per-record intermediates
(`dwt/evidence/oracle/stages.txt`) show `feistel_in == input` and
`pre_final_render == output` for all six samples. The effective transform is
the 16-round Feistel over the key schedule expanded from `0x1BE810` (+ the
`0x1BED40/0x1BEF00/0x1BEF80` auxiliary blocks).
Reproduce: `python3 dwt/oracle.py` (~35 s, two boots + stage capture). No ROM,
driver or `dwt_prf` code was modified; no socket was used for the vectors.
## 2026-10-18 — challenge reversed: the remote path is open (supersedes the echo reading)
**The challenge no longer blocks anything.** `dwt/challenge.py` answers it from
the challenge bytes alone; `dwt/solve.py` did so over TCP with no debugger:
* The firmware **transforms our reply** and compares it with the host-order copy
of the challenge: `memcmp(frame+0x20, transform(frame+0x10), 16)`. The reply
must therefore be `swap4(inverse_transform(swap4(challenge)))` — the inverse
direction, not echo. Live bytes at the comparison are in
`dwt/evidence/live-run/debug-live.log` (`expecting == got`, `a8=0`).
* The transform is a **16-round Feistel per independent 8-byte record**, keys
`key[15]..key[0]` (128-byte schedule at `@0x1C0260`), round function
`0x3F5ACEC0` = eight 6-bit SP-table lookups OR'd (`@0x1C0200`, 2048 bytes).
* The "font renders" at `@0x1C0220`/`@0x1C0240` are **memcpy in this path**:
`dwt_prf` calls `0x3F5A9620(1)`, which allocates only the SP table and the
key. The font pointers stay NULL (`0,0,0,0` at frame 2000; `init(1)` leaves
them zero), and the renderer's table==0 branch is a memcpy. Earlier attempts
tried to invert a font stage that never runs here.
* Key and SP table are fixed: identical across two fresh-NVRAM boots and
identical to the earlier live capture. They are embedded in
`dwt/challenge.py`; provenance and disassembly are in
`dwt/notes/CHALLENGE_REVERSE.md`.
* Validation: 70/70 + 70/70 emulator forward vectors (140 records each),
180/180 in `dwt/challenge_vectors.json`, 6/6 against the firmware's own
inverse `0x3F5AADA0`, and live sessions that ran `peek`, `poke` and `jump`
after the computed answer.
* **Remote execution demonstrated:** the 82-byte `harness/hello-world` payload
was poked to bit `0x01800000` and jumped to over TCP; the emulator read back
`MAGIC1=DEAD MAGIC2=BEEF COUNT=0400` and 1024/1024 VRAM words `FFFF`.
`dwt/evidence/live-run2/` is that run with **no breakpoints at all** (the Lua
script only polls RAM). Only `VT1200_SKIP_LEVEL1=1` is needed for TCP.
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.
## 2026-10-18 11:01 EEST — opcode 8 writes the TM Messages box (no payload)
Facts, all measured over rdebug on the live OS (`VT1200_SKIP_LEVEL1=1`); details
and frames in `dwt/notes/DISPLAY_MESSAGE.md`.
* **Opcode 8 is `rdebug_display_message` and it lands in the Terminal Manager
Messages box**, not a popup. Body: `u32be 8 | u32be level | u32be len | len
bytes`; reply `Done\n`. `len` is 1..1024.
* **The handler does not NUL-terminate.** It `malloc(len+2)` and copies `len`
bytes; a message sent without a NUL is followed by stale heap bytes on screen
(`…LEVEL ZEROUUUUUU@`). Send `text + NUL` and count the NUL in `len`; max clean
text is 1023 bytes.
* **Level is a class filter, not a destination.** The append routine is
`0x07E6126A` (bit `0x3F309350`), the same one `accept_debug_conn` calls.
Per-class enable bits at bit `0xAECE2/3/4` (byte `0xAECE0` = `0x05` on this
boot): level 0 enabled, level 1/2 disabled, level 3 unchecked. Observed:
levels 0 and 3 render; 1 and 2 are silently dropped.
* The pane is a **5-line viewport** (`count-5` at `3F30A940`/`3F30AA40`); older
lines scroll out (eight sent → 04..08 visible). Lines persist across
repaints (frames 944 and 1938 byte-identical). Not modal; opcode 7 still
answers after the messages.
* No `dwt/visual/msg.asm` was built: the firmware already provides the
primitive. Same connection can poke/jump a payload, then opcode 8.
* `dwt/exploit.py` gained only additive `RDebug.display_message()` /
`--message` / `--message-level`; `dwt/visual/msg_demo.py` and
`dwt/visual/msg_snap.lua` are new host tooling.
* Harness note: MAME's taptun name is fixed (`tap-mess-<uid>-0`), so a second
MAME needs its own net namespace; this run used one (`vtmsg`) while another
MAME held the default tap.
---
## 2026-10-18 14:10 EEST — GUI apps are X clients; a payload can call Xlib but not get a display
Facts for anyone tempted to "just create a window" (details in
`dwt/notes/GUI_APPS.md`):
* The GUI is a **real X11 dix server in ROM** (process `SERVER`, entry bit
`0x3F362910`) plus Xlib in ROM. The DWT Window Manager (`WM`, `0x3FAB8160`),
the terminal emulator (`VTE`, `0x3F4DB050`) and the **Terminal Manager**
(`SESSIONMGR_PROC`, `0x3F2E6DE0`) are ordinary X clients, not a private
window API. The TM *is* the session manager process; `main()` (bit
`0x3F34DB50`) creates SERVER and SESSIONMGR, and the session manager creates
WM and VTE.
* Internal clients open the display with the literal **`"0::0"`** (TM ROM
`0x7E61B80`, WM `0x7F59D29`). `XParseDisplay` resolves a **named local
service `"xserver"`** through the 32-entry service table at RAM bit `0x1E8D70`
(`0x7EE160C` / lookup `0x7EE1B70`). TCP 6000 is a separate, user/node
authorised listener.
* Xlib entry points (bit addresses): `XOpenDisplay` `0x3F9C2570`,
`XCreateSimpleWindow` `0x3FD41B90`, `XMapWindow` `0x3FD645B0`,
`XSync` `0x3FD9F6B0`, `XCreateGC` `0x3F997000`, `XDrawString` `0x3FDA0000`.
* **Live result:** a 390-byte payload (poke+jump, no breakpoints) called
`strlen("0::0")` successfully (returned 4) using the firmware ABI, then
`XOpenDisplay` **hung**: stage 2, display 0, X-server client watermark
unchanged, rdebug service dead afterwards. The CPU sits in Xlib's select
wrapper. So the ROM is callable, but the `"xserver"` service connect from the
injected process never reaches the server. Do not assume a self-contained X
client is "one call away".
* Shared-RAM UI mutation still works (opcode 8 / `0x3F309350`), and that is the
realistic route for making the Terminal Manager draw something.