aboutsummaryrefslogtreecommitdiffstats
path: root/fw/ta_fieldflow_audit.py
blob: 0af450892c39e2b48493f035d41af70ba7940d95 (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
#!/usr/bin/env python3
"""Field-level length audit: is the STRUCT FIELD a length comes from ever written unbounded?

Passes 1-8 kept failing the same way. Register-level analysis cannot answer the question that
decides these cases, because the length is loaded from a struct field at the copy site and the
bound lives wherever that field was *written* -- possibly in another function, possibly from a
different field. Concretely:

  * HDCP `0x402cde`   length = `ldrb [r5,#0x38]`; the field is written from a request byte and
                      never bounded  -> REAL BUG (confirmed on hardware)
  * SYS_DRV `+0x44`   length field written from another struct's `+0x120`, and THAT value is
                      bounded `> 0x30 -> error` in the writing function -> NOT a bug

So this tool works at field granularity:

  1. find copy sites whose length is `LOAD [base + off]`
  2. find every store in the module to the same field offset, `str rS, [rX + off]`
  3. classify each writer's source: immediate, a value compared/masked in that function
     (bounded), or an unbounded load / unbounded parameter
  4. report the copy site only if some writer supplies an unbounded value

It is an offset-keyed approximation of field tracking -- two different structs sharing an
offset are conflated -- which makes it over-report rather than under-report, the safe
direction for a hunting tool.

!! DO NOT TRUST THIS TOOL'S OUTPUT -- ITS POSITIVE CONTROL PASSED SPURIOUSLY !!

  The control appeared to pass: hdcp `0x402cde` was reported, with four "unbounded writers"
  of field `+0x38`. Inspecting those four addresses shows they are not stores at all:

      0x4013f6  movw r1, #0xffff
      0x40169c  mov  r6, r0
      0x4016dc  add  r0, sp, #8
      0x4016f6  pop  {r4, pc}

  They are garbage produced by the resynchronising linear sweep, which decodes literal pools
  and ARM-mode regions as Thumb; the store regex then matched inside that garbage. So the
  tool never demonstrated it can trace a real field-writer chain, and its negatives (0 sites
  for the TAs and ASD) are NOT evidence.

  Root cause: regex matching over a resynchronised linear sweep. The fix is to decode only
  within known function boundaries (as fw/ta_copylen_slice.py does via fw/libdumps), and to
  verify that every "writer" address really disassembles to a store before counting it.

  NEGATIVE control (sys_drv sub_E0D6B4 not reported) does still hold, but a detector with
  only a negative control cannot support a negative result.

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

import argparse
import json
import re
from collections import defaultdict
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)

MODULES = {
    "sys_drv": ("psp_out/sys_drv_code.bin", 0xE00100),
    "asd": ("psp_out/asd_code.bin", 0xD00100),
    "hdcp": ("psp_out/hdcp_code.bin", 0x400100),
}


def sweep(b: bytes, base: int):
    ins, off = [], 0
    while off < len(b) - 1:
        got = 0
        for i in THUMB.disasm(b[off:], base + off):
            ins.append(i)
            got += i.size
            if got > 0x4000:
                break
        off += got if got else 2
    return ins


def fn_starts(ins):
    return [i.address for i in ins if i.mnemonic.startswith("push")]


def owner_idx(starts, ins, n):
    a = ins[n].address
    lo = 0
    for k, i in enumerate(ins):
        if i.address in starts and i.address <= a:
            lo = k
        elif i.address > a:
            break
    return lo


def bounded_here(ins, lo, hi, reg):
    chain = {reg}
    for k in range(lo, hi):
        i = ins[k]
        ops = i.op_str or ""
        if i.mnemonic.startswith(("cmp", "cmn", "tst", "cbz", "cbnz", "subs")):
            if any(re.search(r"\b%s\b" % r, ops) for r in chain):
                return True
        if i.mnemonic.startswith(("and", "ubfx", "uxtb", "uxth", "bic")):
            m = re.match(r"(\w+)", ops)
            if m and m.group(1) in chain:
                return True
        m = re.match(r"(\w+),\s*(\w+)$", ops)
        if m and m.group(2) in chain and i.mnemonic.startswith("mov"):
            chain.add(m.group(1))
    return False


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


def field_of_load(ins, lo, at, reg):
    """If reg came from `ldr rD,[rB,#off]`, return off."""
    cur = reg
    for _ in range(5):
        for k in range(at - 1, lo - 1, -1):
            i = ins[k]
            m = re.match(r"(%s),\s*(.+)$" % re.escape(cur), i.op_str or "")
            if not m or i.mnemonic.startswith(("cmp", "str", "push", "tst", "b")):
                continue
            if i.mnemonic.startswith("ldr"):
                mm = LOADF.match(i.op_str or "")
                return int(mm.group(3), 0) if mm else None
            rhs = m.group(2).strip()
            m2 = re.match(r"(r\d+|sb|sl|fp|ip)$", rhs)
            if m2:
                cur, at = m2.group(1), k
                break
            m3 = re.match(r"(r\d+|sb|sl|fp|ip)\b", rhs)
            if m3 and i.mnemonic.startswith(("add", "sub", "mul", "lsl", "orr", "mla")):
                cur, at = m3.group(1), k
                break
            return None
        else:
            return None
    return None


def base_origin(ins, lo, at, reg):
    """Where the BASE pointer came from: a literal-pool global, a parameter, or the stack.

    Keying a field only by its offset conflates every struct sharing it -- SYS_DRV's `+0x4`
    reported 375 writers, which is noise rather than signal. Keying by (base provenance,
    offset) separates distinct objects well enough to make the list triageable.
    """
    cur = reg
    for _ in range(6):
        for k in range(at - 1, lo - 1, -1):
            i = ins[k]
            m = re.match(r"(%s),\s*(.+)$" % re.escape(cur), i.op_str or "")
            if not m or i.mnemonic.startswith(("cmp", "str", "push", "tst", "b")):
                continue
            rhs = m.group(2).strip()
            if i.mnemonic.startswith("ldr") and "pc" in rhs:
                return "global"          # from a literal pool: a module global
            if i.mnemonic.startswith("ldr"):
                return "deref"
            if "sp" in rhs:
                return "stack"
            m2 = re.match(r"(r\d+|sb|sl|fp|ip)$", rhs)
            if m2:
                cur, at = m2.group(1), k
                break
            return "expr"
        else:
            return "param:%s" % cur if cur in ("r0", "r1", "r2", "r3") else "unknown"
    return "unknown"


def writers_of(ins, starts, off, want_scope=None):
    """Every `str rS,[rX,#off]` in the module, with a verdict on rS.

    When want_scope is given, only writers whose BASE pointer has the same provenance are
    counted -- that is what removes the offset-only conflation.
    """
    out = []
    for n, i in enumerate(ins):
        if not i.mnemonic.startswith("str"):
            continue
        m = STOREF.match(i.op_str or "")
        if not m or int(m.group(3), 0) != off:
            continue
        src = m.group(1)
        # A store through sp/fp is a STACK SLOT, not a struct field. Counting them conflated
        # the stack-canary save `str r0,[sp,#0x88]` with writes to a field at +0x88, which is
        # how the top SYS_DRV candidate turned out to be __stack_chk_guard.
        # sp/fp only. Excluding r7 as well broke the positive control: in Thumb r7 is often a
        # frame pointer, but HDCP's field writers use it as an ordinary struct pointer, so
        # excluding it discarded the one bug we can check against.
        if m.group(2) in ("sp", "fp"):
            continue
        if want_scope is not None:
            wlo = owner_idx(starts, ins, n)
            if base_origin(ins, wlo, n, m.group(2)) != want_scope:
                continue
        lo = owner_idx(starts, ins, n)
        if bounded_here(ins, lo, n, src):
            out.append((i.address, "bounded in writer"))
            continue
        f = field_of_load(ins, lo, n, src)
        if f is not None:
            out.append((i.address, "UNBOUNDED from field +%#x" % f))
        else:
            out.append((i.address, "UNBOUNDED (%s)" % src))
    return out


def copy_helpers(b, ins, base):
    out = set()
    for i in ins:
        if i.mnemonic == "blx" and (i.op_str or "").startswith("#"):
            t = int(i.op_str[1:], 16)
            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(path, base):
    b = (REPO / path).read_bytes() if not Path(path).is_absolute() else Path(path).read_bytes()
    ins = sweep(b, base)
    starts = set(fn_starts(ins))
    helpers = copy_helpers(b, ins, base)
    wcache = {}
    findings = []
    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
        lo = owner_idx(starts, ins, n)
        off = field_of_load(ins, lo, n, "r2")
        if off is None:
            continue
        # Keep readers whose base provenance is vague: HDCP's confirmed overflow is one of
        # them, so dropping them fails the positive control. Only the WRITER-side sp/fp
        # exclusion above is a sound restriction.
        scope = base_origin(ins, lo, n, "r2")     # provenance of the READER's base
        key = (off, scope)
        if key not in wcache:
            wcache[key] = writers_of(ins, starts, off, want_scope=scope)
        bad = [w for w in wcache[key] if "UNBOUNDED" in w[1]]
        if bad:
            findings.append((hex(i.address), hex(ins[lo].address), off,
                             len(wcache[key]), bad[:4], scope))
    return findings


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

    if a.selftest:
        hd = analyse(*MODULES["hdcp"])
        pos = [f for f in hd if f[0] == "0x402cde"]
        sd = analyse(*MODULES["sys_drv"])
        neg = [f for f in sd if f[0] in ("0xe0d6c4", "0xe0d6d8")]
        print("SELFTEST (field-level)")
        print("   POSITIVE hdcp 0x402cde reported:           %s" % ("yes" if pos else "NO"))
        if pos:
            print("      %s" % (pos[0][4],))
        print("   NEGATIVE sys_drv sub_E0D6B4 not reported:  %s"
              % ("yes" if not neg else "NO -- %s" % neg))
        ok = bool(pos) and not neg
        print("\nverdict: %s" % ("TRUSTWORTHY" if ok else "NOT trustworthy"))
        return 0 if ok else 1

    path, base = MODULES.get(a.module, (None, None))
    if path is None:
        meta = json.loads((HERE / "syms" / ("%s.meta.json" % a.module)).read_text())
        path, base = meta["path"], 0x400100
    res = analyse(path, base)
    print("=== %s: %d copy site(s) whose length field has an unbounded writer"
          % (a.module, len(res)))
    for site, fn, off, nw, bad, scope in res:
        print("   copy @%s (fn %s) len <- field +%#x [base:%s] ; %d writer(s)"
              % (site, fn, off, scope, nw))
        for wa, v in bad:
            print("        writer @%#x  %s" % (wa, v))
    return 0


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