Files
vt1200/dwt/notes/CHALLENGE_ORACLE.md

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 CALLA pushes 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 bare CALL and plain rets.

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..W3 and stores them host order at frame+0x100 bits — this is the expected value;
  • it ntohls each word into frame+0 and sends those 16 bytes: the wire challenge C, with W = ntohl(C) (per-word byte swap);
  • it receives the reply R, ntohls each word in place → buffer X;
  • it applies the transform in place → Y = f(X);
  • it compares Y with W.

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 input X (post-ntohl buffer).
  • output_hex — 16 bytes, Y = f(X).
  • stage — full for every entry; the intermediate per-record values are in dwt/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=1 is 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 to f.
  • "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.