Files
vt1200/dwt/notes/CHALLENGE_REVERSE.md
SDD 1e5c095b72 dwt: reverse the dwt_prf challenge; remote poke/jump now works
The firmware transforms the reply (16-round Feistel per 8-byte record) and
compares it with the host-order copy of the challenge, so the answer is
swap4(inverse_transform(swap4(challenge))).  The font stages are memcpy in
this path because dwt_prf initialises only mode 1.

challenge.py implements the transform and its inverse using the firmware's
fixed key/SP table; solve.py answers the challenge over TCP using the
unmodified exploit client.  320 forward vectors, the firmware's own inverse
and three live sessions validate it.  A live run with no breakpoints poked
the 82-byte hello-world payload and ran it: MAGIC DEAD/BEEF/0400, VRAM
1024/1024.  Notes and evidence under dwt/notes and dwt/evidence.
2026-10-18 02:36:23 +03:00

12 KiB

The dwt_prf challenge, reversed — and the remote path is open

Status: solved. The 16-byte challenge at bit 0x3F59FDC0 (Ghidra 0x07EB3FB8) can be answered from the challenge alone, offline, with no debugger. The reply is computed with dwt/challenge.py; two live TCP sessions passed the comparison (memcmp returned 0) and ran an 82-byte payload through poke + jump.

ROM untouched: md5 b46a3e442ccb86134d2356bd70d22fd2. No driver edits. The TCP path needs VT1200_SKIP_LEVEL1=1, a documented harness divergence.


1. Direction: the firmware transforms our reply and compares it to the challenge

dwt_prf's post-recv code (reference/dis34-a-v22.txt.gz, decoded with tools/smart34010/dis34, base bit 0x3F59FDC0):

3f5a0140:	push 16 ; push a13 ; push 0x7f ; push session
3f5a01d0:	calla  L3F5A0B20          ; write_message(flag=0x7F, buf=a13, 16)
3f5a0200:	push 0 ; push 16 ; a11 = a13+0x80 ; push a11 ; push session
3f5a02b0:	calla  0x3f5d8e70          ; recv(session, a13+0x10, 16, 0)
3f5a0410:	... ntohl(*a13(0x80)) -> *a13(0x80)
3f5a0490:	... ntohl(*a13(0xa0)) -> *a13(0xa0)      ; +0x04
3f5a0510:	... ntohl(*a13(0xc0)) -> *a13(0xc0)      ; +0x08
3f5a0590:	... ntohl(*a13(0xe0)) -> *a13(0xe0)      ; +0x0c
3f5a0660:	calla  0x3f5a9620          ; init(1): F table + key only
3f5a06c0:	calla  0x3f5aa2c0          ; key schedule from 0x1BE810
3f5a0740:	push 16 ; a11 = a13+0x80 ; push a11
3f5a07b0:	calla  L3F5A09C0          ; transform(reply, 16)  IN PLACE
3f5a0810:	push *a13(0x1a0) ; calla 0x3fb338d0
3f5a0880:	push 16 ; push a11
3f5a08b0:	a9 = a13 + 0x100 (bits)  ; = frame+0x20
3f5a08f0:	calla  0x3f942230          ; memcmp(frame+0x20, a11, 16)
3f5a0930:	jreq -> return 1, else return 0
  • Four runtime words are stored at frame+0x20 (bit offsets 0x100/0x120/ 0x140/0x160), byte-swapped into frame+0, and those network-order bytes are what write_message sends. The comparison uses frame+0x20, i.e. the host-order copy — the wire challenge with each 32-bit word reversed.
  • The reply is received at frame+0x10, ntohl'd in place, and then 0x3F5A09C0 rewrites frame+0x10 in place. Live confirmation at the comparison (dwt/evidence/live-run/debug-live.log):
LIVE2 challenge sent a13=007AE7B0 wire=3DB79CF3.8EB50000.C0A86302.3EB794F3 host=F39CB73D.0000B58E.0263A8C0.F394B73E
LIVE2 pre-transform reply=71C08104.AF81012D.BE5884F7.634E8818
LIVE2 memcmp-args expecting=F39CB73D.0000B58E.0263A8C0.F394B73E got=F39CB73D.0000B58E.0263A8C0.F394B73E
LIVE2 challenge memcmp a8=00000000 (0=equal)

The client sent wire bytes f3 9c b7 3d ... (printed above as little-endian word 3DB79CF3); the host copy at frame+0x20 is 3d b7 9c f3 ... — exactly swap4(wire). The transform's input (pre-transform reply) is 04 81 c0 71 ... = swap4(reply_wire). So:

firmware check:  transform(swap4(reply_wire)) == swap4(challenge_wire)
required reply:  reply_wire = swap4(inverse_transform(swap4(challenge_wire)))

This is the inverse direction. Echo cannot work, and neither can any "computed" byte swap alone. The forward function alone is not enough.

