mirror of
https://git.k-space.ee/arti/vt1200.git
synced 2026-10-30 12:03:54 +02:00
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:
1 parent
041c82cb73
commit
1e5c095b72
22 files changed
+1351
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user