aboutsummaryrefslogtreecommitdiffstats
path: root/dmcub/dmcub_dis.py
blob: a8abc20090472e6dc398ad2dfe06465d908f96d8 (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
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
#!/usr/bin/env python3
"""DMCUB (Xtensa) static analysis: functions, call graph, and annotated listings.

capstone-6 decodes Xtensa mnemonics/operands, but its branch and call operands
are unreliable (`op_str` shows a displacement and `operands[].imm` is wrong), so
call/jump targets are decoded here from the raw encodings:

    CALLn    op0=5, n=bits5:4, imm18=bits23:6   target = (PC&~3) + (imm18<<2) + 4
    J        op0=6, n=0,       imm18=bits23:6   target = PC + 4 + imm18
    B*(RRI8) op0=6/7 family,   imm8=bits23:16   target = PC + 4 + imm8

Requires /home/user/.venvs/cs6/bin/python (capstone >= 6).

usage:
  dmcub_dis.py <image> funcs                   list functions with call counts
  dmcub_dis.py <image> hot [N]                 hottest call targets
  dmcub_dis.py <image> dis <va> [<va>...]      annotated listing of a function
  dmcub_dis.py <image> xrefs <va>              callers of a function
  dmcub_dis.py <image> grep <regex>            functions containing a mnemonic/op pattern
"""
import collections
import importlib.util
import os
import re
import struct
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
_s = importlib.util.spec_from_file_location('dmcub_cfg', os.path.join(HERE, 'dmcub_cfg.py'))
cfg = importlib.util.module_from_spec(_s)
_s.loader.exec_module(cfg)


def w24(p, i):
    return p[i] | (p[i + 1] << 8) | (p[i + 2] << 16)


class NarrowBranch:
    """capstone 6 cannot decode Xtensa's density-option branches BEQZ.N/BNEZ.N
    (op0=0xC with r >= 8) -- ~700 of them in a DCN 3.2 image, i.e. a large slice
    of the control flow, including the `beqz.n a9` that selects
    DMUB_CMD__ABM_INIT_CONFIG. This shim decodes them.

    RI6: word = b0 | b1<<8 ; op0 = word & 0xF = 0xC
         r = (word >> 4) & 0xF     s = (word >> 8) & 0xF   imm4 = word >> 12
         imm6 = ((r & 3) << 4) | imm4        target = PC + 4 + imm6
         r in 8..B -> beqz.n, r in C..F -> bnez.n   (r < 8 is movi.n)
    """

    __slots__ = ('address', 'size', 'bytes', 'mnemonic', 'op_str', 'operands', 'target_va')

    def __init__(self, off_bytes, va):
        word = off_bytes[0] | (off_bytes[1] << 8)
        r = (word >> 4) & 0xF
        s = (word >> 8) & 0xF
        imm4 = (word >> 12) & 0xF
        imm6 = ((r & 3) << 4) | imm4
        self.address = va
        self.size = 2
        self.bytes = bytes(off_bytes[:2])
        self.mnemonic = 'beqz.n' if (r & 0xC) == 0x8 else 'bnez.n'
        self.target_va = va + 4 + imm6
        self.op_str = 'a%d, . 0x%08x' % (s, self.target_va)
        self.operands = []

    @staticmethod
    def match(p, i):
        return (p[i] & 0xF) == 0xC and ((p[i] >> 4) & 0xF) >= 8


def sext(v, bits):
    return v - (1 << bits) if v >> (bits - 1) else v


class Analysis:
    """Function set + call graph.

    The function set starts from the dense run of `entry` prologues (the main code
    region) and is then grown to a fixpoint with every call target that also has
    an `entry` prologue. The growth step matters: some images keep code *below*
    the dense run -- on dcn_3_2_1 `memcpy` itself lives there, and a single-cutoff
    heuristic dropped it along with its 51 call sites. Stray `36 41 xx` matches
    inside literal pools are not call targets, so they stay excluded.
    """

    def __init__(self, path):
        self.img = cfg.Image(path)
        p = self.img.payload
        all_ents = self.img.entries()
        self.code_lo = self.img.code_start(all_ents)
        self.ent_set = set(all_ents)
        self._dis = {}
        self._reach = {}
        ents = [e for e in all_ents if e >= self.code_lo]
        for _ in range(8):
            self._set_funcs(ents, len(p))
            self._build_calls()
            extra = set()
            for t in self.calls:
                off = t - self.img.base
                if off in self.ent_set and t not in self.funcs:
                    extra.add(off)
            if not extra:
                break
            ents = sorted(set(ents) | extra)
            self._dis = {}

    def _set_funcs(self, ents, end):
        self.ents = ents
        self.funcs = {}
        for i, s in enumerate(ents):
            e = ents[i + 1] if i + 1 < len(ents) else end
            self.funcs[self.img.va(s)] = (s, e)

    def _reset_calls(self):
        self.calls = collections.Counter()
        self.callers = collections.defaultdict(set)
        self.callsites = collections.defaultdict(list)

    # ---- decoding -------------------------------------------------------
    STOP = ('retw.n', 'retw', 'ret.n', 'ret', 'j', 'jx', 'ill')

    def _decode_one(self, off, end):
        p = self.img.payload
        if off >= end:
            return None
        ins = next(self.img.md.disasm(p[off:min(off + 3, end)], self.img.va(off), 1), None)
        if ins is not None and ins.mnemonic not in ('.byte', '.db'):
            return ins
        if off + 1 < end and NarrowBranch.match(p, off):
            return NarrowBranch(p[off:off + 2], self.img.va(off))
        return ins

    def dis(self, fva):
        """Decode a function by **recursive descent**, then linear-fill the rest.

        Three problems forced this shape, each of which silently corrupted
        earlier results:

        1. capstone's `skipdata` advances one byte on an unknown opcode, so one
           undecodable instruction desynchronises the rest of the function (41%
           of the code, most of the call graph). Xtensa length depends only on
           op0, so a failed decode advances architecturally.
        2. A *successful* decode can still be garbage that straddles a basic-block
           boundary: an alignment byte before a switch case label gets absorbed
           into a bogus 3-byte instruction, eating the real first instruction of
           the case. That is how the ABM handler's `addi.n a10, a2, 4` vanished,
           which in turn hid a known finding from the taint analysis. Branch,
           call and jump-table targets are therefore used as decode anchors.
        3. Decoding past the end of the body into data invents `jx` sites, so a
           decoded `ill` terminates a path.
        """
        if fva in self._dis:
            return self._dis[fva]
        s, e = self.funcs[fva]
        base = self.img.base
        p = self.img.payload
        found = {}
        skipped = set()
        work = [s]
        while work:
            off = work.pop()
            if off in found or off in skipped or not (s <= off < e):
                continue
            ins = self._decode_one(off, e)
            if ins is None or ins.mnemonic in ('.byte', '.db'):
                # Step over an opcode capstone does not know and keep walking:
                # stopping here truncated the command dispatcher after 20
                # instructions and hid every case stub from the taint analysis.
                skipped.add(off)
                work.append(off + (2 if 8 <= (p[off] & 0xF) <= 0xD else 3))
                continue
            found[off] = ins
            m = ins.mnemonic
            if m == 'ill':
                del found[off]
                continue
            # branch/jump targets inside this function are anchors
            t = self.target(ins)
            if t is not None and s <= t - base < e and not m.startswith('call'):
                work.append(t - base)
            if m == 'jx':
                for tbl in self._jx_table_targets(found, off, s, e):
                    work.append(tbl - base)
            if m not in ('j', 'jx', 'retw.n', 'retw', 'ret.n', 'ret'):
                work.append(off + ins.size)
        # Instructions proven reachable by the descent, kept apart from the
        # linear fill below: only these are real code. Audits that must not be
        # fooled by data (jump-table scans, taint) use dis_reachable().
        self._reach[fva] = set(found)
        # linear fill of bytes no path reached (jump-table bodies, cold code)
        i = s
        while i < e:
            if i in found:
                i += found[i].size
                continue
            ins = self._decode_one(i, e)
            if ins is not None and ins.mnemonic not in ('.byte', '.db', 'ill'):
                nxt = i + ins.size
                if not any(k in found for k in range(i, nxt)):
                    found[i] = ins
                i = nxt
            else:
                i += 2 if 8 <= (p[i] & 0xF) <= 0xD else 3
        out = [found[k] for k in sorted(found)]
        self._dis[fva] = out
        return out

    def dis_reachable(self, fva):
        """Only the instructions the recursive descent actually reached."""
        ins = self.dis(fva)
        reach = self._reach.get(fva, set())
        return [x for x in ins if (x.address - self.img.base) in reach]

    def indirect_targets(self, fva):
        """callx site VA -> candidate callees.

        Sub-command dispatch in this firmware goes through tables of function
        pointers (`l32r base; l32i fp, base[+k]; callx8 fp`), e.g. the PSR op
        table at 0x6001a620 and the Replay one at 0x6001a680. Without resolving
        these, a handler's reachable set is just its own stub and an audit of it
        is worthless.

        Resolution: nearest preceding `l32r` literal is the table base; a constant
        `l32i` displacement selects one entry, otherwise every consecutive entry
        that is a known function start is a candidate.
        """
        out = {}
        ins = self.dis_reachable(fva)
        for i, x in enumerate(ins):
            if not x.mnemonic.startswith('callx'):
                continue
            reg = x.op_str.strip()
            base = None
            disp = None
            track = {reg}
            for y in range(i - 1, max(-1, i - 30), -1):
                z = ins[y]
                p = [q.strip() for q in z.op_str.split(',')]
                if not p or p[0] not in track:
                    continue
                if z.mnemonic in ('l32i', 'l32i.n') and len(p) == 3:
                    try:
                        disp = int(p[2], 0) if disp is None else disp
                    except ValueError:
                        pass
                    track.add(p[1])
                elif z.mnemonic == 'l32r':
                    base = self.lit(z)
                    break
                elif z.mnemonic in ('mov', 'mov.n') and len(p) == 2:
                    track.add(p[1])
                elif z.mnemonic == 'addx4' and len(p) == 3:
                    track.add(p[2])
                    disp = None          # variable index -> whole table
            if base is None:
                continue
            cands = []
            if disp:
                v = self.img.word(base + disp)
                if v in self.funcs:
                    cands = [v]
            if not cands:
                for n in range(64):
                    v = self.img.word(base + 4 * n)
                    if v is None:
                        break
                    if v in self.funcs:
                        cands.append(v)
                    elif v != 0:
                        break
            if cands:
                out[x.address] = cands
        return out

    def _jx_table_targets(self, found, jx_off, s, e):
        """Entries of the jump table feeding a `jx`, so case bodies get anchored."""
        base = None
        for k in sorted(found, reverse=True):
            if k >= jx_off:
                continue
            if found[k].mnemonic == 'l32r':
                base = self.lit(found[k])
                break
        if base is None:
            return []
        out = []
        for n in range(256):
            v = self.img.word(base + 4 * n)
            if v is None or not (self.img.base <= v < self.img.base + len(self.img.payload)):
                break
            out.append(v)
        return out

    def coverage(self):
        """Decode success over the code region: bytes consumed by successfully
        decoded instructions vs bytes stepped over.

        Reported this way rather than as a fraction of function spans, because
        function spans run to the next `entry` prologue and therefore include
        inter-function data, which makes a span-based figure meaningless.
        """
        p = self.img.payload
        dec = tot = 0
        i = self.code_lo
        end = len(p)
        while i < end - 3:
            ins = self._decode_one(i, end)
            if ins is not None and ins.mnemonic not in ('.byte', '.db'):
                dec += ins.size
                tot += ins.size
                i += ins.size
            else:
                step = 2 if 8 <= (p[i] & 0xF) <= 0xD else 3
                tot += step
                i += step
        return dec, tot

    def target(self, ins):
        """Resolved branch/call target VA, or None."""
        if isinstance(ins, NarrowBranch):
            return ins.target_va
        b = ins.bytes
        if not b:
            return None
        op0 = b[0] & 0xF
        pc = ins.address
        if ins.mnemonic.startswith('call') and not ins.mnemonic.startswith('callx'):
            if op0 != 5 or len(b) < 3:
                return None
            imm18 = sext((w24(b, 0) >> 6) & 0x3FFFF, 18)
            return (pc & ~3) + (imm18 << 2) + 4
        if ins.mnemonic == 'j' or ins.mnemonic.startswith('b'):
            # Xtensa has two wide branch encodings -- RRI8 (imm8 at bits 23:16,
            # e.g. BEQI/BGEU) and BRI12 (imm12 at bits 23:12, e.g. BEQZ/BNEZ) --
            # and decoding every branch as RRI8 sent the recursive descent to
            # wrong addresses, which silently truncated big handlers (PSR: 59 of
            # ~300 instructions reached). capstone prints the displacement
            # relative to the instruction address for both forms, so use it.
            m = re.search(r'\.\s*([+-])\s*(0x[0-9a-fA-F]+|\d+)\s*$', ins.op_str)
            if m:
                disp = int(m.group(2), 0)
                return pc + (disp if m.group(1) == '+' else -disp)
            m = re.search(r'0x([0-9a-fA-F]{8})\s*$', ins.op_str)
            if m:
                return int(m.group(1), 16)
            return None
        return None

    def _build_calls(self):
        self._reset_calls()
        for fva in self.funcs:
            for x in self.dis(fva):
                if x.mnemonic.startswith('call') and not x.mnemonic.startswith('callx'):
                    t = self.target(x)
                    if t is None:
                        continue
                    self.calls[t] += 1
                    self.callers[t].add(fva)
                    self.callsites[t].append((fva, x.address))

    # ---- helpers --------------------------------------------------------
    def lit(self, ins):
        """Value loaded by an l32r, or None."""
        if ins.mnemonic != 'l32r':
            return None
        try:
            la = int(ins.op_str.split()[-1], 0)
        except ValueError:
            return None
        return self.img.word(la)

    def func_of(self, va):
        best = None
        for f, (s, e) in self.funcs.items():
            if f <= va < self.img.va(e):
                if best is None or f > best:
                    best = f
        return best

    def listing(self, fva, annotate=True):
        out = []
        for x in self.dis(fva):
            note = ''
            t = self.target(x)
            if t is not None:
                note = ' -> 0x%08x%s' % (t, '  [fn]' if t in self.funcs else '')
            v = self.lit(x)
            if v is not None:
                note = ' = 0x%08x' % v
            out.append('%08x  %-14s %-9s %-28s%s' % (
                x.address, x.bytes.hex(), x.mnemonic, x.op_str, note if annotate else ''))
        return out


def main():
    path, cmd = sys.argv[1], sys.argv[2]
    a = Analysis(path)
    if cmd == 'funcs':
        print('%d functions, code region from payload 0x%x' % (len(a.funcs), a.code_lo))
        for f in sorted(a.funcs):
            s, e = a.funcs[f]
            print('  0x%08x size=%-6d calls_in=%-4d' % (f, e - s, a.calls[f]))
    elif cmd == 'hot':
        n = int(sys.argv[3]) if len(sys.argv) > 3 else 20
        for t, c in a.calls.most_common(n):
            s = a.funcs.get(t)
            print('  0x%08x calls=%-5d callers=%-4d size=%s' % (
                t, c, len(a.callers[t]), (s[1] - s[0]) if s else 'not-a-known-entry'))
    elif cmd == 'dis':
        for v in sys.argv[3:]:
            fva = int(v, 0)
            if fva not in a.funcs:
                fva2 = a.func_of(fva)
                print('# 0x%x is inside 0x%08x' % (fva, fva2))
                fva = fva2
            s, e = a.funcs[fva]
            print('=== 0x%08x  size=%d  callers=%d' % (fva, e - s, len(a.callers[fva])))
            print('\n'.join(a.listing(fva)))
    elif cmd == 'xrefs':
        t = int(sys.argv[3], 0)
        for f, site in sorted(a.callsites[t]):
            print('  0x%08x called from 0x%08x (fn 0x%08x)' % (t, site, f))
    elif cmd == 'grep':
        pat = re.compile(sys.argv[3])
        for f in sorted(a.funcs):
            for x in a.dis(f):
                line = '%s %s' % (x.mnemonic, x.op_str)
                if pat.search(line):
                    print('  fn 0x%08x @0x%08x  %s' % (f, x.address, line))
    else:
        print(__doc__)


if __name__ == '__main__':
    main()