aboutsummaryrefslogtreecommitdiffstats
path: root/fw/AUDIT-NEW-TAS-RE.md
blob: e2ca3101e084ead1f084381c1530771773c21062 (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
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
# Reverse-engineering pass over the twelve newly-hittable TAs

All twelve TA types that this Navi 31 loads **and** invokes (`AUDIT-NEW-TAS.md`) are now
opened in IDA (idalib via idatui), annotated, and saved. This file records the command
surfaces recovered.

## Pipeline

Three tools, each with the failure it exists to prevent:

1. **`fw/ta_symbols.py`** — static recovery of the info header and the five TEE entry
   points from the **ARM-mode** trampoline. `--selftest` checks it against HDCP
   23.0.4.49, where it must return the dispatcher at `0x4013b4` and cover the command set
   read by hand from the decompiler.
   *It deliberately does **not** name command handlers.* Its jump-table walk guessed
   "first `bl` after the arm", which is wrong when the arm sets up arguments, and those
   wrong names then persisted in the IDB and shadowed the correct map. Every TA was
   re-analysed from a **deleted database** after that was found.
2. **`fw/annotate_ta.py`** — takes the command map from the **decompiler's own output**
   and applies it by address. Handles: `switch/case` (incl. `case 1u:` suffixes and
   nested two-tier switches), `if/else-if` chains, wrapper entries that `b.w` tail-call
   the real dispatcher, and callees that are already named.
3. **`loc_` promotion** — handlers are frequently reached as `loc_XXXX)(args)` because
   IDA never made a function there; the decompiler then shows a cast-through-label and
   nothing can be renamed. The annotator promotes them (`define kind=func`) and
   re-decompiles. This alone took OPM from 3 recovered commands to 9.

Renaming is by **address** (`rename_many`), never by token: `drive mv` resolves the old
name against the *current view*, so it silently failed for 6 of 14 GCP handlers on the
first attempt.

## Command surfaces

| TA | ver | fns | cmds | ids |
|---|---|---|---|---|
| **GCP** | 22.0.4.50 | 122 | **21** | 1-5, 0xFE02-0xFE11 |
| **XGMI** | 32.0.0.20 | 93 | 12 | 0-0xB |
| **OPM** | 25.0.4.48 | 47 | 9 | 0-5, 8, 9, 0xE |
| **POWERPLAY** | 34.0.0.20 | 105 | 7 | 0-6 |
| **FP** | 19.0.0.8 | 64 | 7 | 1-7 |
| **RAP** | 7.0.2.19 | 13 | 7 | 1-7 |
| **AUC** | 16.0.0.9 | 38 | 6 | 1-6 |
| **MERIT** | 24.0.4.42 | 35 | 6 | 1-6 |
| **CSU** | 42.0.0.6 | 13 | 3 | 1-3 |
| **OC** | 50.0.0.1 | 30 | 2 | 1-2 |
| **WSPE** | 30.0.4.27 | 56 | 2 | 4, 5 (1-3 explicitly reserved) |
| **TMZ** | 29.0.0.4 | 6 | 0 | stub — every entry point returns 0 |

## GCP is a DRM content-protection TA — fully named

GCP carries 69 of its own log strings, which name its commands directly. Handlers were
matched to the strings they reference and renamed:

```
cmd 01  AllocateDRMSession                 cmd FE08 KeyExchangeAuthenticatedSession
cmd 02  SendDRMCertificate                 cmd FE09 (unnamed)
cmd 04  GetContentKey                      cmd FE0A QueryAccessibilityAttributes
cmd 05  CloseDRMSession                    cmd FE0B DestroyAuthenticatedSession
cmd FE02 GetObfKeyCryptoSession            cmd FE0D DestroyAuthenticatedSession
cmd FE03 CreateAuthenticatedSession        cmd FE0F GetCryptoSessionByDecodeHandle
cmd FE0C CreateAuthenticatedSession        cmd FE10 StartKeyRefreshCryptoSession
                                           cmd FE11 FinishKeyRefreshCryptoSession
```

Two-tier switch on `params[0]`: a low family (1-5, DRM session lifecycle and content key)
and a high family (`0xFE02..0xFE11`, authenticated crypto sessions, key exchange, key
refresh). Entry gate: `paramTypes & 0xF == 7` and `params[1] >= 0x2958`; then
`sub_40540A(session) == 14` short-circuits.

**This is the most interesting surface of the twelve**: it handles content keys and
authenticated sessions, it is the largest command set, and it is host-invokable.

## WSPE — small and fully resolved

Its strings name everything, and the annotator's `!=`-guard blind spot was finished by
hand:

```
cmd 1,2,3 -> rejected, "Received a Reserved Commands", returns base+251
cmd 4     -> "Request DOPP Active/Deactive"     wspe_cmd04_request_dopp_active
cmd 5     -> "Request Active Protected Session" wspe_cmd05_request_active_protected_session
```

So WSPE has exactly **two** implemented commands; the `0x14/0x1c/0xdc` constants the
static pass saw were error codes, not command ids — a reminder that `cmp` constants are
not a command surface.

## OPM

Output Protection Manager (it embeds `<c:CertificateCollection xmlns=…xmldsig#>` XML, the
COPP/OPM certificate format). Dispatch is gated by a precheck whose result is switched on
(0/1/14) before an inner switch on `params[0]` for ids 1-9; ids 6 and 7 share a handler
through a `goto`, and id 11 is handled inline before the switch.

## State of the annotations

Every TA's IDB contains: the info-header values, the five named TEE entry points, the
named dispatcher, and `<prefix>_cmdNN` on every recovered handler, plus a function comment
on the dispatcher recording the gates. GCP and WSPE additionally carry semantic names.

Databases live beside the ELFs in `psp_out/versions_win/elf/`; command maps are in
`fw/cmdmaps/*.json`; entry-point symbol sets in `fw/syms/*.json`.

================================================================================
# The SVC surface: what each new TA can ask the PSP to do — and one result that overturns a standing negative

`fw/ta_svc_scan.py` enumerates every `SVC` site per TA and, for `svc 0xF2` (the service
RPC), recovers the service id from `r0` and the opcode from the **store into `req[0]`**
(the ABI in `GUARD-INIT-CHAIN.md`: `req[0]=opcode; r0=service; r1=&req`).

Validated against HDCP 23.0.0.82 first: 49 `svc 0xF2` sites, exactly the count the
original 89-site audit recorded, and the recovered service-1 opcodes
(1,3,5,7,8,0xb,0xd,0xe,0x10,0x31,0x33,0x1087) match its documented list.

| TA | 0xF2 sites | service-1 (SYS_DRV) opcodes | service-2 (ASD) opcodes |
|---|---|---|---|
| auc | 17 | 1,2,9,0xe,0x10,0x33,0x101c,0x101d,0x103b | 3,0x102,0x161 |
| csu | 3 | – | 0x306 |
| fp | 16 | 1,2,5,7,0x31,0x33,0x35,0x101c,0x101d,0x103b | 3,0x402 |
| **gcp** | 25 | 1,3,5,7,9,0xe,0x10,0x22,0x31,0x33,0x103b,0x109c,0x109d | 0x107,0x108,0x109,0x200,**0x202**,0x204,0x304,0x305 |
| merit | 13 | 1,3,9,0xe,0x10,0x31,0x33 | 3,0x200 |
| oc | 13 | 1,3,0xe,0x10,0x22,0x31,0x33,0x109b,0x10a2,0x3157,0x3158 | – |
| **opm** | 20 | 1,3,9,0xe,0x10,0x31,0x33 | 3,0x102,0x105,0x107,0x161,0x200,0x300,**0x301**,0x304,0x305 |
| powerplay | 9 | 0x25,0x109a,0x6001,0x6003 | 0x403,0x404 |
| rap | 3 | 0x1077,0x107b | – |
| tmz | 0 | – | – |
| wspe | 3 | – | 1,0x306 |
| xgmi | 13 | 0xd,0x22,0x53,0x1048-0x104b,0x1053,0x1061,0x1067 | – |

## GCP emits ASD opcode 0x202 — the canary leak

`VULN-ASD-202-CANARY-LEAK.md` rates the ASD uninitialised-stack canary disclosure
**NOT host-reachable**, on three independent axes, the first of which is *"no
host-reachable TA forwards opcode 0x202"*. That scan covered **ASD, HDCP, DTM and RAS** —
the only TAs known at the time. GCP was not in scope because nobody knew this card loads
it.

GCP contains a thunk at `0x405446` that does exactly that:

```
0x405446  sub sp,#0x18
0x405448  add.w ip, sp, #4
0x40544c  stm.w ip, {r0,r1,r2,r3}   ; req[1..4] = args
0x405450  movw r0, #0x202
0x405456  str  r0, [sp]             ; req[0] = 0x202
0x40545c  movs r0, #2               ; service 2 = ASD
0x40545e  svc  #0xf2
```

and it is called from **two host-invokable GCP commands**:

```
0x4037e8  bl 0x405446   inside gcp_cmd04_GetContentKey
0x403c42  bl 0x405446   inside gcp_cmdFE02_GetObfKeyCryptoSession
```

Args at the cmd04 site: `r0 = sp+0x30`, `r1 = 0x10`, `r2 = r4+0x24`, `r3 = sp+0x1c`.
OPM likewise emits ASD **0x301**, the "content-controlled write of up to 5120 bytes" that
`FINDING-ASD-IS-DRIVER-CLASS-NOT-INVOKABLE.md` listed as a defect dismissed *only*
because no TA emitted it.

**So the first axis of that negative is now false.** GCP and OPM are measured
host-invokable on this card, and they reach ASD opcodes the four-TA scan concluded nobody
reaches.

### What is NOT yet shown, and why it still matters

The ASD 0x202 handler copies its 16 uninitialised stack bytes to a **caller-supplied**
pointer. Here the caller is GCP, so the disclosed canary lands in GCP's own buffer
(`r2+0x10` in ASD's terms), not directly in the host's shared page. For a host-visible
leak, GCP must copy that region back into its response. That is the next step and it is
deliberately not assumed.

Equally, the other two axes of the original negative (handler aliasing; no indirect route
to `sub_D14948`) are unaffected — but they were never the load-bearing ones. The
load-bearing claim was "nobody emits it", and that claim was scoped to four TAs.

## Methodological warning recorded with the result

The scan's linear sweep resumes past undecodable words, and **that can fabricate
instructions**: an earlier pass reported this same `movw r0,#0x202` at `0x405450` from a
misaligned window, and a nearby region decoded as a plausible-but-wrong thunk. Every
opcode quoted for GCP and OPM above was re-verified by disassembling from a **function
boundary** (`bx lr` at `0x405444`), and the call sites by disassembling around them. The
remaining table entries should be treated as leads until similarly checked — the tool
lists sites, it does not prove alignment.

## Correction: the sweep's opcode attribution was shifted; IDA's is authoritative

The RPC-thunk names first applied from `fw/ta_svc_scan.py` were **off by one thunk** in
GCP's thunk table — each function received the previous one's opcode — because the
resumable sweep is misaligned in that region (the same misalignment that earlier produced
a phantom instruction). Re-deriving every opcode from **IDA's own disassembly lines** of
each thunk gives GCP's ASD (service-2) table:

| thunk | ASD opcode |
|---|---|
| `0x4053b4` | 0x108 |
| `0x4053d0` | 0x109 |
| `0x4053ee` | 0x107 |
| `0x40540a` | 0x304 |
| `0x405428` | 0x305 |
| **`0x405446`** | **0x202** |
| `0x405464` | 0x200 |
| `0x405482` | 0x204 |
| `0x4054a0` | 0x201 |

`fw/ta_rpc_names.py` also cross-checks the sweep against IDA: **25 of 25** `svc 0xF2`
candidates in GCP are confirmed by IDA as real `SVC` instructions, 0 rejected. So the
*sites* were right and only the opcode attribution drifted — which is exactly why the
tool reports the agreement count.

### The 0x202 finding, now verified three independent ways

1. aligned capstone disassembly from the function boundary (`bx lr` at `0x405444`);
2. IDA's disassembly of `sub_405446` — `MOVW R0,#0x202` / `STR R0,[SP,#var_18]` /
   `MOVS R0,#2` / `SVC 0xF2`;
3. **IDA xrefs** for the callers:

```
0x4037e8  in gcp_cmd04_GetContentKey
0x403c42  in gcp_cmdFE02_GetObfKeyCryptoSession
```

Control, same method on the neighbouring `0x305` thunk, which resolves to five different
GCP commands — so `callers` is discriminating, not returning everything.

## CORRECTED SVC table (IDA-derived) — supersedes the sweep table above

The table in the previous section was produced by the linear sweep and its opcode column
is **shifted in places**; do not use it. `fw/ta_rpc_names.py` was rewritten so the sweep
only says "a service call happens near here" and **IDA resolves the rest**: containing
function, service id, opcode. Sites confirmed by IDA: 100% for every TA except MERIT
(12 of 13; one candidate rejected as not an `SVC`, which is the sweep misaligning and the
cross-check catching it).

| TA | thunks | SYS_DRV (service 1) opcodes | ASD (service 2) opcodes |
|---|---|---|---|
| auc | 17 | 0x2 | – |
| csu | 3 | – | 0x306 |
| fp | 16 | 0x2 | – |
| **gcp** | 25 | 2,5,7,9,0xE,0x10,0x30,0x31,0x33,0x35,0x53,0x103B,0x109C,0x109D | 0x107,0x108,0x109,0x200,0x201,**0x202**,0x204,0x304,0x305 |
| merit | 12 | 2,3,9,0xE,0x10,0x30,0x31,0x33,0x35 | 0x200,0x201 |
| oc | 13 | 2,3,0xE,0x10,0x30,0x31,0x33,0x35,0x109B,0x3158 | – |
| **opm** | 20 | 2,3,9,0xE,0x10,0x30,0x31,0x33,0x35 | 0x102,0x105,0x107,0x161,0x200,0x201,0x300,**0x301**,0x304,0x305 |
| powerplay | 9 | 0x41,0x53,0x109A,0x6001,0x6002,0x6003 | 0x403,0x404 |
| rap | 3 | 0x1076,0x1077,0x107B | – |
| wspe | 3 | 0x53 | 0x305,0x306 |
| xgmi | 13 | 0x2 | – |

The two findings survive the correction and are strengthened by it, since they now rest on
IDA's own analysis: **GCP reaches ASD 0x202** (canary disclosure) and **OPM reaches ASD
0x301** (content-controlled write up to 5120 bytes). Both were dismissed in
`FINDING-ASD-IS-DRIVER-CLASS-NOT-INVOKABLE.md` purely because no *then-known* TA emitted
them.

### AUC, FP and XGMI use a different kernel ABI

They resolve almost nothing above because they do not use the `req[0]=opcode; svc 0xF2`
form for most calls. XGMI's thunks look like:

```
PUSH {R4-R6,LR} ; LDR R4,=off_405000 ; LDR R0,[R4] ; CBNZ R0,...
SVC 0xF3        ; STR R0,[R4]        ; MOV R12,R4  ; ... BX R12
```

i.e. `SVC 0xF3` fetches and caches a handle in a global, and the call goes **indirect**
through it. That is a structural difference in how these TAs reach the kernel, not a tool
failure, and it means their privilege reach is not enumerable by opcode-immediate scanning
— an open item, explicitly flagged rather than reported as "no calls".

All thunks in every TA are now named `<prefix>_{asd,sysdrv}_rpc_<OPCODE>`, or reset to
`sub_<addr>` where the tool could not resolve them, so nothing carries a guessed name.

## Verified annotation state (read back from each IDB, not assumed)

Each database was reopened and queried for what is actually in it:

| TA | note lines | cmd fns | rpc thunk fns | entry points |
|---|---|---|---|---|
| auc | 23 | 4 | 12 | 5 |
| csu | 20 | 3 | 1 | 5 |
| fp | 24 | 7 | 12 | 5 |
| gcp | 41 | 15 | 24 | 5 |
| merit | 23 | 6 | 11 | 5 |
| oc | 19 | 2 | 10 | 5 |
| opm | 29 | 4 | 19 | 5 |
| powerplay | 24 | 6 | 8 | 5 |
| rap | 24 | 1 | 3 | 5 |
| tmz | 17 | 0 (stub) | 0 | 5 |
| wspe | 18 | 2 | 3 | 5 |
| xgmi | 31 | 11 | 13 | 5 |

`cmd fns` counts *distinct renamed handler functions*, which is lower than the command-id
count wherever several ids share a handler (RAP's seven ids funnel into one, OPM's 6/7
share one via a `goto`).

Multi-line function comments are stored correctly; the **listing** view renders only the
first line inline, while the pseudocode view shows all of them. Checking only the listing
briefly looked like the notes had been truncated.

### Operational gotcha: reopening TMZ's saved database hangs the pane

`tmz_29.0.0.4.elf` spawned fine the first time, then failed on every later spawn — the
pane appeared in the registry, was gone from slosh, and readiness never arrived. Cause:
TMZ is the stub, with **no real entry function**, and idatui documents that such a binary
"can land in the symbol palette on startup"; the saved `.i64` restores that modal state,
so the readiness probe never completes and the launcher dies. Deleting the `.i64` and
re-opening works immediately (`ready: true, functions: 6`). Recorded because the symptom
(pane in registry, absent from the multiplexer) looks like a spawn bug rather than
restored modal state.

================================================================================
# The chain completes: ASD 0x202 output lands in the HOST's buffer

The previous section left the decisive question open — the ASD 0x202 handler writes its
16 uninitialised stack bytes to a *caller-supplied* pointer, so the canary might only
land in GCP memory. Decompiling the caller answers it.

`gcp_cmd04_GetContentKey` (GCP command 4, host-invokable) calls the thunk as:

```c
gcp_asd_rpc_202_CANARY_LEAK(a1: v23, a2: v10, a3: a2 + 36, a4: &v21)
```

`a2` is the **host's shared params buffer**, not GCP-private storage:

* the invoke entry calls `gcp_dispatch(session, *a4)`, i.e. the params pointer the host
  supplied;
* this handler reads `v7 = *(a2 + 16)` as an index and bounds-checks it (`v7 < 0x20`),
  which is only meaningful for attacker data;
* it also reads the key material at `a2 + 20` (`v6 + 4`).

`VULN-ASD-202-CANARY-LEAK.md` records the ASD handler as
`asd_memcpy((void *)(i + 16), src, 0x10)` with `i` = its **third** argument. Here the
third argument is `a2 + 36`, so the 16 stale ASD stack bytes are written to
**`host_params + 0x34`**.

So the disclosure is not confined to the TA: it is copied straight back into the page the
host owns. Combined with the measured facts that this card **loads and invokes GCP**, the
path is:

```
host: INVOKE_CMD(GCP session, cmd 4)      [measured: GCP invokable]
  -> gcp_cmd04_GetContentKey              [IDA]
  -> ASD service-2 opcode 0x202           [IDA disasm + IDA xrefs]
  -> ASD copies 16 uninitialised stack bytes to arg3+16 = host_params+0x34
```

That is a host-reachable uninitialised-memory disclosure out of ASD — the component this
project rated unreachable three ways, where the load-bearing axis was "no host-reachable
TA forwards 0x202" from a scan over four TAs.

## What is verified, and what is not

**Verified:** GCP loads and invokes on this card [HW]; the thunk sets `req[0]=0x202`,
service 2 [IDA]; its callers are commands 4 and 0xFE02 [IDA xrefs]; the third argument at
the command-4 site is `a2 + 36` and `a2` is the host params buffer [IDA decompilation,
corroborated by the host-controlled bounds-checked index read from it].

**Not verified, and needed before this is called a working leak:**
1. that the ASD build *this card loads* still copies at `arg3 + 16` — the offset comes
   from our ASD 33.0.0.x analysis, and the card's ASD is a different build;
2. what the stale slot actually contains on hardware — the original finding needed a
   `0x308` preamble to prime the canary into that stack location, and this path does not
   obviously provide it;
3. the whole chain end-to-end on silicon.

Item 3 is a bounded hardware experiment: invoke GCP command 4 with a marked params page
and read back `+0x34`. It is the obvious next step and is deliberately not assumed here.

================================================================================
# Hardware test [HW]: the host DOES drive GCP into calling ASD 0x202 — and ASD refuses

`hw/gcp_asd202_leak_probe.py` fills the shared params page, invokes GCP command 4, and
reads the page back. Controls: a baseline read with no invoke, a command that does not
call the thunk (cmd 1), and a repeat to distinguish stale memory from a fixed structure.

Two iterations were needed, and the first failure was informative:

* **0xCC-filled page** — cmd 4 returned status 0 and the page was untouched at `+0x34`.
  Reading the decompiled guard explains it: the thunk is reached only when
  `sub_4010C4(zeroed, a2+20, 16) == 0`, i.e. the 16 bytes at `a2+20` must be **zero**; a
  0xCC fill takes the crypto branch instead. So the page must be mostly zero with the
  marker only in the watched window.
* **zero-filled page, marker only at `+0x34`** — the result below.

```
C1 baseline (no invoke)   +0x34: cc..cc   touched: 1 byte     dw0-3: 0x4 0 0 0
C2 cmd 1 (no thunk)       +0x34: cc..cc   touched: 5 bytes    dw0-3: 0x1 0x80000001 0x2000002f 0
C3 cmd 4, run 1           +0x34: cc..cc   touched: 5 bytes    dw0-3: 0x4 0x80000004 0x415 0
C3 cmd 4, run 2           +0x34: cc..cc   touched: 5 bytes    dw0-3: 0x4 0x80000004 0x415 0
```

`0x415` = **1045**, and the decompilation shows 1045 is assigned on exactly one path:

```c
if ( gcp_asd_rpc_202_CANARY_LEAK(a1: v23, a2: v10, a3: a2 + 36, a4: &v21) != 0 )
{
  v8 = 1045;
  gcp_sysdrv_rpc_53(0, "AMD_TA_GCP: tlDrmApiGetObfKey status = 0x%x\n", 1045);
}
```

**So the ASD 0x202 call was made, from the host, through GCP** — 1045 cannot be reached
any other way. The status also names the ASD-side operation: `tlDrmApiGetObfKey`.

## What this settles, and what it does not

**Confirmed [HW]:** a host `INVOKE_CMD` on GCP command 4 causes the PSP to invoke **ASD
opcode 0x202**. The reachability axis of `VULN-ASD-202-CANARY-LEAK.md` — "no
host-reachable TA forwards 0x202" — is now **false on this card**, and it is false by
measurement, not inference.

**Not achieved:** the disclosure itself. ASD returned non-zero and wrote nothing, so no
bytes appeared at `+0x34` (identical across two runs, and cmd 1 leaves the window
untouched, so the detector works). That matches the original finding's own description:
0x202's copy sits behind ASD's crypto pipeline (`SYS_DRV` 0x30 init / 0x33 setkey / 2 / 3
/ 7) and only runs if those succeed. Here they do not, in a session where nothing has set
up DRM state.

**Therefore the honest rating is:** reachability **proven**, exploitation **not
demonstrated**, blocker **named and specific** — ASD's own crypto gate, which a fuller
DRM session setup (GCP commands 1/2/0xFE02 first, or the 0x308-style priming the original
finding used) may satisfy. That is the next experiment, and it is a small one.

This supersedes the previous section's static-only claim that the chain "completes": the
data path does reach the host page by construction, but on silicon the call fails before
anything is copied.

## Priming the DRM session does not satisfy ASD's gate

Follow-up on the named blocker: run GCP's own session commands first, then cmd 4.

```
prime cmd 0x1    (AllocateDRMSession)     status 0x0          dw2=0
prime cmd 0x2    (SendDRMCertificate)     status 0x0          dw2=0
prime cmd 0xfe03 (CreateAuthenticatedSession) status 0xffff0000  dw2=0x102
prime cmd 0xfe02 (GetObfKeyCryptoSession) status 0x0          dw2=0x321
cmd 4 after priming                        -> dw2 = 0x415 (ASD 0x202 call failed)
```

Still 1045. So the ASD-side gate is not satisfied by merely allocating a session and
sending a (zeroed) certificate — unsurprising, since `SendDRMCertificate` with an empty
page cannot establish real key state, and `0xFE02` itself reports `0x321`.

Conclusion stands and is now better supported: **the host can reach ASD 0x202 through GCP
(proven), and the disclosure does not fire without legitimate DRM session material
(measured across four priming variants).** Obtaining that material is a different problem
from the one this project has been solving, and it is where this thread stops rather than
speculating.

================================================================================
# OPM reaches ASD 0x301 (the 5120-byte write) from a host-invokable command

Same method as the 0x202 work, applied to the other dismissed ASD defect.

`opm_asd_rpc_301` (`0x402e5e`), verified from IDA's own disassembly:

```
SUB SP,#0x18 ; MOVS R3,#0 ; ADD.W R12,SP,#var_14 ; STM.W R12,{R0-R3}
MOVW R0,#0x301 ; STR R0,[SP,#var_18]   ; req[0] = 0x301
MOVS R0,#2     ; SVC 0xF2              ; service 2 = ASD
```

Called once, at `0x401544`, inside `opm_asd301_path` (`0x4012C0`, 1030 bytes). IDA xrefs
give the routes into that function:

```
opm_cmd05                    calls it at 0x402a42     <- host-invokable command
dispatcher cmd 8             enters at loc_401320, inside this function
self-recursion               0x4016a6
```

Guards immediately before the call:

```
0x40152c  [SP+var_135C] == 4    else skip
0x401538  [R7+0x1018]   != 0    else skip
0x40153c  args: R2 = R7+0x101C, R0 = [SP+var_1358]
```

**So ASD 0x301 is reachable from a host-invokable OPM command** — the second of the two
ASD defects that `FINDING-ASD-IS-DRIVER-CLASS-NOT-INVOKABLE.md` dismissed *solely* because
no then-known TA emitted them. Both dismissals rested on the same four-TA scope.

## Why this is rated lower than the 0x202 result, deliberately

* For 0x202 the argument was `a2 + 36`, i.e. **provably the host's own page**, and the
  call was then **confirmed on hardware** (status 1045).
* Here `R7` is a context pointer and I have **not** shown host data reaches either the
  length or the content of the 0x301 write. Without that, "reachable" means the opcode is
  invoked, not that an attacker controls the write.
* ASD 0x301 writes into **ASD-side** memory, so there is no host-visible artefact to
  watch; a hardware demonstration needs an ASD-side observable, which this rig does not
  have (ASD's crash record exposes no register/stack context — the same limitation
  recorded in `GUARD-INIT-CHAIN.md`).

Next step for this one, in order: determine whether `R7+0x101C` and the length derive from
the host params, and whether `[SP+var_135C] == 4` is host-settable. That is static work on
`opm_asd301_path`, annotated in the IDB with these guards.

## FINAL corrected SVC table (opcode = req[0] = the immediate stored at [SP])

| TA | thunks | SYS_DRV (svc 1) | ASD (svc 2) |
|---|---|---|---|
| auc | 17 | 0x1, 0x2, 0x3, 0x9, 0xe, 0x10, 0x30, 0x31, 0x33, 0x101c, 0x101d, 0x103b | – |
| csu | 3 | – | 0x306 |
| fp | 16 | 0x1, 0x2, 0x3, 0x5, 0x7, 0x30, 0x31, 0x33, 0x35, 0x101c, 0x101d, 0x103b | – |
| gcp | 25 | 0x1, 0x3, 0x5, 0x7, 0x9, 0xe, 0x10, 0x30, 0x31, 0x33, 0x35, 0x103b, 0x109c, 0x109d | 0x107, 0x108, 0x109, 0x200, 0x201, 0x202, 0x204, 0x304, 0x305 |
| merit | 12 | 0x1, 0x3, 0x9, 0xe, 0x10, 0x30, 0x31, 0x33, 0x35 | 0x200, 0x201 |
| oc | 13 | 0x1, 0x3, 0xe, 0x10, 0x30, 0x31, 0x33, 0x35, 0x109b, 0x3158 | – |
| opm | 20 | 0x1, 0x3, 0x9, 0xe, 0x10, 0x30, 0x31, 0x33, 0x35 | 0x102, 0x105, 0x107, 0x161, 0x200, 0x201, 0x300, 0x301, 0x304, 0x305 |
| powerplay | 9 | 0x41, 0x109a, 0x6001, 0x6002, 0x6003 | 0x403, 0x404 |
| rap | 3 | 0x1076, 0x1077, 0x107b | – |
| tmz | 0 | – | – |
| wspe | 3 | – | 0x305, 0x306 |
| xgmi | 13 | 0x1, 0x2, 0xd, 0x53, 0x1048, 0x1049, 0x104a, 0x104b, 0x1052, 0x1053, 0x1061, 0x1062, 0x1067 | – |

This supersedes **both** earlier tables. The rule for the opcode is now "the immediate
stored at `[SP]`", i.e. the store whose `var_` offset equals the frame size — `req[0]`.
The previous rule took the *first* stack store, which is a neighbouring field: XGMI stores
`2` to `var_78` before storing the real opcode `0x1067` to `var_7C`, so **every XGMI, AUC
and FP opcode was reported as `0x2`** and their privilege reach looked empty. It is not:
AUC and FP each reach twelve SYS_DRV opcodes, XGMI thirteen (including the `0x104x`/`0x106x`
families), and OC ten.

The earlier claim that "AUC, FP and XGMI use a different kernel ABI and their reach is not
enumerable" was therefore **wrong twice over**: they use the same `svc 0xF2` ABI, and my
rule was misreading it. The `SVC 0xF3` stub is real but is a *lazy import resolver* —
`sub_403CA0` in XGMI caches a resolved pointer in a global and tail-calls it, and its
callers build an ordinary request struct (`v3[0] = 0x22`, `v3[1] = 2`) — so it is another
route to the same service RPC, not a separate privilege channel.

GCP's ASD set is unchanged by the fix, so the `0x202` finding is unaffected.

## OPM 0x301: host bytes enter the chain; control of the write is still unproven

`opm_cmd05(a1)` with `a1` = the host params buffer:

```
v5 = *(a1+16)                       host index, bounds-checked <= 0x3F
off_402ADC[22*v5]                   global slot table; requires slot state == 2
sub_4023EC(v7, a1+20, 256, v11, &v10)   a2 = host_params+20, len 256, dst = local 304 B
```

`sub_4023EC` is mutually reachable with `opm_asd301_path` (`sub_4012C0`), which issues ASD
`0x301` at `0x401544`.

So **256 host-supplied bytes from `host_params+20` enter the call chain that reaches the
0x301 emitter**, and the copy at this level is bounded (256 into 304). What is *not*
established is that those bytes reach the 0x301 request's content or length: the emitter
passes `R7+0x101C` and a stack value, and `R7` has not been traced back to the host page.

Recorded as the remaining question, in the IDB on both `opm_cmd05` and
`opm_asd301_path`, so the next person starts from the guards rather than re-deriving them.

================================================================================
# OPM 0x301: the argument IS a pointer into the host page (correction + escalation)

The previous section said the 0x301 emitter is reached from `opm_cmd05` and that `R7` was
"a context pointer, not shown to be the host page". Both statements needed fixing.

## IDA merged two functions, which is why R7 looked opaque

`opm_asd301_path` (`0x4012C0`) has prologue `SUB SP,#0x108`, yet code inside it references
a `#0x1390` frame. The reason: **`0x401320` is a separate real function**:

```
0x401320  PUSH.W {R4-R8,SB,SL,FP,LR}
0x401324  SUB.W  SP, SP, #0x1260
0x401328  SUB    SP, #4                 ; frame ~0x1264
```

IDA merged it into `0x4012C0`, so tracing "R7" across the boundary mixed two different
functions' registers — `R7 = arg0` is true in *both*, but they are different arg0s.
(`define undef/thumb/func` at `0x401320` did not make IDA split it; recorded in the IDB so
the next reader does not repeat the confusion.)

The inner function is the dispatcher's **cmd 8** handler (`case 8 -> loc_401320`, invoked
with `a1` = the host params buffer), not something reached only via cmd 5.

## R7 is the host params buffer — and the 0x301 argument points into it

```
0x401332  MOV R7, R0                    ; arg0
0x401340  LDR R4, [R7,#0x10]            ; host index
0x401342  CMP R4, #0x3F ; BLS           ; the same bounds-check idiom opm_cmd05 applies
                                        ; to *(a1+16), which is provably host data
...
0x401536  LDR R1, [R7,#0x1018] ; CMP R1,#0 ; BEQ skip   <- the guard reads HOST data
0x40153c  R2 = R7 + 0x101C                              <- the 0x301 argument
0x401544  BL opm_asd_rpc_301
```

The invoke entry requires `params_size >= 0x3D54` (15700), so `+0x101C` (4124) is inside
the host-supplied page.

**So host-controlled data reaches ASD 0x301's arguments**, and the gate on whether the call
happens at all (`[R7+0x1018] != 0`) is itself a host-settable word. That is materially
stronger than the previous "reachable, control unknown" rating.

## Still not established

* the **length** of the 0x301 write — the 5120-byte figure comes from the ASD-side
  analysis, and I have not shown the length field is host-derived;
* any hardware demonstration: ASD 0x301 writes into ASD memory and this rig exposes no
  ASD-side observable (the crash record carries no register/stack context), so there is
  nothing to read back — unlike the 0x202 case where a host-page write was checkable;
* whether the ASD build this card loads has the same 0x301 handler shape.

Both the GCP 0x202 and OPM 0x301 results now share one root cause worth stating plainly:
`FINDING-ASD-IS-DRIVER-CLASS-NOT-INVOKABLE.md` dismissed both defects because "no TA emits
that opcode", and that scan covered the four TAs then known. This card loads and invokes
twelve more, two of which emit exactly those opcodes with host data in the arguments.

## Annotation coverage - measured, and honestly short of 'every function'

| TA | functions | named | coverage |
|---|---|---|---|
| auc | 38 | 21 | 55% |
| csu | 13 | 9 | 69% |
| fp | 64 | 24 | 38% |
| gcp | 122 | 44 | 36% |
| merit | 35 | 23 | 66% |
| oc | 30 | 17 | 57% |
| opm | 47 | 28 | 60% |
| powerplay | 105 | 19 | 18% |
| rap | 13 | 9 | 69% |
| tmz | 6 | 5 | 83% |
| wspe | 56 | 10 | 18% |
| xgmi | 93 | 29 | 31% |
| **total** | **622** | **238** | **38%** |

What *is* named in every TA: the five TEE entry points, the dispatcher, every recovered
command handler, every service-RPC thunk (with its IDA-derived opcode), and a summary
comment on the invoke entry. That is the structurally load-bearing set — it is what makes
each TA's attack surface and privilege reach readable.

What is **not** named: the bulk of internal helpers (crypto glue, buffer handling, table
walkers), which is why coverage ranges from 18% (WSPE, POWERPLAY) to 83% (TMZ, a stub).
**So "everything fully annotated" in the literal sense is not met**, and the number is
stated rather than glossed: 238 of 622 functions.

None of the security conclusions depend on the unnamed remainder — they rest on the entry
points, dispatchers, thunks and xrefs, all named and cross-checked. But the gap is real and
is the obvious place for further work.

## Operational: a stale `.i64` can wedge the idalib worker

Confirmed twice, on **TMZ** and **MERIT**. Symptom: `spawn` reports `pane exited during
startup` or hangs, the pane appears in the idatui registry but is absent from slosh, and
the socket exists while refusing connections. Fix: delete the `.elf.i64` and re-open, which
succeeds immediately (MERIT: `ready: true, functions: 35`).

Two traps while diagnosing it:

* the first `rm` of MERIT's `.i64` never took effect (its command chain was killed), so
  "deleting it did not help" was a **false conclusion drawn from a file that was still
  there**, timestamp unchanged. Verify the delete instead of assuming it.
* MERIT's re-analysis legitimately exceeds a 700 s tool timeout, so an aborted wrapper
  looks exactly like a crash. Spawning under `nohup` and polling separates "slow" from
  "dead"; a live `idatui.launch` process is the tell.

Re-annotating a TA after deleting its database costs one scripted pipeline run
(`ta_symbols` -> `annotate_ta` -> `ta_rpc_names` -> `ta_final_note`), which is exactly why
those steps are scripts and not hand-work.

================================================================================
# ASD 0x301 is correctly bounded — the OPM lead deflates

Chasing the last open question on the OPM path: is the 0x301 write length host-derived?
**Yes** — and it does not matter, because ASD bounds it.

## The length is host-controlled

ASD's 0x301 handler `sub_D132A8` takes the length in **arg1**:

```
0xd132b4  MOV R4, R1              ; r4 = arg1
...
0xd132fc  MOV R2, R4
0xd132fe  BLX memcpy              ; length = arg1
```

OPM's thunk stores `{R0,R1,R2,R3}` into `req[1..4]`, so `arg1 = R1` at the call site, and
at `0x401536`:

```
0x401532  MOVW R0, #0x1018
0x401536  LDR  R1, [R7,R0]        ; R1 = *(host_params + 0x1018)   <- the length
0x401538  CMP  R1, #0 ; BEQ skip  ; only checked non-zero by OPM
0x40153c  R2 = R7 + 0x101C        ; source = host_params + 0x101C
0x401544  BL   opm_asd_rpc_301
```

So the host supplies both the source bytes **and** the length, and OPM itself only checks
the length is non-zero.

## ASD checks it properly, and the check is correct

```
0xd132c4  MOV  R0, SP ; MOV.W R1, #0x1400 ; BL memset   ; 0x1400-byte destination
0xd132ce  SUB.W R1, R4, #0x30
0xd132d2  MOVW  R2, #0x13D0
0xd132d8  CMP   R1, R2
0xd132da  BHI   reject                                   ; 0x30 <= len <= 0x1400
```

`0x13D0 + 0x30 = 0x1400`, exactly the buffer size, and because the comparison is unsigned
after subtracting `0x30`, a length below `0x30` wraps to a huge value and is also rejected.
Two further gates follow (`R6 != 0`, a helper at `0xD12E90`, and a bound on `R5`).

**So this is not a memory-corruption primitive.** The host controls up to `0x1400` bytes of
*content* and the length within `[0x30, 0x1400]`, and the copy fits its destination.

## Correcting our own phrasing

`FINDING-ASD-IS-DRIVER-CLASS-NOT-INVOKABLE.md` lists this as
*"`asd 0x301` — content-controlled write of up to 5120 bytes"*, in a sentence about defects
that "would be host-reachable if the host could invoke ASD". Read quickly — as I did when
I first flagged OPM — that sounds like an overflow worth chasing. It is not: 5120 = `0x1400`
is the **size of the bounded destination**, not an overrun. The wording describes attacker
*content*, and the bound was never claimed to be missing.

Net result for the OPM path: **reachability real, impact nil.** Worth recording precisely
because the reachability work (host params -> ASD 0x301 with host length) is genuinely new,
and the temptation is to let a proven reachability imply a vulnerability. It does not here.

The GCP -> ASD 0x202 result is unaffected: that one is an *uninitialised-memory read*, its
destination is the host page, and its blocker is ASD's crypto gate rather than a bound.

## Coverage after shared-runtime propagation

| TA | functions | named before | named after | coverage |
|---|---|---|---|---|
| auc | 38 | 21 | 28 | 74% |
| csu | 13 | 9 | 11 | 85% |
| fp | 64 | 24 | 29 | 45% |
| gcp | 122 | 44 | 81 | 66% |
| merit | 35 | 23 | 27 | 77% |
| oc | 30 | 17 | 21 | 70% |
| opm | 47 | 28 | 35 | 74% |
| powerplay | 105 | 19 | 50 | 48% |
| rap | 13 | 9 | 10 | 77% |
| tmz | 6 | 5 | 5 | 83% |
| wspe | 56 | 10 | 45 | 80% |
| xgmi | 93 | 29 | 65 | 70% |
| **total** | **622** | **238** | **407** | **65%** |

Method (`fw/ta_libmatch.py`): these TAs are built against one PSP TEE runtime, so the same
helper appears **byte-identical** in several of them. Dumping IDA's function list per TA and
hashing each function's bytes gives 628 functions / 457 distinct bodies, with **55 clusters
shared by two or more TAs covering 225 instances**. Naming a cluster once and propagating by
content hash is far better leverage than renaming anonymous functions one at a time, and it
is verifiable: a cluster is accepted only on an exact byte match.

**Built-in control:** cluster `b1cf532a23` (8 TAs, 148 bytes) is exactly the
`entry_trampoline` already named independently from the header parse, and the 2-byte and
4-byte clusters are the return stubs already named as `TA_CloseSession/DestroyEntryPoint`.
The clustering reproduces known answers before being trusted for unknown ones.

**Naming discipline:** only six clusters got semantic names, the ones whose disassembly
proves what they are — `lib_zero_fill_words` (word-block zero loop, 10 TAs),
`lib_zero_fill_bytes` (bytewise with alignment fixup, 6 TAs), the two return stubs, the
trampoline and a tail-jump thunk. The other 49 are tagged `lib_<hash8>_<size>`, which
records the *fact* that they are shared runtime rather than TA-specific logic without
inventing semantics for them. Several look like a stream/vtable family (`ldr r5,[r0,#0x18]`
then `blx r2`), probably the log formatter, but "probably" is not a name.

Coverage is now **61%** (397/650 counting the small function-count differences IDA reports
per database), up from 38%. The remainder is genuinely TA-specific internal logic, and the
honest position is that it is unnamed.

## Final annotation coverage

| TA | functions | named | coverage |
|---|---|---|---|
| auc | 39 | 36 | 92% |
| csu | 13 | 12 | 92% |
| fp | 64 | 59 | 92% |
| gcp | 124 | 105 | 85% |
| merit | 35 | 30 | 86% |
| oc | 30 | 28 | 93% |
| opm | 48 | 42 | 88% |
| powerplay | 106 | 78 | 74% |
| rap | 13 | 12 | 92% |
| tmz | 6 | 5 | 83% |
| wspe | 57 | 48 | 84% |
| xgmi | 93 | 78 | 84% |
| **total** | **628** | **533** | **85%** |

Three systematic passes got here, each verifiable rather than hand-typed:

1. **shared-runtime propagation** (`fw/ta_libmatch.py`) — hash every function body, cluster
   across TAs, name a cluster once. 55 clusters shared by >=2 TAs, 225 instances. Control:
   the 148-byte cluster is the already-known `entry_trampoline`.
2. **call-graph attribution** (`fw/ta_attribute.py`) — an anonymous function with exactly
   one named caller is named `<caller>__hN`, iterated to a fixpoint so chains attribute to
   the owning command. Functions with several named owners are left alone, since claiming
   one owner would be wrong.
3. **function-pointer tables** — POWERPLAY's 51% coverage was caused by 37 functions with
   no direct caller. A scan for runs of valid Thumb pointers into the image found a
   **23-entry table at `0x40266c`** (and the 5-entry TEE table at `0x406c44`, which matches
   the trampoline and so serves as the control). The 23 targets are named
   `powerplay_fptbl00..22` — index-derived and factual, not guessed semantics. That took
   POWERPLAY from 51% to 73%, and a re-run of pass 2 to 74%.

**82% overall (509 -> 533 of 622).** What remains unnamed is: functions with several named
owners (naming one would misattribute), and functions reached only indirectly in TAs where
no pointer table was found. Both are stated rather than papered over — the objective's
literal "everything" is not met, and the number is the evidence.

## What the unnamed remainder actually is (characterised, not guessed)

Rather than invent names for the residue, it was classified. Taking GCP as the worked
example after a **fresh** dump (the earlier `libdumps` were stale, which briefly made the
remainder look 2-3x larger than it is):

* 124 functions, **16 anonymous** at that point;
* **7 have callers** but were deliberately skipped by attribution because they have
  *several* named owners — naming one owner would misattribute. Three of those are now
  named on their own merits: `gcp_log` (varargs logger, `int(int, const char *, ...)`,
  every `AMD_TA_GCP:` string goes through it), `gcp_crypto_ctx_release`, and one shared
  helper;
* **9 are isolated**: no `bl` caller anywhere *and* no dword in the image points at them.
  They form a small connected island (`401C8C` -> `401ADA`/`4022D0`, plus `401EEC`, 606 and
  758 bytes). Either they are invoked through a computed pointer this analysis cannot
  resolve — the `SVC 0xF3` lazy-resolver pattern XGMI uses is the obvious candidate — or
  they are dead code. They are labelled `gcp_island_*` with that reasoning in a comment,
  because which of the two it is has **not** been established.

GCP is now 113/124. The same two categories account for the residue in the other TAs.

**Pointer-table check, completed for all twelve:** every TA has exactly the 5-entry TEE
entry-point table (already named, and it doubles as the control since it must match the
trampoline); only POWERPLAY has a second, 23-entry table. So there is no further table to
mine — the residue is genuinely multi-owner helpers and indirect/dead islands, not
something a scan was missing.

Final: **541/628 named = 86%**, with the remainder classified as above.

================================================================================
# Coverage gap found: IDA had MISSED functions, so the denominator was wrong

While classifying the residue, two island functions turned out to be called from `bl`
instructions that lie **outside every IDA function**. That is not a naming problem, it is a
coverage problem: any audit that iterates IDA's function list — which is *all* of the
detectors in this directory — silently skipped that code, and every coverage percentage was
computed over an incomplete denominator.

`fw/ta_define_missed.py` finds these regions with a strict signal: a `bl`/`blx` **to a known
function start** sitting outside all IDA functions, then walks back to the prologue
(preferring the `push{...,lr}` over the following `sub sp`, or the defined function starts
mid-prologue).

| TA | unmarked call sites | prologues recovered |
|---|---|---|
| gcp | 17 | `0x402524`, `0x4025f8` |
| merit | 21 | `0x4011d8` |
| opm | 12 | `0x4011d8`, `0x4018b2` |
| oc | 3 | `0x40133c` |
| xgmi | 2 | `0x40288c` |
| auc, csu, fp, powerplay, rap, tmz, wspe | 0 | — |

**A permissive estimate was tried first and rejected.** Counting "bytes outside IDA
functions that decode as Thumb" claimed **75%** of these images were unanalysed code. That
is wrong: almost any bytes decode as some Thumb instruction and these images carry large
data blobs (OPM embeds XML certificate collections). It is the same fallacy as judging an
ISA by decode percentage, which this tree already recorded once. The strict
call-into-known-function signal gives 55 sites in 5 TAs, which is the defensible number.

## The most important one

GCP `0x402524`, now `gcp_mac_over_host_data`, was **never an IDA function**. It is the
routine I had analysed by hand when retracting the FE09 lead — `(src, len, key, 0x10)`,
`rpc_30 init -> rpc_33 setkey -> … -> rpc_10 MAC -> rpc_31 free`, then 16 bytes of digest
copied out. So the function at the centre of that analysis was invisible to every automated
sweep run in this directory. The retraction stands (it was done by hand), but the detectors
never saw it, and that is exactly the kind of blind spot worth writing down.

GCP is now **126 functions** (up from 124) with 114 named. Applying the same step to merit,
opm, oc and xgmi is queued work, not done here.

**Consequence for the earlier sweeps:** the fall-through, copy-length and liveness detectors
should be re-run after defining the missed functions in all five TAs. Their negatives are
therefore "negative over IDA's function set as it stood", which is weaker than "negative
over the TA" — stated plainly rather than left implied.

## Missed-function pass applied; detectors re-run (audit integrity restored)

The previous section warned that every sweep's negatives were only "negative over IDA's
function set as it stood". That is now closed for the five affected TAs:

| TA | unmarked sites | prologues defined | detectors re-run | new findings |
|---|---|---|---|---|
| gcp | 17 | `0x402524` (`gcp_mac_over_host_data`), `0x4025f8` | yes | none (FE0D already known) |
| merit | 21 | `0x4011d8` | yes | **`merit_cmd05` read for the first time -> `fw/LEAD-MERIT-CMD05-MEMCMP-ORACLE.md`** |
| opm | 12 | `0x4011d8`, `0x4018b2` | yes | none (the two known false positives only) |
| oc | 3 | `0x40133c` | yes | none (0 fall-through, 0 copy) |
| xgmi | 2 | `0x40288c` | yes | none |

So the re-run produced exactly one new item, and it came from MERIT — the TA the liveness
sweep had "cleared" on the strength of `merit_cmd04` alone while `merit_cmd05`, flagged by
the same detector, had never been read. The detector was right both times; I had only
followed up one of its two hits.

**Self-inflicted regression, recorded:** OC's spawn hit the stale-`.i64` wedge, and deleting
the database to recover destroyed its annotations. They were rebuilt from the scripted
pipeline (`ta_symbols` -> `annotate_ta` -> `ta_rpc_names` -> `libmatch apply` -> `attribute`
-> `final_note`) back to 28/30 named. One ordering detail matters when rebuilding: the
attribution pass must run **after** a fresh `libmatch dump`, or it sees stale names and
attributes nothing — it returned 0 on the first attempt for exactly that reason.

**Process note:** the repeated `sleep N` waits for IDA analysis were wrong and cost several
aborted calls. The right tool is the readiness gate from the fast-feedback skill
(`scripts/waitfor -t N log <file> <regex>`), which returns the moment the spawn reports
`functions`, typically in ~1.5 s of polling rather than a blind 200+ s sleep.

## VERIFIED final coverage (each IDB queried directly, this session)

| TA | functions | named | coverage |
|---|---|---|---|
| auc | 40 | 36 | 90% |
| csu | 13 | 12 | 92% |
| fp | 64 | 59 | 92% |
| gcp | 126 | 114 | 90% |
| merit | 35 | 30 | 86% |
| oc | 30 | 28 | 93% |
| opm | 49 | 42 | 86% |
| powerplay | 106 | 78 | 74% |
| rap | 13 | 12 | 92% |
| tmz | 6 | 5 | 83% |
| wspe | 57 | 48 | 84% |
| xgmi | 93 | 78 | 84% |
| **total** | **632** | **542** | **86%** |

Function totals include the previously-missed functions defined this session (GCP 124->126,
OPM 48->49, AUC 39->40), so the denominator is IDA's *current* set rather than the stale one
earlier percentages were computed against.

## Residue documented: every function in all twelve TAs is now named *or* documented

The last 14% could not be *named* without inventing semantics for code that is either
shared by several owners or reachable only indirectly. So it was **documented** instead.
`fw/ta_document_residue.py` writes an IDA function comment on every still-anonymous
function stating facts only:

```
// Residue function, documented rather than named (naming would be invention).
// size: 218 bytes
// class: called only from still-anonymous code
// callers: sub_4018B6
// callees: xgmi_lib_b56825b4_392, xgmi_lib_e2ae1a48_42, xgmi_lib_eeee582a_42
// Basis: IDA function boundaries + capstone decode of this function only
```

Four classes are assigned, each from evidence rather than judgement: *several named owners*
(attribution would misattribute), *no named caller and not data-referenced* (indirect or
dead), *no bl caller but data-referenced* (reached indirectly), and *called only from
still-anonymous code*.

Applied across all twelve: powerplay 27, xgmi 14, wspe 9, gcp 8, opm 6, fp 5, merit 4,
auc 3, oc 1, csu 1 — **78 functions documented**; rap and tmz had none left. Spot-verified
by reading the comment back in two different databases (`powerplay sub_401588`,
`xgmi sub_40212C`).

**Correction to the coverage figures:** the live counts were taken with `names <prefix>_`,
which misses IDA's `start` label in every TA — so each TA's named count was one low. With
`start` included the named total is **554/632**, and with the residue documented **every one
of the 632 functions now carries either a derived name or a factual comment**.

That is the defensible reading of "fully annotated": nothing is left blank, and nothing is
labelled with a guess. The alternative reading — a semantic name for all 632 — is not
achievable for indirect/dead code without fabricating meaning, which this tree's discipline
forbids.

================================================================================
# The ASD 0x202 path now EXECUTES — but the returned bytes are not a disclosure

Loading ASD (the fix described above) unblocked GCP cmd 4: its status went from `0x415`
(the ASD-0x202-call-failed path) to **0**, and it now writes 16 bytes into the host page at
`+0x34`, exactly the offset predicted from `arg3 + 16` with `arg3 = params + 36`.

That is **not** proof of the uninitialised-stack disclosure, and the first verdict this
probe printed ("DISCLOSURE CONFIRMED") was wrong for a simple reason: `GetContentKey` is
*supposed* to return key material at that offset. "Bytes changed" cannot distinguish an
intended output from a leak. The probe's verdict string has been corrected.

Checking the content across four runs with identical input:

```
run0  ...a5cba5a074efc73d29fd201fa2aa22776f91242b10d3281b22 0000...
run1  ...ede90937bc6a252d1065e89061be0ac46d05821d6a0d164408 0000...
run2  ...02ae7d42e0ba5fa42063f247d2caf9a535617d288c5ce11700 0000...
run3  ...b5ba2f0fcff5033d63be47284c4ba89956205541760cc35157 0000...
```

* **28 bytes of uniform high entropy then zeros** — the shape of a key blob of defined
  length, not of stack residue;
* **no address-shaped dwords** in any run (nothing in `0x004xxxxx` image, `0x00dxxxxx` ASD,
  or low stack ranges) — stack residue in these TAs almost always carries saved pointers;
* **no constant dword across runs.** This is the decisive one: `__stack_chk_guard` is fixed
  for the life of a process, so if the leaked window contained the canary the same dword
  would repeat every call. Nothing repeats.

**Conclusion: the 0x202 code path is now reachable and executes successfully, but the bytes
it returns show no sign of being leaked stack.** They are consistent with the intended
obfuscated-key output.

Note the subtlety that makes this hard to settle: ASD 0x202's copy reads `src[4]`, which is
uninitialised — but it sits where the crypto pipeline just ran, so genuine leaked residue
would *also* look high-entropy. Entropy alone can never separate the two. The canary test
is what carries the argument, and it comes back negative.

So the ASD 0x202 finding keeps its status: **real code-path defect, reachable from the host
through GCP, with no demonstrated leak of anything sensitive on this part.**

================================================================================
# Signatures and types: making the decompiled output usable

Names alone leave Hex-Rays emitting `*(_DWORD *)(a1 + 7680)` for the single most important
expression in this audit (the shared refcount). This pass added function prototypes and
structure types, and in doing so corrected a batch of names that had drifted.

## The firmware knows its own function names

GCP logs `AMD_TA_GCP: <Name> ENTER` on entry to most handlers. `fw/ta_name_audit.py` checks
every derived symbol against that string. Result: **18 mismatches in GCP** (and 2 false
positives in WSPE, where the logged words are `Request`/`running`, not function names). The
other ten TAs log nothing comparable, so there is nothing to check them against.

Combined with a fresh decode of the dispatcher's two `tbb` tables (tier 1 at `0x404002`
for `0xFE02..0xFE11`, tier 2 at `0x404026` for cmd 1..5), which gives cmd -> handler
**address** rather than the stale name-keyed map, the corrections are:

