aboutsummaryrefslogtreecommitdiffstats
path: root/psp_emu/FIDELITY-NOTES.md
blob: 498e19f34859c89ac1d94d10b7f892dcd5d4fb25 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
# Emulator fidelity — what the hardware work taught us

Notes on making `pspemu` behave more like the real PSP, grounded in findings from
the hardware/RE effort (see `../psp_out/` docs). Implemented items are done;
opportunities are ranked by value.

## Implemented (this pass)

### 1. Per-load stack canary (SVC 0xA2 TRNG + loader write)  — DONE
The biggest emulator/hardware gap, flagged in
`../psp_out/VULN-ASD-202-CANARY-LEAK.md`: *"the emulator uses the build-time guard
placeholder; on hardware the same code discloses the runtime (loader-randomised)
guard."*

Mechanism recovered from `bl_load_ta_image` (sos2 `0x1665A`): the TA header field
at `image_base+0x20` holds the VA of `__stack_chk_guard`; the loader reads one CCP
TRNG word (`SVC 0xA2`) and stores it there. The shipped image only carries the
`0x33333333` placeholder.

Now modelled:
* `Component.trng_word()` — seedable TRNG source (`config['trng_seed']`).
* `svc_read_trng` (SVC 0xA2) — returns a fresh TRNG word.
* `Component._randomize_canary()` — at load, reads `image_base+0x20` and writes a
  TRNG word to `__stack_chk_guard` (HDCP `0x404334`, ASD `0xD19878`, verified).
* `config['randomize_canary']=False` keeps the placeholder for deterministic runs.

Effect: emulated stack overflows now hit a random canary (can't pass without a
leak), and canary-leak PoCs disclose the runtime-random value — matching hardware.
Reproducible per seed.

### 2. Optional stale-memory fill  — DONE
Real hardware does not zero stack/scratch between calls, so uninitialised reads
return stale data — that is precisely how the ASD `0x202` leak (uninit `src[4]`)
and the `bl_derive_key` 4-byte kernel-stack leak disclose secrets. Unicorn
zero-fills, hiding them.

`config['stale_fill']=<byte>` pre-fills stack+scratch with a recognisable pattern
so uninitialised-read disclosures surface instead of reading `0`. Default `None`
(zero) preserves existing behaviour and tests. Combined with the canary model, the
0x202 leak reproduces naturally: prime via `0x308` (writes the random canary onto
the stack frame), then `0x202` reads the stale word back.

## Opportunities — INVESTIGATED, confirmed not worth implementing

An SVC-usage survey across the whole TA command surface (HDCP `0x00-0x15`, ASD
`0x100/0x103/0x160/0x180/0x1a0/0x1a7/0x200/0x202/0x300/0x308`) settled these
empirically: **the only SVCs any TA execution path invokes are `0xF2` (RPC gate)
and `0x8D` (ASIC id) — both handled, zero unhandled.** The rest never arise.

### A. Correct SVC 0x5A/0x5B semantics — confirmed LOW value
`svc_5A_5B_wait_events` (sos2 `0x6E40`) is a **wait-on-events** primitive, not the
map/reserve an older name implied. But no TA path invokes `0x5A/0x5B` at all (the
survey shows only `0xF2`/`0x8D`), so the `ZERO_SVCS` treatment is never exercised
by a TA harness. Only relevant to a full mailbox-loop/scheduler model — not built.

### B. TMR fence poison — confirmed LOW value
Hardware fences the TMR (host reads `0xFF`, writes dropped;
`../psp_out/VULN-CONFIRMED-TMR-RANGE-ROLLBACK.md`). TAs never address the TMR;
confirmed — no TA path touches it. Only a full-boot/SYS_DRV model would need it.

### C. Crypto DACR confinement — already modelled *architecturally*
`SERVICE1-CRYPTO-ANALYSIS.md`: the crypto service (SVC 0xF2 id 1) writes only into
the caller's MMU domain (DACR switch), which is why its overflow stays TA-side.
The emulator gives each TA its **own** Unicorn instance mapping only that TA's
regions, so a crypto write outside the caller's space **faults on unmapped
memory** — exactly the boundary the DACR enforces. The confinement is therefore
modelled by construction; no explicit check needed.

