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
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
|
"""green-eye instrumentation session: hooks, breakpoints, watchpoints, events.
This is the Frida-like core. It is deliberately transport-agnostic; the RSP
client is the default backend, and a Session can also be constructed around a
plugin-supplied backend later.
"""
from __future__ import annotations
import re
import time
from dataclasses import dataclass, field as _field
from typing import Callable
from .errors import GreenEyeError, RspTimeout, SymbolNotFound
from .memory import Memory
from .record import NullRecorder, Recorder
from .rsp import RspClient, StopReply
from .symbols import Symbol, SymbolDB
#: Registers saved and restored around ``Session.call`` (RISC-V ABI + pc).
RESTORE_REGS = (
["ra", "sp", "gp", "tp", "t0", "t1", "t2", "s0", "s1"]
+ [f"a{i}" for i in range(8)]
+ [f"s{i}" for i in range(2, 12)]
+ [f"t{i}" for i in range(3, 7)]
+ ["pc"]
)
@dataclass
class Hit:
"""Everything known about one stop event."""
kind: str # breakpoint | leave | watchpoint | step | fault | exit | stop
addr: int
regs: dict = _field(default_factory=dict)
symbol: Symbol | None = None
retval: int | None = None
watch: "Watchpoint | None" = None
stop: StopReply | None = None
session: "Session | None" = None
data: dict = _field(default_factory=dict)
@property
def pc(self) -> int:
return self.regs.get("pc", self.addr)
def arg(self, index: int) -> int:
"""RISC-V ABI argument a0..a7 -> x10..x17."""
return self.regs.get(f"a{index}", 0)
def label(self) -> str:
if self.symbol is not None:
return self.symbol.label(self.addr)
return f"{self.addr:#x}"
def __str__(self) -> str: # pragma: no cover - cosmetic
base = f"[{self.kind}] {self.label()}"
if self.kind in ("breakpoint", "leave"):
base += f" a0={self.arg(0):#x} a1={self.arg(1):#x}"
if self.kind == "leave" and self.retval is not None:
base += f" -> {self.retval:#x}"
if self.kind == "watchpoint" and self.watch is not None:
base += f" {self.watch.kind}@{self.watch.addr:#x}"
return base
@dataclass
class Breakpoint:
addr: int
kind: str = "sw" # sw | hw
temporary: bool = False
purpose: str = "user" # user | leave
on_enter: Callable[[Hit], None] | None = None
on_leave: Callable[[Hit], None] | None = None
symbol: Symbol | None = None
attached: bool = True
hits: int = 0
listener: str | None = None
@property
def label(self) -> str:
return self.symbol.label(self.addr) if self.symbol else f"{self.addr:#x}"
@dataclass
class Watchpoint:
addr: int
size: int = 8
kind: str = "w" # w | r | a
callback: Callable[[Hit], None] | None = None
symbol: Symbol | None = None
hits: int = 0
listener: str | None = None
@property
def label(self) -> str:
return self.symbol.label(self.addr) if self.symbol else f"{self.addr:#x}"
class HookHandle:
def __init__(self, session: "Session", breakpoint: Breakpoint):
self._session = session
self._bp = breakpoint
@property
def addr(self) -> int:
return self._bp.addr
@property
def hits(self) -> int:
return self._bp.hits
@property
def label(self) -> str:
return self._bp.label
def detach(self) -> None:
self._session.remove_breakpoint(self._bp)
class Session:
def __init__(
self,
*,
client: RspClient,
target=None,
symbols: SymbolDB | None = None,
record: str | bool | None = None,
name: str = "session",
meta: dict | None = None,
):
self.client = client
self.target = target
self.symbols = symbols or SymbolDB()
self.name = name
self.image = None
self._owns_target = target is not None
if isinstance(record, str):
self.recorder = Recorder(record, name=name, meta=meta or {})
elif record:
self.recorder = Recorder.new(name=name, meta=meta or {})
else:
self.recorder = NullRecorder()
self.memory = Memory(client, symbols=self.symbols, recorder=self.recorder)
self._bps: dict[int, Breakpoint] = {}
self._bp_seq = 0
self._bps_by_addr: dict[int, list[Breakpoint]] = {}
self._watches: dict[tuple[int, int, str], Watchpoint] = {}
self._watches_by_addr: dict[int, Watchpoint] = {}
self._leave_pending: dict[int, list[Breakpoint]] = {}
self._stopped = True
self._event_hooks: list[tuple[str, Callable[[Hit], None]]] = []
self.recorder.event("session_start", name=name)
# -- convenience proxies ----------------------------------------------
def load_image(
self,
image,
*,
write_segments: bool = False,
set_pc: bool = False,
bias: int = 0,
load_symbols: bool = True,
source: str = "elf",
):
"""Register a firmware image with the session.
``write_segments`` copies every PT_LOAD through the RSP client (for
targets started without their loader devices); ``set_pc`` points the
CPU at the (relocated) entry. With QEMU's ``-kernel`` fast path both
are already handled by QEMU, so the default only loads symbols.
"""
self.image = image
if load_symbols:
self.symbols.load_elf(image, bias=bias, source=source)
if write_segments:
for addr, data in image.flat_load_plan(bias=bias):
self.client.write_mem(addr, data)
if set_pc:
self.client.write_reg("pc", image.relocated_entry(bias=bias))
self.recorder.event(
"image_loaded",
path=getattr(image, "path", None),
entry=image.entry,
bias=bias,
write_segments=write_segments,
)
return image
@property
def pc(self) -> int:
return self.client.pc
def read_regs(self) -> dict[str, int]:
return self.client.read_registers()
def snapshot(self, addr: int, size: int, name: str = "region") -> bytes:
return self.memory.snapshot(addr, size, name=name)
def note(self, text: str, **fields) -> None:
self.recorder.event("note", text=text, **fields)
# -- symbol helpers ----------------------------------------------------
def resolve(self, target: int | str) -> int:
"""Resolve an address, a symbol name, or a ``symbol+0xNN`` expression."""
if isinstance(target, int):
return target
if isinstance(target, str):
if target.lower().startswith("0x"):
return int(target, 16)
expression = re.fullmatch(
r"([A-Za-z_][A-Za-z0-9_.]*)\s*([+-])\s*(0x[0-9a-fA-F]+|\d+)", target.strip()
)
if expression:
base = self.symbols.get(expression.group(1))
if base is None:
raise SymbolNotFound(f"symbol {expression.group(1)!r} not found in symbol DB")
delta = int(expression.group(3), 0)
return base.addr + delta if expression.group(2) == "+" else base.addr - delta
sym = self.symbols.get(target)
if sym is None:
raise SymbolNotFound(f"symbol {target!r} not found in symbol DB")
return sym.addr
raise GreenEyeError(f"cannot resolve target {target!r}")
def symbol_at(self, addr: int) -> Symbol | None:
"""Return the symbol containing ``addr``, or the nearest one (alias of
``symbols.resolve``); ``None`` when the database is empty."""
return self.symbols.resolve(addr)
# -- breakpoints / hooks ----------------------------------------------
def hook(
self,
target: int | str,
on_enter: Callable[[Hit], None] | None = None,
on_leave: Callable[[Hit], None] | None = None,
*,
kind: str = "sw",
temporary: bool = False,
symbol: Symbol | None = None,
listener: str | None = None,
) -> HookHandle:
addr = self.resolve(target)
sym = symbol or self.symbols.resolve(addr)
bp = Breakpoint(
addr=addr,
kind=kind,
temporary=temporary,
on_enter=on_enter,
on_leave=on_leave,
symbol=sym,
listener=listener,
)
self.add_breakpoint(bp)
self.recorder.event(
"hook",
addr=addr,
symbol=sym.name if sym else None,
kind=kind,
on_leave=bool(on_leave),
listener=listener,
)
return HookHandle(self, bp)
def breakpoint(self, target: int | str, callback: Callable[[Hit], None] | None = None, **kw) -> Breakpoint:
addr = self.resolve(target)
bp = Breakpoint(addr=addr, on_enter=callback, symbol=self.symbols.resolve(addr), **kw)
self.add_breakpoint(bp)
return bp
def add_breakpoint(self, bp: Breakpoint) -> None:
self._bp_seq += 1
self._bps[self._bp_seq] = bp
self._bps_by_addr.setdefault(bp.addr, []).append(bp)
if bp.kind == "hw":
self.client.set_hw_break(bp.addr)
else:
self.client.set_sw_break(bp.addr)
def remove_breakpoint(self, bp: Breakpoint) -> None:
for key, candidate in list(self._bps.items()):
if candidate is bp:
del self._bps[key]
lst = self._bps_by_addr.get(bp.addr, [])
if bp in lst:
lst.remove(bp)
if not lst:
self._bps_by_addr.pop(bp.addr, None)
self._leave_pending.pop(bp.addr, None)
try:
if bp.kind == "hw":
self.client.clear_hw_break(bp.addr)
else:
self.client.clear_sw_break(bp.addr)
except Exception:
pass
bp.attached = False
def on_event(self, kind: str, callback: Callable[[Hit], None]) -> None:
self._event_hooks.append((kind, callback))
# -- watchpoints -------------------------------------------------------
def watch(
self,
target: int | str,
size: int = 8,
kind: str = "w",
callback: Callable[[Hit], None] | None = None,
listener: str | None = None,
) -> Watchpoint:
addr = self.resolve(target)
wp = Watchpoint(
addr=addr,
size=size,
kind=kind,
callback=callback,
symbol=self.symbols.resolve(addr),
listener=listener,
)
key = (addr, size, kind)
if key in self._watches:
raise GreenEyeError(f"watchpoint {kind}@{addr:#x}/{size} already exists")
self._watches[key] = wp
self._watches_by_addr[addr] = wp
self.client.set_watch(addr, size=size, kind=kind)
self.recorder.event(
"watch",
addr=addr,
size=size,
kind=kind,
symbol=wp.symbol.name if wp.symbol else None,
listener=listener,
)
return wp
def unwatch(self, wp: Watchpoint) -> None:
key = (wp.addr, wp.size, wp.kind)
self._watches.pop(key, None)
self._watches_by_addr.pop(wp.addr, None)
try:
self.client.clear_watch(wp.addr, size=wp.size, kind=wp.kind)
except Exception:
pass
# -- execution ---------------------------------------------------------
def step(self, count: int = 1) -> list[Hit]:
hits: list[Hit] = []
for _ in range(count):
stop = self.client.step()
hit = self._dispatch_stop(stop, from_step=True)
if hit is not None:
hits.append(hit)
if stop.is_exit:
break
return hits
# -- Frida-like remote call / run-until -------------------------------
def _scratch_addr(self, override: int | None = None) -> int:
if override:
return override
if self.target is not None and getattr(self.target, "memory_mb", 0):
base = getattr(self.target, "ram_base", 0x80000000)
return base + self.target.memory_mb * 1024 * 1024 - 0x1000
if self.image is not None and getattr(self.image, "max_vaddr", 0):
return self.image.max_vaddr + 0x10000
return 0x801FF000
def run_until(self, target: int | str, timeout: float = 5.0) -> Hit:
"""Run until execution reaches ``target`` (Frida's run-to-cursor).
Sets a temporary breakpoint, services intervening stops, and returns
the Hit for the target. Raises ``RspTimeout`` if not reached.
"""
addr = self.resolve(target)
if self.pc == addr:
return Hit(kind="stop", addr=addr, regs=self.read_regs(), symbol=self.symbols.resolve(addr), session=self)
temp = Breakpoint(addr=addr, kind="sw", temporary=True, on_enter=None)
self.add_breakpoint(temp)
deadline = time.monotonic() + timeout
try:
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise RspTimeout(f"run_until {addr:#x}: not reached in {timeout}s")
try:
stop = self.client.continue_(timeout=remaining)
except RspTimeout:
self.client.interrupt()
raise RspTimeout(f"run_until {addr:#x}: not reached in {timeout}s")
hit = self._dispatch_stop(stop)
if hit is not None and hit.addr == addr and hit.kind == "breakpoint":
return hit
if stop.is_exit:
raise GreenEyeError(f"run_until {addr:#x}: target exited")
finally:
self.remove_breakpoint(temp)
def call(self, target: int | str, args: tuple | list = (), timeout: float = 5.0, scratch: int | None = None) -> int:
"""Call a function in the target from the debugger (Frida NativeFunction).
Writes an ``ebreak`` trampoline into a scratch page, sets ``ra`` to it,
loads ``a0..a7`` and a private stack, jumps to ``target``, services
intervening stops (hooks/watchpoints included), and returns ``a0``.
All caller-saved registers and ``pc`` are restored afterwards.
"""
addr = self.resolve(target)
scratch = self._scratch_addr(scratch)
stack = scratch + 0x800
saved = self.read_regs()
temp = Breakpoint(addr=scratch, kind="sw", temporary=True, purpose="user")
self.add_breakpoint(temp)
try:
for name in ("a0", "a1", "a2", "a3", "a4", "a5", "a6", "a7"):
self.client.write_reg(name, 0)
for index, value in enumerate(args[:8]):
self.client.write_reg(f"a{index}", int(value) & 0xFFFF_FFFF_FFFF_FFFF)
self.client.write_reg("ra", scratch)
self.client.write_reg("sp", stack)
self.client.write_reg("pc", addr)
self.recorder.event("call_enter", addr=addr, args=list(args), scratch=scratch)
deadline = time.monotonic() + timeout
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise RspTimeout(f"call {addr:#x}: no return in {timeout}s")
try:
stop = self.client.continue_(timeout=remaining)
except RspTimeout:
self.client.interrupt()
raise RspTimeout(f"call {addr:#x}: no return in {timeout}s")
hit = self._dispatch_stop(stop)
pc = self.client.pc
if pc == scratch or (hit is not None and hit.addr == scratch):
retval = self.client.read_reg("a0")
self.recorder.event("call_return", addr=addr, retval=retval)
return retval
if stop.is_exit:
raise GreenEyeError(f"call {addr:#x}: target exited")
finally:
if temp.attached:
self.remove_breakpoint(temp)
for name in RESTORE_REGS:
if name in saved:
try:
self.client.write_reg(name, saved[name])
except Exception:
pass
def run(
self,
duration: float | None = None,
max_stops: int | None = None,
timeout: float | None = None,
) -> int:
"""Resume and service events until the deadline/budget/exit.
``duration`` (or ``timeout`` when duration is None) is a wall-clock
budget: when it expires the target is interrupted once, the resulting
stop is dispatched, and the call returns. Returns the number of stops
serviced.
"""
limit = duration if duration is not None else timeout
deadline = time.monotonic() + limit if limit is not None else None
stops = 0
while True:
if deadline is not None:
remaining = deadline - time.monotonic()
if remaining <= 0:
break # budget spent while stopped: leave the target stopped
remaining = max(0.001, remaining)
else:
remaining = None
interrupted = False
try:
stop = self.client.continue_(timeout=remaining)
except RspTimeout:
if deadline is None:
raise
stop = self.client.interrupt()
interrupted = True
stops += 1
self._dispatch_stop(stop)
if stop.is_exit:
break
if max_stops is not None and stops >= max_stops:
break
if interrupted or (deadline is not None and time.monotonic() >= deadline):
break
return stops
def stop(self) -> Hit | None:
stop = self.client.interrupt()
return self._dispatch_stop(stop)
# -- stop dispatch -----------------------------------------------------
def _dispatch_stop(self, stop: StopReply, from_step: bool = False) -> Hit | None:
if stop.is_exit:
hit = Hit(kind="exit", addr=0, stop=stop, session=self)
self.recorder.event("exit", status=stop.exit_status)
self._fire_event_hooks(hit)
return hit
regs = self.client.read_registers()
pc = regs.get("pc", 0)
sym = self.symbols.resolve(pc)
if stop.watch_addr is not None:
wp = self._watches_by_addr.get(stop.watch_addr)
hit = Hit(
kind="watchpoint",
addr=stop.watch_addr,
regs=regs,
symbol=self.symbols.resolve(stop.watch_addr),
watch=wp,
stop=stop,
session=self,
)
if wp is not None:
wp.hits += 1
if wp.callback is not None:
wp.callback(hit)
self.recorder.event(
"watch_hit",
addr=stop.watch_addr,
kind=wp.kind if wp else stop.watch_kind,
size=wp.size if wp else None,
symbol=hit.symbol.name if hit.symbol else None,
pc=pc,
regs=_json_regs(regs),
)
self._fire_event_hooks(hit)
# QEMU stops *at* the instruction that performed the watched
# access. Step it once with the watchpoint temporarily cleared so
# resuming does not re-trigger on the same store forever.
try:
if wp is not None:
self.client.clear_watch(wp.addr, size=wp.size, kind=wp.kind)
self.client.step()
except RspTimeout:
pass
finally:
if wp is not None:
try:
self.client.set_watch(wp.addr, size=wp.size, kind=wp.kind)
except Exception:
pass
return hit
hit = Hit(
kind="step" if from_step else "stop",
addr=pc,
regs=regs,
symbol=sym,
stop=stop,
session=self,
)
bps = list(self._bps_by_addr.get(pc, []))
leave_queue = self._leave_pending.get(pc, [])
if not bps and not leave_queue:
self.recorder.event(
"stop",
pc=pc,
symbol=sym.name if sym else None,
signal=stop.signal,
reason="step" if from_step else "halt",
)
self._fire_event_hooks(hit)
return hit
hit.kind = "breakpoint"
self.recorder.event(
"bp_hit",
pc=pc,
addr=pc,
symbol=sym.name if sym else None,
signal=stop.signal,
regs=_json_regs(regs),
)
for bp in bps:
bp.hits += 1
if bp.on_enter is not None:
bp.on_enter(hit)
if bp.on_leave is not None and not bp.temporary:
self._arm_leave(regs.get("ra", 0), bp)
if bp.temporary:
self.remove_breakpoint(bp)
# Return hooks: pop one pending hook for this return address.
if leave_queue:
bp = leave_queue.pop()
if not leave_queue:
self._leave_pending.pop(pc, None)
for candidate in list(self._bps_by_addr.get(pc, [])):
if candidate.purpose == "leave":
self.remove_breakpoint(candidate)
hit.kind = "leave"
hit.retval = regs.get("a0", 0)
if bp.symbol is not None:
hit.symbol = bp.symbol
hit.addr = bp.addr
if bp.on_leave is not None:
bp.on_leave(hit)
# Software breakpoints need a single-step over them before resuming.
self._step_over(pc)
self._fire_event_hooks(hit)
return hit
def _arm_leave(self, retaddr: int, bp: Breakpoint) -> None:
if not retaddr:
return
queue = self._leave_pending.get(retaddr)
if queue is None:
queue = []
self._leave_pending[retaddr] = queue
temp = Breakpoint(
addr=retaddr,
kind="sw",
temporary=True,
purpose="leave",
symbol=self.symbols.resolve(retaddr),
)
self.add_breakpoint(temp)
queue.append(bp)
def _step_over(self, pc: int) -> None:
sw = [bp for bp in self._bps_by_addr.get(pc, []) if bp.kind == "sw"]
if not sw:
return
for bp in sw:
self.client.clear_sw_break(pc)
self.client.step()
for bp in sw:
if bp.attached:
self.client.set_sw_break(pc)
def _fire_event_hooks(self, hit: Hit) -> None:
for kind, callback in self._event_hooks:
if kind in (hit.kind, "*"):
callback(hit)
# -- lifecycle ---------------------------------------------------------
def close(self, kill_target: bool | None = None) -> None:
try:
for bp in list(self._bps.values()):
self.remove_breakpoint(bp)
for wp in list(self._watches.values()):
self.unwatch(wp)
except Exception:
pass
self.client.close()
if kill_target is None:
kill_target = self._owns_target
if kill_target and self.target is not None and not getattr(self.target, "keep_alive", False):
self.target.kill()
self.recorder.close()
self._owns_target = False
def __enter__(self) -> "Session":
return self
def __exit__(self, *exc) -> None:
self.close()
def _json_regs(regs: dict[str, int]) -> dict[str, int]:
return {name: value for name, value in regs.items() if isinstance(value, int)}
|