aboutsummaryrefslogtreecommitdiffstats
path: root/fw/ta_copylen_slice.py
blob: 6bfaddd1efed279aad7b1f606ac8fd75abcb3d67 (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
#!/usr/bin/env python3
"""Backward-slice the LENGTH register at every call into an ARM-mode copy helper.

Why this exists: the pseudocode-level hunt was measuring nothing, because these TAs reach
their copy helpers with `BLX` into **ARM-mode** code and Hex-Rays drops the arguments at
exactly those sites. And the older capstone audit reported "r2 unknown" for 15 of its 18
leads, because it only looked at the instruction immediately preceding the call.

So: identify the ARM-mode copy helpers (BLX targets whose ARM body is a load/store loop),
then for every call site slice the length register backwards through register moves and
arithmetic, up to a few levels, until it reaches an immediate, a memory load, or nothing.

The shape that matters is HDCP's confirmed overflow:

    LDRB.W R8, [R5,#0x38]      ; a request byte
    ADD.W  R7, R8, R8,LSL#2    ; len = 5*N
    BLX    sub_401058          ; memcpy(stack_buffer, src, len)

so the slicer must survive one move and one arithmetic step, which the old last-write
heuristic could not.

CONTROL: run against HDCP and the known site must appear with a request-byte origin.

Usage:
  ./ta_copylen_slice.py --selftest
  ./ta_copylen_slice.py --all
"""
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)


def arm_copy_helpers(b: bytes, fns: list, base: int) -> set[int]:
    """BLX targets whose ARM-mode body looks like a load/store loop."""
    blx = set()
    for f in fns:
        sz = f.get("size", 0)
        if not sz:
            continue
        for i in THUMB.disasm(b[f["ea"] - base:f["ea"] - base + sz], f["ea"]):
            if i.mnemonic == "blx" and (i.op_str or "").startswith("#"):
                blx.add(int(i.op_str[1:], 16))
    out = set()
    for t in blx:
        off = t - base
        if not (0 <= off < len(b) - 16):
            continue
        ins = list(ARM.disasm(b[off:off + 0x60], t))
        txt = " ".join(i.mnemonic for i in ins)
        # A BLX target is ARM, but not every BLX target is a copy: OPM's __stack_chk_fail
        # produced three false "copies" whose "length" was actually the saved canary. Two
        # guards: the ARM decode must be plausible (coprocessor/undefined opcodes mean we
        # are decoding Thumb or data as ARM), and the body must both load AND store.
        if any(i.mnemonic.startswith(("cdp", "mcr", "mrc", "stc", "ldc", "undefined"))
               for i in ins):
            continue
        has_load = any(i.mnemonic.startswith(("ldr", "ldm")) for i in ins)
        has_store = any(i.mnemonic.startswith(("str", "stm")) for i in ins)
        if has_load and has_store:
            out.add(t)
    return out


def slice_reg(ins: list, at: int, reg: str, depth: int = 4) -> str:
    """Describe where `reg` came from, walking backwards from index `at`."""
    cur = reg
    for _ in range(depth):
        for k in range(at - 1, -1, -1):
            i = ins[k]
            ops = i.op_str or ""
            m = re.match(r"(%s)\s*,\s*(.+)$" % re.escape(cur), ops)
            if not m or i.mnemonic.startswith(("cmp", "cmn", "tst", "str", "push", "b")):
                continue
            rhs = m.group(2)
            if i.mnemonic.startswith("ldr"):
                return "LOAD %s %s" % (i.mnemonic, rhs)          # <- the interesting case
            if re.fullmatch(r"#(0x[0-9a-f]+|\d+)", rhs.strip()):
                return "immediate %s" % rhs.strip()
            m2 = re.match(r"(r\d+|sb|sl|fp|ip)\b", rhs.strip())
            if m2:
                cur = m2.group(1)                                  # follow the chain
                at = k
                break
            return "%s %s" % (i.mnemonic, rhs)
        else:
            return "unresolved (%s never written in this function)" % cur
    return "chain too deep (last: %s)" % cur


def scan(ta: str, base: int = 0x400100):
    meta = json.loads((HERE / "syms" / ("%s.meta.json" % ta)).read_text())
    body = Path(meta["path"])
    if not body.is_absolute():
        body = REPO / body
    b = body.read_bytes()
    fns = json.loads((HERE / "libdumps" / ("%s.json" % ta)).read_text())
    helpers = arm_copy_helpers(b, fns, base)
    rows = []
    for f in fns:
        sz = f.get("size", 0)
        if not sz:
            continue
        ins = list(THUMB.disasm(b[f["ea"] - base:f["ea"] - base + sz], f["ea"]))
        for n, i in enumerate(ins):
            if i.mnemonic not in ("blx", "bl") or not (i.op_str or "").startswith("#"):
                continue
            if int(i.op_str[1:], 16) not in helpers:
                continue
            rows.append((f["name"], i.address, slice_reg(ins, n, "r2"),
                         slice_reg(ins, n, "r0"), slice_reg(ins, n, "r1")))
    return helpers, rows


def dst_class(d: str) -> str:
    """A host-controlled DESTINATION with a fixed length is as exploitable as an
    unbounded length, and nothing here had searched for it."""
    if d.startswith("LOAD"):
        return "POINTER-FROM-MEMORY"      # the case worth reading
    if "sp" in d:
        return "stack"
    if d.startswith("immediate"):
        return "immediate"
    return "other"


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--selftest", action="store_true")
    ap.add_argument("--all", action="store_true")
    ap.add_argument("--prefix")
    args = ap.parse_args()

    if args.selftest:
        # HDCP: the known overflow site must show a request-derived length
        import types
        meta = types.SimpleNamespace(path=str(REPO / "psp_out" / "hdcp_code.bin"))
        b = Path(meta.path).read_bytes()
        fns = [{"ea": 0x402A88, "size": 0x300, "name": "sub_402A88"}]
        helpers = {0x401058}
        ins = list(THUMB.disasm(b[0x402A88 - 0x400100:0x402A88 - 0x400100 + 0x300], 0x402A88))
        found = None
        for n, i in enumerate(ins):
            if i.mnemonic in ("blx", "bl") and (i.op_str or "").startswith("#") \
               and int(i.op_str[1:], 16) in helpers:
                ln = slice_reg(ins, n, "r2")
                if "LOAD" in ln or "add" in ln:
                    found = (hex(i.address), ln)
        print("SELFTEST on HDCP's confirmed overflow site:")
        print("   %s" % (("FOUND %s len <- %s" % found) if found else "MISSED"))
        print("\nverdict: %s" % ("TRUSTWORTHY" if found else "NOT trustworthy"))
        return 0 if found else 1

    idx = json.loads((HERE / "syms" / "index.json").read_text())
    for ta in (sorted(idx) if args.all else [args.prefix]):
        helpers, rows = scan(ta)
        ptr_dst = [r for r in rows if dst_class(r[3]) == "POINTER-FROM-MEMORY"]
        print("\n=== %s: %d helper(s), %d call site(s), %d with a MEMORY-SOURCED DESTINATION"
              % (ta, len(helpers), len(rows), len(ptr_dst)))
        for nm, ea, ln, dst, src in ptr_dst:
            print("   %-30s @%#x  dst <- %-28s len <- %-22s src <- %s"
                  % (nm[:30], ea, dst[:28], ln[:22], src[:22]))
    return 0


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