### D. Reachability facts — harness-shaping, already followed
* ASD refuses host `INVOKE_CMD` (`0x4`); reachable only as SVC 0xF2 service 2 from
  a TA. Harnesses drive ASD via the RPC gate / its dispatcher, not a host-invoke.
* The GFX-ring allow-list is bypassed at runtime (`ring==0xFFFF`); reachable
  SYS_DRV RPC set is ~30, not 8. `sysdrv_env` already exercises the broad set.

## Bonus: the canary model reproduces the exact hardware crash
With the per-load canary in place, driving HDCP `subcmd12` into the crypto MAC
overrun now faults at **`pc=0x0040433c` -> read of `0xdeadbeef`** — byte-for-byte
the `__stack_chk_fail` crash signature recorded in
`../psp_out/HANG-ROOT-CAUSE.md` (fault_lr `0x40173e`, PC `0x40433c`, FAR
`0xdeadbeef`). Before the model the canary was the placeholder; the crash location
is now hardware-accurate, which is what makes the emulator usable for judging
canary-gated exploitability.

## Caveats carried over
* `sysdrv_env`'s synthesised config/flash overlays only *unblock init* — any
  fuzzing fault touching cfg-derived state is SUSPECT until checked on real config.
* The `.53` builds were extracted from a linux-firmware container; prefer the
  `../psp_out/handover/elf/versions/` copies for version-accurate work.

## Boot-chain emulation (self-bootstrapping) — foundation built, roadmap

Goal: instead of running one module with stubbed boundaries, have the components
run *each other* — sOS stands up, SYS_DRV/ASD register as services, a TA's
SVC 0xF2 reaches the REAL SYS_DRV/ASD code. This finds bugs the stubbed model
cannot (real crypto, real `sysdrv_tee_map_shared_mem`, real cross-component flow).

### Done: `pspemu/system.py` — the unified address space
The resident components occupy non-overlapping ranges (verified), so `System`
maps sOS (0x0), ASD (0xD00000), SYS_DRV (0xE00000) and a TA window (0x400000)
into ONE Unicorn instance plus the shared MMIO/mailbox/host-shm apertures.
Demonstrated: real sOS `memmove_aligned` executes in the shared space with all
three other images resident. This is the prerequisite brick for everything below.

### The four remaining pieces, ranked by value/effort

1. **Nested SVC 0xF2 routing** (MEDIUM effort, HIGHEST value). `System.route_svc_f2`
   is a stub hook point. Real routing = when a TA traps on SVC 0xF2, save its
   frame, enter sOS's `svc_f2_rpc_gate` (0x22B4) with the caller's R0/R1, run to
   its return (a magic-return trampoline), then resume the TA with R0. This alone
   turns the stubs into real inter-component calls and is the single biggest step.

2. **"Warm-boot" service state** (MEDIUM). Rather than run the hardware-coupled
   cold boot, synthesise the post-init state sOS/SYS_DRV need: the
   `service_desc_table` (so 0xF2 routes id 1->crypto, 2->ASD, SYS_DRV), the
   resource/PCB tables, and the per-process caps (so the SVC type-gate behaves).
   `sysdrv_env` already does this piecemeal for SYS_DRV standalone; generalise it.

3. **Cooperative scheduler** (MEDIUM-LARGE). sOS spawns tasks
   (`bl_legacy_mailbox_loop`, `bl_ring_service_loop`) and yields via SVC 0x60 /
   0x56 / 0x5B (wait-events). A real boot needs a run queue and context switch.
   Only needed to model the *event-driven* command delivery, not single RPCs.

