diff --git a/qiling/debugger/gdb/gdb.py b/qiling/debugger/gdb/gdb.py index f6d6498d8..77c0623fa 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 @@ -30,7 +31,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 +57,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'' @@ -124,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. """ @@ -226,33 +246,24 @@ 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 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: - # 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 @@ -662,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 @@ -678,11 +696,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}' + + except KeyboardInterrupt: + # emulation was interrupted with ctrl+c + return f'S{SIGINT:02x}' - # if emulation has been stopped, signal program termination - if self.ql.emu_state is QL_STATE.STOPPED: - return f'S{SIGTERM: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{getattr(self.ql.os, "exit_code", 0):02x}' # otherwise, this is just single stepping return f'S{SIGTRAP:02x}' @@ -823,6 +853,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 c8df8ae0c..17ae7b8d1 100644 --- a/tests/test_debugger.py +++ b/tests/test_debugger.py @@ -44,6 +44,61 @@ 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 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. + """ + + # 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 +223,141 @@ 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_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 + # 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')