# Systematic TOCTOU / double-fetch search across the PSP firmware **Question:** is there an actual TOCTOU / double-fetch avenue reachable from the host? **Answer:** real double-fetch *patterns* exist in the firmware (5 found, listed below), but **none is host-reachable on consumer Navi31**, because every host-facing entry point snapshots attacker memory before parsing it. One candidate (`bl_ring_service_loop`) is *not* snapshot-protected but is SR-IOV-gated, so it is an avenue only on virtualised parts. ## Method (and why it is trustworthy) An ARM/Thumb instruction-level detector (`/tmp/dfscan.py`, reproduced below) flags the precise double-fetch signature: > the same `[base, #off]` is loaded **twice** in one function, with a compare/branch > between the loads, and `base` is **not rewritten** in between → validate-then-reload **Validated against ground truth before use.** It independently rediscovers the known double-fetch in `hdcp2_rx_RepeaterAuth_ReceiverID_List` (gap=9). Two bugs were caught and fixed during validation: * wrong ELF `p_filesz` offset (read `p_paddr`) — silently skipped *every* sOS function and produced a false "0 candidates" clean bill. * a scratch file named `/tmp/dis.py` shadowed stdlib `dis`, so capstone imported and executed the script — producing stale output. Both would have yielded a confident but meaningless negative. ## Coverage — all 6 modules, 3471 functions | module | funcs | candidates | |---|---|---| | sOS (`sos2.elf`) | 526 | 82 | | SYS_DRV (`sys_drv2.elf`) | 1141 | 287 | | ASD (`asd2.elf`) | 1562 | 80 (63 register-based) | | RAS (`ras2.elf`) | 119 | 19 | | HDCP (`hdcp2.elf`) | 109 | 24 | | DTM (`dtm2.elf`) | 14 | 2 | Candidates were then triaged by *what the base register points at* (`[sp,#N]`/`[pc,#N]` are compiler spills and literal pools, not attacker memory), and every host-relevant hit was traced to its origin. ASD triage: of 63 register-based hits, the bulk are display-sequencing reads from internal context structs (offsets 76/360/412/432). The two that looked host-adjacent were checked directly: `asd_srm_read`'s repeated `[r1,#0]` is a **pc-relative global** (ASD's own SRM state, pointer-validated via `bl 0xd12e90`), and `asd_event_dispatch` likewise reads internal state. ASD also refuses host `INVOKE_CMD` (established separately), so its command surface is not host-driven in the first place. ## Real double-fetches found, and why each is not reachable ### 1. `hdcp2_rx_RepeaterAuth_ReceiverID_List` (hdcp2 `0x402a88`) — genuine bug ```c v12 = (unsigned __int8)a2[2]; // read #1 -> kept if ( 5 * (unsigned __int8)a2[2] + 36 == a3 ) // read #2 -> the BOUNDS CHECK memcpy((_BYTE *)(a1 + 85), (int *)a2 + 9, 5 * v12); // uses read #1 ``` Validation and use read `DEVICE_COUNT` separately; `v12=0xFF` with a passing check gives a **~1275-byte overflow**. This is the classic HDCP2 repeater flaw. **Not host-reachable:** `hdcp2_process_auth_message_v2` does `memcpy_aligned(&v24, a1, 0x244u)` first, so `a2` points at a **TA-local stack snapshot**. Reachable only from the display-link side or from code already inside the PSP. ### 2. `tee_unmarshal_params` (sOS `0x17884`) — genuine bug ```asm 0x178fa ldr r3,[r7,#0xc] ; READ #1 of MEMREF length 0x178fc cmp r3,r0 ; bhi reject ; validate len <= region size 0x17902 cmp r3,r0 ; bls ok ; validate off+len <= region size 0x17916 ldr r0,[r7,#0xc] ; READ #2 -- reloaded 0x17918 str r0,[r2,#8] ; delivered to the TA as params[i].size (UNVALIDATED) ``` The validated length is **not** the length handed to the TA. **Not host-reachable:** `tee_mgmt_ring_dispatch` copies 0x400 bytes to its own stack (`blx #0x12164`, `r0=sp`) and passes `sp` down, so `r7` is **sOS stack**. ### 3. `sysdrv_set_protected_region` (SYS_DRV `0xe1420c`, opcode 0x3111) — genuine bug ```asm 0xe14222 ldr r2,[r4,#8] ; READ #1 of length 0xe14224 cmp.w r2,#0x800 ; bls ok ; validate len <= 0x800 0xe14252 ldr r2,[r4,#8] ; READ #2 -- UNVALIDATED 0xe14258 blx memcpy_thunk ; memcpy(global_dst, mapped_src, READ#2) -> overflow 0xe1425c ldr r0,[r4,#8] ; READ #3 stored as the recorded region size ``` **Not host-reachable:** `sysdrv_rpc_3111` builds the argument struct on **sOS's stack** (`strd r6,r5,[sp,#0x38]`, `str r4,[sp,#0x40]`) from register arguments. ### 4. `sysdrv_tee_load_firmware` (SYS_DRV `0xe12d80`, opcode 0x1007) `[r6,#8]` (the firmware size) is re-read **6×** — for the min-size check, `map_client_buffer`, `buffer_copy_bounded`, `vf_table_lookup` and `verify_image_signature`. **Not host-reachable:** `v2 = *a1` resolves to `slot`, an sOS stack array; `sysdrv_rpc_1007` marshals `{slot, src+864}` as `{ptr,len}` descriptors. ### 5. `bl_ring_service_loop` (sOS `0x14e88`) — NOT snapshot-protected ```asm ldr r0,[r4] ; cmp r0,#0 ; blt exit ; validity check (read #1) ldr r0,[r4] ; ubfx r0,r0,#0x10,#4 ; cmp #2 ; dispatch compare (read #2) ldr r0,[r4] ; ubfx ... ; cmp #0xc ; dispatch compare (read #3) ldr r0,[r4] ; ubfx ... ; cmp #4 ; dispatch compare (read #4) ... ``` The command id is re-read from the ring for **every** dispatch comparison, so the id can change mid-dispatch and steer data intended for one command into another handler (**type confusion**). The `cmd==2` handler then takes a host-supplied 64-bit pointer from `[r4,#4]`/`[r4,#8]` and maps it with size `0x1000`. `r4 = *(0x206068 + 0x7c) + 0xa94`. **Reachability:** this loop requires SR-IOV (`*(*(0x2060E8+4)+0x234)==2`); on consumer Navi31 the host ring is drained by `bl_legacy_mailbox_loop`, which **does** snapshot (copies 0x20 bytes to `sp+0x14`, then reads only the copy). So this is an avenue on **SR-IOV / virtualised parts only**, not on this card. *Caveat:* that `r4` is guest-writable ring memory is inferred from the pointer's provenance and the surrounding code, not proven on hardware (we cannot enable SR-IOV here). ## Why the host-facing surface is immune: snapshot-before-parse Every entry point that touches host memory copies it first: | path | snapshot | |---|---| | `bl_legacy_mailbox_loop` (active host ring) | `0x20` bytes → `sp+0x14` | | `tee_cmd_dispatch` | `bl_memcpy(dst, src, 0x400)` | | `tee_mgmt_ring_dispatch` (TA invoke) | `0x400` bytes → its own stack | | HDCP `hdcp2_process_auth_message_v2` | `memcpy_aligned(&v24, a1, 0x244)` | | SYS_DRV RPC args | built on sOS's stack by the `sysdrv_rpc_*` stubs | | firmware images | verification gates *all* header parsing (12/12 header offsets return one code) | Direct TA handlers do receive a **live** shared-buffer pointer (`TA_InvokeCommandEntryPoint` passes `v5 + 12`), but each reads its fields **once** into registers/locals before use — checked for all 14 HDCP handlers and across all 1562 ASD functions. This is consistent and deliberate, not accidental. It also explains the hardware results: the bridge-SRAM flip experiments measured a single fetch (clean sigmoid, `diverged=0` over 262 successful loads), because there is only ever one read of attacker memory. ## Limitations — what this search could still have missed Function coverage was complete (3471/3471, 0 skipped), but the **detector** is not: | blind spot | measured | why it matters | |---|---|---| | `ldm`/bulk loads | **2754 excluded** | whole structs read at once; DTM parses its entire host struct this way | | indexed `ldr [rX,rY]` | **792 excluded** | array/loop reads — where a length-controlled copy loop would read | | `ldrd` pairs | **625 excluded** | 64-bit address/size pairs | | *(analysed)* | 16594 | ~80% of loads | So **~20% of all loads were invisible to the tool.** Further structural limits: * **Intra-procedural only.** A double-fetch split across functions (validate in A, use in B, both reading the same host memory) is not detected. The host-reachable paths were traced by hand instead, but that is manual and therefore incomplete. * **Base register keyed by number.** `mov r5,r4` between two loads defeats it. * **Requires a compare between the loads.** Validation that is implicit (table lookup, switch, or done inside a callee) is missed. * **Mode guessing.** Thumb/ARM chosen per function by best-effort decode; a mis-decoded function yields no usable hits. Concrete illustration that the gap is real: **DTM** `TA_InvokeCommandEntryPoint` passes `params[0].buffer + 0x10` to `dtm_cmd_topology_update_v2` as a **live** host/bridge pointer with *no* memcpy snapshot (unlike HDCP). That handler validates `[r4,#0x24] <= 0x17` and then reads the rest of the struct with `ldm` — instruction forms the detector excluded entirely. It was checked **by hand** and is safe (the `ldm`/`stm` pair forms an effective snapshot, the validated value is the one stored, and `asd_rpc_100_topology_update` receives the stack copy, not the live pointer). But the tool could not have established that, and other `ldm`-based parsers were not hand-audited. **Honest confidence:** high that no *simple same-offset* double-fetch is host-reachable; only moderate that none exists at all. ## Round 2 — extended detector (`dfscan2.py`) Closes the instruction-form gap: `ldrd` (both words), `ldm` (expanded to N offsets), indexed `ldr [rB,rIdx]` (`--indexed`), plus **pointer identity** (propagates `mov rD,rS` and `add rD,rS,#imm`) instead of keying on register number. Stack-rooted and pc-relative-global-rooted bases are auto-filtered. Validation: still finds the ground-truth HDCP repeater bug, and now pinpoints **`off=0x2` (`a2[2]` = DEVICE_COUNT)** — the actual vulnerable field, which v1 missed. It also independently rediscovers both hand-found bugs (`tee_unmarshal_params` `base=r73 off=0xc`; `sysdrv_set_protected_region` `base=t3 off=0x8`). Counts *fell* (sOS 82->64, SYS_DRV 287->192, RAS 19->4, DTM 2->0) because two large false-positive classes were removed: stack spills, and **`__stack_chk_guard` canary** reads (entry/exit), which look exactly like a double-fetch. 7 indexed-load double-fetches were found — a class v1 could not see — and all were triaged as not host-reachable: `check_client_permission` (validate -> reload -> `SVC 0x7C` register write, but the base is a pc-relative **PSP global** client-context, via `get_client_ctx_alt`), `parse_directory_entries` (post-verification), `bn_sqr`/`bn_divmod`/`ccp_hmac_sha2` (crypto scratch), `sos_task_set_handles` (kernel table), `hdcp2_tx_SKE_Send_Eks` (TX-side, session state). ## Round 3 — interprocedural detector (`dfscan3.py`) Covers the split pattern `F: validate P->x ; G(P)` / `G: use P->x`, which neither intra-procedural tool can see. Summarises every function as `{arg -> offsets read}` / `{arg -> offsets validated}`, propagates summaries to a fixpoint through the call graph, then reports call sites where the caller validated `(P, off)` and the callee re-reads the same offset. Results: sOS **0**, HDCP **0**, DTM **0**, ASD 6, SYS_DRV 30, RAS 41 — **77 total, all benign**: every hit is in `vsnprintf`/`fmt_*`/`bn_*` helpers sharing a PSP-internal context struct between caller and callee. None involves host-controlled memory. That is itself consistent with the architecture: because host buffers are snapshotted at entry, no long-lived host pointer survives to flow through the call graph. ### Known gaps in the interprocedural pass (not closed) * **Only argument-rooted pointers are tracked.** A host pointer obtained as an *out-parameter* of `map_client_buffer` / `bl_map_rw_buffer` (stored to stack, later reloaded) gets an opaque root and is excluded — so the very case where a mapped host buffer is passed into callees (e.g. `verify_image_signature(..., v56[0], ...)`) is **not** covered. Fixing this means seeding `HOST` roots at the known mapping functions' out-params. * **The "validated" marker is proximity-based**, not dataflow: an access is marked validated if a compare follows within a few instructions, whether or not the compared value derives from that load. This over-reports validation (and so over-reports hits). * Summaries are per-argument; pointers passed through memory or via more than 4 arguments are not modelled. ## Bottom line * **Actual double-fetch bugs exist** in this firmware — five, including a textbook overflow in HDCP2 repeater handling and an unvalidated `memcpy` length in `sysdrv_set_protected_region`. * **None is reachable from the host** on consumer Navi31. * The only non-snapshot-protected host-facing parser is `bl_ring_service_loop`, gated behind SR-IOV — making it the one credible TOCTOU avenue, on virtualised parts. ## Reproduce ```bash cd psp_out python3 /tmp/dfscan.py handover/elf/sos2.elf handover/symbols/sos.json python3 /tmp/dfscan.py handover/elf/sys_drv2.elf handover/symbols/sys_drv.json python3 /tmp/dfscan.py handover/elf/hdcp2.elf handover/symbols/hdcp.json # must find 0x402a88 ```