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