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
|
# 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
```
|