4. **ARM MMU / DACR** — ~~LARGE, or SKIP~~ **SUPERSEDED; do not follow this item.**
   It recommended running flat-physical and no-opping the MMU SVCs. That advice
   is obsolete and contradicts the measured section "Unicorn *does* have an ARM
   MMU" below in this same file: the MMU is enabled by sOS's own init, QEMU
   honours the short-descriptor tables, and `DACR = 0x00004505` is live.

   Domain enforcement is not merely present but *effective*, and this is now
   checked by `harness/sos_gate.py` rather than asserted: a section placed in a
   no-access domain faults, a client domain with `AP=0` faults, and the manager
   case reads cleanly as a positive control. sOS's own DACR writes
   (`mcr p15,0,rX,c3,c0,0` at 0x1B4 / 0x264 / 0x294) execute 3944 times in a
   bounded boot. Nothing here needs building; it needed measuring.

### Cold boot vs warm boot — the honest call
A *fully faithful cold boot* (sOS reset vector -> init -> load SYS_DRV -> ...) needs
the whole SoC hardware model (SPI, CCP, IRQ controller, SMU, MMU) and the on-chip
boot ROM (not in any image we have). That is a multi-week effort with steep
hardware-modelling risk. The **warm-boot path** (items 1+2, flat MMU) reaches the
valuable end state — real inter-component RPC execution — for a fraction of the
cost, and is the recommended next milestone. `System` is the foundation it builds on.

## 3. SVC 0x72 heap-pool provider — DONE (the SYS_DRV heap was DEAD)

The single biggest SYS_DRV fidelity gap found so far: **every `heap_alloc` in the
emulator failed**, so `gp_object_allocate`, `gp_operation_allocate`,
`object_buffer_realloc` and the whole chunk allocator were unreachable. No
emulator fuzzing had ever executed SYS_DRV heap code.

Mechanism, recovered from `heap_alloc` (0xE08884):

```asm
r0 = client ; r1 = sp+4 ; r2 = sp
svc  #0x72
cbnz r0, bail          ; NONZERO RETURN = ERROR
r0 = [sp+4]            ; pool BASE written by the syscall
r1 = [sp]              ; pool SIZE written by the syscall
bl  heap_pool_init
```

`0x72` is an **out-parameter** syscall: write pool base to `*R1`, pool size to
`*R2`, return 0. It was unregistered, so it defaulted to 0, `[sp+4]` stayed 0,
and `heap_alloc` dereferenced `0+4` — `READ_UNMAPPED at 0x4, pc=0xE088A6` — or
silently returned NULL. A first attempt that returned the pool pointer *as the
return value* also failed, because `cbnz r0` reads any nonzero as an error.

Also note `heap_pool_get` (0xE0ECB0) is **not** a getter: it is
`MOV.W R1,#0x2A000 ; SVC 0x7D`, i.e. it asks the kernel for the pool. The
generic `svc_reg_block` stub ignored R1 and returned a fresh 0x40 block per
call, so the "pool pointer" changed on every call. 0x2A000 is the pool size the
firmware itself requests, and is now the `make(pool_size=...)` default.

Implemented as `_install_heap_pool()` in `pspemu/sysdrv_env.py`; `make()` calls
it and exposes `c.heap_pool = {"base","size"}` for harnesses.

Verified: after `make()` + `run_init()`, six `heap_alloc` calls succeed and the
chunk list walks cleanly — 7 live chunks, 32-byte headers, payload at node+32,
magic 0x56, free tail sized 0x29D60.

## 4. Instrumentation gotcha: mem-write hooks perturb or corrupt

Measured in this Unicorn/Python build, and the reason
`harness/fuzz_sysdrv_hostreach.py` uses snapshot/diff rather than a PC-filtered
`UC_HOOK_MEM_WRITE`:

* Adding a mem-write hook **after** the first `emu_start` corrupts execution:
  `heap_alloc` then returns into its own literal pool
  (`READ_UNMAPPED at 0x01c4ae40, pc=0xE088DC`, which is data, not code).
* Adding it **before** `run_init` perturbs init itself: the pool lands at a
  different base, the 2nd allocation fails (returns 0) and the 3rd and 4th
  return the same pointer. Raising `count` from 2e6 to 5e7 changes nothing, so
  it is not instruction-budget exhaustion.

Rule: install instrumentation before any emulation, or prefer snapshot/diff.
Snapshot/diff cannot perturb the guest and was validated with an explicit
positive control (a 0x48-byte memcpy into a 0x40 payload is detected reaching
the adjacent chunk header) and negative control (0 diffs when idle).

