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
|
#!/usr/bin/env python3
"""ARM/Thumb decoding switch, against real Thumb code.
Thumb isn't a property of the bytes — it's a mode the CPU is in — so a raw image
gives IDA no way to know. At a Thumb entry point it decodes 16-bit instructions
as 32-bit ARM and produces confident nonsense, and `c` either fails or carves
garbage. `t` switches the mode.
Uses experiments/fibonacci.bin (real Thumb), so the encodings are not a guess.
Needs IDA. ~40s.
"""
#: spawns a real worker on Thumb code and drives the pilot.
#: Read by tests/run.py (--fast skips every NEEDS_IDA file).
NEEDS_IDA = True
import asyncio
import os
import shutil
import sys
import tempfile
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from textual.widgets import Static # noqa: E402
from idatui._sync import settle # noqa: E402
from idatui.app import DecompView, IdaTui, ListingView # noqa: E402
PASS = FAIL = 0
BIN = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"experiments", "fibonacci.bin")
def check(name, ok, detail=""):
global PASS, FAIL
if ok:
PASS += 1
print(f" ok {name}")
else:
FAIL += 1
print(f" FAIL {name} {detail}")
#: Every phase gets its OWN copy of the fixture.
#:
#: This suite used to delete <BIN>.i64 and reopen the SAME path for each phase.
#: That was safe when the TUI owned a private worker that died with it; under
#: Code Mode the database is leased and the previous phase's worker can still
#: hold it through its lease grace, so the delete raced a live owner and the
#: next open never produced a listing (the crash this fixed). Separate paths
#: cannot collide, and nothing has to wait for anyone else to let go.
_SCRATCH = []
def fresh_copy(src: str, tag: str) -> str:
d = tempfile.mkdtemp(prefix=f"idatui-thumb-{tag}-")
_SCRATCH.append(d)
dst = os.path.join(d, os.path.basename(src))
shutil.copy2(src, dst)
return dst
def drop_scratch() -> None:
for d in _SCRATCH:
shutil.rmtree(d, ignore_errors=True)
_SCRATCH.clear()
def status_of(app) -> str:
return str(app.query_one("#status", Static).render())
async def wait(pred, pilot, t=240.0):
for _ in range(int(t / 0.05)):
await pilot.pause(0.05)
try:
if pred():
return True
except Exception: # noqa: BLE001
pass
return False
async def run() -> int:
# A fresh database every time: the T flag and the segment's addressing mode
# are SAVED in the .i64, so a previous run would answer the question for us.
app = IdaTui(open_path=fresh_copy(BIN, "arm"), keepalive=False,
load_args="-parm")
async with app.run_test(size=(140, 44)) as pilot:
await wait(lambda: app._func_index is not None
and app._func_index.complete, pilot)
await wait(lambda: app._cur is not None, pilot, 60)
lst = app.query_one(ListingView)
lst.focus()
lst.cursor = lst.model.index_of_ea(0)
lst._scroll_cursor_into_view()
await pilot.pause(0.3)
check("starts undefined at the entry", lst.model.get(lst.cursor).kind == "unknown",
f"{lst.model.get(lst.cursor).text!r}")
# `c` in the wrong mode: this is the failure being fixed. It must NOT
# quietly carve garbage — either it refuses, or whatever it makes is not
# the Thumb prologue.
m0 = lst.model
await pilot.press("c")
await pilot.pause(2.5)
h = lst.model.get(lst.model.index_of_ea(0))
check("`c` alone does not produce the Thumb prologue",
h is None or h.kind != "code" or "PUSH" not in h.text.upper(),
f"{h.text if h else None!r}")
m1 = lst.model
await pilot.press("t")
# The mode switch announces itself; wait for THAT, plus quiescence.
# `lst.model is not m1` used to be the gate, but an item edit now keeps
# the listing's walk instead of rebuilding it, so the model object is
# never replaced -- every one of these waits sat out its full 60s and
# the suite still "passed", four times over.
await settle(app, lambda: "Thumb" in status_of(app), timeout=60)
status = status_of(app)
check("the status says it switched to Thumb", "Thumb" in status, status[:90])
# Thumb doesn't exist in AArch64, and -parm on a headerless blob gives a
# 64-bit segment, so setting T alone would change nothing and look broken.
check("and says it forced the segment to 32-bit",
"32-bit" in status, status[:90])
m = lst.model
rows = [m.get(m.index_of_ea(ea)) for ea in (0x0, 0x2, 0x4)]
check("the entry decodes as Thumb",
rows[0] is not None and rows[0].kind == "code"
and "PUSH" in rows[0].text.upper(),
f"{rows[0].text if rows[0] else None!r}")
# 16-bit instructions: the addresses are 2 apart, which is the whole
# point — in ARM mode these would be one 4-byte instruction.
check("instructions are 16-bit wide",
all(r is not None and r.kind == "code" and r.size == 2 for r in rows),
f"{[(hex(r.ea), r.size, r.text) for r in rows if r]}")
check("and it kept disassembling past the first one",
sum(1 for i in range(20) if (m.get(i) or h).kind == "code") > 5,
"expected a run of instructions, not one")
# Toggling back must be possible — the mode is a guess and guesses get
# revised.
m2 = lst.model
lst.cursor = lst.model.index_of_ea(0)
await pilot.press("t")
await settle(app, lambda: "ARM @" in status_of(app), timeout=60)
status = status_of(app)
check("`t` toggles back to ARM", "ARM @" in status, status[:80])
# -- and the reason a carved function wouldn't decompile ---------------- #
# Bare 'arm' gives a 64-BIT database. Thumb doesn't exist there, and
# Hex-Rays refuses a 32-bit function outright ("only 64-bit functions can be
# decompiled in the current database") — so `t` produces correct-looking
# disassembly that F5 can never turn into pseudocode. The database's bitness
# is fixed at load and cannot be corrected afterwards, so the only honest
# thing is to say so.
app = IdaTui(open_path=fresh_copy(BIN, "arm64"), keepalive=False,
load_args="-parm") # 64-bit
async with app.run_test(size=(140, 44)) as pilot:
await wait(lambda: app._func_index is not None
and app._func_index.complete, pilot)
await wait(lambda: app._cur is not None, pilot, 60)
lst = app.query_one(ListingView)
lst.focus()
lst.cursor = lst.model.index_of_ea(0)
lst._scroll_cursor_into_view()
await pilot.pause(0.3)
m = lst.model
await pilot.press("t")
await settle(app, lambda: "64-bit" in status_of(app), timeout=60)
status = status_of(app)
check("a 64-bit database warns that Hex-Rays won't decompile",
"64-bit" in status and "decompile" in status, status[:120])
check("and names the fix", "ARMv7-A" in status, status[:120])
# And if you ignore that and carry on, the failure has to say WHY. The
# plain decompile tool reports "Decompilation failed at 0x0" and drops
# the reason, which is the only part that tells you what to do — with it
# missing, F5 doing nothing is indistinguishable from a bug in the TUI.
lst.cursor = lst.model.index_of_ea(0)
await pilot.pause(0.2)
mp = lst.model
await pilot.press("p")
# The function appearing in the index IS the signal; the model identity
# never was one.
await settle(app, lambda: app._func_index is not None
and len(app._func_index) > 0, timeout=60)
await pilot.press("tab")
await wait(lambda: "cannot decompile" in
str(app.query_one("#status", Static).render()), pilot, 90)
status = str(app.query_one("#status", Static).render())
# The message must say what to DO. Hex-Rays' own sentence ("only 64-bit
# functions can be decompiled in the current database") describes the
# database, not the fix, and is long enough that a status bar cuts off
# the end — which is where an appended hint would have lived.
check("a failed decompile names the fix, not just the diagnosis",
"Ctrl+L" in status and "ARMv7-A" in status, status[:130])
check("and the reason survives the view reloading under it",
"cannot decompile" in status, status[:130])
check("the message fits a narrow status bar",
len(status) < 110, f"{len(status)} chars: {status[:130]}")
# -- the whole point: a 32-bit database decompiles ---------------------- #
app = IdaTui(open_path=fresh_copy(BIN, "armv7a"), keepalive=False,
load_args="-parm:ARMv7-A")
async with app.run_test(size=(140, 44)) as pilot:
await wait(lambda: app._func_index is not None
and app._func_index.complete, pilot)
# A 32-bit ARM database also lets auto-analysis do its job on Thumb code,
# which is why this one lands in the symbol picker rather than nowhere.
check("a 32-bit ARM database finds functions by itself",
len(app._func_index) > 5, f"n={len(app._func_index)}")
await pilot.press("escape")
await pilot.pause(0.5)
f = app._func_index.all_loaded()[0]
app._goto_ea(f.addr, push=True)
await wait(lambda: app._cur is not None
and app.query_one(ListingView).model is not None, pilot, 60)
app.query_one(ListingView).focus()
await pilot.press("tab")
dec = app.query_one(DecompView)
got = await wait(lambda: dec.display and dec._texts, pilot, 90)
check("Tab decompiles a Thumb function", got and len(dec._texts) > 3,
f"lines={len(dec._texts or [])}")
check("and it reads like C",
any("(" in t and ")" in t for t in (dec._texts or [])[:3]),
f"{(dec._texts or [])[:3]}")
# -- Thumb entry points from a vector table ----------------------------- #
# An ARM function pointer carries the mode in bit 0: odd means Thumb. A
# Cortex-M vector table is therefore a list of Thumb entry points, and IDA
# won't follow them on a headerless image because nothing says those words
# are pointers at all.
vec = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"experiments", "cortexm.bin")
if not os.path.isfile(vec):
check("the cortexm fixture exists", False, vec)
else:
app = IdaTui(open_path=fresh_copy(vec, "cortexm"), keepalive=False,
load_args="-parm:ARMv7-M")
async with app.run_test(size=(140, 44)) as pilot:
await wait(lambda: app._func_index is not None
and app._func_index.complete, pilot)
check("a bare vector table gives IDA nothing to go on",
len(app._func_index) == 0, f"n={len(app._func_index)}")
if type(app.screen).__name__ != "Screen":
await pilot.press("escape")
await pilot.pause(0.5)
app._goto_ea(0, push=True)
lst = app.query_one(ListingView)
await wait(lambda: lst.model is not None, pilot, 60)
lst.focus()
lst.cursor = lst.model.index_of_ea(0)
lst._scroll_cursor_into_view()
await pilot.pause(0.3)
await pilot.press("T")
await wait(lambda: app._func_index is not None
and len(app._func_index) >= 3, pilot, 90)
names = sorted(f.name for f in app._func_index.all_loaded())
check("scanning the table finds the Thumb handlers",
names == ["sub_200", "sub_240", "sub_280"], f"{names}")
# The table also holds an even word (the initial stack pointer), an
# even in-range word and an odd word pointing outside the image. All
# three must be ignored — marking a data word as code corrupts the
# listing, so the cost of a false positive is high.
check("and ignores the words that aren't Thumb pointers",
len(app._func_index) == 3, f"n={len(app._func_index)}")
status = str(app.query_one("#status", Static).render())
check("the result survives the reload AND the reindex",
"3 Thumb entries" in status, status[:90])
drop_scratch()
print(f"\n{PASS} passed, {FAIL} failed")
return 1 if FAIL else 0
if __name__ == "__main__":
if not os.path.isfile(BIN):
print(f"no such binary: {BIN}")
raise SystemExit(2)
raise SystemExit(asyncio.run(run()))
|