aboutsummaryrefslogtreecommitdiffstats
path: root/fw/ta_fallthrough_audit.py
blob: acf31a5cbea8eccb2a8c4289696d274ca1c946a7 (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
#!/usr/bin/env python3
"""Find handlers where a REJECTED request still falls through to state mutation.

Generalises the GCP FE0D finding (`fw/FINDING-GCP-FE0D-REFCOUNT-DESYNC.md`). There, a
handle check was compiled as an `ITTE` block that merely *selects an error code*, so the
failure path continued into the refcount decrement and the ASD release call. Its sibling
FE0B branched away instead and was safe. The difference is visible in the instruction
stream:

    safe:    CMP ...; BNE error_label          (failure leaves the mutating code)
    unsafe:  CMP ...; IT{T,E}; MOVcc Rx,#err   (failure only sets a value, then falls through)

So the detector is: inside a command handler, an `IT`-block that materialises an
error-looking constant, followed - before any return - by a store to a **fixed context
offset** (`STR Rn,[Rm,#imm]` or a pc-relative global), i.e. persistent state changed after
a rejection.

It is a heuristic and reports leads, not verdicts: conditional execution around an error
code is perfectly normal when nothing is mutated afterwards. Every hit needs the same
sibling-comparison treatment FE0D got. Reviewed hits are recorded in the accompanying doc.

Function boundaries come from IDA (`fw/libdumps/*.json`), so decoding is correctly aligned.

usage: ta_fallthrough_audit.py [--prefix X] [--min-err 0x20]
"""
import argparse
import glob
import json
import os
import re

import capstone as cs

BASE = 0x400100
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
MD = cs.Cs(cs.CS_ARCH_ARM, cs.CS_MODE_THUMB)
IT = re.compile(r'^it[te]*$')
# capstone writes the condition BEFORE the width suffix ('movne.w', not 'mov.wne'),
# which an earlier pattern got backwards and so found nothing -- it failed its own
# control (it must rediscover GCP FE0D) and that is how the bug was caught.
MOVCC = re.compile(r'^mov(eq|ne|cs|cc|hi|ls|ge|lt|gt|le)(?:s|\.w)?$')
STORE = re.compile(r'^str[bh]?(?:\.w)?$')


def audit_fn(body, ea, size):
    """Return list of (it_addr, err_value, store_addr, store_text)."""
    ins = list(MD.disasm(body[ea - BASE:ea - BASE + size], ea))
    hits = []
    for n, i in enumerate(ins):
        if not IT.match(i.mnemonic):
            continue
        # error constant materialised under predication?
        err = None
        for k in range(n + 1, min(n + 5, len(ins))):
            m = MOVCC.match(ins[k].mnemonic)
            if m and '#' in ins[k].op_str:
                try:
                    v = int(ins[k].op_str.split('#')[1], 0)
                except ValueError:
                    continue
                if v >= 0x20:            # error codes here are 0x100, 0x2xx, 1043...
                    err = v
        if err is None:
            continue
        # after the IT block, is persistent state stored before a return?
        for k in range(n + 1, len(ins)):
            t = ins[k]
            if t.mnemonic.startswith('pop') and 'pc' in t.op_str:
                break
            if t.mnemonic == 'bx' and 'lr' in t.op_str:
                break
            # skip predicated stores: those belong to the success arm of the IT block
            if (STORE.match(t.mnemonic) and '[' in t.op_str
                    and 'sp' not in t.op_str):
                hits.append((i.address, err, t.address,
                             '%s %s' % (t.mnemonic, t.op_str)))
                break
    return hits


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('--prefix')
    ap.add_argument('--all', action='store_true',
                    help='every non-library function, not just command handlers')
    a = ap.parse_args()
    dumps = sorted(glob.glob(os.path.join(REPO, 'fw', 'libdumps', '*.json')))
    total = 0
    for p in dumps:
        ta = os.path.basename(p)[:-5]
        if a.prefix and ta != a.prefix:
            continue
        fns = json.load(open(p))
        meta = json.load(open(os.path.join(REPO, 'fw', 'syms', '%s.meta.json' % ta)))
        body = open(meta['path'], 'rb').read()
        rows = []
        for f in fns:
            # only handlers and their attributed helpers -- not library code
            nm = f['name']
            if 'lib_' in nm:
                continue            # shared runtime, not TA request handling
            if not a.all and '_cmd' not in nm and '_dispatch' not in nm:
                continue
            for it, err, st, txt in audit_fn(body, f['ea'], f['size']):
                rows.append((nm, it, err, st, txt))
        if rows:
            print('=== %s' % ta)
            for nm, it, err, st, txt in rows:
                print('   %-46s IT@%#x err=%#-6x -> store@%#x  %s'
                      % (nm[:46], it, err, st, txt))
            total += len(rows)
    print('\n%d lead(s). Each needs a sibling/branch comparison before it is a finding.'
          % total)
    return 0


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