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.
This commit is contained in:
SDD committed 2026-10-18 02:36:23 +03:00
1 parent 041c82cb73
commit 1e5c095b72
22 files changed
+1351

No files matched your search

+260
View File
@@ -0,0 +1,260 @@
# 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)
```
4. `scratch+0 = bswap32(a9)`, `scratch+4 = bswap32(a10)` — written big-endian
as `(R, L)`, because `a9` is stored first.
5. `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
```sh
# 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.
+37
View File
@@ -248,3 +248,40 @@ output bits on average (23–42).
Reproduce: `python3 dwt/oracle.py` (~35 s, two boots + stage capture). No ROM,
driver or `dwt_prf` code was modified; no socket was used for the vectors.
## 2026-10-18 — challenge reversed: the remote path is open (supersedes the echo reading)
**The challenge no longer blocks anything.** `dwt/challenge.py` answers it from
the challenge bytes alone; `dwt/solve.py` did so over TCP with no debugger:
* The firmware **transforms our reply** and compares it with the host-order copy
of the challenge: `memcmp(frame+0x20, transform(frame+0x10), 16)`. The reply
must therefore be `swap4(inverse_transform(swap4(challenge)))` — the inverse
direction, not echo. Live bytes at the comparison are in
`dwt/evidence/live-run/debug-live.log` (`expecting == got`, `a8=0`).
* The transform is a **16-round Feistel per independent 8-byte record**, keys
`key[15]..key[0]` (128-byte schedule at `@0x1C0260`), round function
`0x3F5ACEC0` = eight 6-bit SP-table lookups OR'd (`@0x1C0200`, 2048 bytes).
* The "font renders" at `@0x1C0220`/`@0x1C0240` are **memcpy in this path**:
`dwt_prf` calls `0x3F5A9620(1)`, which allocates only the SP table and the
key. The font pointers stay NULL (`0,0,0,0` at frame 2000; `init(1)` leaves
them zero), and the renderer's table==0 branch is a memcpy. Earlier attempts
tried to invert a font stage that never runs here.
* Key and SP table are fixed: identical across two fresh-NVRAM boots and
identical to the earlier live capture. They are embedded in
`dwt/challenge.py`; provenance and disassembly are in
`dwt/notes/CHALLENGE_REVERSE.md`.
* Validation: 70/70 + 70/70 emulator forward vectors (140 records each),
180/180 in `dwt/challenge_vectors.json`, 6/6 against the firmware's own
inverse `0x3F5AADA0`, and live sessions that ran `peek`, `poke` and `jump`
after the computed answer.
* **Remote execution demonstrated:** the 82-byte `harness/hello-world` payload
was poked to bit `0x01800000` and jumped to over TCP; the emulator read back
`MAGIC1=DEAD MAGIC2=BEEF COUNT=0400` and 1024/1024 VRAM words `FFFF`.
`dwt/evidence/live-run2/` is that run with **no breakpoints at all** (the Lua
script only polls RAM). Only `VT1200_SKIP_LEVEL1=1` is needed for TCP.
Files: `dwt/challenge.py`, `dwt/solve.py`, `dwt/notes/CHALLENGE_REVERSE.md`,
`dwt/evidence/oracle-*.log`, `dwt/evidence/VALIDATION.txt`,
`dwt/evidence/live-run{,2}/`, `dwt/evidence/scripts/{oracle_call,live_verify,live_check}.lua`.
`dwt/exploit.py` and `dwt/mop_console.py` were not modified.