mirror of
https://git.k-space.ee/arti/vt1200.git
synced 2026-10-31 12:05:05 +02:00
182 lines
8.6 KiB
Markdown
182 lines
8.6 KiB
Markdown
# 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 `ntohl`s 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`, `ntohl`s 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**:
|
|
|
|
```json
|
|
{"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:
|
|
|
|
```python
|
|
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
|
|
|
|
```sh
|
|
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.
|