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
|
#!/usr/bin/env python3
"""Inter-procedural length/index audit: propagate caller-established invariants.
Eight passes of intra-procedural scanning produced the same failure three times -- a callee
whose arithmetic is locally unguarded, bounded by its caller:
* `OPM 0x402106` re-loaded host index, bounded by the callee it is passed to
* `SYS_DRV sub_E0D6B4` unsigned `size - len` with no compare, bounded by `sub_E08EBC`
* `powerplay 0x109a` unchecked `12 * index`, emitter passes a constant
So an intra-procedural detector's output is a work queue, not a finding list. This one asks
the question that actually matters:
a value reaches a length or a scaled index unguarded in function F.
Is it bounded by EVERY path that can supply it?
Method
1. resynchronising Thumb sweep -> instructions, function starts (prologues), call graph
2. per function, find risk sites: a length/index that traces back to a PARAMETER (r0-r3)
with no compare/mask in that function
3. for each caller, look at what it passes in that argument position: an immediate is safe,
a bounded local is safe, an unbounded memory load is NOT -- and if the caller itself got
it from a parameter, recurse (bounded depth)
4. report only sites where some path supplies an unbounded value
SCOPE LIMITATION -- READ THIS BEFORE TRUSTING ANY OUTPUT
This tool handles only one class: a length that is an INCOMING PARAMETER of the function
performing the copy, so that callers can be asked what they supply. That was chosen to make
caller propagation tractable, and it excludes the other class -- a length loaded from a
STRUCT FIELD -- by construction.
HDCP's confirmed overflow is the excluded class: its length is `ldrb [r5,#0x38]`, a field
load, reached directly at the copy site with no wrapper function. So the positive control
FAILS, and it fails for a structural reason rather than a fixable bug:
./ta_interproc_audit.py --selftest
POSITIVE hdcp 0x402cde reported: NO
NEGATIVE sys_drv sub_E0D6B4: correctly not reported
The negative control passes -- it does correctly dismiss the bounded-by-caller case that
motivated the tool. But with no passing positive control, an empty result here means
nothing, and per this tree's rule (every detector must first rediscover a known bug) the
output is NOT reported as a negative.
Making it sound requires field-level value tracking, not register tracking: the SYS_DRV
case showed the bound can sit on a DIFFERENT field (`+0x120`) from the one consumed
(`+0x44`), so a module-wide "is this offset ever compared" heuristic would re-introduce the
false positive it was built to remove. That is a dataflow engine, not an afternoon.
Usage:
./ta_interproc_audit.py --selftest
./ta_interproc_audit.py --module sys_drv|asd|<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),
}
ARGREGS = ("r0", "r1", "r2", "r3")
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, {i.address: n for n, i in enumerate(ins)}
def functions(ins: list) -> list[int]:
return [i.address for i in ins if i.mnemonic.startswith("push")]
def owner(starts: list[int], addr: int) -> int | None:
lo = None
for s in starts:
if s <= addr:
lo = s
else:
break
return lo
def callgraph(ins: list, starts: list[int]):
cg = defaultdict(list) # callee -> [(caller_fn, call_addr, insn_index)]
for n, i in enumerate(ins):
if i.mnemonic in ("bl", "blx") and (i.op_str or "").startswith("#"):
t = int(i.op_str[1:], 16)
f = owner(starts, i.address)
if f is not None:
cg[t].append((f, i.address, n))
return cg
def bounded_in(ins: list, lo: int, hi: int, reg: str) -> bool:
"""Is reg (or a mov-chain source) compared or masked anywhere in [lo,hi)?"""
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
def traces_to_param(ins: list, lo: int, at: int, reg: str) -> str | None:
"""If reg's value at `at` came from an incoming parameter, return that parameter."""
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"):
return None # from memory, not a parameter
m2 = re.match(r"(r\d+|sb|sl|fp|ip)$", rhs)
if m2:
cur, at = m2.group(1), k
break
return None
else:
return cur if cur in ARGREGS else None
return None
def arg_at_call(ins: list, lo: int, call_idx: int, reg: str):
"""Classify what a caller puts in `reg` before a call: immediate / bounded / UNBOUNDED."""
cur = reg
for _ in range(6):
for k in range(call_idx - 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 re.fullmatch(r"#(0x[0-9a-f]+|\d+)", rhs):
return "immediate %s" % rhs
if i.mnemonic.startswith("ldr"):
return ("bounded-load" if bounded_in(ins, lo, call_idx, cur)
else "UNBOUNDED-load %s" % rhs)
m2 = re.match(r"(r\d+|sb|sl|fp|ip)$", rhs)
if m2:
cur = m2.group(1)
call_idx = k
break
return "bounded-expr" if bounded_in(ins, lo, call_idx, cur) else "expr %s" % rhs
else:
return "parameter %s" % cur if cur in ARGREGS else "unknown"
return "unknown"
def copy_helpers(b: bytes, ins: list, base: int) -> set[int]:
out = set()
for i in ins:
if i.mnemonic == "blx" and (i.op_str or "").startswith("#"):
t = int(i.op_str[1:], 16)
off = t - base
if not (0 <= off < len(b) - 16):
continue
a = list(ARM.disasm(b[off:off + 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: str, base: int):
b = (REPO / path).read_bytes()
ins, byaddr = sweep(b, base)
starts = functions(ins)
cg = callgraph(ins, starts)
helpers = copy_helpers(b, ins, base)
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
f = owner(starts, i.address)
if f is None:
continue
lo = byaddr[f]
if bounded_in(ins, lo, n, "r2"):
continue # bounded locally: not interesting
param = traces_to_param(ins, lo, n, "r2")
if not param:
continue # from memory: the intra-procedural passes covered it
# the length is an incoming parameter -- ask every caller what it supplies
verdicts = []
for (cf, ca, ci) in cg.get(f, []):
clo = byaddr.get(owner(starts, ca) or -1)
if clo is None:
continue
verdicts.append((hex(ca), arg_at_call(ins, clo, ci, param)))
if not verdicts:
verdicts = [("<no caller found>", "unknown")]
if any("UNBOUNDED" in v or v.startswith("parameter") for _, v in verdicts):
findings.append((hex(i.address), hex(f), param, verdicts))
return findings
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--selftest", action="store_true")
ap.add_argument("--module")
a = ap.parse_args()
if a.selftest:
ok = True
hd = analyse(*MODULES["hdcp"])
pos = [f for f in hd if f[0] == "0x402cde"]
print("SELFTEST")
print(" POSITIVE hdcp 0x402cde reported: %s"
% ("yes" if pos else "NO -- untrustworthy"))
ok &= bool(pos)
sd = analyse(*MODULES["sys_drv"])
neg = [f for f in sd if f[0] in ("0xe0d6c4", "0xe0d6d8")]
print(" NEGATIVE sys_drv sub_E0D6B4 NOT reported: %s"
% ("yes" if not neg else "NO -- false positive %s" % neg))
ok &= 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())
p = Path(meta["path"])
path = str(p if p.is_absolute() else p)
base = 0x400100
res = analyse(path, base)
print("=== %s: %d site(s) where a caller supplies an unbounded length" % (a.module, len(res)))
for site, fn, param, verdicts in res:
print(" copy @%s in fn %s, length = incoming %s" % (site, fn, param))
for ca, v in verdicts[:6]:
print(" caller @%-12s supplies %s" % (ca, v[:60]))
return 0
if __name__ == "__main__":
raise SystemExit(main())
|