## 5. Faithful param-slot population (do not patch param_slot_lookup)

`gp_crypto_dispatch`/`sysdrv_rpc_dispatch` reject a whole request with
`-65521` (0xFFFF000F) when a `{ptr,len}` slot is non-zero but
`param_slot_lookup(opcode, slot)` returns 0 for it. A harness that fills all
four slots therefore gets 0xFFFF000F for every GP opcode and fuzzes nothing —
observed directly.

`harness/fuzz_sysdrv.py` patches `param_slot_lookup` to `return 1`, which makes
*every* slot a pointer slot — more permissive than hardware, and the source of
the "scalars in pointer slots clobber stack" artifacts its own docstring warns
about. The better approach, used in `fuzz_sysdrv_hostreach.py`, is to **query**
`param_slot_lookup(op, slot)` in the guest and populate only the slots the
firmware itself expects. Faithful and it unblocks the path.

## Full sOS boot from the reset vector (`harness/boot_sos_full.py`)

sOS boots from image offset 0 (ARM mode, reset = `b #0x98`) through to
`bl_main`'s service loop: 42.8 M instructions, 255 distinct blocks, 17 M SVCs.
See `psp_out/FINDING-FULL-SOS-BOOT-IN-EMULATOR.md`.

**Three divergences this harness deliberately introduces — do not draw security
conclusions through them:**

1. **Two CP15 writes are NOP'd**: `0x5274 MCR p15,0,r0,c1,c1,0` (**SCR**) and
   `0x527a MCR p15,0,r0,c12,c0,1` (**MVBAR**). Unicorn's Cortex-A5 model has no
   TrustZone banked registers and raises `UC_ERR_INSN_INVALID`. Both are
   write-only config on this path, but if anything later reads SCR back this
   harness is silently wrong. (They are Thumb T32 encodings; an ARM-mode-only
   disassembly scan finds neither.)
2. **All SVCs return 0.** The boot path only branches on failure
   (`CBNZ R0 -> bl_fatal_hang`), so 0 = success everywhere. This makes the boot
   *permissive*: some of those 17 M SVCs would fail on hardware and take other
   branches. The boot proves the path is executable, not faithful.
3. ~~**Page tables are not honoured.** `sos_mmu_init` runs and `mmu_map_page`
   executes, but Unicorn has no ARM MMU, so everything stays flat/identity.
   In particular the real TA page attributes (RW+X, `DACR=0x55555555`) are
   not modelled.~~

   **RETRACTED — this is wrong.** Unicorn is QEMU and implements the ARMv7
   short-descriptor MMU fully; measured after a normal boot,
   `SCTLR = 0x00C5107D` (M=1, MMU **enabled**). From the moment `sos_mmu_init`
   sets SCTLR.M, every guest access is translated by sOS's own page tables and
   `uc.mem_map()` only controls what physical memory exists behind them. See
   "RETRACTION: Unicorn DOES implement the ARM MMU" later in this file for the
   measurement and for the `count=` trap that follows from it. The retraction
   is repeated here because this list is what a reader hits first, and the
   project rule is to propagate retractions to the text they contradict.


**AXI apertures must be mapped explicitly** and *around* `sos_map()`'s existing
`mmio_lo`/`mmio_hi` (`0x3000000-0x3240000`): SMN `0x1000000-0x3000000`,
fabric reg `0x3240000`, Syshub `0x4000000+`. An **overlapping `mem_map()` fails
silently** (`UcError` swallowed), which produced a phantom "address still
unmapped" fault during bring-up. Also note a second `UC_HOOK_MEM_INVALID`
handler is **shadowed by `Component`'s own**, so on-demand mapping added from
outside the class never fires.

## CCP device model (`pspemu/ccp.py`)

CCP5-generation block: MMIO register file, 3 command queues with in-memory
rings, 32-byte descriptors, 128x32-byte LSB. Engines implemented: **PASSTHRU,
SHA-256/384/512, RSA, ZLIB**. AES/XTS/DES3/ECC deliberately **raise** rather
than silently succeeding — a CCP that quietly "works" for an unimplemented
engine would manufacture false negatives in every harness above it.