2. Why earlier attempts failed to see the structure

Earlier notes described a "font render + Feistel" pipeline with the font tables at @0x1C0220 / @0x1C0240. The pipeline does contain two render calls, but in the rdebug path they are memcpy:

  • 0x3F5A9620 is the only initialiser of those globals (its one direct calla in the linear disassembly is in dwt_prf, which calls it with mode 1), and at frame 2000 of a booted OS all four globals are still zero — the OS boot never calls it.
  • Mode 1 allocates and fills @0x1C0200 (the SP tables) and @0x1C0260 (128-byte key) and returns; it never allocates @0x1C0220 / @0x1C0240. A frame-2000 read of all four globals in a booted OS is 0,0,0,0; after init(1) only the first and third are nonzero.
  • The renderer 0x3F5AC710 branches on its table argument: if the table pointer is zero it calls 0x3FE169C0 with 8 bytes, i.e. memcpy(dest, src, 8). Both render calls therefore copy, and the two copies cancel inside one record. What remains is a pure Feistel network.

The "font" part of the pipeline exists, but it is dead on this code path. That is why the captured render probes looked nonlinear yet did not fit a font.

3. The transform

0x3F5A09C0 (Ghidra 0x07EB4138) walks the buffer in 8-byte records (addi 0x40, a9 = +8 bytes, subk 8, a10):

3f5a09f0: a9 = arg0 (buffer), a10 = arg1 (length)
3f5a0a30: if length == 0 return
3f5a0a40: push a9 ; calla 0x3f5aba40 ; a9 += 8 bytes ; a10 -= 8 ; loop

Each record is independent of the other. 0x3F5ABA40 (Ghidra 0x07EB5748) does, per record:

  1. render(record, @0x1C0220, scratch) — memcpy on this path.
  2. a10 = bswap32(word at scratch+0), a9 = bswap32(word at scratch+4) (0x3F5AE670 swaps bytes 0<->3 and 1<->2), i.e. the two halves are read big-endian.
  3. 16 Feistel rounds, keys from @0x1C0260 + 0x78 down to @0x1C0260 + 0x00, 8 bytes each (subi 0x40, a11 after every round):
round n (n = 0..15):  k = key[15 - n]
  n even:  a10 ^= F(a9,  k)
  n odd :  a9  ^= F(a10, k)
  1. scratch+0 = bswap32(a9), scratch+4 = bswap32(a10) — written big-endian as (R, L), because a9 is stored first.
  2. render(scratch, @0x1C0240, record) — memcpy back on this path.

Round function F — 0x3F5ACEC0 (Ghidra 0x07EB59D8)

F(x, k8) forms eight 6-bit indices and ORs one 32-bit word per index:

