aboutsummaryrefslogtreecommitdiffstats
path: root/fw/ta_slotflow2.py
blob: 13c598b22a7689b2ebfe1dc57ffffb356c1ac07e (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
#!/usr/bin/env python3
"""Detect the callee-written in/out length slot pattern, and whether it is re-validated.

This is the one bug class in this hunt with a proven instance, and the class field-flow v2 is
blind to by construction (the length lives in a stack slot written by a callee through a
pointer, not in a struct field written by a `str [base,#imm]`).

The pattern, from `FINDING-XGMI-CALLEE-LENGTH-STACK-COPY.md`:

    mov.w sl, #0x40           ; capacity
    str.w sl, [sp, #8]        ; in/out length slot = 64
    add   r1, sp, #8          ; &slot passed to the callee
    bl    <callee>            ; callee writes the REAL length into the slot
    ldr   r4, [sp, #8]        ; re-load it
    mov   r2, r4              ; use as a copy length ...
    blx   <memcpy>            ; ... with no re-validation   <-- BUG

and the safe version, ASD `sub_D12DEC`:

    ldr   r5, [sp, #8]        ; callee-written length
    ldr   r0, [sp, #0x74]     ; the caller's capacity
    cmp   r5, r0 ; bls ok     ; <-- validated, then copy

So the detector looks for: a stack slot initialised from an immediate, its address taken, a
call, a re-load of the same slot, and that value reaching a copy length -- then asks whether
any `cmp` involving the re-loaded register occurs between the re-load and the copy.

CONTROLS (both required):
  POSITIVE  xgmi `0x402fc4` reported   (no re-validation)
  NEGATIVE  asd  `0xd12e4c` NOT reported (compared against the caller's capacity)

Usage:
  ./ta_slotflow_audit.py --selftest
  ./ta_slotflow_audit.py --module <ta prefix>|asd|sys_drv|hdcp
"""
from __future__ import annotations

import argparse
import json
import re
from pathlib import Path

import capstone as cs

HERE = Path(__file__).resolve().parent
REPO = HERE.parent
THUMB = cs.Cs(cs.CS_ARCH_ARM, cs.CS_MODE_THUMB)
ARM = cs.Cs(cs.CS_ARCH_ARM, cs.CS_MODE_ARM)
CACHE = HERE / "audits" / "fnbounds.json"
MODULES = {"hdcp": ("psp_out/hdcp_code.bin", 0x400100),
           "sys_drv": ("psp_out/sys_drv_code.bin", 0xE00100),
           "asd": ("psp_out/asd_code.bin", 0xD00100)}

SLOT_STORE = re.compile(r"^(\w+),\s*\[sp,\s*#(0x[0-9a-f]+|\d+)\]$")
SLOT_LOAD = re.compile(r"^(\w+),\s*\[sp,\s*#(0x[0-9a-f]+|\d+)\]$")
ADDR_OF = re.compile(r"^(\w+),\s*sp,\s*#(0x[0-9a-f]+|\d+)$")


def load_module(name):
    if name in MODULES:
        bpath, base = MODULES[name]
        b = (REPO / bpath).read_bytes()
        fns = json.loads(CACHE.read_text())[name]
    else:
        base = 0x400100
        meta = json.loads((HERE / "syms" / ("%s.meta.json" % name)).read_text())
        bp = Path(meta["path"])
        b = (bp if bp.is_absolute() else REPO / bp).read_bytes()
        fns = [[f["ea"], f.get("size", 0)]
               for f in json.loads((HERE / "libdumps" / ("%s.json" % name)).read_text())
               if f.get("size", 0) > 0]
    return b, base, fns


def split_merged(b, base, fns):
    """Split IDA-merged extents at interior prologues.

    Both directions matter here: a merged extent can pair a slot init in one function with a
    call in another (false positive), and can let a `cmp` from a neighbouring function mark a
    slot "validated" when its own function never checks it (false NEGATIVE).
    """
    out = []
    for ea, sz in fns:
        o = ea - base
        if o < 0 or sz <= 0 or o + sz > len(b):
            continue
        ins = list(THUMB.disasm(b[o:o + sz], ea))
        cuts = [ea]
        for n, i in enumerate(ins):
            if i.address == ea or not i.mnemonic.startswith("push"):
                continue
            if any(x.mnemonic in ("sub", "subw", "sub.w")
                   and re.match(r"sp,\s*(?:sp,\s*)?#", x.op_str or "")
                   for x in ins[n + 1:n + 4]):
                cuts.append(i.address)
        cuts.append(ea + sz)
        for k in range(len(cuts) - 1):
            out.append([cuts[k], cuts[k + 1] - cuts[k]])
    return out


def copy_helpers(b, base, fns):
    """ARM-mode load+store bodies reached by BLX, decoded inside function extents."""
    tgts = set()
    for ea, size in fns:
        off = ea - base
        if off < 0 or off + size > len(b):
            continue
        for i in THUMB.disasm(b[off:off + size], ea):
            if i.mnemonic == "blx" and (i.op_str or "").startswith("#"):
                tgts.add(int(i.op_str[1:], 16))
    out = set()
    for t in tgts:
        o = t - base
        if not (0 <= o < len(b) - 16):
            continue
        a = list(ARM.disasm(b[o:o + 0x60], t))
        if any(x.mnemonic.startswith(("cdp", "mcr", "mrc", "stc", "ldc")) for x in a):
            continue
        if any(x.mnemonic.startswith(("ldr", "ldm")) for x in a) and \
           any(x.mnemonic.startswith(("str", "stm")) for x in a):
            out.add(t)
    return out


def analyse(name, verbose=False):
    b, base, fns = load_module(name)
    helpers = copy_helpers(b, base, fns)
    fns = split_merged(b, base, fns)
    findings = []
    for ea, size in fns:
        off = ea - base
        if off < 0 or size <= 0 or off + size > len(b):
            continue
        ins = list(THUMB.disasm(b[off:off + size], ea))
        # slots initialised from an immediate, and slots whose address is taken
        init, addr_taken, sp_taken = {}, set(), False
        for n, i in enumerate(ins):
            if i.mnemonic.startswith("str"):
                m = SLOT_STORE.match(i.op_str or "")
                if m:
                    src = m.group(1)
                    # XGMI sets the capacity (`mov.w sl,#0x40`) ~12 instructions before it
                    # stores it, so an 8-instruction window loses the positive control.
                    for k in range(n - 1, max(0, n - 40) - 1, -1):
                        mm = re.match(r"(%s),\s*#(0x[0-9a-f]+|\d+)$" % re.escape(src),
                                      ins[k].op_str or "")
                        if mm and ins[k].mnemonic.startswith("mov"):
                            init[int(m.group(2), 0)] = (int(mm.group(2), 0), i.address)
                            break
            if i.mnemonic.startswith("add"):
                m = ADDR_OF.match(i.op_str or "")
                if m:
                    addr_taken.add(int(m.group(2), 0))
            if i.mnemonic.startswith("mov") and re.fullmatch(r"\w+,\s*sp", i.op_str or ""):
                sp_taken = True
        if not init:
            continue
        for n, i in enumerate(ins):
            if not (i.mnemonic in ("blx", "bl") and (i.op_str or "").startswith("#")):
                continue
            if int(i.op_str[1:], 16) not in helpers:
                continue
            # what feeds r2 (the length)?  a re-load of an initialised, address-taken slot
            src_reg, reload_idx, slot = None, None, None
            cur = "r2"
            for k in range(n - 1, max(0, n - 30) - 1, -1):
                m = re.match(r"(%s),\s*(.+)$" % re.escape(cur), ins[k].op_str or "")
                if not m or ins[k].mnemonic.startswith(("cmp", "str", "push", "b")):
                    continue
                if ins[k].mnemonic.startswith("ldr"):
                    mm = SLOT_LOAD.match(ins[k].op_str or "")
                    if mm:
                        slot = int(mm.group(2), 0)
                        src_reg, reload_idx = cur, k
                    break
                m2 = re.match(r"(r\d+|sb|sl|fp|ip)$", m.group(2).strip())
                if m2:
                    cur = m2.group(1)
                    continue
                break
            if slot is None:
                continue
            # The slot must be REACHABLE by a callee: either its own address is taken, or a
            # struct pointer covering it is formed (`add rX, sp, #j` with j <= slot, or
            # `mov rX, sp`). Dropping this along with the immediate-init requirement made v2
            # flag [sp+4] in sub_E08984 -- a constant 48 that no callee ever writes.
            reachable = any(j <= slot for j in addr_taken) or sp_taken
            if not reachable:
                continue
            # v2: the immediate-init requirement is dropped. XGMI initialises its slot from an
            # immediate, but SYS_DRV's equivalent (0xe1ffd2) keeps the out-length in a FIELD of
            # a struct passed by pointer, so it is never immediate-initialised in the caller --
            # and the v1 pattern silently could not match it. What actually matters is: the
            # slot is re-loaded AFTER a call, and the value is used as a length unchecked.
            calls_between = any(ins[k].mnemonic in ("bl", "blx") for k in range(0, reload_idx))
            if not calls_between:
                continue
            cap = init.get(slot, (None, None))[0]
            # is the re-loaded value compared before it is used as the length?
            validated = any(ins[k].mnemonic.startswith(("cmp", "cbz", "cbnz", "subs"))
                            and re.search(r"\b%s\b" % re.escape(src_reg), ins[k].op_str or "")
                            for k in range(reload_idx + 1, n))
            if not validated:
                findings.append((i.address, ea, slot, cap))
    return findings


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--selftest", action="store_true")
    ap.add_argument("--module")
    ap.add_argument("--all", action="store_true")
    a = ap.parse_args()

    if a.selftest:
        xg = analyse("xgmi")
        pos = [f for f in xg if f[0] == 0x402FC4]
        asd = analyse("asd")
        neg = [f for f in asd if f[0] == 0xD12E4C]
        print("SELFTEST (callee-written in/out length slot)")
        print("   POSITIVE xgmi 0x402fc4 reported:      %s" % ("yes" if pos else "NO"))
        if pos:
            print("      slot [sp+%#x], capacity %d" % (pos[0][2], pos[0][3]))
        print("   NEGATIVE asd 0xd12e4c not reported:   %s" % ("yes" if not neg else "NO"))
        ok = bool(pos) and not neg
        print("\nverdict: %s" % ("TRUSTWORTHY" if ok else "NOT trustworthy"))
        return 0 if ok else 1

    mods = (["gcp", "opm", "xgmi", "merit", "fp", "auc", "powerplay", "wspe", "rap", "oc",
             "csu", "tmz", "asd", "sys_drv"] if a.all else [a.module])
    for m in mods:
        try:
            res = analyse(m)
        except Exception as exc:  # noqa: BLE001
            print("%-10s SKIP (%s)" % (m, str(exc)[:50]))
            continue
        print("%-10s %d site(s)%s" % (m, len(res),
              "" if not res else ":"))
        for site, fn, slot, cap in res:
            print("     copy @%#x (fn %#x) length <- slot [sp+%#x]%s, callee-written, "
                  "NOT re-validated" % (site, fn, slot,
                  (" (init %d)" % cap) if cap is not None else " (struct-held)"))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())