8.6 KiB
dwt_prf challenge transform — runtime oracle (ground truth)
Status: DONE. The firmware's dwt_prf transform has been driven directly on
the emulated VT1200 with no TCP connection, and its input→output behaviour
captured for 180 vector instances (177 distinct 16-byte inputs).
ROM MD5 b46a3e442ccb86134d2356bd70d22fd2; no driver edits; private NVRAM.
Deliverables:
| path | what |
|---|---|
dwt/oracle.lua |
MAME -autoboot_script; calls the firmware's own INIT / EXPAND / TRANSFORM / FREE |
dwt/oracle.py |
host driver: deterministic vector generation, two fresh-NVRAM boots, live-probe cross-check, characterisation, JSON writer |
dwt/challenge_vectors.json |
the vectors (top-level JSON array) |
dwt/evidence/oracle/ |
per-boot inputs/outputs, full stdout.log with seed/key dumps, characterisation.json |
1. Method — direct invocation of the firmware's own code
dwt_prf (0x3F59FDC0) performs exactly this sequence around the transform
(disassembly in harness/mame/hostport_dis34.txt:160152):
0x3F5A9620(1) ; allocate/init (Ghidra 0x07EB52C4)
0x3F5AA2C0(0x1BE810) ; expand key schedule (Ghidra 0x07EB5458)
0x3F5A09C0(buf, 16) ; the transform (Ghidra 0x07EB4138)
0x3F5A9FA0() ; free (Ghidra 0x07EB53F4)
dwt/oracle.lua calls those same ROM routines in that same order at frame 3000
(OS up, VT1200_SKIP_LEVEL1=1), once per run for INIT/EXPAND, then once per
vector for the transform, and FREE at the end. No reply bytes are faked and no
firmware code is bypassed: everything between the calls is the stock firmware.
Calling convention (read from the firmware's call sites, e.g. 3f5a06c0):
- caller pushes A14, writes arguments upward starting at A14, then
CALLApushes the return address; - a routine called with one or more args ends with
rets 2, which pops the return address and the saved A14; a zero-argument routine (0x3F5A9FA0) uses a bareCALLand plainrets.
The harness supplies a scratch stack at bit 0x01840000 and argument block at
0x01800000 (top 1 MiB of main RAM, also used by the hello-world payload),
disables ST.IE for the duration, and puts a breakpoint at a synthetic return
address (0x018C0000). The interrupted firmware PC, SP, A14 and ST are saved
and restored after every call. MAME keeps pumping frame callbacks while the
debugger is halted (running_machine::run → video.frame_update when paused),
which is what lets the Lua state machine advance one call per stop.
Validation against live TCP captures
dwt/evidence/run-live/render-probes.txt has 11 chosen replies captured through
a real TCP connection and the actual dwt_prf path. The direct call
reproduces all 11, byte for byte, e.g.
live zero reply -> out 90 de 09 b4 72 85 58 17 (x2 records)
direct f(00*8) -> 90 de 09 b4 72 85 58 17 (x2 records)
characterisation.json records live_probe_agreement: 11/11.
2. Direction — what the buffer holds before vs after
From the dwt_prf disassembly (3f59ff50..3f5a08f0) and the live dumps
chal_sent.bin / cmp_a9.bin:
- the terminal picks four 32-bit words
W0..W3and stores them host order atframe+0x100bits — this is the expected value; - it
ntohls each word intoframe+0and sends those 16 bytes: the wire challengeC, withW = ntohl(C)(per-word byte swap); - it receives the reply
R,ntohls each word in place → bufferX; - it applies the transform in place →
Y = f(X); - it compares
YwithW.
So the firmware transforms the reply, not the challenge. The pass condition is
f(ntohl(R)) == W <=> R = ntohl(f^-1(W)) = ntohl(f^-1(ntohl(C)))
The vectors' input_hex is X (post-ntohl); output_hex is Y = f(X).
An echo R = C gives X = W and fails unless f is the identity; the live
memcmp returns were 0xFFFFFFFF for echo and 1 for the word-swapped answer,
which is exactly what a non-identity f predicts. Live challenge used in the
vectors (one historical capture):
f32cec8d0000ead00263a8c0f324ec8e, whose f input is
8dec2cf3d0ea0000c0a863028eec24f3.
3. Characterisation
| property | result | evidence |
|---|---|---|
| deterministic across boots | yes — 177 distinct inputs identical on two fresh-NVRAM boots; a third boot that hijacks at frame 2500 is also identical | runA/out.txt vs runB/out.txt vs runC_frame2500/out.txt |
| key schedule fixed | yes — seed block 0x1BE810 and full 128-byte key schedule at @0x1C0260 are identical in both boots (seed sha256 dafc2461…, key sha256 4f897a57…) |
runA/stdout.log |
| per-8-byte independence | yes — records are processed separately; 0/32 independence-grid errors; 64 single-bit changes in record 0 changed record 1's output in 0 cases; a repeated record gives a repeated output | characterisation.json |
| permutation on samples | 152 distinct inputs → 152 distinct outputs; 0 fixed points | characterisation.json |
| avalanche | flip one bit of the first record: 32.66 / 64 output bits change on average (min 23, max 42) | 64 flips of the ascending baseline |
| in-place | yes — the 16-byte buffer is overwritten with the result | 0x3F5A09C0 writes through its argument pointer |
Intermediate states (dwt/evidence/oracle/stages.txt)
dwt/evidence/scripts/oracle_stage.lua calls the per-record stage
0x3F5ABA40 alone and stops at two interior points:
0x3F5ABB30 after the first font render returns -> "feistel_in"
0x3F5AC620 before the final font render -> "pre_final_render"
For all six captured records feistel_in == input and
pre_final_render == output. That is not a broken probe: dwt_prf calls
INIT with argument 1, and that mode leaves @0x1C0220 / @0x1C0240
NULL (0x3F5A9990: dec a9; jreq L3F5A99F0), so both "font render" calls in
0x3F5ABA40 take their memcpy branch (0x3FE169C0, via the
jrne L3F5AC820 test on the second argument). On this path the advertised
font-render layers are identity copies, and the effective transform is the
16-round Feistel over the 128-byte key schedule expanded from 0x1BE810
(plus the 0x1BED40/0x1BEF00/0x1BEF80 auxiliary blocks). This does not
contradict the static note that the calls are present; it says what they do
under dwt_prf's mode.
4. Vector file
dwt/challenge_vectors.json is a top-level JSON array:
{"input_hex": "00000000000000000000000000000000",
"output_hex": "90de09b47285581790de09b472855817",
"meta": {"stage": "full", "boot": 2, "kind": "zero", "index": 0}}
input_hex— 16 bytes, the transform's inputX(post-ntohlbuffer).output_hex— 16 bytes,Y = f(X).stage—fullfor every entry; the intermediate per-record values are indwt/evidence/oracle/stages.txt(columns:index input feistel_in pre_final_render output).boot— number of independent fresh-NVRAM boots that produced the same output (2 for every entry).
Load with:
import json
vectors = json.load(open("dwt/challenge_vectors.json"))
for v in vectors:
x = bytes.fromhex(v["input_hex"]); y = bytes.fromhex(v["output_hex"])
Composition: 180 instances / 177 distinct inputs — 13 structured specials, the live wire challenge and its word-swapped form, 11 live-probe inputs, 64 single-bit flips of the ascending baseline's first record, 8 flips of its second record, a 32-vector independence grid, 48 random inputs, and two deliberate duplicate inputs (identical outputs).
5. Reproduce
cd /home/arti/code/k-space/vt1200
python3 dwt/oracle.py # two boots + stage capture; ~35 s
# options: --quick (one boot), --no-run (rewrite JSON from saved outputs),
# --skip-stages
Each boot is VT1200_SKIP_LEVEL1=1, -video none -sound none -nothrottle,
-debug -debugger none, its own -nvram_directory under
dwt/evidence/oracle/run*/nvram.
6. Caveats
VT1200_SKIP_LEVEL1=1is the documented harness divergence used to reach the OS (and therefore the same firmware code the live TCP captures reached). It does not change the transform: the direct outputs equal the live captures.- The live challenge is time-dependent; the value in the file is one real
capture from
dwt/evidence/run-live/remote-attempt.txt. Any 16-byte value is a valid input tof. - "Fixed key" is established for this ROM image and boot state: the seed and auxiliary blocks are boot-constant (two fresh NVRAMs, plus a differently timed hijack). Nothing here proves another firmware version shares them.
- No live TCP session was used during the vector runs; the live cross-check is the 11 previously captured probe replies plus the challenge/memcmp dumps.