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
|
#!/usr/bin/env python3
"""Differential check: the current `_idatui_spans` vs the one at a git ref.
The span walker turns IDA's colour-tagged disassembly line into (spans, ops).
It is on the hot path of every listing row, so it is worth optimising -- but its
output drives highlighting, operand marking and the cursor's column arithmetic,
so "faster" is only acceptable if it is byte-identical.
This pulls both implementations out of `server/patch_server.py` (the current
working tree, and whatever `--ref` names), runs them over every tagged line of
a real binary, and reports the first disagreement.
/usr/bin/python3 .auto/diff_spans.py [--ref HEAD] [--target targets/bash]
[--limit 60000]
"""
from __future__ import annotations
import argparse
import os
import shutil
import subprocess
import sys
import tempfile
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def load_impl(path: str, name: str):
"""Exec just the span-walker section of a patch_server.py's injected BODY.
BODY is a normal (non-raw) triple-quoted string, so the escapes in it are
resolved once by importing the module -- slicing the file text instead would
compile the *undecoded* source and silently test a different program (an
escaped backslash became a literal one, and every 's' in a comment turned
into a space).
"""
import importlib.util
spec = importlib.util.spec_from_file_location(f"_ps_{name}", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod) # IDA-free at import time
body = mod.BODY
a = body.index("def _idatui_head_row")
b = body.index("def _idatui_struct_member_rows")
g = {"__name__": name}
exec(compile(body[a:b], name, "exec"), g) # noqa: S102
return g
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--ref", default="HEAD")
ap.add_argument("--target", default="targets/bash")
ap.add_argument("--limit", type=int, default=60000)
a = ap.parse_args()
d = tempfile.mkdtemp(prefix="diffspans-")
old_path = os.path.join(d, "patch_server_old.py")
with open(old_path, "w") as fh:
fh.write(subprocess.run(
["git", "-C", ROOT, "show", f"{a.ref}:server/patch_server.py"],
capture_output=True, text=True, check=True).stdout)
gnew = load_impl(os.path.join(ROOT, "server", "patch_server.py"), "new")
gold = load_impl(old_path, "old")
new, old = gnew["_idatui_spans"], gold["_idatui_spans"]
new_row, old_row = gnew["_idatui_head_row"], gold["_idatui_head_row"]
binary = os.path.join(ROOT, a.target)
tgt = os.path.join(d, os.path.basename(binary))
shutil.copy2(binary, tgt)
shutil.copy2(binary + ".i64", tgt + ".i64")
try:
import idapro
if idapro.open_database(tgt, run_auto_analysis=False) != 0:
raise SystemExit("could not open database")
import ida_bytes
import ida_lines
import ida_segment
import idaapi
checked = bad = 0
for si in range(ida_segment.get_segm_qty()):
seg = ida_segment.getnseg(si)
if seg is None:
continue
ea = seg.start_ea
while ea < seg.end_ea and checked < a.limit:
line = ida_lines.generate_disasm_line(ea, 0)
if line:
checked += 1
ra, rb = old(line), new(line)
if ra != rb:
bad += 1
if bad <= 3:
print(f"SPAN MISMATCH @ {ea:#x}\n line={line!r}\n"
f" old={ra!r}\n new={rb!r}")
# The whole row, not just the spans: `text`, the spans/text
# agreement guard and the name all moved around too.
ra, rb = old_row(ea), new_row(ea)
if ra != rb:
bad += 1
if bad <= 3:
print(f"ROW MISMATCH @ {ea:#x}\n"
f" old={ra!r}\n new={rb!r}")
nxt = ida_bytes.get_item_end(ea)
ea = nxt if nxt > ea else ea + 1
if checked >= a.limit:
break
print(f"checked {checked} lines, {bad} mismatches")
idapro.close_database(False)
return 1 if bad else 0
finally:
shutil.rmtree(d, ignore_errors=True)
if __name__ == "__main__":
sys.exit(main())
|