Validated by running the **firmware's own drivers**, not reimplementations:

* `harness/ccp_selftest.py` — sOS `ccp_sha2` / `ccp_sha256` / `ccp_hmac_sha2`,
  **11/11** vs hashlib/hmac, including multi-part midstate.
* `harness/ccp_sysdrv_rsa.py` — SYS_DRV `rsa_modexp`, **6/6** vs `pow()` at
  1024/2048/4096 bits with both short and long exponents.

### Facts that were derived, not assumed

* **LSB byte order is proven.** The context is the big-endian state reversed
  across the whole region — the firmware's IV table `dword_7028` is byte-for-byte
  `sha256_iv_be[::-1]`, with SHA-384 at `+0x20` and SHA-512 at `+0x60`.
* **SHA-384 truncation is load-bearing.** On EOM the hardware must place the
  *48-byte digest* reversed in the low 48 bytes, not the whole reversed 64-byte
  state — otherwise SHA-384 comes out rotated by exactly 16 bytes while
  SHA-256/512 (where digest == context length) are unaffected.
* **Two different completion protocols.** sOS polls `HEAD_LO` until it equals
  the doorbell; SYS_DRV polls `INT_STATUS` (queue+0x10) **bit 1** and reads
  `Q_STATUS` (queue+**0x100**) & 0x3F as an error code. Completion must raise
  INT_STATUS bit 1 or `crypto_wait_completion` spins forever.
* **The tail wraps.** `ccp_ring_doorbell` writes `ring_pa + 32*((slot+1) &
  (size-1))`, so once the ring fills the tail is numerically *below* the head;
  a linear head..tail walk runs off the end. Ring base is recovered by masking,
  which is sound because `ccp_queue_init` rejects rings not aligned to size*32.
* **RSA operand layout came from RAW DISASSEMBLY.** Hex-Rays renders
  `rsa_modexp`'s three memcpys as `memcpy_thunk(15007744)` with arguments
  hidden — the same failure that hid the copy_state overflow. Layout:
  base at `0xE50000` (verbatim), exponent at `+keysize` (**byte-reversed**),
  modulus at `0xE51000` (verbatim), `keysize = round_up(size,32)`,
  `ctrl = flags|0x18|(keysize<<8)|0x400000`.
* **RSA input is little-endian but output is BIG-endian.** `rsa_modexp`
  reverses the result on the way out while copying base/modulus verbatim.
  Confirmed independently: `rsa_pad_and_verify` checks `v22[modlen-1] == 0xBC`,
  the PSS trailer, which is only last in big-endian. Getting this backwards
  produces a perfect byte-reversal of the right answer — a failure mode that
  looks like a broken engine.

### Known divergence / shortcut

`harness/ccp_sysdrv_rsa.py` publishes the scratch globals
(`dword_E257D0/D4`, ring VA/PA) directly instead of running
`desc_query_and_map`, whose SVC 0x78/0x59 contract is not yet modelled. It does
run the real `ccp_base_set`, `ccp_queue_reset` and `ccp_queue_config`. The
shortcut touches no value the RSA operation itself consumes.

### FIRMWARE BUG found via the model

`ccp_hmac_sha2` computes a **wrong HMAC-SHA512**. `ccp_scratch` is 372 bytes;
the inner digest lands at `+128+blocksize` and `ccp_sha2`'s init pass memcpy's
the IV to `+304`. For SHA-512 those are `[256:320]` and `[304:368]`, so the opad
pass clobbers the last 16 bytes of the inner digest with the head of the
reversed SHA-512 IV before it is hashed. The emulator's output matches the
*clobbered* value byte-exactly, which is what shows the emulator is right and
the firmware is wrong. SHA-256 overlaps by 0; **SHA-384 ends exactly at 304 —
correct with zero margin**. Both in-tree callers
(`sos_s3_build_auth_blob`, `sos_s3_verify_resume_cmd`) pass alg=4, so the broken
path is dead code.

## RETRACTION: Unicorn DOES implement the ARM MMU, and sOS turns it on