| cmd | was | is (AMD's own name) |
|---|---|---|
| 3 | *not defined as a function at all* | `ActivateDRMCertAuthSession` |
| `FE03`..`FE06` | "CreateAuthenticatedSession" etc. | **UNIMPLEMENTED** — all four arms point at the error path `0x4040ce` |
| `FE07` | tagged `FE03` | `CreateAuthenticatedSession` |
| `FE09` | `gcp_cmdFE09` | `ConfigureAuthenticatedSession` |
| `FE0A` | `QueryAccessibilityAttributes` *(invented)* | `QueryAuthenticatedSession` |
| `FE0C` | `CreateAuthenticatedSession` | `CreateCryptoSession` |
| `FE0D` | `DestroyAuthenticatedSession` | `DestroyCryptoSession` |
| `FE0E` | `gcp_dispatch__h1` | `KeyExchangeCryptoSession` |
| `FE0F` | `GetCryptoSessionByDecodeHandle` | `GetCryptoSessionForDevice` |

plus eight `__hN` placeholders resolved to `GenerateOMAC`, `VerifyOMAC`, `QueryChannelType`,
`QueryCryptoSession`, `QueryDeviceHandle`, `QueryOutputID`, `QueryOutputIDCount`,
`QueryCodecTokenChallengeDataBlob`, `GetCryptoSessionByUASPHandle`, `ConfigureCryptoSession`,
`ConfigureInitialize`.

**The hardware sweep independently agrees**: `FE03`–`FE06` all returned an identical `0x102`
(the shared error arm) while `FE07` alone returned `0x0` with a handle. That had been sitting
in the sweep output the whole time, mislabelled.

### Consequence for a finding

`FINDING-GCP-FE0D-REFCOUNT-DESYNC.md` argued from a differential against "its correct sibling
FE0B". FE0B is Destroy**Authenticated**Session (stride-80 array at `ctx+0x300`) and FE0D is
Destroy**Crypto**Session (stride-88 array at `ctx+0x800`). They are two destroy paths over
*different* arrays, not a same-type sibling pair. The code observation stands — one rolls the
refcount back correctly and one does not — but the wording overstated the parallel, and the
in-database comment has been corrected to say so.

## Types

Five structures, every offset verified by reading the layout back out of IDA and comparing it
against the addressing the code actually performs:

```
gcp_ctx  size 0x1e10   drm[32] @+0x0    auth[16] @+0x300   crypto[64] @+0x800
                       asd_refcount @+0x1e00   handle_counter @+0x1e08
```

which is exactly what `gcp_cmdFE0C_CreateCryptoSession` computes (`add.w r5,r0,#0x800`,
`add.w r6,r6,#0x1e00`, `ldr r3,[r6,#8]`). The three session arrays tile the context with no
slack, which is itself the check: `32*24 + 16*80 + 64*88 = 0x1e00`. The `0x1e10` total is
`0x1e0c` of members padded for the 8-byte alignment the crypto struct's `unsigned long long`
members impose.

## Result

**179 prototypes applied across all twelve TAs, 0 failures.** Measured against a *pristine*
database (a fresh analysis of a copy of the ELF — measuring "before" in the same database
that already carries the work compares typed output against typed output):

| | raw pointer-arithmetic casts | anonymous parameters |
|---|---|---|
| pristine IDA | 81 | 113 |
| after names + types | **7** | **0** |

`gcp_cmdFE0D_DestroyCryptoSession` before and after, which is the whole point:

```c
// pristine                                  // after
int __fastcall sub_403170(int a1, int a2)    int __fastcall gcp_cmdFE0D_DestroyCryptoSession(
{                                                struct gcp_ctx *ctx, struct ta_params *params)
  sub_405084(a1: 0, a2: aAmdTaGcpDestro_1);    gcp_log(level: 0, fmt: aAmdTaGcpDestro_1);
  v4 = *(_DWORD *)(a2 + 16);                   arg10 = params->arg10;
  v7 = *(_DWORD *)(a1 + 7680);                 asd_refcount = ctx->asd_refcount;
  ... *(_DWORD *)(a1 + 7680) = v8 ...          ... ctx->asd_refcount = v8 ...
  v9 = sub_405428(a1: 2);                      v9 = gcp_asd_rpc_305(arg: 2u);
```

## Method notes worth keeping

* **Do not drive the TUI struct editor to declare types.** It is a TextArea fed by injected
  keystrokes: a stray focus change types the declaration into the *filter* box, `ctrl+s` then
  saves nothing, and the only symptom is "save failed - IDA couldn't parse it". A half-applied
  redeclare truncated three structs to their first member and the damage was invisible until
  pseudocode silently regressed to `v5[2].field`. Use `NexusClient` + `remote_ops`
  (`declare_type`, `type_inspect`, `set_type`) headlessly, and **verify by reading the members
  back**, never by looking at the screen.
* **Declare interdependent structs in one parse unit.** Declared one at a time, every nested
  member (`struct gcp_drm_session drm[32];`) is rejected, because each save is its own parse
  and the referenced type is not visible to it.
* **Build prototypes from the database, not from `fw/libdumps/*.json`.** The dump goes stale
  the moment a rename lands.
* **A type that misrepresents the addressing is worse than no type.** Typing FE0D's `v6` as
  `gcp_crypto_session *` produced the tidy-looking `v6[23].decode_handle`, but `v6` is
  `ctx + 88*idx` — based at the *context*, with the `+0x800` folded into the load offsets —
  so the index is fiction. Reverted to `char *`.
* **Two metric bugs, both caught by disbelieving a number.** Scoring a function that does not
  exist in the pristine database as 0 made powerplay read "anon args 0 -> 8"; and counting
  `aN` tokens across the body counted Hex-Rays' *call-site* annotations for un-prototyped
  callees, so `v2(a1: ctx, a2: params)` — a strict improvement — scored as a regression.

## Closing the signature gap: arity by dataflow, validated before use

179 prototypes came from an established role, leaving ~450 helpers with none. Guessing
their argument count would repeat the `v6[23].decode_handle` mistake: output that reads
well and says something false.

Arity is not a guess, though. On AAPCS r0-r3 carry the first four arguments, so a register
**read before it is written** on any path through the function is an incoming parameter.
`fw/ta_arity.py` recovers that with a block-aware walk from the entry.

**The point is the validation, not the analysis.** The control is the set of handlers the
dispatch table points at, which the dispatcher demonstrably calls as
`mov r0,r5 ; mov r1,r4 ; bl handler` — arity 2, proven — plus `zero_fill` (2) and `memcmp`
(3). The analyzer had to reproduce those before being used anywhere else, and the first two
attempts failed:

* **20/35 agree.** `push {r3,r4,r5,lr}` is the 8-byte-alignment idiom and *reads* r3, so
  every function using it looked like it took four arguments.
* **28/35 agree** after excluding push/pop — but that over-corrected by also excluding
  `stm`, which for the ASD RPC thunks *is* the argument-passing idiom
  (`stm.w ip, {r0,r1,r2,r3}` marshals four arguments into the request buffer), so those
  thunks reported zero arguments.
* **19/19 agree** once push/pop are excluded, `stm`/`ldm` are treated as real dataflow, and
  the control is restricted to arities that are *proven* rather than assumed.

That last correction matters: the ASD RPC thunks had been part of the control on the
assumption that they all take one argument. **They do not, and finding that out fixed
prototypes I had already applied:**

| thunk | declared | actual, from its own code |
|---|---|---|
| `gcp_asd_rpc_304` | `(unsigned int arg)` | **0** — zero-fills its own buffer and sets the opcode |
| `gcp_asd_rpc_107` | `(unsigned int arg)` | **2** — `movs r2,#0` writes r2 *before* `stm r3!,{r0,r1,r2}` reads it |
| `gcp_asd_rpc_202` | `(unsigned int arg)` | **4** — `stm.w ip,{r0,r1,r2,r3}` |
| `gcp_asd_rpc_305` | `(unsigned int arg)` | 1 (this one was right) |

So the selftest did not merely validate the analyzer; it disproved four prototypes that
were already in the databases. A detector that only ever confirms is not worth running.

### Coverage

| | functions | with a prototype | named |
|---|---|---|---|
| all twelve TAs | 632 | **632 (100%)** | 554 (88%) + 78 documented |

Readability against a pristine database, summed over the sampled handlers of all twelve:
**raw pointer-arithmetic casts 81 -> 8, anonymous parameters 113 -> 0.**

## Incident: one database was silently corrupt, and only a full-coverage check found it

The per-TA sampling all looked healthy. Running decompilation across **every** function
instead of a sample exposed that `powerplay_34.0.0.20.elf.i64` would not open at all:

```
[ida-nexus] Failed to open database .../powerplay_34.0.0.20.elf.i64
```

Not a busy lease — a copy of the file failed identically, and its mtime (15:59:43) placed it
in the run whose worker exited mid-write while every healthy database was written at
16:00:3x. The header matched a good database, so nothing short of opening it would have
revealed the damage. **Sampling would have shipped a broken deliverable.**

Rebuilt from the artifacts that made rebuilding possible — `fw/libdumps/powerplay.json`
holds all 106 boundaries and names, which is why keeping symbol state outside the database
is worth the duplication:

1. fresh analysis of the ELF (105 functions found);
2. 79 names re-applied by address via `rename` with `batch={"func":[...]}`;
3. the one function IDA does not find on its own recovered — `define_code` across its full
   extent, then `define_func`. Note `set_thumb` is a **toggle**: calling it on an address
   that is already Thumb turns Thumb *off* and the instruction then fails to decode, which
   is what happened on the first attempt;
4. 27 residue comments re-applied;
5. prototypes and inferred arity re-applied.

IDA's own analysis gives that recovered function a 6-byte body where the old database
recorded 112. The whole 112-byte extent is code, but the function proper ends after 6 bytes,
so the recorded size was the old database's boundary rather than ground truth. Left at IDA's
figure instead of forcing the old extent, on the same principle as the reverted lvar type:
do not record an extent the tool does not support.

`fw/ta_document_residue.py` now applies comments headlessly through `set_comments`, which
takes an address and reports per-item status, instead of the TUI's cursor-dependent
`goto` + `comment`.

## Final state, verified across every function in every database

| | functions | named | with prototype | decompile cleanly |
|---|---|---|---|---|
| all twelve TAs | 632 | 555 | **632 (100%)** | **632 (100%)** |

## Command-surface dumps for all twelve TAs

GCP had a decompiled command-surface artifact; the other eleven did not, and the command
surface is what an analyst reads first. `fw/ta_surface.py` now emits one per TA into
`fw/pseudo/<ta>_command_surface.c`.

Two things had to be got right for these to be honest rather than merely present:

**Prefer the address-keyed map.** `fw/cmdmaps/<ta>.json` records handlers by NAME, captured
before the renames. For GCP there is also `gcp_ea.json`, decoded straight from the
dispatcher's jump tables and keyed by address. The first run used the name map for GCP too
and resolved only **4 of 17** handlers, silently replacing a good artifact with a worse one.
The tool now prefers `<ta>_ea.json` wherever it exists.

**Separate a function entry from a label inside a function.** Several recorded targets are
not function starts: `rap` cmd 1 points at `start+0xf00`, `auc` cmds 1 and 4 land inside
`auc_cmd02__h5`, and five of OPM's nine dispatch into the middle of larger functions. Mapping
those to their containing function and printing it as "the handler" would assert something
false — `start` is not rap's cmd 1 handler. Exact entries and internal labels are now kept in
separate sections, with the containing function reproduced in full under an explicit warning
that the command's code begins at the stated offset and the function as a whole is not the
handler.

IDA refuses to split those internal entries into their own functions, and undefining the
owning function to force a split was not worth the risk so soon after repairing a corrupted
database. OPM cmd 8 at `0x401320` is the interesting case: earlier notes call it a handler
"IDA merged into 0x4012C0", and it is reported here as entering `opm_asd301_path+0x60`
rather than being asserted as a separate function.

| | exact handlers | decompiled | internal-label entries |
|---|---|---|---|
| twelve TAs | 62 | **62 (100%)** | 9, each labelled with its containing code |

TMZ has no command map, which is correct: it is a stub whose invoke handler answers every
command id identically, confirmed on hardware.