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.
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 offsets0x100/0x120/ 0x140/0x160), byte-swapped intoframe+0, and those network-order bytes are whatwrite_messagesends. The comparison usesframe+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 then0x3F5A09C0rewritesframe+0x10in 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:
0x3F5A9620is the only initialiser of those globals (its one directcallain the linear disassembly is indwt_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 is0,0,0,0; afterinit(1)only the first and third are nonzero. - The renderer
0x3F5AC710branches on its table argument: if the table pointer is zero it calls0x3FE169C0with 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:
render(record, @0x1C0220, scratch)— memcpy on this path.a10 = bswap32(word at scratch+0),a9 = bswap32(word at scratch+4)(0x3F5AE670swaps bytes 0<->3 and 1<->2), i.e. the two halves are read big-endian.- 16 Feistel rounds, keys from
@0x1C0260 + 0x78down to@0x1C0260 + 0x00, 8 bytes each (subi 0x40, a11after every round):
round n (n = 0..15): k = key[15 - n]
n even: a10 ^= F(a9, k)
n odd : a9 ^= F(a10, k)
scratch+0 = bswap32(a9),scratch+4 = bswap32(a10)— written big-endian as(R, L), becausea9is stored first.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 (printa8, never write it): two connections, bothmemcmp a8=00000000; the second ranpoke+jump.dwt/evidence/live-run2/— no breakpoints at all (the Lua script only polls RAM/VRAM once per frame). Client transcriptdwt/evidence/live-run2/client.log, emulator logdwt/evidence/live-run2/mame.log, BOOTP logserve.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=1is 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 (
0x3F5AA2C0has a data-dependent path through0x1C0280). 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.logat the repository root was overwritten by MAME's-debuglogwhile running these tests; the live content was copied todwt/evidence/live-run/debug-live.log. It is not part of this change.