Earlier notes in this file and in `psp_out/FINDING-FULL-SOS-BOOT-IN-EMULATOR.md`
state that "Unicorn has no ARM MMU, so everything stays flat/identity" and that
`sos_mmu_init` runs but its page tables "are not honoured". **Both are wrong.**

Measured after a normal boot (CP15 read via `UC_ARM_REG_CP_REG`):

    SCTLR (c1,c0,0,0) = 0x00C5107D    -> M=1 (MMU ENABLED), C=1, I=1
    TTBR0 (c2,c0,0,0) = 0x0000FC48
    DACR  (c3,c0,0,0) = 0x00004505

Unicorn is QEMU, and QEMU implements the ARMv7 short-descriptor MMU fully. So
from the moment `sos_mmu_init` sets SCTLR.M, **every guest access is translated
by sOS's own page tables**, and `uc.mem_map()` only controls what physical
memory exists behind them.

### Why this mattered in practice

Running the stage-2 task (`bl_stage2_task`, VA 0x204894) faulted immediately at
`strb.w r0,[sp,#0x1c]` with a repeating `intno=4` (EXCP_DATA_ABORT) and an
empty `c.faults` list. That combination is diagnostic:

  * `c.faults` empty  => not a host-unmapped access (UC_HOOK_MEM_INVALID never
    fired), so `mem_map` was not the problem;
  * `intno=4` looping => a GUEST MMU fault, re-raised forever because the
    INTR handler only understands SVC and leaves PC on the faulting insn.

`bl_main` requests the task stack with `SVC 0x62 (va=0x280000, pa, 0x51 pages,
flags=0xC0000000)`. Stubbing that SVC to return 0 reports success while
creating **no translation**, so the VA is genuinely absent from the guest tables.
Host-mapping 0x280000 changes nothing.

### Consequence

Any SVC that establishes or removes a mapping (0x62 map, 0x63 unmap, 0x7D
window reserve, 0x68 cache maintenance) cannot be stubbed once the MMU is on --
it must actually edit the page tables. An "SVC returns 0" stub is not neutral
here; it is a silent lie that surfaces much later as an unexplained abort.

This also means the emulator can model the real page permissions (AP bits, XN,
domains) rather than treating memory as flat -- so the RW+X TA pages and the
DACR domain model are observable, not just inferred.

### TRAP: once the MMU is on, `count=` does NOT bound emu_start

An access to a VA with no guest translation takes an abort that **retires zero
instructions**. Unicorn's `count=` limit only decrements on retired
instructions, so the abort loop is unbounded: `emu_start(..., count=1000)` and
`emu_start(..., count=200000)` both hang *forever*, identically. It presents as
"the emulator is slow", which is misleading -- it is not slow, it is stuck.

Two symptoms that identify it:
  * `c.faults` is EMPTY (UC_HOOK_MEM_INVALID never fires -- the host memory is
    mapped; it is the *guest* translation that is missing);
  * an instruction trace shows a block restarting with registers rolled back,
    e.g. `sub sp,#0x28` appearing to have no effect on SP.

Use `armmmu.PageTableEditor.require_mapped(pc=..., sp=..., ret=..., arg=...)`
before any post-boot `emu_start`. It refuses in microseconds instead of hanging.

**All existing harnesses are unaffected**: `ccp_selftest`, `ccp_sysdrv_rsa`,
`verify_real_image`, `fuzz_pss_differential` and the SYS_DRV/TA fuzzers never
boot sOS, so the MMU is off and VA == PA for them. The trap only applies to
work that runs *after* `sos_mmu_init`.

**UNRESOLVED**: even with pc/sp/ret/arg all verified mapped, calling an
arbitrary sOS function post-boot still hangs. So the missing-translation case
above is *a* cause, not the only one. Do not treat `require_mapped()` passing
as proof that a post-boot call is safe.

---

## Harness runtime: where the wall-clock actually went

The boot harnesses used to take minutes. Almost none of it was emulation of
anything: it was the harnesses continuing to run a system that had already
finished the work being measured. Three distinct causes, measured separately.

