Address review comments

This commit is contained in:
Duncan Ogilvie
2026-07-23 16:25:30 +02:00
parent c5195e4d3b
commit e9245c779b
10 changed files with 373 additions and 113 deletions
-6
View File
@@ -5,12 +5,6 @@
Speakeasy supports interactive debugging via the GDB Remote Serial Protocol. When you pass `--gdb`, the emulator pauses before the first instruction and waits for a GDB client to connect. You can then set breakpoints, inspect registers and memory, single-step, and continue execution — all through a standard GDB interface.
GDB support is included in the standard installation:
```
pip install speakeasy-emulator
```
### Debugging a 32-bit DLL
Start speakeasy with `--gdb` on a sample DLL. It will pause before the first instruction and wait for GDB to connect. In a second terminal, connect with `gdb` (or `gdb-multiarch`) and set the architecture to `i386`:
-18
View File
@@ -2,24 +2,6 @@
Speakeasy supports interactive debugging of emulated binaries via the GDB Remote Serial Protocol. When enabled, the emulator pauses before the first instruction and waits for a GDB client to connect. You can then set breakpoints, inspect registers and memory, single-step, and continue execution — all through a standard GDB interface.
The GDB server is implemented directly by Speakeasy and has no optional runtime dependencies.
---
## Installation
GDB support is included in a normal installation:
```console
pip install speakeasy-emulator
```
Or, when installing from source:
```console
pip install -e .
```
---
## Quick Start
-2
View File
@@ -8,8 +8,6 @@ Speakeasy requires Python 3.10+.
python3 -m pip install speakeasy-emulator
```
GDB remote debugging support is included in the standard package.
Verify installation:
```console
+7 -1
View File
@@ -271,7 +271,13 @@ class EmuEngine:
return hook.disable()
def hook_remove(self, hid):
return self.emu.hook_del(hid) # type: ignore[union-attr]
if hid not in self._callbacks:
return self.emu.hook_del(hid) # type: ignore[union-attr]
status = _uc.uc_hook_del(self.emu._uch, uc_hook_h(hid)) # type: ignore[union-attr]
if status != uc.UC_ERR_OK:
raise uc.UcError(status)
self._callbacks.pop(hid, None)
def close(self):
if self.emu is None:
+109 -28
View File
@@ -77,7 +77,7 @@ class RegisterInfo:
class GdbServer:
"""An all-stop RSP server backed directly by a WindowsEmulator."""
def __init__(self, emu: Any, port: int, host: str = "127.0.0.1"):
def __init__(self, emu: Any, port: int, host: str):
self.emu = emu
self.host = host
self.port = port
@@ -97,8 +97,28 @@ class GdbServer:
self._exec_breakpoints: dict[tuple[int, int], int] = {}
self._read_watchpoints: dict[tuple[int, int], int] = {}
self._write_watchpoints: dict[tuple[int, int], int] = {}
self._access_watchpoints: dict[tuple[int, int], int] = {}
self._registers = self._make_registers()
self._hooks: list[Any] = []
self._code_hook: Any | None = None
self._read_hook: Any | None = None
self._write_hook: Any | None = None
def __enter__(self) -> GdbServer:
try:
self.start()
except BaseException:
self.close()
raise
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: Any,
) -> None:
self.close()
# ------------------------------------------------------------------
# Transport
@@ -126,6 +146,13 @@ class GdbServer:
except OSError:
pass
def notify_signal(self, signal: int) -> None:
if not self._closed.is_set():
try:
self._reply(f"X{signal & 0xFF:02x}".encode("ascii"))
except OSError:
pass
def close(self) -> None:
self._closed.set()
for sock in (self._client, self._listener):
@@ -140,9 +167,14 @@ class GdbServer:
pass
for hook in self._hooks:
try:
hook.disable()
self._remove_hook(hook)
except Exception:
pass
# Prevent BinaryEmulator.set_hooks() from restoring debugger hooks if
# emulation is started again after this session closes.
for registered_hooks in getattr(self.emu, "hooks", {}).values():
if isinstance(registered_hooks, list):
registered_hooks[:] = [hook for hook in registered_hooks if hook not in self._hooks]
@staticmethod
def _extract_packet(buffer: bytearray) -> tuple[bytes, bytes, bool] | None:
@@ -233,17 +265,25 @@ class GdbServer:
return ResumeAction(detach=True)
action = self._handle_command(payload)
if action is not None:
if not action.detach and not action.kill:
# Mark the resume transition so Ctrl-C arriving before
# begin_run() becomes a pending interrupt, not a queued '?'.
with self._state_lock:
self._running = True
return action
return ResumeAction(detach=True)
def begin_run(self, action: ResumeAction) -> None:
def begin_run(self, action: ResumeAction) -> bool:
with self._state_lock:
self._running = True
self._stop_pending = False
if self._stop_pending:
return False
self._stop_reason = StopReason()
pc = self.emu.get_pc()
at_breakpoint = any(begin <= pc < begin + size for begin, size in self._exec_breakpoints)
self._resume_from_breakpoint = pc if at_breakpoint else None
self._refresh_hooks()
return True
def finish_run(self, action: ResumeAction) -> StopReason | None:
with self._state_lock:
@@ -272,13 +312,38 @@ class GdbServer:
self._commands.put(b"?")
def _install_hooks(self) -> None:
self._hooks.extend(
(
self.emu.add_code_hook(self._on_code),
self.emu.add_mem_read_hook(self._on_read),
self.emu.add_mem_write_hook(self._on_write),
)
self._code_hook = self.emu.add_code_hook(self._on_code)
self._read_hook = self.emu.add_mem_read_hook(self._on_read)
self._write_hook = self.emu.add_mem_write_hook(self._on_write)
self._hooks.extend((self._code_hook, self._read_hook, self._write_hook))
self._refresh_hooks()
@staticmethod
def _remove_hook(hook: Any) -> None:
if hook.added and hook.native_hook:
hook.emu_eng.hook_remove(hook.handle)
hook.handle = 0
hook.added = False
hook.enabled = False
@classmethod
def _set_hook_enabled(cls, hook: Any | None, enabled: bool) -> None:
if hook is None:
return
if enabled and not hook.added:
hook.add()
elif not enabled and hook.added:
# Hook.disable() still enters Python for every native event. Remove
# the Unicorn hook entirely to avoid that callback overhead.
cls._remove_hook(hook)
def _refresh_hooks(self) -> None:
self._set_hook_enabled(
self._code_hook,
bool(self._exec_breakpoints) or self._resume_from_breakpoint is not None,
)
self._set_hook_enabled(self._read_hook, bool(self._read_watchpoints or self._access_watchpoints))
self._set_hook_enabled(self._write_hook, bool(self._write_watchpoints or self._access_watchpoints))
def _on_code(self, _emu: Any, address: int, _size: int) -> bool:
if self._resume_from_breakpoint == address:
@@ -292,12 +357,16 @@ class GdbServer:
return True
def _on_read(self, _emu: Any, _access: int, address: int, size: int, _value: int) -> bool:
if self._overlaps_watchpoint(self._read_watchpoints, address, size):
if self._overlaps_watchpoint(self._access_watchpoints, address, size):
self._request_stop(StopReason(kind="awatch", address=address))
elif self._overlaps_watchpoint(self._read_watchpoints, address, size):
self._request_stop(StopReason(kind="rwatch", address=address))
return True
def _on_write(self, _emu: Any, _access: int, address: int, size: int, _value: int) -> bool:
if self._overlaps_watchpoint(self._write_watchpoints, address, size):
if self._overlaps_watchpoint(self._access_watchpoints, address, size):
self._request_stop(StopReason(kind="awatch", address=address))
elif self._overlaps_watchpoint(self._write_watchpoints, address, size):
self._request_stop(StopReason(kind="watch", address=address))
return True
@@ -411,12 +480,7 @@ class GdbServer:
elif bp_type == 3:
target = self._read_watchpoints
elif bp_type == 4:
target = self._read_watchpoints
other = self._write_watchpoints
if set_breakpoint:
other[key] = bp_type
else:
other.pop(key, None)
target = self._access_watchpoints
else:
self._reply(b"")
return
@@ -424,6 +488,7 @@ class GdbServer:
target[key] = bp_type
else:
target.pop(key, None)
self._refresh_hooks()
self._reply(b"OK")
except ValueError:
self._reply(b"E01")
@@ -470,7 +535,7 @@ class GdbServer:
("gs", ux.UC_X86_REG_GS),
)
)
definitions.extend((f"st{i}", getattr(ux, f"UC_X86_REG_ST{i}"), 10, "float") for i in range(8))
definitions.extend((f"st{i}", getattr(ux, f"UC_X86_REG_FP{i}"), 10, "float") for i in range(8))
# Unicorn does not expose all x87 environment fields independently.
# Keep their standard RSP slots so clients retain the canonical layout.
definitions.extend(
@@ -489,8 +554,11 @@ class GdbServer:
register = self._registers[index]
if register.unicorn_id is None:
return bytes(register.size)
value = int(self._uc.reg_read(register.unicorn_id))
return value.to_bytes(register.size, "little")
value = self._uc.reg_read(register.unicorn_id)
if register.name.startswith("st"):
mantissa, exponent = value
return int(mantissa).to_bytes(8, "little") + int(exponent).to_bytes(2, "little")
return int(value).to_bytes(register.size, "little")
def _read_registers(self) -> None:
try:
@@ -504,11 +572,10 @@ class GdbServer:
data = bytes.fromhex(encoded.decode("ascii"))
offset = 0
for register in self._registers:
value = int.from_bytes(data[offset : offset + register.size], "little")
if len(data[offset : offset + register.size]) != register.size:
register_data = data[offset : offset + register.size]
if len(register_data) != register.size:
raise ValueError
if register.unicorn_id is not None:
self._uc.reg_write(register.unicorn_id, value)
self._write_register_value(register, register_data)
offset += register.size
if offset != len(data):
raise ValueError
@@ -516,6 +583,16 @@ class GdbServer:
except Exception:
self._reply(b"E01")
def _write_register_value(self, register: RegisterInfo, value: bytes) -> None:
if register.unicorn_id is None:
return
if register.name.startswith("st"):
mantissa = int.from_bytes(value[:8], "little")
exponent = int.from_bytes(value[8:], "little")
self._uc.reg_write(register.unicorn_id, (mantissa, exponent))
else:
self._uc.reg_write(register.unicorn_id, int.from_bytes(value, "little"))
def _read_register(self, encoded_index: bytes) -> None:
try:
index = int(encoded_index, 16)
@@ -531,8 +608,7 @@ class GdbServer:
value = bytes.fromhex(value_text.decode("ascii"))
if len(value) != register.size:
raise ValueError
if register.unicorn_id is not None:
self._uc.reg_write(register.unicorn_id, int.from_bytes(value, "little"))
self._write_register_value(register, value)
self._reply(b"OK")
except (ValueError, IndexError, UnicodeDecodeError):
self._reply(b"E01")
@@ -576,8 +652,13 @@ class GdbServer:
# XML and process metadata
def _handle_xfer(self, payload: bytes) -> bytes | None:
# qXfer packets have the form:
# qXfer:<object>:<operation>:<annex>:<offset>,<length>
qxfer_prefix = b"qXfer:"
try:
object_name, operation, annex, position = payload[6:].split(b":", 3)
if not payload.startswith(qxfer_prefix):
raise ValueError
object_name, operation, annex, position = payload.removeprefix(qxfer_prefix).split(b":", 3)
if operation != b"read":
return None
offset_text, length_text = position.split(b",", 1)
+1 -1
View File
@@ -27,7 +27,7 @@ class WinKernelEmulator(WindowsEmulator, IoManager):
Class used to emulate Windows drivers
"""
def __init__(self, config, debug=False, exit_event=None, gdb_port=None, gdb_host="127.0.0.1"):
def __init__(self, config, debug=False, exit_event=None, gdb_port=None, gdb_host=None):
super().__init__(config, debug=debug, exit_event=exit_event, gdb_port=gdb_port, gdb_host=gdb_host)
self.disasm_eng: object | None = None
+1 -1
View File
@@ -31,7 +31,7 @@ class Win32Emulator(WindowsEmulator):
User Mode Windows Emulator Class
"""
def __init__(self, config, argv=None, debug=False, exit_event=None, gdb_port=None, gdb_host="127.0.0.1"):
def __init__(self, config, argv=None, debug=False, exit_event=None, gdb_port=None, gdb_host=None):
super().__init__(config, debug=debug, exit_event=exit_event, gdb_port=gdb_port, gdb_host=gdb_host)
self.last_error = 0
+61 -29
View File
@@ -17,6 +17,7 @@ import speakeasy.winenv.defs.nt.ddk as ddk
import speakeasy.winenv.defs.windows.windows as windef
from speakeasy.binemu import BinaryEmulator
from speakeasy.errors import WindowsEmuError
from speakeasy.gdb import GdbServer, ResumeAction, StopReason
from speakeasy.profiler import MemAccess, Run
from speakeasy.profiler_events import TracePosition
from speakeasy.report import ErrorInfo, RegionInfo
@@ -81,12 +82,12 @@ class WindowsEmulator(BinaryEmulator):
"""Initialize configured processes. Subclasses must implement."""
...
def __init__(self, config, exit_event=None, debug=False, gdb_port=None, gdb_host="127.0.0.1"):
def __init__(self, config, exit_event=None, debug=False, gdb_port=None, gdb_host=None):
super().__init__(config)
self.debug: bool = debug
self.gdb_port: int | None = gdb_port
self.gdb_host: str = gdb_host
self.gdb_host: str | None = gdb_host
self.arch: int = 0
self.modules: list[Any] = []
self._setup_done: bool = False
@@ -546,8 +547,10 @@ class WindowsEmulator(BinaryEmulator):
self.mem_write(base, bytes(data))
def resume(self, addr, count=-1):
"""
Resume emulation at the specified address.
"""Resume emulation directly at an address.
This low-level API bypasses the GDB command loop; callers that enable
GDB should drive execution through :meth:`start` instead.
"""
timeout = 0 if self.gdb_port is not None else self.config.timeout
self.emu_eng.start(addr, timeout=timeout, count=count) # type: ignore[union-attr]
@@ -569,23 +572,35 @@ class WindowsEmulator(BinaryEmulator):
# so the first stop reports a meaningful PC/SP/etc.
self._prepare_run_context(run)
debugger = None
debug_action = None
detached_resume_addr = None
completed = True
if self.gdb_port is not None:
from speakeasy.gdb import GdbServer
if self.gdb_host is None:
raise WindowsEmuError("A GDB bind host is required when gdb_port is set")
with GdbServer(self, self.gdb_port, self.gdb_host) as debugger:
debug_action = debugger.command_loop()
if debug_action.kill:
completed = True
elif debug_action.detach:
debugger.close()
completed = self._execute_runs()
else:
completed = self._execute_runs(debugger, debug_action)
else:
completed = self._execute_runs()
debugger = GdbServer(self, self.gdb_port, self.gdb_host)
debugger.start()
debug_action = debugger.command_loop()
if debug_action.kill:
debugger.close()
self.on_emu_complete()
return
if debug_action.detach:
debugger.close()
debugger = None
if completed:
self.on_emu_complete()
def _execute_runs(
self,
debugger: GdbServer | None = None,
debug_action: ResumeAction | None = None,
) -> bool:
"""Execute prepared runs, optionally under control of an active GDB session."""
if debugger is not None:
assert debug_action is not None
detached_resume_addr = None
terminal_signal = 0
timeout = 0 if debugger is not None else self.config.timeout
if self.profiler:
@@ -600,17 +615,15 @@ class WindowsEmulator(BinaryEmulator):
resume_addr = detached_resume_addr or self.curr_run.start_addr # type: ignore[union-attr]
detached_resume_addr = None
instruction_count = 1 if debugger is not None and debug_action.step else self.config.max_instructions
if debugger is not None:
debugger.begin_run(debug_action)
self.emu_eng.start(resume_addr, timeout=timeout, count=instruction_count) # type: ignore[union-attr]
should_execute = debugger is None or debugger.begin_run(debug_action)
if should_execute:
self.emu_eng.start(resume_addr, timeout=timeout, count=instruction_count) # type: ignore[union-attr]
if debugger is not None:
stop_reason = debugger.finish_run(debug_action)
if stop_reason is not None:
debug_action = debugger.command_loop(stop_reason)
if debug_action.kill:
debugger.close()
self.on_emu_complete()
return
return True
if debug_action.detach:
detached_resume_addr = self.get_pc()
debugger.close()
@@ -622,10 +635,12 @@ class WindowsEmulator(BinaryEmulator):
logger.error("* Timeout of %d sec(s) reached.", timeout)
except KeyboardInterrupt:
logger.error("* User exited.")
return
if debugger is not None:
debugger.notify_signal(2)
return False
except Exception as e:
if self.exit_event and self.exit_event.is_set():
return
return False
stack_trace = traceback.format_exc()
try:
@@ -635,6 +650,21 @@ class WindowsEmulator(BinaryEmulator):
error = self.get_error_info(str(e), self.get_pc(), traceback=stack_trace)
self.curr_run.error = error # type: ignore[union-attr]
terminal_signal = 11
if debugger is not None:
# Ensure a pending Ctrl-C cannot be lost, then report the
# target fault while its register state is still available.
debugger.finish_run(debug_action)
debug_action = debugger.command_loop(
StopReason(signal=terminal_signal, kind="exception", address=self.get_pc())
)
if debug_action.kill:
return True
if debug_action.detach:
debugger.close()
debugger = None
timeout = self.config.timeout
run = self.on_run_complete()
if not run:
@@ -646,9 +676,11 @@ class WindowsEmulator(BinaryEmulator):
break
if debugger is not None:
debugger.notify_exit(0)
debugger.close()
self.on_emu_complete()
if terminal_signal:
debugger.notify_signal(terminal_signal)
else:
debugger.notify_exit(0)
return True
def get_current_run(self):
"""
+91 -24
View File
@@ -5,12 +5,40 @@ import subprocess
import sys
import textwrap
import time
from dataclasses import dataclass
import pytest
TESTS_DIR = os.path.dirname(__file__)
@dataclass(frozen=True)
class X86Registers:
eax: int
ecx: int
edx: int
ebx: int
esp: int
ebp: int
esi: int
edi: int
eip: int
eflags: int
cs: int
ss: int
ds: int
es: int
fs: int
gs: int
@classmethod
def from_rsp(cls, encoded: str):
# The i386 core feature begins with these sixteen 32-bit registers in
# the same order as the dataclass fields above.
core_layout = struct.Struct("<16I")
return cls(*core_layout.unpack(bytes.fromhex(encoded)[: core_layout.size]))
class GdbRspClient:
def __init__(self, port: int, timeout: float = 10.0):
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
@@ -68,6 +96,9 @@ class GdbRspClient:
def read_registers(self) -> str:
return self.send("g")
def read_x86_registers(self) -> X86Registers:
return X86Registers.from_rsp(self.read_registers())
def read_memory(self, addr: int, size: int) -> str:
return self.send(f"m{addr:x},{size:x}")
@@ -138,6 +169,29 @@ _PAUSE_SERVER_SCRIPT = textwrap.dedent("""\
""")
_FAULT_SERVER_SCRIPT = textwrap.dedent("""\
import json
import sys
from speakeasy import Speakeasy
port = int(sys.argv[1])
config_path = sys.argv[2]
with open(config_path) as f:
cfg = json.load(f)
se = Speakeasy(config=cfg, gdb_port=port)
address = se.load_shellcode(data=b"\\x90\\xc3", arch="x86")
def fail_start(*args, **kwargs):
raise RuntimeError("forced execution failure")
se.emu.emu_eng.start = fail_start
se.run_shellcode(address)
se.shutdown()
""")
_EXIT_SERVER_SCRIPT = textwrap.dedent("""\
import json
import sys
@@ -213,9 +267,7 @@ def gdb_emulator():
@pytest.fixture
def gdb_x64_emulator():
# Exercise the explicit non-loopback override while clients still connect
# through loopback in the isolated test process.
port, proc = _start_module_server("dll_test_x64.dll.xz", host="0.0.0.0")
port, proc = _start_module_server("dll_test_x64.dll.xz")
yield port
_stop_server(proc)
@@ -231,11 +283,7 @@ def test_gdb_connect_and_read_registers(gdb_emulator):
assert len(regs) > 0
assert all(char in "0123456789abcdefxx" for char in regs.lower())
# x86 GDB register order: eax(0), ecx(8), edx(16), ebx(24),
# esp(32), ebp(40), esi(48), edi(56), eip(64)
# Each register is 4 bytes = 8 hex chars.
eip_hex = regs[64:72].lower()
assert eip_hex not in ("00000000", "xxxxxxxx")
assert client.read_x86_registers().eip != 0
client.continue_()
finally:
@@ -248,11 +296,8 @@ def test_gdb_read_memory(gdb_emulator):
try:
client.query_halt_reason()
regs_hex = client.read_registers()
sp_hex = regs_hex[32:40] # esp is at offset 32 (register #4)
sp = struct.unpack("<I", bytes.fromhex(sp_hex))[0]
mem = client.read_memory(sp, 4)
registers = client.read_x86_registers()
mem = client.read_memory(registers.esp, 4)
assert len(mem) == 8
assert all(char in "0123456789abcdef" for char in mem.lower())
@@ -305,6 +350,20 @@ def gdb_pause_emulator():
_stop_server(proc)
@pytest.fixture
def gdb_fault_emulator():
port = _find_free_port()
config_path = os.path.join(TESTS_DIR, "test.json")
proc = subprocess.Popen(
[sys.executable, "-c", _FAULT_SERVER_SCRIPT, str(port), config_path],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
_wait_for_port(port, proc)
yield port, proc
_stop_server(proc)
@pytest.fixture
def gdb_exit_emulator():
port = _find_free_port()
@@ -326,8 +385,7 @@ def test_gdb_breakpoint_stop_keeps_session_alive(gdb_emulator):
try:
client.query_halt_reason()
client.step()
registers = client.read_registers()
pc = struct.unpack("<I", bytes.fromhex(registers[64:72]))[0]
pc = client.read_x86_registers().eip
code = bytes.fromhex(client.read_memory(pc, 16))
instruction = next(iter(capstone.Cs(capstone.CS_ARCH_X86, capstone.CS_MODE_32).disasm(code, pc)))
breakpoint = pc + instruction.size
@@ -337,11 +395,9 @@ def test_gdb_breakpoint_stop_keeps_session_alive(gdb_emulator):
assert stop.startswith("T05")
assert "swbreak:;" in stop
# The old udbserver backend closed its socket immediately after the
# stop reply. A register query proves the native session remains live.
stopped_registers = client.read_registers()
stopped_pc = struct.unpack("<I", bytes.fromhex(stopped_registers[64:72]))[0]
assert stopped_pc == breakpoint
# A register query after the stop reply verifies that the native
# debugger session remains live.
assert client.read_x86_registers().eip == breakpoint
assert client.query(f"z0,{breakpoint:x},1") == "OK"
assert client.continue_().startswith(("T", "W"))
@@ -385,6 +441,19 @@ def test_gdb_detach(gdb_pause_emulator):
client.close()
def test_gdb_reports_target_fault_and_nonzero_termination(gdb_fault_emulator):
port, proc = gdb_fault_emulator
client = GdbRspClient(port)
try:
client.query_halt_reason()
assert client.continue_().startswith("T0b")
assert client.read_x86_registers().eip != 0
assert client.continue_() == "X0b"
assert proc.wait(timeout=10) == 0
finally:
client.close()
def test_gdb_reports_clean_exit(gdb_exit_emulator):
port, proc = gdb_exit_emulator
client = GdbRspClient(port)
@@ -402,14 +471,12 @@ def test_gdb_single_step(gdb_emulator):
try:
client.query_halt_reason()
registers = client.read_registers()
previous_eip = struct.unpack("<I", bytes.fromhex(registers[64:72]))[0]
previous_eip = client.read_x86_registers().eip
for _ in range(3):
response = client.step()
assert response.startswith(("S", "T"))
registers = client.read_registers()
eip = struct.unpack("<I", bytes.fromhex(registers[64:72]))[0]
eip = client.read_x86_registers().eip
assert eip != previous_eip
previous_eip = eip
+103 -3
View File
@@ -1,7 +1,10 @@
from dataclasses import dataclass
from types import SimpleNamespace
from xml.etree import ElementTree
from speakeasy.gdb import GdbServer, _rsp_escape, _rsp_packet
from unicorn import UC_ARCH_X86, UC_MODE_64, Uc
from speakeasy.gdb import GdbServer, ResumeAction, _rsp_escape, _rsp_packet
from speakeasy.winenv import arch
@@ -15,6 +18,20 @@ class DummyThread:
teb: DummyTeb
class DummyHook:
def __init__(self):
self.enabled = True
self.added = True
self.native_hook = True
self.handle = 1
self.emu_eng = SimpleNamespace(hook_remove=lambda handle: None)
def add(self):
self.enabled = True
self.added = True
self.handle = 1
class DummyModule:
def __init__(self, path, base):
self.emu_path = path
@@ -32,12 +49,21 @@ class DummyEmulator:
def get_arch(self):
return arch.ARCH_AMD64
def add_code_hook(self, callback):
return DummyHook()
def add_mem_read_hook(self, callback):
return DummyHook()
def add_mem_write_hook(self, callback):
return DummyHook()
def make_server():
return GdbServer(DummyEmulator(), 0)
return GdbServer(DummyEmulator(), 0, "127.0.0.1")
def test_server_bind_host_defaults_to_loopback_and_can_be_overridden():
def test_server_uses_provided_bind_host():
assert make_server().host == "127.0.0.1"
assert GdbServer(DummyEmulator(), 0, "0.0.0.0").host == "0.0.0.0"
@@ -112,6 +138,80 @@ def test_packet_extraction_validates_checksum_across_fragmented_input():
assert GdbServer._extract_packet(bytearray(oversized))[2] is False
def test_interrupt_during_resume_transition_is_preserved():
server = make_server()
server.emu.emu_eng = SimpleNamespace(stop=lambda: None)
server._running = True
server._interrupt()
assert not server.begin_run(ResumeAction())
reason = server.finish_run(ResumeAction())
assert reason is not None
assert reason.signal == 2
def test_instruction_and_memory_hooks_are_enabled_only_when_needed():
server = make_server()
server._reply = lambda payload: None
server._install_hooks()
assert not server._code_hook.enabled
assert not server._read_hook.enabled
assert not server._write_hook.enabled
server._change_breakpoint(b"Z0,1000,1")
assert server._code_hook.enabled
server._change_breakpoint(b"z0,1000,1")
assert not server._code_hook.enabled
server._change_breakpoint(b"Z4,2000,4")
assert server._read_hook.enabled
assert server._write_hook.enabled
server._change_breakpoint(b"z4,2000,4")
assert not server._read_hook.enabled
assert not server._write_hook.enabled
def test_access_watchpoint_does_not_clobber_write_watchpoint():
server = make_server()
replies = []
server._reply = replies.append
server._change_breakpoint(b"Z4,1000,4")
server._change_breakpoint(b"Z2,1000,4")
server._change_breakpoint(b"z4,1000,4")
assert (0x1000, 4) not in server._access_watchpoints
assert (0x1000, 4) in server._write_watchpoints
assert replies == [b"OK", b"OK", b"OK"]
def test_access_watchpoint_reports_awatch_stop_reason():
server = make_server()
reasons = []
server._access_watchpoints[(0x1000, 4)] = 4
server._request_stop = reasons.append
server._on_read(None, 0, 0x1001, 1, 0)
assert len(reasons) == 1
assert reasons[0].kind == "awatch"
assert b"awatch:1001;" in server._stop_reply(reasons[0])
def test_x87_registers_use_full_80_bit_wire_format():
emulator = DummyEmulator()
emulator.emu_eng = SimpleNamespace(emu=Uc(UC_ARCH_X86, UC_MODE_64))
server = GdbServer(emulator, 0, "127.0.0.1")
st0_index = next(index for index, register in enumerate(server._registers) if register.name == "st0")
wire_value = (3).to_bytes(8, "little") + (0x7FFF).to_bytes(2, "little")
server._write_register_value(server._registers[st0_index], wire_value)
assert server._read_register_value(st0_index) == wire_value
def test_xfer_escaping_respects_advertised_packet_size():
server = make_server()
server._executable_path = lambda: "$#}*" * 5000