Files
vt1200/dwt/notes/EXPLOIT_ATTEMPT.md
T

293 lines
13 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.
# 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 <copy of harness/mame/cfg-net> -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.*