### 1. Running long after quiescence (the big one)

`harness/boot_sos_full.py` ran a fixed `count=60_000_000` instruction budget.
sOS reaches `bl_main`'s service loop at **block ~8,700** and then spins there on
SVC 0x60 forever, because nothing drives the mailbox. The remaining budget was
spent executing the same five idle blocks 8.5M times each:

| | blocks | distinct | SVCs | wall |
| --- | --- | --- | --- | --- |
| fixed 60M budget | 42,834,576 | 255 | 17,130,369 | 171.8s |
| stop at quiescence | 208,698 | 255 | 80,018 | 0.83s |

**99.96% of the run was idle spin.** `boot()` now stops when no *new* basic
block has been discovered for `IDLE_QUIESCE_BLOCKS` (200k, ~24x the blocks
needed to reach the loop) **and** PC is inside the service loop — i.e. when the
system has demonstrably settled, not after a guessed budget.

Equivalence was checked by set-differencing the executed-block sets of the two
runs **in both directions**: empty both ways, 255 distinct blocks either way.
`--full` restores the exhaustive run and still reproduces the old numbers
exactly (42,834,576 / 255 / 17,130,369), so the fast path is a shortcut, not a
different experiment.

The 42M-entry `blocks` list was also replaced by a `Counter`. It cost ~1.5 GB of
int objects to answer `len()`, `set()` and `most_common(5)`, all of which a
histogram answers directly.

### 2. Fixed drains that outlive the thing they wait for

`boot_and_mailbox.py` drained `for _ in range(10): run(5_000_000)` after the
ring TA-load and `range(8)` after the invoke — **90M blocks, unconditionally**,
whether or not the firmware had already finished. Replaced with
`drain_until(run, cond, slices, blocks)`, which polls the actual success
condition (session slot present / `TA_InvokeCommandEntryPoint` reached).

`slices*blocks` equals the old `N*BIG`, so a slow path keeps exactly the same
patience — the only change is that the common case stops when the firmware is
done. Same fix applied to `fuzz_ta_invoke.py`'s setup (~90s per fuzzer start;
per-invoke cost is unchanged at ~1.9s, measured against a clean worktree).

Measured decomposition for `boot_and_mailbox.py`, each step cumulative:

| | wall | note |
| --- | --- | --- |
| original | 188s | fixed 90M-block drains, `period_blocks=2000` |
| drains -> `drain_until` | 32.4s | -156s; the drains were 83% of the harness |
| + `TIMER_PERIOD=200` | 14.8s | -17.6s; guest clock rate (see below) |

All three produce the identical result: 8/9 mailbox commands answered, 4 ring
commands into `tee_cmd_dispatch`, session slot [0], 22 TA blocks,
`TA_InvokeCommandEntryPoint` reached, 0 faults.

### 3. Guest clock rate — a real fidelity knob, not free speed

`boot_sos_and_tas.py` showed module loads at a **regular ~9s cadence** (0.10s,
9.13s, 18.15s, 26.97s). That is not load work; it is the firmware sitting in
delay loops polling `HW_COUNTER`.

The emulated clock advances `COUNTER_PER_MS` once every `period_blocks` blocks,
so with the old `period_blocks=2000` one emulated millisecond costs 2000 blocks.
A ~2-second firmware delay therefore costs ~4M blocks ≈ 9s of host time.

| `TIMER_PERIOD` | wall | blocks | distinct | modules | TA blocks |
| --- | --- | --- | --- | --- | --- |
| 2000 (old) | 40.1s | 19,397,108 | 2920 | 4 | 28 |
| 500 | 14.3s | 7,370,237 | 2960 | 4 | 28 |
| **200 (new default)** | **6.5s** | 3,114,159 | 2960 | 4 | 28 |
| 100 | 4.8s | 2,259,641 | 2962 | 4 | 28 |

Identical verdict and identical CCP totals (`PASSTHRU 93 / SHA 30 / RSA 3`,
0 errors) at every setting, and coverage goes **up** slightly (2920 -> 2960)
because more timer paths get exercised.