a2 = rol32(x, 1)                       (sll 1 + carry of bit 31, or'd in)
i0 = k[7] ^ a2              i4 = k[3] ^ (x >> 15)
i1 = k[6] ^ (x >>  3)       i5 = k[2] ^ (x >> 19)
i2 = k[5] ^ (x >>  7)       i6 = k[1] ^ (x >> 23)
i3 = k[4] ^ (x >> 11)       i7 = k[0] ^ ((x >> 27) | ((x & 1) << 5))
each & 0x3F

Table i lives at SP + (7-i)*0x100 + index*4 (64 little-endian 32-bit words per table, eight tables = 2048 bytes at @0x1C0200). The disassembly's a11 = table + 0x3800 (bits) then subi 0x800 per lookup is the same thing in bit addresses.

Key and tables

0x3F5AA2C0(0x1BE810) (0x3F5A9620(1) → mode 1) derives the 128-byte key from boot-time RAM blocks that are themselves boot-time copies of ROM data (0x1BED40, 0x1BEF00, 0x1BEF80, 0x1C0280). It is fixed:

  • two fresh-NVRAM boots produced byte-identical key and SP table (dwt/evidence/oracle-forward-boot1.log / boot2.log);
  • they also match the key captured from a live TCP challenge in the earlier session (dwt/evidence/run-live/key.bin, parsed little-endian).

The values embedded in dwt/challenge.py are the firmware's own output, not a guess: they were extracted by calling the firmware's initialiser in MAME (dwt/evidence/scripts/oracle_call.lua). They are not a "response table"; the response still depends on the per-connection challenge.

4. Inverse

A Feistel network is invertible for any F. Running the same rounds backwards with the same keys gives the inverse:

forward:  L ^= F(R,k15); R ^= F(L,k14); ... ; R ^= F(L,k0)   (16 rounds)
inverse:  start from (L,R) = (out[4:8], out[0:4]);
          for n = 15..0: if n even L ^= F(R,k[15-n]) else R ^= F(L,k[15-n])

The firmware contains this inverse as a separate function, 0x3F5AADA0 (Ghidra 0x07EB55B4): same two renders, the same 16 keys in ascending order, and the two halves swapped on store. Our challenge.invert() matches its output on every test vector (6/6, dwt/evidence/oracle-inverse.log), which is independent confirmation both of the round structure and of the inversion.

5. Implementation

dwt/challenge.py (standard library only):

respond(challenge_wire) = swap4(invert(swap4(challenge_wire)))
transform(buf16) / invert(buf16) / check_reply(challenge, reply)

dwt/solve.py wraps dwt/exploit.py unmodified: it patches RDebug.answer_challenge to compute the reply with challenge.respond(). (dwt/exploit.py --challenge-raw accepts the same bytes by hand.)

6. Validation

ground truth result
70 vectors from MAME calling the real 0x3F5A09C0, fresh boot 1 70/70 (140 independent records)
same on fresh boot 2 (independent key/table dump) 70/70
dwt/challenge_vectors.json (other agent, 180 vectors) 180/180
firmware's own inverse 0x3F5AADA0 6/6
live wire challenges answered by respond() 3/3 sessions proceeded past the check; memcmp a8=0 where observed
payload over TCP after the computed answer MAGIC1=DEAD MAGIC2=BEEF COUNT=0400, VRAM 1024/1024 FFFF, 0 holes

The two live runs:

  • dwt/evidence/live-run/ — read-only breakpoints (print a8, never write it): two connections, both memcmp a8=00000000; the second ran poke+jump.
  • dwt/evidence/live-run2/ — no breakpoints at all (the Lua script only polls RAM/VRAM once per frame). Client transcript dwt/evidence/live-run2/client.log, emulator log dwt/evidence/live-run2/mame.log, BOOTP log serve.log.

Run 2 transcript:

[+] challenge f7 b4 6c 6b 00 00 6b 3f 02 63 a8 c0 f7 ac 6c 6c
[+] reply     49 0a ad 58 f1 ea b2 5a 3d fe f0 ee 92 6b 0a 20
opcode 7 -> Done\n
peek 0x3f200000 (8) -> e9 7a e2 f5 aa aa 55 55
poke-file .../payload.bin: 82 bytes at 0x01800000: ok
jump 0x01800000: sent
CHECK payload-ran: MAGIC1=DEAD MAGIC2=BEEF COUNT=0400
CHECK payload-ran: VRAM block 1024/1024 words FFFF, 0 holes

7. Reproduction

# offline: known answers and inverse checks
python3 dwt/challenge.py --selftest
python3 dwt/challenge.py --challenge f32cec8d0000ead00263a8c0f324ec8e

# emulator ground truth, no network: calls the firmware's own routines
ORACLE_COUNT=70 VT1200_SKIP_LEVEL1=1 \
  /home/arti/code/k-space/mame0289/vt1200 vt1200 \
  -rompath harness/mame/roms -video none -sound none -nothrottle \
  -debug -debugger none -debuglog -nvram_directory <fresh dir> \
  -autoboot_script dwt/evidence/scripts/oracle_call.lua -autoboot_delay 0

# live (needs the tap + BOOTP; see harness/net-setup.sh, harness/serve.py)
./harness/net-setup.sh
sudo python3 -u harness/serve.py 192.168.99.1 --bootp &
VT1200_SKIP_LEVEL1=1 /home/arti/code/k-space/mame0289/vt1200 vt1200 \
  -rompath harness/mame/roms -video none -sound none -nothrottle \
  -debug -debugger none -debuglog -cfg_directory <copy of cfg-net> \
  -nvram_directory <private dir> -networkprovider taptun \
  -autoboot_script dwt/evidence/scripts/live_check.lua -autoboot_delay 0
python3 dwt/solve.py --host 192.168.99.2 --peek 0x3F200000 --len 8 \
  --poke-file 0x01800000 --file harness/hello-world/payload.bin \
  --jump 0x01800000

8. Caveats and open points

  • VT1200_SKIP_LEVEL1=1 is required for the TCP stack; the result is about the firmware service with the OS up, not about a real terminal's POST.
  • The key is fixed for this firmware image. It is derived at run time from boot-time RAM copies of ROM data, but the derivation itself was not reduced to a ROM-offset formula (0x3F5AA2C0 has a data-dependent path through 0x1C0280). The constants were therefore captured from the firmware; this does not affect generality on this image, and the key was verified stable across two fresh boots and against a live challenge.
  • The challenge is not a password in the cryptographic sense: since the key is fixed and the transform is public code, any client that implements it can pass. The earlier "no ROM/NVRAM constant is compared" observation stands; the key material is a runtime table schedule, not a secret in NVRAM.
  • debug.log at the repository root was overwritten by MAME's -debuglog while running these tests; the live content was copied to dwt/evidence/live-run/debug-live.log. It is not part of this change.