Files
Willi Ballenthin d17497eb7a Move function-level imports to module level
None of these imports breaks an import cycle. The import of types in
kernel_mods stays inside the function, because a module-level import
would add the types module to the globals that _get_kmods scans.
2026-10-11 20:13:28 +01:00

769 lines
22 KiB
Python

import os
import socket
import struct
import subprocess
import sys
import textwrap
import time
from dataclasses import dataclass
import capstone
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)
self.sock.settimeout(timeout)
self.sock.connect(("127.0.0.1", port))
def close(self):
self.sock.close()
def _checksum(self, data: str) -> str:
return f"{sum(ord(c) for c in data) & 0xFF:02x}"
def send(self, data: str) -> str:
packet = f"${data}#{self._checksum(data)}"
self.sock.sendall(packet.encode())
return self._recv()
@staticmethod
def _decode_rle(data: str) -> str:
"""Decode GDB RSP run-length encoding.
x*N means: one explicit x, then (ord(N)-29) additional copies.
"""
result = []
i = 0
while i < len(data):
if i + 2 < len(data) and data[i + 1] == "*":
repeat = ord(data[i + 2]) - 29
result.append(data[i] * (repeat + 1))
i += 3
else:
result.append(data[i])
i += 1
return "".join(result)
def _recv(self) -> str:
buf = b""
while b"#" not in buf or len(buf) < buf.index(b"#") + 3:
chunk = self.sock.recv(4096)
if not chunk:
break
buf += chunk
ack_end = 0
while ack_end < len(buf) and buf[ack_end : ack_end + 1] == b"+":
ack_end += 1
buf = buf[ack_end:]
if buf.startswith(b"$"):
end = buf.index(b"#")
return self._decode_rle(buf[1:end].decode())
return self._decode_rle(buf.decode())
def query_halt_reason(self) -> str:
return self.send("?")
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}")
def continue_(self) -> str:
return self.send("c")
def step(self) -> str:
return self.send("s")
def query(self, packet: str) -> str:
return self.send(packet)
def send_no_wait(self, data: str):
packet = f"${data}#{self._checksum(data)}"
self.sock.sendall(packet.encode())
def interrupt(self) -> str:
self.sock.sendall(b"\x03")
return self._recv()
def _find_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def _port_is_listening(port: int) -> bool:
"""Check if a port is in use without consuming any connection."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
try:
s.bind(("127.0.0.1", port))
return False
except OSError:
return True
def _wait_for_port(port: int, proc: subprocess.Popen, timeout: float = 15.0):
"""Wait until port is listening, or the subprocess dies."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if proc.poll() is not None:
_, stderr = proc.communicate(timeout=5)
pytest.fail(f"GDB server exited early (rc={proc.returncode}): {stderr.decode(errors='replace')}")
if _port_is_listening(port):
return
time.sleep(0.1)
proc.kill()
_, stderr = proc.communicate(timeout=5)
pytest.fail(f"GDB server did not start within {timeout}s: {stderr.decode(errors='replace')}")
_PAUSE_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"\\xeb\\xfe", arch="x86")
se.run_shellcode(address)
se.shutdown()
""")
_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()
""")
_MEMORY_FAULT_SERVER_SCRIPT = textwrap.dedent("""\
import json
import sys
from speakeasy import Speakeasy
port = int(sys.argv[1])
config_path = sys.argv[2]
shellcode = bytes.fromhex(sys.argv[3])
with open(config_path) as f:
cfg = json.load(f)
se = Speakeasy(config=cfg, gdb_port=port)
address = se.load_shellcode(data=shellcode, arch="x86")
print(hex(address), flush=True)
se.run_shellcode(address)
se.shutdown()
""")
_EXIT_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"\\xc3", arch="x86")
se.run_shellcode(address)
se.shutdown()
""")
_EXIT_PROCESS_SERVER_SCRIPT = textwrap.dedent("""\
import json
import struct
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)
# push 0; mov eax, ExitProcess; call eax; jmp $
address = se.load_shellcode(data=b"\\x6a\\x00\\xb8\\x00\\x00\\x00\\x00\\xff\\xd0\\xeb\\xfe", arch="x86")
exit_process = se.emu.get_proc("kernel32", "ExitProcess")
se.mem_write(address + 3, struct.pack("<I", exit_process))
se.run_shellcode(address)
se.shutdown()
""")
_LIBRARY_LOAD_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\\xeb\\xfe", arch="x86")
loaded = []
def load_once(*args, **kwargs):
if not loaded:
loaded.append(True)
se.emu.load_module_by_name("gdbtestlib")
se.emu.add_code_hook(cb=load_once)
se.run_shellcode(address)
se.shutdown()
""")
_WATCHPOINT_SERVER_SCRIPT = textwrap.dedent("""\
import json
import struct
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" * 12, arch="x86")
scratch = se.mem_alloc(0x1000)
se.mem_write(scratch, b"\\0" * 4)
# inc dword [scratch]; mov eax, 42; ret
code = b"\\xff\\x05" + struct.pack("<I", scratch) + b"\\xb8\\x2a\\x00\\x00\\x00\\xc3"
se.mem_write(address, code)
print(hex(address), hex(scratch), flush=True)
se.run_shellcode(address)
se.shutdown()
""")
_SERVER_SCRIPT = textwrap.dedent("""\
import json
import lzma
import sys
from speakeasy import Speakeasy
port = int(sys.argv[1])
config_path = sys.argv[2]
bin_path = sys.argv[3]
host = sys.argv[4]
with open(config_path) as f:
cfg = json.load(f)
with lzma.open(bin_path) as f:
data = f.read()
se = Speakeasy(config=cfg, gdb_port=port, gdb_host=host)
module = se.load_module(data=data)
se.run_module(module, all_entrypoints=True)
se.shutdown()
""")
def _start_module_server(filename: str, host: str = "127.0.0.1") -> tuple[int, subprocess.Popen]:
port = _find_free_port()
config_path = os.path.join(TESTS_DIR, "test.json")
bin_path = os.path.join(TESTS_DIR, "bins", filename)
proc = subprocess.Popen(
[sys.executable, "-c", _SERVER_SCRIPT, str(port), config_path, bin_path, host],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
_wait_for_port(port, proc)
return port, proc
def _stop_server(proc: subprocess.Popen):
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=5)
@pytest.fixture
def gdb_emulator():
"""Start a 32-bit target with GDB enabled in an isolated subprocess."""
port, proc = _start_module_server("dll_test_x86.dll.xz")
yield port
_stop_server(proc)
@pytest.fixture
def gdb_x64_emulator():
port, proc = _start_module_server("dll_test_x64.dll.xz")
yield port
_stop_server(proc)
def test_gdb_connect_and_read_registers(gdb_emulator):
port = gdb_emulator
client = GdbRspClient(port)
try:
reason = client.query_halt_reason()
assert reason
regs = client.read_registers()
assert len(regs) > 0
assert all(char in "0123456789abcdefxx" for char in regs.lower())
assert client.read_x86_registers().eip != 0
client.continue_()
finally:
client.close()
def test_gdb_read_memory(gdb_emulator):
port = gdb_emulator
client = GdbRspClient(port)
try:
client.query_halt_reason()
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())
client.continue_()
finally:
client.close()
def test_gdb_ida_process_metadata(gdb_emulator):
client = GdbRspClient(gdb_emulator)
try:
supported = client.query("qSupported:multiprocess+;xmlRegisters=i386")
assert "qXfer:libraries:read+" in supported
assert "qXfer:exec-file:read+" in supported
assert "qXfer:threads:read+" in supported
target = client.query("qXfer:features:read:target.xml:0,4000")
assert "<architecture>i386</architecture>" in target
assert 'name="eip" bitsize="32"' in target
libraries = client.query("qXfer:libraries:read::0,4000")
assert libraries.startswith("l<library-list")
assert "kernel32.dll" in libraries
assert '<segment address="0x' in libraries
executable = client.query("qXfer:exec-file:read::0,4000")
assert executable.startswith("lC:\\")
assert executable.endswith((".exe", ".dll", ".sys"))
threads = client.query("qXfer:threads:read::0,4000")
assert threads == 'l<threads><thread id="1" name="main"/></threads>'
assert client.query("qGetTIBAddr:1") != "0"
client.continue_()
finally:
client.close()
@pytest.fixture
def gdb_pause_emulator():
port = _find_free_port()
config_path = os.path.join(TESTS_DIR, "test.json")
proc = subprocess.Popen(
[sys.executable, "-c", _PAUSE_SERVER_SCRIPT, str(port), config_path],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
_wait_for_port(port, proc)
yield port
_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_memory_fault_emulator(request):
port = _find_free_port()
config_path = os.path.join(TESTS_DIR, "test.json")
proc = subprocess.Popen(
[sys.executable, "-c", _MEMORY_FAULT_SERVER_SCRIPT, str(port), config_path, request.param],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
_wait_for_port(port, proc)
assert proc.stdout is not None
address = int(proc.stdout.readline(), 16)
yield port, proc, address
_stop_server(proc)
@pytest.fixture
def gdb_exit_emulator():
port = _find_free_port()
config_path = os.path.join(TESTS_DIR, "test.json")
proc = subprocess.Popen(
[sys.executable, "-c", _EXIT_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_process_emulator():
port = _find_free_port()
config_path = os.path.join(TESTS_DIR, "test.json")
proc = subprocess.Popen(
[sys.executable, "-c", _EXIT_PROCESS_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_library_load_emulator():
port = _find_free_port()
config_path = os.path.join(TESTS_DIR, "test.json")
proc = subprocess.Popen(
[sys.executable, "-c", _LIBRARY_LOAD_SERVER_SCRIPT, str(port), config_path],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
_wait_for_port(port, proc)
yield port
_stop_server(proc)
@pytest.fixture
def gdb_watchpoint_emulator():
port = _find_free_port()
config_path = os.path.join(TESTS_DIR, "test.json")
proc = subprocess.Popen(
[sys.executable, "-c", _WATCHPOINT_SERVER_SCRIPT, str(port), config_path],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
_wait_for_port(port, proc)
assert proc.stdout is not None
address, scratch = (int(value, 16) for value in proc.stdout.readline().split())
yield port, proc, address, scratch
_stop_server(proc)
def test_gdb_breakpoint_stop_keeps_session_alive(gdb_emulator):
client = GdbRspClient(gdb_emulator)
try:
client.query_halt_reason()
client.step()
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
assert client.query(f"Z0,{breakpoint:x},1") == "OK"
stop = client.continue_()
assert stop.startswith("T05")
assert "swbreak:;" in stop
# 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"))
finally:
client.close()
def test_gdb_reports_runtime_library_load(gdb_library_load_emulator):
client = GdbRspClient(gdb_library_load_emulator)
try:
client.query_halt_reason()
libraries = client.query("qXfer:libraries:read::0,4000")
assert libraries.startswith("l<library-list")
assert "gdbtestlib.dll" not in libraries
stop = client.continue_()
assert stop.startswith("T05")
assert "library:;" in stop
libraries = client.query("qXfer:libraries:read::0,4000")
assert "gdbtestlib.dll" in libraries
# The library change was reported; the next stop must not repeat it.
client.send_no_wait("c")
time.sleep(0.05)
stop = client.interrupt()
assert stop.startswith("T02")
assert "library:;" not in stop
finally:
client.close()
def test_gdb_ctrl_c_pauses_running_target(gdb_pause_emulator):
client = GdbRspClient(gdb_pause_emulator)
try:
client.query_halt_reason()
client.send_no_wait("c")
time.sleep(0.05)
stop = client.interrupt()
assert stop.startswith("T02")
assert client.read_registers()
finally:
client.close()
def test_gdb_x64_target_description_and_registers(gdb_x64_emulator):
client = GdbRspClient(gdb_x64_emulator)
try:
target = client.query("qXfer:features:read:target.xml:0,4000")
assert target.startswith("l")
assert "<architecture>i386:x86-64</architecture>" in target
assert 'name="rip" bitsize="64"' in target
assert 'name="xmm15" bitsize="128"' in target
assert len(client.read_registers()) == 1072
finally:
client.close()
def test_gdb_detach(gdb_pause_emulator):
client = GdbRspClient(gdb_pause_emulator)
try:
client.query_halt_reason()
assert client.query("D") == "OK"
finally:
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()
@pytest.mark.parametrize(
("gdb_memory_fault_emulator", "fault_offset"),
[
pytest.param("a110000000c3", 0, id="invalid_read"),
pytest.param("a310000000c3", 0, id="invalid_write"),
pytest.param("b810000000ffe0", None, id="invalid_fetch"),
],
indirect=["gdb_memory_fault_emulator"],
)
def test_gdb_stops_at_handled_memory_fault(gdb_memory_fault_emulator, fault_offset):
port, proc, address = gdb_memory_fault_emulator
client = GdbRspClient(port)
try:
client.query_halt_reason()
assert client.continue_().startswith("T0b")
expected_pc = 0x10 if fault_offset is None else address + fault_offset
assert client.read_x86_registers().eip == expected_pc
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)
try:
client.query_halt_reason()
stop = client.continue_()
assert stop.startswith("T05")
assert client.read_memory(client.read_x86_registers().esp, 4)
assert client.continue_() == "W00"
assert proc.wait(timeout=10) == 0
finally:
client.close()
def test_gdb_stops_after_exit_process_before_exit_reply(gdb_exit_process_emulator):
port, proc = gdb_exit_process_emulator
client = GdbRspClient(port)
try:
client.query_halt_reason()
start = client.read_x86_registers().eip
stop = client.continue_()
assert stop.startswith("T05")
assert "swbreak:;" not in stop
assert "hwbreak:;" not in stop
# The final stop keeps the state left by ExitProcess: the stack still
# holds the return address of the call.
esp = client.read_x86_registers().esp
assert struct.unpack("<I", bytes.fromhex(client.read_memory(esp, 4)))[0] == start + 9
assert client.read_memory(start, 2) == "6a00"
assert client.continue_() == "W00"
assert proc.wait(timeout=10) == 0
finally:
client.close()
def test_gdb_single_step(gdb_emulator):
port = gdb_emulator
client = GdbRspClient(port)
try:
client.query_halt_reason()
previous_eip = client.read_x86_registers().eip
for _ in range(3):
response = client.step()
assert response.startswith(("S", "T"))
eip = client.read_x86_registers().eip
assert eip != previous_eip
previous_eip = eip
client.continue_()
finally:
client.close()
def test_gdb_hardware_breakpoint_large_range(gdb_emulator):
client = GdbRspClient(gdb_emulator)
try:
client.query_halt_reason()
eip = client.read_x86_registers().eip
for size in (0x1C000, 0x28000, 0x2D000):
assert client.query(f"Z1,{eip:x},{size:x}") == "OK"
assert client.query(f"z1,{eip:x},{size:x}") == "OK"
assert client.query(f"Z1,{eip:x},2d000") == "OK"
stop = client.continue_()
assert stop.startswith("T05")
assert "hwbreak:;" in stop
assert client.read_registers()
assert client.query(f"z1,{eip:x},2d000") == "OK"
assert client.continue_().startswith(("T", "W"))
finally:
client.close()
@pytest.mark.parametrize("resume", ["continue", "step"])
def test_gdb_write_watchpoint_resume_does_not_repeat_guest_side_effect(gdb_watchpoint_emulator, resume):
port, proc, address, scratch = gdb_watchpoint_emulator
client = GdbRspClient(port)
try:
client.query_halt_reason()
assert client.read_x86_registers().eip == address
assert client.read_memory(scratch, 4) == "00000000"
assert client.query(f"Z2,{scratch:x},4") == "OK"
stop = client.continue_() if resume == "continue" else client.step()
assert stop.startswith("T05") and f"watch:{scratch:x};" in stop, stop
# The stop follows the completed write, so resuming does not repeat it.
assert client.read_x86_registers().eip == address + 6
assert client.read_memory(scratch, 4) == "01000000"
assert client.query(f"z2,{scratch:x},4") == "OK"
ret = address + 11
assert client.query(f"Z0,{ret:x},1") == "OK"
stop = client.continue_()
assert stop.startswith("T05") and "swbreak:;" in stop, stop
regs = client.read_x86_registers()
assert (regs.eip, regs.eax) == (ret, 42)
assert client.read_memory(scratch, 4) == "01000000"
assert client.query(f"z0,{ret:x},1") == "OK"
assert client.continue_().startswith("T05")
assert client.read_memory(scratch, 4) == "01000000"
assert client.continue_() == "W00"
assert proc.wait(timeout=10) == 0
finally:
client.close()