**State the risk plainly.** Shortening the tick makes the emulated CPU *slower*
relative to guest time (200 blocks/ms instead of 2000), and it was already far
slower than the real part. Anything the firmware guards with a timeout is
therefore *more* likely to fire, not less. The evidence that it is benign here
is empirical — same loads, same CCP descriptor counts, no new faults — and it is
a per-harness knob (`TIMER_PERIOD`), not a change to the device model. If a
result ever depends on it, pin `TIMER_PERIOD=2000` and say so.

`boot_sos_and_tas.py` additionally takes a `done` predicate (its own success
criterion: 4 modules loaded + TA code executing), checked at WFI resume
boundaries — an already-paid poll point, ~30/s — followed by a `settle` window
so in-flight work finishes before the numbers are read. `RUN_FULL=1` restores
the unconditional wall-clock run.

### Cheap hooks

Measured cost of per-block Python callbacks over an 8M-instruction boot:

| hooks installed | blocks/s |
| --- | --- |
| block list + INTR (old) | 314,094 |
| block hook only | 409,835 |
| INTR hook only | 365,911 |
| **none** | **495,382** |

A per-block Python callback costs ~1.6x. Unicorn's `begin`/`end` arguments
filter in C, so range-scoped hooks (e.g. "TA blocks only", `0x400000..0x4FFFFF`)
cost nothing on the hot path — `boot_and_mailbox.py` and `boot_sos_and_tas.py`
now use that instead of testing the address inside the callback.

Note this is a *floor*, not a target: `Interrupts._on_block` is mandatory (it
delivers IRQs and enforces the deterministic block budget), so a booting harness
always pays one per-block callback.

---

## The crypto-service gap is real but measurably not load-bearing (for `fuzz_hdcp`)

The remaining fidelity row says the crypto service (SVC 0xF2, id 1) is a SHA-256
stand-in and that on hardware the "real MAC always fails for unauthenticated
data". That row was checked rather than assumed, and the check produced a scare
that turned out to be wrong. Both halves are recorded, because the scare is easy
to re-derive.

### The scare: `hdcp_verify_mac` appears to succeed 37.5% of the time

Instrumenting `hdcp_verify_mac` (`0x403ff4`) across 3000 `fuzz_hdcp` iterations:

```
entries        : 761
return values  : {0xffff0000: 120, 0x00000000: 72}      (192 captured returns)
```

and in **every** one of those 192 the crypto MAC op really was invoked, so they
are not early-outs. Read naively that says the stand-in authenticates 72/192
calls that hardware would reject — which would make every fuzz result downstream
of one unfaithful, exactly the way the neutered `asd_validate_ptr` did.

Two refinements narrowed it without settling it: all 72 successes share one
signature (`mac_len=8`, `dst-buf=-112`), but 20 *failures* have `mac_len=8` too,
and `dst` never overlaps `buf`, so it is not the stand-in's output aliasing its
own input.

### The refutation: swap the secret and see if anything moves

Decisive test — keep the stand-in, but give each MAC call a **fresh random
secret**, so any comparison that depends on the digest being reproducible must
break:

| crypto model | `verify_mac` success | harness stats |
| --- | --- | --- |
| deterministic stand-in | 72 / 192 | `ok 391, CANARY_SMASH 9` |
| fresh secret per call | **72 / 192** | `ok 391, CANARY_SMASH 9` |

**Identical.** The `R0 == 0` returns do not depend on the MAC value at all, so
they are not authentication verdicts — that call site returns 0 on a path that
does not report the comparison result. The fidelity row stands: the digest never
matches, and nothing here contradicts it.

### The technique is the reusable part

Swapping the crypto model and diffing the *whole* result is a cheap differential
test for "does any conclusion in this harness depend on crypto fidelity?" Here
the answer is **no** — every statistic is bit-identical under a completely
different secret. So the crypto stand-in, while genuinely unfaithful, is not
load-bearing for `fuzz_hdcp`'s results, and closing that gap would not change
them.

That bounds the gap instead of merely restating it. It also guards against the
inverse error: `R0` from a function whose name contains "verify" is not a
verdict oracle until something shows it varies with what is being verified.