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
|
#!/usr/bin/env python3
"""First-pass audit tooling for the MES (Micro Engine Scheduler) firmware.
MES is the RV64 scheduler microcontroller on gfx11; the driver drives it with API
packets (`mes_v11_api_def.h`), so it has the same shape of host-reachable command
surface as the DMCUB inbox that `../dmcub/` audits -- and it had never been looked
at in this tree beyond an ISA classification.
Container (verified, same nested-header trick as DMUB/MEC):
0x000 common_firmware_header + mes_firmware_header_v1_0 (header_size 0x48)
0x100 payload, whose first 0x100 bytes are a **PSP header** ($PS1 at +0x10)
0x200 ucode image, mapped at `mes_uc_start_addr - 0x2000`
(the first 0x2000 bytes are zero, so code begins at file 0x2200 =
uc_start_addr, 0xf0005000)
+data data image at `mes_ucode_data_offset_bytes`, mapped at
`mes_data_start_addr` (0xf0100000)
Requires capstone (RISC-V); the repo venv at /home/user/.venvs/cs6 works.
usage:
mes_audit.py <image> info container, ISA evidence, function count
mes_audit.py <image> dispatch every indirect jump/call with table + bound
mes_audit.py <image> tables code-pointer tables in the data image
"""
import argparse
import collections
import re
import struct
import sys
import capstone as cs
GUARDS = ('bltu', 'bgeu', 'blt', 'bge', 'bne', 'beq', 'bnez', 'beqz', 'bltz', 'bgez')
INDIRECT = ('jalr', 'jr', 'c.jalr', 'c.jr')
class Mes:
"""Handles both `mes_firmware_header_v1_0` (MES/MES1) and
`gfx_firmware_header_v2_0` (PFP/ME/MEC), so the same dispatch audit runs on
every RV64 gfx microcontroller on this ASIC."""
def __init__(self, path):
self.raw = open(path, 'rb').read()
self.path = path
(self.size, self.hdr_size, self.hv_maj, self.hv_min, self.ip_maj, self.ip_min,
self.ucode_version, self.ucode_size, self.ucode_array_offset,
self.crc32) = struct.unpack_from('<IIHHHHIIII', self.raw, 0)
if self.hdr_size >= 0x48:
# mes_firmware_header_v1_0
self.kind = 'mes'
self.mes_ucode_version, = struct.unpack_from('<I', self.raw, 0x20)
(self.mes_ucode_size, self.mes_ucode_offset,
self.mes_data_version, self.mes_data_size, self.mes_data_offset,
self.uc_start_lo, self.uc_start_hi,
self.data_start_lo, self.data_start_hi) = struct.unpack_from('<9I', self.raw, 0x24)
else:
# gfx_firmware_header_v2_0: feature, ucode size/off, data size/off, start addr
self.kind = 'gfx2'
(_feat, self.mes_ucode_size, self.mes_ucode_offset,
self.mes_data_size, self.mes_data_offset,
self.uc_start_lo, self.uc_start_hi) = struct.unpack_from('<7I', self.raw, 0x20)
self.mes_data_version = 0
self.data_start_lo = self.data_start_hi = 0
self.psp_hdr = None
if self.raw[self.ucode_array_offset + 0x10:self.ucode_array_offset + 0x14] == b'$PS1':
self.psp_hdr = self.raw[self.ucode_array_offset:self.ucode_array_offset + 0x100]
self.uc_file = self.ucode_array_offset + (0x100 if self.psp_hdr else 0)
self.uc = self.raw[self.uc_file:self.uc_file + self.mes_ucode_size]
# code starts at uc_start_addr; the leading zero pad tells us the image base
pad = 0
while pad + 64 <= len(self.uc) and self.uc[pad:pad + 64] == bytes(64):
pad += 64
self.code_off = pad
self.uc_base_va = (self.uc_start_lo - pad) & 0xffffffff
self.data_file = self.ucode_array_offset + self.mes_data_offset
self.data = self.raw[self.data_file:self.data_file + self.mes_data_size]
# gfx v2 images give no data load address; their pointer tables live in the
# ucode image itself, so point the data view at the ucode base instead.
self.data_va = self.data_start_lo if self.kind == 'mes' else self.uc_base_va
if self.kind != 'mes':
self.data = self.uc
self.md = cs.Cs(cs.CS_ARCH_RISCV, cs.CS_MODE_RISCV64 | cs.CS_MODE_RISCVC)
self.md.skipdata = True
self._ins = None
# ---- decode ---------------------------------------------------------
def ins(self):
if self._ins is None:
self._ins = list(self.md.disasm(self.uc, self.uc_base_va))
return self._ins
def real(self):
return [x for x in self.ins()
if x.mnemonic not in ('.byte', '.db', 'unimp', 'c.unimp')]
def code_lo(self):
return self.uc_base_va + self.code_off
def code_hi(self):
return self.uc_base_va + len(self.uc)
def word(self, va):
"""32-bit word at a VA in either image."""
if self.data_va <= va < self.data_va + len(self.data) - 3:
return struct.unpack_from('<I', self.data, va - self.data_va)[0]
if self.uc_base_va <= va < self.uc_base_va + len(self.uc) - 3:
return struct.unpack_from('<I', self.uc, va - self.uc_base_va)[0]
return None
def is_code(self, va):
return self.code_lo() <= va < self.code_hi()
# ---- analyses -------------------------------------------------------
def functions(self):
return [x.address for x in self.real()
if x.mnemonic == 'addi' and x.op_str.startswith('sp, sp, -')]
def pointer_tables(self, min_len=6):
"""Runs of consecutive dwords that are code addresses, in the data image."""
runs, cur = [], []
for i in range(0, len(self.data) - 3, 4):
w = struct.unpack_from('<I', self.data, i)[0]
if self.is_code(w):
cur.append((self.data_va + i, w))
else:
if len(cur) >= min_len:
runs.append(cur)
cur = []
if len(cur) >= min_len:
runs.append(cur)
return runs
def dispatches(self):
"""Every indirect jump/call, with the table base and bound if recoverable.
Pattern this recognises (what the MES API dispatcher actually compiles to):
lw aX, off(pkt) ; opcode out of the host packet
li aY, N
bltu aY, aX, default ; opcode > N -> default
lui aT, hi ; addi aT, lo ; table base
slli aX, aX, 2 ; add ; lw ; jr
"""
ins = self.ins()
out = []
for i, x in enumerate(ins):
if x.mnemonic not in INDIRECT:
continue
lo = max(0, i - 24)
win = ins[lo:i + 1]
base = bound = None
tbl_reg_hi = None
for w in win:
if w.mnemonic == 'lui':
m = re.match(r'(\w+), (0x[0-9a-f]+|\d+)', w.op_str)
if m:
tbl_reg_hi = (m.group(1), int(m.group(2), 0) << 12)
elif w.mnemonic == 'addi' and tbl_reg_hi and w.op_str.startswith(tbl_reg_hi[0] + ','):
m = re.match(r'\w+, \w+, (-?0x[0-9a-f]+|-?\d+)', w.op_str)
if m:
base = tbl_reg_hi[1] + int(m.group(1), 0)
elif w.mnemonic in ('li',):
m = re.match(r'(\w+), (-?0x[0-9a-f]+|-?\d+)', w.op_str)
if m:
bound = (m.group(1), int(m.group(2), 0))
elif w.mnemonic in GUARDS:
if bound and bound[0] in w.op_str.split(',')[0]:
bound = ('%s %s' % (w.mnemonic, w.op_str), bound[1])
if base is None and tbl_reg_hi:
base = tbl_reg_hi[1]
n = 0
if base is not None:
while n < 256:
v = self.word(base + 4 * n)
if v is None or not self.is_code(v):
break
n += 1
guard = next((w for w in reversed(win) if w.mnemonic in GUARDS), None)
out.append(dict(at=x.address, table=base, entries=n,
guard=('%s %s' % (guard.mnemonic, guard.op_str)) if guard else None,
bound=bound[1] if bound else None))
return out
MEMCPY_VA = 0xf0021b28 # verified: xor/andi-7 alignment test, a7 = dst + len, word loop + byte tail
LOAD_MNEM = ('lw', 'lwu', 'lb', 'lbu', 'lh', 'lhu', 'ld', 'c.lw', 'c.ld', 'c.lwu')
def _ops(insn):
return [o.strip() for o in insn.op_str.split(',')]
def provenance(img, ins, addrs, site, reg, back=200, depth=0):
"""Walk back linearly for the last writer of `reg` before `site`.
Folds the RISC-V `lui`/`addi` constant pair, which is why site 0xf0014804 is a
CONSTANT 0x2a154 and not a variable length -- an earlier version of this pass
reported it as host-derived, which was wrong. Linear and intra-block only: it
does not follow control flow, so treat 'unknown' as 'needs eyes', not as safe.
"""
if depth > 4:
return ('deep', '', None)
i = addrs.index(site)
for a in reversed(addrs[max(0, i - back):i]):
x = ins[a]
o = _ops(x)
if not o or o[0] != reg:
continue
if x.mnemonic in ('li', 'c.li'):
return ('const', int(o[1], 0), a)
if x.mnemonic in ('addi', 'c.addi') and len(o) > 2:
if o[1] == 'zero':
return ('const', int(o[2], 0), a)
base = provenance(img, ins, addrs, a, o[1], back, depth + 1)
if base[0] == 'const': # lui/addi pair -> one constant
return ('const', (base[1] + int(o[2], 0)) & 0xffffffffffffffff, a)
return ('computed', x.op_str, a)
if x.mnemonic == 'lui':
return ('const', int(o[1], 0) << 12, a)
if x.mnemonic in LOAD_MNEM:
return ('load', x.op_str, a)
if x.mnemonic in ('mv', 'c.mv'):
return provenance(img, ins, addrs, a, o[1], back, depth + 1)
return ('computed', '%s %s' % (x.mnemonic, x.op_str), a)
return ('unknown', '', None)
def cmd_copies(img):
"""Classify every memcpy call site by the provenance of its length argument.
This is the MES analogue of dmcub/dmcub_copyaudit.py, which is what found the
DMCUB ABM_INIT_CONFIG overflow (FINDING-1): a u16 length loaded straight from a
host frame into memcpy. Result for gc_11_0_0_mes_2: no such site exists.
"""
ins = {x.address: x for x in img.ins()}
addrs = sorted(ins)
sites = []
for a in addrs:
x = ins[a]
if x.mnemonic not in ('jal', 'call'):
continue
tgt = x.op_str.split()[-1]
if not tgt.startswith('0x') or int(tgt, 16) != MEMCPY_VA:
continue
sites.append((a, provenance(img, ins, addrs, a, 'a2'),
provenance(img, ins, addrs, a, 'a0'),
provenance(img, ins, addrs, a, 'a1')))
print('memcpy(0x%08x) call sites: %d' % (MEMCPY_VA, len(sites)))
counts = {}
for _, ln, _, _ in sites:
counts[ln[0]] = counts.get(ln[0], 0) + 1
print('length provenance: %s\n' % counts)
for site, ln, dst, src in sites:
if ln[0] == 'const':
desc = 'len=0x%x const' % ln[1]
flag = ''
elif ln[0] == 'load':
desc = 'len<-LOAD %s' % ln[1]
flag = ' <== REVIEW: length from memory'
else:
desc = 'len<-%s %s' % (ln[0], str(ln[1])[:28])
flag = ' <== REVIEW: length computed'
print(' 0x%08x %-34s dst<-%-9s %s%s' % (
site, desc, dst[0], str(dst[1])[:24], flag))
var = [s for s in sites if s[1][0] != 'const']
print('\n%d/%d sites use a constant length; %d need review' % (
len(sites) - len(var), len(sites), len(var)))
return sites
def main():
ap = argparse.ArgumentParser()
ap.add_argument('image')
ap.add_argument('cmd', choices=['info', 'dispatch', 'tables', 'copies'])
a = ap.parse_args()
m = Mes(a.image)
if a.cmd == 'info':
print('%s' % a.image)
print(' header %d.%d ip %d.%d, header_size 0x%x' % (
m.hv_maj, m.hv_min, m.ip_maj, m.ip_min, m.hdr_size))
print(' nested PSP header: %s' % ('$PS1 present' if m.psp_hdr else 'absent'))
print(' ucode: file 0x%x size 0x%x -> image base VA 0x%08x, code at VA 0x%08x' % (
m.uc_file, m.mes_ucode_size, m.uc_base_va, m.code_lo()))
print(' data : file 0x%x size 0x%x -> VA 0x%08x' % (
m.data_file, m.mes_data_size, m.data_va))
ins, real = m.ins(), m.real()
print(' decode: %d instructions, %d real (%.1f%%)' % (
len(ins), len(real), 100.0 * len(real) / max(1, len(ins))))
top = collections.Counter(x.mnemonic for x in real).most_common(8)
print(' top mnemonics: %s' % ' '.join('%s:%d' % kv for kv in top))
print(' function prologues (addi sp,sp,-N): %d' % len(m.functions()))
print(' indirect jumps/calls: %d' % len(m.dispatches()))
elif a.cmd == 'copies':
cmd_copies(m)
elif a.cmd == 'tables':
for r in m.pointer_tables():
print(' table VA 0x%08x n=%-4d first: %s' % (
r[0][0], len(r), ' '.join('0x%08x' % v for _, v in r[:4])))
else:
print('%-11s %-11s %-7s %-7s %s' % ('site', 'table', 'entries', 'bound', 'guard'))
unguarded = 0
for h in m.dispatches():
if h['guard'] is None:
unguarded += 1
print('0x%08x %-11s %-7s %-7s %s' % (
h['at'], ('0x%08x' % h['table']) if h['table'] else '-',
h['entries'] or '-', h['bound'] if h['bound'] is not None else '-',
h['guard'] or '*** none in window ***'))
print('\n%d indirect sites, %d with no guard in the preceding window' % (
len(m.dispatches()), unguarded))
if __name__ == '__main__':
main()
|