aboutsummaryrefslogtreecommitdiffstats
path: root/psp_out/toctou_detect.py
blob: bebffd68ec302015e441b7e97b657b879e7b0b19 (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
"""STEP 1: detect how many times the PSP reads the image buffer (read multiplicity).

Why statistical rather than "load a corrupt image and watch it run": corrupting a
real IP firmware (MEC/SMU/...) to observe the loaded result risks hanging or
mis-programming the GPU. Verification *failure*, by contrast, is inert -- the PSP
just returns an error and loads nothing. So we measure read multiplicity purely
from verify pass/fail statistics, which is safe and still answers the security
question directly.

Method
------
The bridge flipper alternates one signature-covered byte between A (genuine) and
B (corrupt) with a controllable duty cycle d = fraction of time the byte is A.
Issue LOAD_IP_FW many times and measure the pass rate p.

  * PSP hashes the image ONCE   -> p ~= d
  * PSP reads it TWICE (e.g. hash pass then load/copy pass, both covered by the
    check) -> both reads must see A -> p ~= d^2
  * N reads -> p ~= d^N

Sweeping d and fitting log(p)/log(d) gives N. N > 1 is the double-fetch that makes
the bridge-SRAM TOCTOU exploitable; N == 1 means verify and load are a single read
and the attack does not apply to this path.

Control arms (must hold or the measurement is meaningless):
  * byte pinned to A -> p must be ~1.0
  * byte pinned to B -> p must be ~0.0   (proves the byte is actually hashed)

SAFETY: only ever submits an image whose bytes are either the genuine signed ones
or a one-byte corruption. A corrupted image FAILS verification and is not loaded.
Run with a fw_type that is already loaded, so a success merely reloads identical
content.
"""
import time, math, ctypes
from tinygrad import Device
from tinygrad.runtime.autogen.am import am
from psp_out.toctou_bridge import Flipper, M_ARM, M_DISARM


class Detector:
  def __init__(self, flip_off=0x800):
    self.f = Flipper()
    self.adev = self.f.adev
    self.psp = self.f.psp
    self.flip_off = flip_off
    self.f.select_method(0)          # XDATA window: does not disturb the 0x7000 staging path

  def pick_image(self):
    """Choose an already-loaded IP firmware: reloading identical content is inert."""
    for types, blob in self.adev.fw.descs:
      if len(blob) > self.flip_off + 16:
        return types[0], blob
    raise RuntimeError("no suitable firmware descriptor found")

  def submit(self, fw_type, blob) -> int:
    """One LOAD_IP_FW. Returns the PSP status word (0 == verified+loaded)."""
    self.psp._prep_msg1(blob)
    cmd = am.struct_psp_gfx_cmd_resp(cmd_id=am.GFX_CMD_ID_LOAD_IP_FW)
    from tinygrad.helpers import data64
    cmd.cmd.cmd_load_ip_fw.fw_phy_addr_hi, cmd.cmd.cmd_load_ip_fw.fw_phy_addr_lo = data64(self.psp.msg1_addr)
    cmd.cmd.cmd_load_ip_fw.fw_size = len(blob)
    cmd.cmd.cmd_load_ip_fw.fw_type = fw_type
    resp = self.psp._ring_submit(cmd)
    return resp.resp.status

  def trial(self, fw_type, blob, mode, trials=12):
    """mode: 'A' pin genuine, 'B' pin corrupt, 'flip' alternate."""
    genuine = blob[self.flip_off]
    corrupt = genuine ^ 0xFF
    ok = 0
    for _ in range(trials):
      self.psp._prep_msg1(blob)                       # restage pristine image
      if mode == "A":    self.f.poke(self.flip_off, genuine)
      elif mode == "B":  self.f.poke(self.flip_off, corrupt)
      else:              self.f.arm(self.flip_off, genuine, corrupt)
      try:
        st = self.submit(fw_type, blob)
      except Exception:
        st = -1
      finally:
        if mode == "flip": self.f.disarm()
      ok += (st == 0)
    return ok / trials


if __name__ == "__main__":
  d = Detector()
  fw_type, blob = d.pick_image()
  print("target fw_type=%d size=%#x  flip_off=%#x" % (fw_type, len(blob), d.flip_off))
  print("\n[controls] these must hold or the measurement is invalid:")
  pa = d.trial(fw_type, blob, "A")
  print("  byte pinned GENUINE : pass rate %.2f  (expect ~1.00)" % pa)
  pb = d.trial(fw_type, blob, "B")
  print("  byte pinned CORRUPT : pass rate %.2f  (expect ~0.00 => byte IS hashed)" % pb)
  if not (pa > 0.8 and pb < 0.2):
    print("\nCONTROLS FAILED -> offset %#x is probably not covered by the signature check;"
          " pick a different flip_off before trusting any flip result." % d.flip_off)
  else:
    pf = d.trial(fw_type, blob, "flip", trials=24)
    print("\n[flip] free-running A/B : pass rate %.2f" % pf)
    print("  single-read model predicts ~0.50; two reads predict ~0.25")
    if pf > 0.05:
      print("  -> verification passed at least once while the byte was flipping.")
      print("     Next: prove verify-saw-A while load-saw-B (the actual TOCTOU).")
    else:
      print("  -> never passed; window may be too tight, or reads are single+atomic.")