From de34db212da6e6295e86a0cdb9c2e1b9ad7d9bf8 Mon Sep 17 00:00:00 2001 From: retrocpugeek Date: Fri, 7 Aug 2026 22:28:08 +1000 Subject: [PATCH 1/3] gdb: report SIGTRAP (not SIGTERM) on single-step handle_s answered every single-step ('s') with a SIGTERM stop-reply whenever ql.emu_state was QL_STATE.STOPPED. But emu_start always leaves the state STOPPED after running the requested step count, so the guard was true on every step and gdb clients saw a spurious termination signal, disconnecting mid-debug (issues #1377, #1538). Give handle_s the same exit-vs-trap discrimination handle_c already uses: a step reports SIGTRAP unless it carried pc to the emulation exit point, in which case the guest has actually terminated and we reply W{exit_code}. Also wrap the step in the same UcError/KeyboardInterrupt handling as handle_c so a fault while stepping maps to a signal instead of crashing the stub, and hoist the shared uc-error->signal map to a module constant. Add a regression test that single-steps over the gdb stub and asserts the stop-reply is 'S05' (SIGTRAP), which fails as 'S0f' (SIGTERM) without the fix. Co-Authored-By: Claude Opus 4.8 --- qiling/debugger/gdb/gdb.py | 52 ++++++++++++++--------- tests/test_debugger.py | 86 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 19 deletions(-) diff --git a/qiling/debugger/gdb/gdb.py b/qiling/debugger/gdb/gdb.py index f6d6498d8..d31544eec 100644 --- a/qiling/debugger/gdb/gdb.py +++ b/qiling/debugger/gdb/gdb.py @@ -30,7 +30,7 @@ ) from qiling import Qiling -from qiling.const import QL_ARCH, QL_ENDIAN, QL_OS, QL_STATE +from qiling.const import QL_ARCH, QL_ENDIAN, QL_OS from qiling.debugger import QlDebugger from qiling.debugger.gdb.xmlregs import QlGdbFeatures from qiling.debugger.gdb.utils import QlGdbUtils @@ -56,6 +56,21 @@ SIGCONT = 17 SIGSTOP = 18 +# translate a unicorn cpu fault into the closest posix signal, shared by the +# continue ('c') and single-step ('s') handlers when emulation faults +UC_ERROR_SIGMAP = { + UC_ERR_READ_UNMAPPED : SIGSEGV, + UC_ERR_WRITE_UNMAPPED : SIGSEGV, + UC_ERR_FETCH_UNMAPPED : SIGSEGV, + UC_ERR_WRITE_PROT : SIGSEGV, + UC_ERR_READ_PROT : SIGSEGV, + UC_ERR_FETCH_PROT : SIGSEGV, + UC_ERR_READ_UNALIGNED : SIGBUS, + UC_ERR_WRITE_UNALIGNED : SIGBUS, + UC_ERR_FETCH_UNALIGNED : SIGBUS, + UC_ERR_INSN_INVALID : SIGILL +} + # common replies REPLY_ACK = b'+' REPLY_EMPTY = b'' @@ -226,21 +241,8 @@ def handle_c(subcmd: str) -> Reply: try: self.gdb.resume_emu() except UcError as err: - sigmap = { - UC_ERR_READ_UNMAPPED : SIGSEGV, - UC_ERR_WRITE_UNMAPPED : SIGSEGV, - UC_ERR_FETCH_UNMAPPED : SIGSEGV, - UC_ERR_WRITE_PROT : SIGSEGV, - UC_ERR_READ_PROT : SIGSEGV, - UC_ERR_FETCH_PROT : SIGSEGV, - UC_ERR_READ_UNALIGNED : SIGBUS, - UC_ERR_WRITE_UNALIGNED : SIGBUS, - UC_ERR_FETCH_UNALIGNED : SIGBUS, - UC_ERR_INSN_INVALID : SIGILL - } - # determine signal from uc error; default to SIGTERM - reply = f'S{sigmap.get(err.errno, SIGTERM):02x}' + reply = f'S{UC_ERROR_SIGMAP.get(err.errno, SIGTERM):02x}' except KeyboardInterrupt: # emulation was interrupted with ctrl+c @@ -678,11 +680,23 @@ def handle_s(subcmd: str) -> Reply: """Perform a single step. """ - self.gdb.resume_emu(steps=1) + try: + self.gdb.resume_emu(steps=1) + except UcError as err: + # stepping faulted; report the closest posix signal + return f'S{UC_ERROR_SIGMAP.get(err.errno, SIGTERM):02x}' - # if emulation has been stopped, signal program termination - if self.ql.emu_state is QL_STATE.STOPPED: - return f'S{SIGTERM:02x}' + except KeyboardInterrupt: + # emulation was interrupted with ctrl+c + return f'S{SIGINT:02x}' + + # emu_start always leaves emu_state as STOPPED after a step, so that + # cannot tell an ordinary step apart from the guest exiting (see + # issues #1377 and #1538). instead, the guest has terminated only + # when the step carried pc all the way to the emulation exit point. + if getattr(self.ql.arch, 'effective_pc', self.ql.arch.regs.arch_pc) == self.gdb.exit_point: + # program terminated; report its exit code + return f'W{self.ql.os.exit_code:02x}' # otherwise, this is just single stepping return f'S{SIGTRAP:02x}' diff --git a/tests/test_debugger.py b/tests/test_debugger.py index c8df8ae0c..792d8e2f8 100644 --- a/tests/test_debugger.py +++ b/tests/test_debugger.py @@ -44,6 +44,55 @@ def send(self, msg: str): self.__file.flush() +class ReadingGdbClient: + """A minimal gdb remote client that can also read replies, so tests can + assert on the stop-reply packets the server sends back. + """ + + def __init__(self, host: str, port: int): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect((host, port)) + sock.settimeout(10) + + self.__sock = sock + self.__buf = b'' + + def __enter__(self): + return self + + def __exit__(self, ex_type, ex_value, ex_traceback): + self.__sock.close() + + @staticmethod + def checksum(data: str) -> int: + return sum(ord(c) for c in data) & 0xff + + def send(self, msg: str): + self.__sock.sendall(f'${msg}#{ReadingGdbClient.checksum(msg):02x}'.encode('latin')) + + def read_packet(self) -> str: + """Read a single '$#' reply, skipping any '+'/'-' acks. + """ + + # wait for a packet start marker + while b'$' not in self.__buf: + self.__buf += self.__sock.recv(4096) + + start = self.__buf.index(b'$') + + # wait until the terminating '#' and its two checksum digits arrived + while True: + end = self.__buf.find(b'#', start) + if end != -1 and len(self.__buf) >= end + 3: + break + self.__buf += self.__sock.recv(4096) + + data = self.__buf[start + 1:end] + self.__buf = self.__buf[end + 3:] + + return data.decode('latin') + + class DebuggerTest(unittest.TestCase): def test_gdbdebug_file_server(self): ql = Qiling(["../examples/rootfs/x8664_linux/bin/x8664_hello"], "../examples/rootfs/x8664_linux", verbose=QL_VERBOSE.DEBUG) @@ -168,6 +217,43 @@ def gdb_test_client(): ql.run() del ql + def test_gdbdebug_stepi_reports_sigtrap(self): + # regression for issues #1377 and #1538: a single-step ('s') used to be + # answered with a SIGTERM stop-reply because emu_state is always STOPPED + # after a step, which made gdb clients believe the program had died. the + # stop-reply for an ordinary step must be a SIGTRAP ('S05'), never a + # SIGTERM ('S0f'). + ql = Qiling(["../examples/rootfs/x8664_linux/bin/x8664_hello"], "../examples/rootfs/x8664_linux", verbose=QL_VERBOSE.OFF) + ql.debugger = 'gdb:127.0.0.1:9996' + + replies = [] + + def gdb_test_client(): + # yield to allow ql to launch its gdbserver + time.sleep(1.337 * 2) + + with ReadingGdbClient('127.0.0.1', 9996) as client: + client.send('qSupported:multiprocess+;swbreak+;hwbreak+;vContSupported+;xmlRegisters=i386') + client.read_packet() + client.send('QStartNoAckMode') + client.read_packet() + + # step a few instructions; every reply must be a SIGTRAP stop + for _ in range(3): + client.send('s') + replies.append(client.read_packet()) + + client.send('k') + + thread = threading.Thread(target=gdb_test_client, daemon=True) + thread.start() + + ql.run() + thread.join(timeout=30) + del ql + + self.assertEqual(replies, ['S05', 'S05', 'S05']) + def test_gdbdebug_shellcode_server(self): X8664_LIN = bytes.fromhex('31c048bbd19d9691d08c97ff48f7db53545f995257545eb03b0f05') From c5a8a9b73ea4ceb40b424a9a3373a11beddc077f Mon Sep 17 00:00:00 2001 From: retrocpugeek Date: Sun, 12 Jul 2026 19:15:25 +1000 Subject: [PATCH 2/3] gdb: support async interrupt (ctrl-c) to break into a running guest The stub drove continue by calling emu_start synchronously and only read the socket again once the target stopped on its own, so the bare \x03 break byte a client sends to pause a running target was never seen. A guest that free-runs (e.g. an idle/event loop) could not be interrupted at all -- gdb/Ghidra reported 'Cannot execute this command while the target is running'. Poll the client socket for the break byte from the per-instruction run hook (dbg_hook), which does run on the emulation thread during emu_start: - GdbSerialConn.poll_interrupt(): non-blocking select+recv, True on \x03. - QlGdbUtils.dbg_hook: throttled (every INTR_POLL_INTERVAL insns) check of an installed check_interrupt callback; on a break, stop emulation and record it. - handle_c: reply SIGINT when the stop was an interrupt rather than a breakpoint or normal exit. Add a regression test that lets an infinite-loop guest free-run, sends the bare break byte and asserts the stop-reply is 'S02' (SIGINT). Without the fix no reply ever arrives, so the test stops the guest itself and fails rather than hanging the run. Also verified against a free-running MIPS64 BE guest: \x03 -> S02 in <1ms. Co-Authored-By: Claude Opus 4.8 (1M context) --- qiling/debugger/gdb/gdb.py | 31 +++++++++++++++++++- qiling/debugger/gdb/utils.py | 35 +++++++++++++++++++++++ tests/test_debugger.py | 55 ++++++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 1 deletion(-) diff --git a/qiling/debugger/gdb/gdb.py b/qiling/debugger/gdb/gdb.py index d31544eec..c6f591a5f 100644 --- a/qiling/debugger/gdb/gdb.py +++ b/qiling/debugger/gdb/gdb.py @@ -15,6 +15,7 @@ import os import socket +import select import re import tempfile from functools import partial @@ -139,6 +140,10 @@ def run(self): server = GdbSerialConn(self.ip, self.port, self.ql.log) killed = False + # let the run hook break into a free-running guest when the client sends + # an async interrupt (ctrl-c / \x03); see QlGdbUtils.dbg_hook. + self.gdb.check_interrupt = server.poll_interrupt + def __hexstr(value: int, nibbles: int = 0) -> str: """Encode a value into a hex string. """ @@ -249,7 +254,10 @@ def handle_c(subcmd: str) -> Reply: reply = f'S{SIGINT:02x}' else: - if getattr(self.ql.arch, 'effective_pc', self.ql.arch.regs.arch_pc) == self.gdb.last_bp: + if self.gdb.interrupted: + # emulation was stopped by an async interrupt from the client + reply = f'S{SIGINT:02x}' + elif getattr(self.ql.arch, 'effective_pc', self.ql.arch.regs.arch_pc) == self.gdb.last_bp: # emulation stopped because it hit a breakpoint reply = f'S{SIGTRAP:02x}' else: @@ -837,6 +845,27 @@ def close(self): self.client.close() self.sock.close() + def poll_interrupt(self) -> bool: + """Non-blocking check for an async interrupt from the client. + + While the target is running the only thing gdb sends is a bare ``\\x03`` + break byte (it waits for a stop reply before sending anything else), so + any readable bytes here are that interrupt or a stray protocol ack. + Returns True if a break was seen. Called from the run hook, so it must + never block. + """ + + readable, _, _ = select.select([self.client], [], [], 0) + if not readable: + return False + + try: + incoming = self.client.recv(self.BUFSIZE) + except (ConnectionError, OSError): + return False + + return b'\x03' in incoming + def readpackets(self) -> Iterator[bytes]: """Iterate through incoming packets in an active connection until it is terminated. diff --git a/qiling/debugger/gdb/utils.py b/qiling/debugger/gdb/utils.py index 5e3b208bf..6413d1eed 100644 --- a/qiling/debugger/gdb/utils.py +++ b/qiling/debugger/gdb/utils.py @@ -14,6 +14,11 @@ class QlGdbUtils: + # how often (in guest instructions) the run hook polls the client socket for + # an async interrupt. small enough to feel instant, large enough that the + # extra non-blocking socket check does not dominate the per-instruction hook. + INTR_POLL_INTERVAL = 200 + def __init__(self, ql: Qiling, entry_point: int, exit_point: int): self.ql = ql @@ -21,6 +26,14 @@ def __init__(self, ql: Qiling, entry_point: int, exit_point: int): self.swbp = set() self.last_bp = None + # async-interrupt support: `check_interrupt` is a callable installed by the + # gdb stub that returns True when the client sent a break (ctrl-c / \x03) + # while the target was running. `interrupted` records that the last resume + # stopped for that reason (rather than a breakpoint or normal exit). + self.check_interrupt = None + self.interrupted = False + self._poll_counter = 0 + def __entry_point_hook(ql: Qiling): ql.hook_del(ep_hret) ql.hook_code(self.dbg_hook) @@ -36,6 +49,24 @@ def dbg_hook(self, ql: Qiling, address: int, size: int): if getattr(ql.arch, 'is_thumb', False): address |= 1 + # poll for an async interrupt from the client (gdb sends a bare \x03 while + # the target is running). throttled so the socket check stays off the hot + # path. this is the only way to break into a free-running guest, since the + # stub's packet loop is blocked inside emu_start until the target stops. + if self.check_interrupt is not None: + self._poll_counter += 1 + + if self._poll_counter >= self.INTR_POLL_INTERVAL: + self._poll_counter = 0 + + if self.check_interrupt(): + self.interrupted = True + self.last_bp = None + + ql.log.info(f'{PROMPT} interrupted by client, stopped at {address:#x}') + ql.stop() + return + # resuming emulation after hitting a breakpoint will re-enter this hook. # avoid an endless hooking loop by detecting and skipping this case if address == self.last_bp: @@ -83,4 +114,8 @@ def resume_emu(self, address: Optional[int] = None, steps: int = 0): op = f'stepping {steps} instructions' if steps else 'resuming' self.ql.log.info(f'{PROMPT} {op} from {address:#x}') + # clear any pending interrupt state from a previous resume + self.interrupted = False + self._poll_counter = 0 + self.ql.emu_start(address, self.exit_point, count=steps) diff --git a/tests/test_debugger.py b/tests/test_debugger.py index 792d8e2f8..6219cca41 100644 --- a/tests/test_debugger.py +++ b/tests/test_debugger.py @@ -70,6 +70,12 @@ def checksum(data: str) -> int: def send(self, msg: str): self.__sock.sendall(f'${msg}#{ReadingGdbClient.checksum(msg):02x}'.encode('latin')) + def send_break(self): + """Send an async interrupt: a bare '\x03' byte, outside of packet framing. + """ + + self.__sock.sendall(b'\x03') + def read_packet(self) -> str: """Read a single '$#' reply, skipping any '+'/'-' acks. """ @@ -254,6 +260,55 @@ def gdb_test_client(): self.assertEqual(replies, ['S05', 'S05', 'S05']) + def test_gdbdebug_async_interrupt(self): + # a free-running guest could not be interrupted at all: the stub is blocked + # inside emu_start while the target runs, so the bare '\x03' break byte a + # client sends to pause it was never read, and clients reported 'Cannot + # execute this command while the target is running'. a break must stop the + # guest and yield a SIGINT ('S02') stop-reply. + INFINITE_LOOP = bytes.fromhex('90ebfd') # nop ; jmp -3 + + ql = Qiling(code=INFINITE_LOOP, archtype=QL_ARCH.X8664, ostype=QL_OS.LINUX, verbose=QL_VERBOSE.OFF) + ql.debugger = 'gdb:127.0.0.1:9994' + + replies = [] + + def gdb_test_client(): + # yield to allow ql to launch its gdbserver + time.sleep(1.337 * 2) + + with ReadingGdbClient('127.0.0.1', 9994) as client: + client.send('qSupported:multiprocess+;swbreak+;hwbreak+;vContSupported+;xmlRegisters=i386') + client.read_packet() + client.send('QStartNoAckMode') + client.read_packet() + + # let the guest free-run; it never stops on its own + client.send('c') + + # give it time to spin, then break into it + time.sleep(1.337) + client.send_break() + + try: + replies.append(client.read_packet()) + except OSError: + # the stub ignored the break and the guest is still spinning. + # stop it from here so the assertion below reports the failure + # instead of hanging the test run forever + ql.stop() + + client.send('k') + + thread = threading.Thread(target=gdb_test_client, daemon=True) + thread.start() + + ql.run() + thread.join(timeout=30) + del ql + + self.assertEqual(replies, ['S02']) + def test_gdbdebug_shellcode_server(self): X8664_LIN = bytes.fromhex('31c048bbd19d9691d08c97ff48f7db53545f995257545eb03b0f05') From a13a8c8c1faf2ed0575999803045d89ab5102c1e Mon Sep 17 00:00:00 2001 From: retrocpugeek Date: Sun, 23 Aug 2026 22:15:04 +1000 Subject: [PATCH 3/3] gdb: accept vCont C/S actions carrying any signal A client that resumes while a signal is pending sends a vCont action of the form 'C' (continue and deliver) or 'S' (step and deliver), e.g. 'vCont;S0f:pa410.1996;c:pa410.-1' -- the exact packet reported in issue #1377. handle_v matched only 'c'/'C05' and 's'/'S05', so any other signal fell through to an empty reply and clients aborted the session with 'Invalid remote reply:'. We do not deliver host signals to the guest, so the signal value carries no meaning for us: accept any of them and carry the action out as a plain resume or step, which is what the client asked for. This matters more now that the stub can stop with SIGINT on an async interrupt, since a client may well resume from such a stop with 'C02'. Also stop assuming os.exit_code exists when reporting termination: bare-metal os layers (QlOsMcu) do not define it, which turns the exit path into an AttributeError (seen in issue #1276). The underlying MCU interrupt handling of #1276 is out of scope here. Add a regression test asserting the '#1377' packet is answered with a SIGTRAP stop-reply and that a signalled continue runs the guest to termination; both replies are empty without the fix. Fixes #1377 Co-Authored-By: Claude Opus 5 (1M context) --- qiling/debugger/gdb/gdb.py | 18 ++++++++++---- tests/test_debugger.py | 49 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/qiling/debugger/gdb/gdb.py b/qiling/debugger/gdb/gdb.py index c6f591a5f..77c0623fa 100644 --- a/qiling/debugger/gdb/gdb.py +++ b/qiling/debugger/gdb/gdb.py @@ -261,8 +261,9 @@ def handle_c(subcmd: str) -> Reply: # emulation stopped because it hit a breakpoint reply = f'S{SIGTRAP:02x}' else: - # emulation has completed successfully - reply = f'W{self.ql.os.exit_code:02x}' + # emulation has completed successfully. note bare-metal os layers + # do not have an exit code (issue #1276) + reply = f'W{getattr(self.ql.os, "exit_code", 0):02x}' return reply @@ -672,10 +673,17 @@ def handle_v(subcmd: str) -> Reply: for grp in groups: cmd, *tid = grp.split(':', maxsplit=1) - if cmd in ('c', f'C{SIGTRAP:02x}'): + # 'C sig' and 'S sig' resume or step while delivering a signal + # to the guest. we do not deliver signals, so the signal value + # is ignored and the action is carried out as a plain resume or + # step. matching only 'C05' and 'S05' here made clients that + # resume with any other pending signal (e.g. 'S0f' after a stop + # reply we sent) receive an empty reply and bail out with + # 'Invalid remote reply' (issue #1377) + if cmd[:1] in ('c', 'C'): return handle_c('') - elif cmd in ('s', f'S{SIGTRAP:02x}'): + elif cmd[:1] in ('s', 'S'): return handle_s('') # FIXME: not sure how to handle multiple command @@ -704,7 +712,7 @@ def handle_s(subcmd: str) -> Reply: # when the step carried pc all the way to the emulation exit point. if getattr(self.ql.arch, 'effective_pc', self.ql.arch.regs.arch_pc) == self.gdb.exit_point: # program terminated; report its exit code - return f'W{self.ql.os.exit_code:02x}' + return f'W{getattr(self.ql.os, "exit_code", 0):02x}' # otherwise, this is just single stepping return f'S{SIGTRAP:02x}' diff --git a/tests/test_debugger.py b/tests/test_debugger.py index 6219cca41..17ae7b8d1 100644 --- a/tests/test_debugger.py +++ b/tests/test_debugger.py @@ -260,6 +260,55 @@ def gdb_test_client(): self.assertEqual(replies, ['S05', 'S05', 'S05']) + def test_gdbdebug_vcont_signal_actions(self): + # regression for issue #1377: a client resuming with a pending signal sends + # a 'vCont;S' or 'vCont;C' action (e.g. 'S0f' after a stop-reply we + # sent). the stub only recognized 'S05' and 'C05', replied empty to anything + # else, and the client bailed out with 'Invalid remote reply'. any signal must + # be accepted and carried out as a plain step or resume. + ql = Qiling(["../examples/rootfs/x8664_linux/bin/x8664_hello"], "../examples/rootfs/x8664_linux", verbose=QL_VERBOSE.OFF) + ql.debugger = 'gdb:127.0.0.1:9995' + + replies = [] + + def gdb_test_client(): + # yield to allow ql to launch its gdbserver + time.sleep(1.337 * 2) + + with ReadingGdbClient('127.0.0.1', 9995) as client: + client.send('qSupported:multiprocess+;swbreak+;hwbreak+;vContSupported+;xmlRegisters=i386') + client.read_packet() + client.send('QStartNoAckMode') + client.read_packet() + + client.send('vCont?') + replies.append(client.read_packet()) + + # step while delivering SIGTERM; this is the packet reported in #1377 + client.send('vCont;S0f:p1.1;c:p1.-1') + replies.append(client.read_packet()) + + # resume while delivering SIGTERM; runs the guest to completion + client.send('vCont;C0f:p1.1') + replies.append(client.read_packet()) + + client.send('k') + + thread = threading.Thread(target=gdb_test_client, daemon=True) + thread.start() + + ql.run() + thread.join(timeout=30) + del ql + + self.assertEqual(replies[0], 'vCont;c;C;s;S') + + # the signalled step is an ordinary step: SIGTRAP, not an empty reply + self.assertEqual(replies[1], 'S05') + + # the signalled resume ran to termination and reported an exit code + self.assertTrue(replies[2].startswith('W'), f'unexpected reply to signalled resume: {replies[2]!r}') + def test_gdbdebug_async_interrupt(self): # a free-running guest could not be interrupted at all: the stub is blocked # inside emu_start while the target runs, so the bare '\x03' break byte a