# rdebug exploit attempt — gates, loader, and what actually stops a remote attacker Status: **the loader works; the remote path is stopped by gate B.** Gate A is not a gate on this image at all. ROM untouched: `md5 b46a3e442ccb86134d2356bd70d22fd2` (re-checked after every run). Driver untouched. No `/tmp` artefacts. Harness caveat: the emulated OS/TCP stack is only reachable with `VT1200_SKIP_LEVEL1=1`, which drives DUART1 IP3 high — a modelled board input, not the real machine's POST. That divergence is what lets the terminal boot far enough to listen on 771; it does not change the rdebug code, the gate fields or the challenge, but the remote result is about the firmware's service with the OS up, not about a real terminal's level-1 run. Two results must not be confused: | result | how it was reached | |---|---| | **remote** — TCP client → accept → dispatcher → challenge | real TCP connection to `192.168.99.2:771`, emulator running `VT1200_SKIP_LEVEL1=1` with the LANCE on a host tap | | **debugger-assisted** — TCP client → poke → jump → payload | same, but one Lua breakpoint forced the challenge comparison to `0` for that connection | The second proves the **loader**, not a remote bypass. The remote client never got past the challenge. --- ## 1. Gate A: `SETF 2` is a *two-bit* field, and it is zero The static note read this as a 16-bit word compare and concluded "0x8070 ⇒ reject". That is a field-size misread. The listing (`harness/mame/hostport_dis34.txt:156211`): ``` 3f58b280: setf 2, 0, 0 3f58b290: move @0xaecd2, a7, 0 3f58b2c0: jreq L3F58B430 ; zero -> ACCEPT path ... 3f58b430: ... ; accept, create "remote debug" 3f58b2d0: ... ; " rejected", return -1 ``` `SETF`'s first operand is the field size **in bits**, not a width code: * `src/devices/cpu/tms34010/34010dsm.cpp:531` prints `"SETF %Xh,%X,"` with `(op & 0x1f) ? op & 0x1f : 0x20` — i.e. `2` means 2 bits. * `src/devices/cpu/tms34010/34010ops.hxx:839` stores that size in `m_st`, and `src/devices/cpu/tms34010/34010fld.hxx:23` dispatches size 2 to `wfield_02` / `WFIELDMAC(0x03,15)` — a 2-bit field. So the compared value is **bits 2..3 of the 16-bit word at bit `0xAECD0`**, not the word. That word is `0x0000` early and `0x8070` from frame ~600; in both cases bits 2..3 are `0`: ``` dwt/evidence/run-branch/gate_branch.log SAMPLE frame 1 word@0xAECD0=0000 field(bits2..3)=0 SAMPLE frame 600 word@0xAECD0=8070 field(bits2..3)=0 SAMPLE frame 1000 word@0xAECD0=8070 field(bits2..3)=0 ``` **Executed the gate code itself** (not just read memory): at frame 1000 the script redirects the CPU into `0x3F58B280` and breaks on both targets. The accept target is hit, with `a7 = 0`: ``` REDIRECT from 3F57A0E0 GATE-TARGET ACCEPT pc=3F58B430 a7=00000000 ``` ### Who writes the word * The two 2-bit fields are written by `3F329F00` (`@0xAECD0`) and `3F32A050` (`@0xAECD2`) from config lookups (cells `0x155830` / `0x1565b0`). Those stores **did not execute** in the `VT1200_SKIP_LEVEL1=1` boot (breakpoints never fired). * The `0x8070` word arrives between frames 200 and 600 from a boot-time **PIXBLT block copy** at PC `3FB446D0` — a watchpoint caught `0xAECC0=0x8000, 0xAECD0=0x8070, 0xAECE0=0xA005, 0xAECF0=0x0008` (`dwt/evidence/run-gate/debug.log`). The copy source was not identified. * It is therefore a **16-bit word in a copied RAM settings/bitmap block**, not a semaphore or a computed flag. Whether a user-visible setting can make the 2-bit field nonzero is **not established**; what is established is that on this image it is zero and the accept path is taken. ### Remote evidence that Gate A is open A real TCP connection reached the accept handler, the dispatcher and the challenge (all breakpoints fired on the same connection): ``` dwt/evidence/run-live/debug.log LIVE accept_debug_conn entry pc=3F58AD60 LIVE dispatcher entry pc=3F59F320 LIVE dwt_prf challenge entry pc=3F59FDC0 ``` **Verdict:** a remote attacker does not need to clear Gate A. It is open by default on the emulated image; the 0x8070 is irrelevant to the compared field. This corrects `dwt/notes/rdebug-static.md` §7 and the corresponding CROSS.md entry. --- ## 2. Gate B: the 16-byte challenge is real, and echo does not pass `dwt_prf` (`0x3F59FDC0`) runs once per connection, before the first opcode ≥ 2 (opcodes 0 and 1 skip it; opcode 7 does **not**). It sends four runtime words with frame flag `0x7F`, reads exactly 16 bytes back, and the dispatcher kills the session when the function returns 0. Live result with **echo**: ``` [>] 00 00 00 07 [<] len=17 flag=0x7f payload=f3 2c ec 8d 00 00 ea d0 02 63 a8 c0 f3 24 ec 8e [.] answering echo challenge ... [>] f3 2c ec 8d 00 00 ea d0 02 63 a8 c0 f3 24 ec 8e error: connection closed after 0/4 bytes ``` Emulator side: `LIVE challenge memcmp a8=FFFFFFFF (0=equal)` — **not equal**, so the process exits. A failed challenge is fatal and it is per connection. ### Why echo fails: two layers 1. **Byte order.** The four words are stored in host order at `frame+0x20`, byte-swapped by `ntohl` (`0x07F65828`, a full 32-bit swap) into `frame+0`, and the *swapped* bytes are what goes on the wire. The memcmp's first argument is `frame+0x20`, so the expected value is the wire challenge with **each 32-bit word byte-swapped**. `dwt/exploit.py --challenge computed` implements exactly that. 2. **An in-place transform.** The 16 received bytes are passed to `0x3F5A09C0`, which processes them as two 8-byte records. It runs a font renderer (`0x3F5AC710`, tables at `@0x1C0220` / `@0x1C0240`), a 16-round Feistel/SP-table cipher (round function `0x3F5ACEC0`, 128-byte key schedule at `@0x1C0260`, 8 SP tables at `@0x1C0200`), and a second render — and it writes the result **back into the reply buffer**. The memcmp is `memcmp(frame+0x20, reply, 16)`. With the byte-swapped answer (`computed`), the memcmp returned `0x00000001` — still not equal. So the second layer is the real blocker. ### What was established about the transform * Deterministic and per-8-byte-record: equal 8-byte records produce equal 8-byte outputs, independent of time (two all-zero runs agreed exactly). * Nonlinear: `f(03) != f(01) ^ f(02) ^ f(00)`, so it is not a bit-linear map. * It rewrites the reply buffer in place; the observed outputs for chosen inputs are in `dwt/evidence/run-live/render-probes.txt` (e.g. `f(00*8) = 90 de 09 b4 72 85 58 17`). * The cipher-in/cipher-out dumps are saved (`key.bin`, `sbox.bin`, `cipher_in.bin`, `cipher_out.bin`). They were not consistent enough (the dump breakpoints fire once per 8-byte record and overwrite each other) to reduce the pipeline to an invertible form in the time available. No claim is made here about the cipher's exact input/output relation. `dwt/exploit.py --challenge echo|computed` implements layers 1a and 1b. `--challenge-raw HEX` exists to plug in a full answer once the transform is inverted. ### Protocol trap (worth knowing) The opcode's arguments must be sent **after** the challenge answer, not with the opcode. Sending `opcode|args` in one write makes `dwt_prf` consume the first 16 bytes of the arguments as its answer; the handler then reads shifted fields and reports nonsense such as `rdebug_peek: maximum peek size is 1024 bytes` for a length of 8. The client now sends the opcode alone, answers, then sends the arguments. This is a real property of the firmware's sequential reads. --- ## 3. `poke` / `peek` / `jump` semantics (measured) All four requests below used the real TCP handlers. **Address is a bit address.** `peek 0x3F200000` (bit) returned `e9 7a e2 f5 aa aa 55 55`, byte-for-byte the first 8 bytes of `vt1200_v22.bin` (file offset 0). A poke to `0x01820000` (bit) with `DE AD BE EF` read back as `DE AD BE EF`. **Maximum transfer is 0x400 = 1024 bytes** per request (code `CMPI 0x400`; the firmware's own text `rdebug_peek: maximum peek size is %d bytes`, `reference/rom-strings.txt:2579`, and `rdebug_poke: ... %d bytes`, line 2581). Repeat requests for larger images. **ROM is write-protected by the memory map, not by the handler.** A poke to `0x3F200000` returned `Done`, but a following peek still returned `e9 7a e2 f5 aa aa 55 55`. The handler does no protection check; the driver maps ROM with `.rom()`. **`jump` is `call(bit_addr, args...)`.** The handler reads `{addr, nargs}` plus `nargs` big-endian words (≤ 20), then jumps through a 20-entry stub table at `0x3F5A6AA0`; stub *n* pushes *n* arguments and does `call *a13`. Arguments are therefore ordinary firmware stack arguments, not registers. There is no PC assignment and no return-address check. **`peek` reads back `poke`** (the `DEADBEEF` test above), so the write primitive is honest. --- ## 4. The loader, proved debugger-assisted To separate "the loader works" from "gate B blocks it", one Lua breakpoint at `0x3F5A0920` forced the challenge comparison to equality (`a8 = 0`) **for that connection only**. Everything else is the real path over TCP: ``` LIVE challenge memcmp FORCED a8=FFFFFFFF -> 0 LIVE rdebug_peek entry ... (implicitly, via the peek/poke tests) LIVE rdebug_poke entry pc=3F5A1B50 LIVE rdebug_jump entry pc=3F5A3890 ``` The 82-byte `harness/hello-world/payload.bin` was poked to bit `0x01800000` and called with `jump 0x01800000`. The emulator read back: ``` LIVE payload-ran: MAGIC1=DEAD MAGIC2=BEEF COUNT=0400 LIVE payload-ran: VRAM block 1024/1024 words FFFF, 0 holes ``` That is the hello-world payload (DEAD/BEEF/1024 iterations + the 256×64 VRAM block), **executed through the stock loader**. The snapshot call failed only because this MAME's Lua `machine.screens[1]` index is not valid; the RAM and VRAM readback are the evidence. **Boundary:** this is debugger-assisted. It proves `poke` + `jump` can deliver and run our code; it does **not** prove a remote attacker can pass gate B. --- ## 5. Reproduction ```sh # build/ROM check md5sum vt1200_v22.bin # b46a3e442ccb86134d2356bd70d22fd2 # Gate A, offline: sample the 2-bit field, and enter the gate code itself M=/home/arti/code/k-space/mame0289/vt1200 cd dwt/evidence/run-branch VT1200_SKIP_LEVEL1=1 $M vt1200 -rompath ../../../harness/mame/roms \ -video none -sound none -nothrottle -debug -debugger none -debuglog \ -nvram_directory $PWD/nvram \ -autoboot_script ../scripts/gate_branch.lua -autoboot_delay 0 # expect: GATE-TARGET ACCEPT pc=3F58B430 a7=00000000 # Live TCP (needs the tap + BOOTP; see harness/net-setup.sh and harness/serve.py) ./harness/net-setup.sh sudo python3 -u harness/serve.py 192.168.99.1 --bootp & # MAME: -cfg_directory -networkprovider taptun # VT1200_SKIP_LEVEL1=1, -debug -debugger none -debuglog # after the terminal answers ping 192.168.99.2: python3 dwt/exploit.py --host 192.168.99.2 --opcode 7 --verbose # challenge appears, echo is sent, connection closes, memcmp = FFFFFFFF python3 dwt/exploit.py --host 192.168.99.2 --opcode 7 --challenge computed # memcmp = 00000001 -- byte order fixed, in-place transform still blocks # Debugger-assisted loader proof: run live_probe.lua with the memcmp forced to 0 python3 dwt/exploit.py --host 192.168.99.2 --poke 0x01820000 --hex DEADBEEF python3 dwt/exploit.py --host 192.168.99.2 --peek 0x01820000 --len 4 python3 dwt/exploit.py --host 192.168.99.2 \ --poke-file 0x01800000 --file harness/hello-world/payload.bin python3 dwt/exploit.py --host 192.168.99.2 --jump 0x01800000 --jump-wait 0 ``` ## 6. What a remote attacker can and cannot do * **Can**: connect to TCP 771 (gate A passes), reach the dispatcher, and trigger the challenge and the public opcodes that require it (the challenge will kill the session on failure). Nothing useful is reachable without passing it. * **Cannot**: pass gate B. Echo and word-swapped echo both fail; the firmware's fixed-key font/cipher transform rewrites the reply in place and was not inverted here. Therefore `peek`, `poke`, `jump`, `start_telnet`, `display_message` and `usage_stats` are **not remotely reachable on this image** without either solving the transform or a different memory-safety bug. * **Not a gate**: the `0xAECD2` word. Do not patch it; the compared field is already zero. ## 7. Files | path | contents | |---|---| | `dwt/exploit.py` | stdlib client: opcode 7, peek, poke, poke-file, jump, echo/computed/raw challenge, `--dry-run` | | `dwt/evidence/run-branch/` | gate-field samples, the ACCEPT branch hit, `debug.log` | | `dwt/evidence/run-gate/` | write watchpoints on the 0x8070 word | | `dwt/evidence/run-static/` | the 128-byte block at `0x1BE810` and table pointers | | `dwt/evidence/run-live/` | live logs, challenge dumps, key/SP-table dumps, render probes, `mame.log` with the payload markers | | `dwt/evidence/scripts/` | every Lua probe and the oracle harness | *ROM unmodified. No driver edits. Evidence gathered on the emulator only.*