From 035580490827f558c43e978e13757dc43e6ec250 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 2 Jul 2024 13:51:38 -0300 Subject: [PATCH] Move addition of custom commands to the Debugger-agnostic API (#2254) --- pwndbg/commands/__init__.py | 27 +++---- pwndbg/dbg/__init__.py | 53 +++++++++++-- pwndbg/dbg/gdb.py | 148 +++++++++++++++++++++++++++++++++++- 3 files changed, 204 insertions(+), 24 deletions(-) diff --git a/pwndbg/commands/__init__.py b/pwndbg/commands/__init__.py index efef6485a..64b2984eb 100644 --- a/pwndbg/commands/__init__.py +++ b/pwndbg/commands/__init__.py @@ -90,7 +90,7 @@ GDB_BUILTIN_COMMANDS = list_current_commands() pwndbg_is_reloading = getattr(gdb, "pwndbg_is_reloading", False) -class Command(gdb.Command): +class Command: """Generic command wrapper""" builtin_override_whitelist: Set[str] = {"up", "down", "search", "pwd", "start", "ignore"} @@ -114,7 +114,10 @@ class Command(gdb.Command): if command_name is None: command_name = function.__name__ - super().__init__(command_name, gdb.COMMAND_USER, gdb.COMPLETE_EXPRESSION, prefix=prefix) + def _handler(_debugger, arguments, is_interactive): + self.invoke(arguments, is_interactive) + + self.handle = pwndbg.dbg.add_command(command_name, _handler) self.function = function if command_name in command_names: @@ -141,7 +144,7 @@ class Command(gdb.Command): A ``(tuple, dict)``, in the form of ``*args, **kwargs``. The contents of the tuple/dict are undefined. """ - return gdb.string_to_argv(argument), {} + return pwndbg.dbg.lex_args(argument), {} def invoke(self, argument: str, from_tty: bool) -> None: """Invoke the command with an argument string""" @@ -170,23 +173,13 @@ class Command(gdb.Command): if not from_tty: return False - lines = gdb.execute("show commands", from_tty=False, to_string=True) - lines = lines.splitlines() + last_line = pwndbg.dbg.history(1) # No history - if not lines: - return False - - last_line = lines[-1] - number_str, command = last_line.split(maxsplit=1) - try: - number = int(number_str) - except ValueError: - # In rare cases GDB will output a warning after executing `show commands` - # (i.e. "warning: (Internal error: pc 0x0 in read in CU, but not in - # symtab.)"). + if not last_line: return False + number, command = last_line[-1] # A new command was entered by the user if number not in Command.history: Command.history[number] = command @@ -547,7 +540,7 @@ class _ArgparsedCommand(Command): ) def split_args(self, argument: str): - argv = gdb.string_to_argv(argument) + argv = pwndbg.dbg.lex_args(argument) return (), vars(self.parser.parse_args(argv)) diff --git a/pwndbg/dbg/__init__.py b/pwndbg/dbg/__init__.py index 4751bac1f..9ac7c41e7 100644 --- a/pwndbg/dbg/__init__.py +++ b/pwndbg/dbg/__init__.py @@ -5,31 +5,74 @@ The abstracted debugger interface. from __future__ import annotations from typing import Any +from typing import Callable +from typing import List from typing import Tuple dbg: Debugger = None +class CommandHandle: + """ + An opaque handle to an installed command. + """ + + def remove(self) -> None: + """ + Removes this command from the command palette of the debugger. + """ + raise NotImplementedError() + + class Debugger: """ - The base class + The base class representing a debugger. """ def setup(self, *args: Any) -> None: """ Perform debugger-specific initialization. + This method should be run immediately after `pwndbg.dbg` is set to an + instance of this class, and, as such, is allowed to run code that + depends on it being set. + Because we can't really know what a given debugger object will need as part of its setup process, we allow for as many arguments as desired to be passed in, and leave it up to the implementations to decide what they - need. - - This shouldn't be a problem, seeing as, unlike other methods in this - class, this should only be called as part of the debugger-specific + need. This shouldn't be a problem, seeing as, unlike other methods in + this class, this should only be called as part of the debugger-specific bringup code. """ raise NotImplementedError() + def history(self, last: int = 10) -> List[Tuple[int, str]]: + """ + The command history of the interactive session in this debugger. + + This function returns the last `last` items in the command history, as + an oldest-to-youngest-sorted list of tuples, where the first element in + each tuple is the index of the command in the history, and the second + element is a string giving the command itself. + """ + raise NotImplementedError() + + def lex_args(self, command_line: str) -> List[str]: + """ + Lexes the given command line into a list of arguments, according to the + conventions of the debugger being used and of the interactive session. + """ + raise NotImplementedError() + + def add_command( + self, name: str, handler: Callable[[Debugger, str, bool], None] + ) -> CommandHandle: + """ + Adds a command with the given name to the debugger, that invokes the + given function every time it is called. + """ + raise NotImplementedError() + # WARNING # # These are hacky parts of the API that were strictly necessary to bring up diff --git a/pwndbg/dbg/gdb.py b/pwndbg/dbg/gdb.py index 08256597c..fbe4ee4d7 100644 --- a/pwndbg/dbg/gdb.py +++ b/pwndbg/dbg/gdb.py @@ -2,9 +2,11 @@ from __future__ import annotations import signal from typing import Any +from typing import List from typing import Tuple import gdb +from typing_extensions import Callable from typing_extensions import override import pwndbg @@ -13,7 +15,30 @@ import pwndbg.gdblib from pwndbg.commands import load_commands from pwndbg.gdblib import gdb_version from pwndbg.gdblib import load_gdblib -from pwndbg.gdblib import prompt + + +class GDBCommand(gdb.Command): + def __init__( + self, + debugger: GDB, + name: str, + handler: Callable[[pwndbg.dbg_mod.Debugger, str, bool], None], + ): + self.debugger = debugger + self.handler = handler + super().__init__(name, gdb.COMMAND_USER, gdb.COMPLETE_EXPRESSION) + + def invoke(self, args: str, from_tty: bool) -> None: + self.handler(self.debugger, args, from_tty) + + +class GDBCommandHandle(pwndbg.dbg_mod.CommandHandle): + def __init__(self, command: gdb.Command): + self.command = command + + def remove(self) -> None: + # GDB doesn't support command removal. + pass class GDB(pwndbg.dbg_mod.Debugger): @@ -22,6 +47,13 @@ class GDB(pwndbg.dbg_mod.Debugger): load_gdblib() load_commands() + # Importing `pwndbg.gdblib.prompt` ends up importing code that has the + # side effect of setting a command up. Because command setup requires + # `pwndbg.dbg` to already be set, and this module is used as part of the + # process of setting it, we have to wait, and do the import as part of + # this method. + from pwndbg.gdblib import prompt + prompt.set_prompt() pre_commands = f""" @@ -69,7 +101,119 @@ class GDB(pwndbg.dbg_mod.Debugger): config_mod.init_params() - pwndbg.gdblib.prompt.show_hint() + prompt.show_hint() + + @override + def add_command( + self, name: str, handler: Callable[[pwndbg.dbg_mod.Debugger, str, bool], None] + ) -> pwndbg.dbg_mod.CommandHandle: + command = GDBCommand(self, name, handler) + return GDBCommandHandle(command) + + @override + def history(self, last: int = 10) -> List[Tuple[int, str]]: + # GDB displays commands in groups of 10. We might want more than that, + # so we fetch multiple blocks of 10 and assemble them into the final + # history in a second step. + parsed_blocks = [] + parsed_lines_count = 0 + parsed_lines_min = None + parsed_lines_max = None + parsed_lines_base = None + + while parsed_lines_count < last: + # Fetch and parse the block we're currently interested in. + base = f" {parsed_lines_base}" if parsed_lines_base else "" + + lines = gdb.execute(f"show commands{base}", from_tty=False, to_string=True) + lines = lines.splitlines() + + parsed_lines = [] + for line in lines: + number_str, command = line.split(maxsplit=1) + try: + number = int(number_str) + except ValueError: + # In rare cases GDB will output a warning after executing `show commands` + # (i.e. "warning: (Internal error: pc 0x0 in read in CU, but not in + # symtab.)"). + return [] + + parsed_lines.append((number, command)) + + # We have nothing more to parse if GDB gives us nothing here. + if len(parsed_lines) == 0: + break + + # Set the maximum command index we know about. This is simply the + # last element of the first block. + if not parsed_lines_max: + parsed_lines_max = parsed_lines[-1][0] + + # Keep track of the minimum command index we've seen. + # + # This is usually the first element in the most recent block, but + # GDB isn't very clear about whether running commands with + # `gdb.execute` affects the command history, and what the exact size + # of the command history is. This means that, at the very end, the + # first index in the last block might be one greater than the last + # index in the second-to-last block. + # + # Additionally, the value of the first element being greater than + # the minimum also means that we reached the end of the command + # history on the last block, can break out of the loop early, and + # don't even need to bother with this block. + if parsed_lines_min: + if parsed_lines[0][0] < parsed_lines_min: + parsed_lines_min = parsed_lines[0][0] + else: + break + else: + parsed_lines_min = parsed_lines[0][0] + + parsed_blocks.append(parsed_lines) + parsed_lines_count += len(parsed_lines) + + # If we've just pulled the block with command index 0, we know we + # can't possibly go back any farther. + if parsed_lines_base == 0: + break + + # The way GDB displays the command history is _weird_. The argument + # we pass to `show commands ` is the index of the 6th element + # in the block, meaning we'll get a block whose values range from + # at most - 5 to at most + 4, inclusive. + # + # Given that we want the first element in this block to the just one + # past the maximum range of the block returned by the next arguemnt, + # and that we know the last element in a block is at most + 4, + # we can subtract five from its index to land in the right spot. + parsed_lines_base = max(0, parsed_lines[0][0] - 5) + + # We've got nothing. + if len(parsed_blocks) == 0: + return [] + + # Sort the elements in the block into the final history array. + remaining = parsed_lines_max - parsed_lines_min + 1 + plines: List[Tuple[int, str]] = [None] * remaining + while remaining > 0 and len(parsed_blocks) > 0: + block = parsed_blocks.pop() + for pline in block: + index = pline[0] - parsed_lines_min + if not plines[index]: + plines[pline[0] - parsed_lines_min] = pline + remaining -= 1 + + # If this fails, either some of our assumptions were wrong, or GDB is + # doing something funky with the output, either way, not good. + assert remaining == 0, "There are gaps in the command history" + + return plines[-last:] + + @override + def lex_args(self, command_line: str) -> List[str]: + return gdb.string_to_argv(command_line) @override def addrsz(self, address: Any) -> str: