Files
vt1200/dwt/notes/EXPLOIT_ATTEMPT.md

13 KiB
Raw Permalink Blame History

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

# 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.