You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
pwndbg/pwndbg/dbg/lldb/repl/__init__.py

1325 lines
46 KiB
Python

"""
The Pwndbg REPL that is the interface to all debugging on LLDB.
Pwndbg has an event system that allows it to react to events in the process
being debugged, such as when new executable modules get added to the its address
space, when the value of memory and registers change, and pretty much all
possible changes to its execution state. We'd like to have the event system work
the same way under LLDB as it does under GDB.
Fortunately for us, the events types that are native to LLDB map really well to
the event types in GDB and Pwndbg. Very, very unfortunately for us, however,
that's basically where our luck ends.
LLDB, as of version 18, only provides two ways to capture events: registering
directly with the broadcaster, or registering globally. The former is not
available to us in the standard LLDB REPL, as we don't get access to the process
object until after it's been launched[1]. Likewise for the latter, as the
interactive debugger will register to receive the global process state change
events before everyone else, and LLDB doesn't allow for multiple listeners for
the same event bits in the same event class[2].
This leaves us with handling process management ourselves as the only option we
really have to implement event dispatch in Pwndbg. Easy, right? We can just
hijack the commands that deal with target and process creation, and leave
everything else untouched. Unfortunately for us, again, shadowing builtin
commands is simply not allowed[3][4].
So, really, all that's left for us is either implement our own REPL, or get rid
of the event system.
[1]: https://discourse.llvm.org/t/understanding-debugger-launch-events-sequence/39717/2
[2]: https://github.com/llvm/llvm-project/blob/3b5b5c1ec4a3095ab096dd780e84d7ab81f3d7ff/lldb/source/Utility/Broadcaster.cpp#L409
[3]: https://github.com/llvm/llvm-project/blob/3b5b5c1ec4a3095ab096dd780e84d7ab81f3d7ff/lldb/source/Commands/CommandObjectCommands.cpp#L439
[4]: https://github.com/llvm/llvm-project/blob/3b5b5c1ec4a3095ab096dd780e84d7ab81f3d7ff/lldb/source/Interpreter/CommandInterpreter.cpp#L1157
"""
from __future__ import annotations
import argparse
import asyncio
import os
import re
import shutil
import signal
import sys
import threading
from contextlib import contextmanager
from io import BytesIO
from io import TextIOBase
from io import TextIOWrapper
from typing import Any
from typing import Awaitable
from typing import BinaryIO
from typing import Callable
from typing import Coroutine
from typing import List
from typing import Tuple
import lldb
from typing_extensions import override
import pwndbg
import pwndbg.dbg.lldb
from pwndbg.color import message
from pwndbg.dbg import EventType
from pwndbg.dbg.lldb import LLDB
from pwndbg.dbg.lldb import LLDBProcess
from pwndbg.dbg.lldb import OneShotAwaitable
from pwndbg.dbg.lldb.pset import InvalidParse
from pwndbg.dbg.lldb.pset import pget
from pwndbg.dbg.lldb.pset import pset
from pwndbg.dbg.lldb.repl.io import IODriver
from pwndbg.dbg.lldb.repl.io import get_io_driver
from pwndbg.lib.tips import color_tip
from pwndbg.lib.tips import get_tip_of_the_day
HAS_FZF = shutil.which("fzf") is not None
if HAS_FZF:
from pwndbg.dbg.lldb.repl.fuzzy import PROMPT
from pwndbg.dbg.lldb.repl.fuzzy import get_prompt_session
from pwndbg.dbg.lldb.repl.fuzzy import wrap_with_history
else:
from pwndbg.dbg.lldb.repl.readline import PROMPT
from pwndbg.dbg.lldb.repl.readline import enable_readline
from pwndbg.dbg.lldb.repl.readline import wrap_with_history
def print_error(msg: str, *args):
"""
Print an error message in the style of the LLDB CLI.
"""
print(message.error("error:"), msg, *args)
def print_warn(msg: str, *args):
"""
Print a warning message in the style of the LLDB CLI.
"""
print(message.warn("warn:"), msg, *args)
def print_hint(msg: str, *args):
"""
Print a hint message in the style of the LLDB CLI.
"""
print(message.hint("hint:"), msg, *args)
def print_info(msg: str, *args):
"""
Print an information message in the style of the LLDB CLI.
"""
print(message.info("info:"), msg, *args)
from pwndbg.dbg.lldb.repl.proc import EventHandler
from pwndbg.dbg.lldb.repl.proc import LaunchResultConnected
from pwndbg.dbg.lldb.repl.proc import LaunchResultEarlyExit
from pwndbg.dbg.lldb.repl.proc import LaunchResultError
from pwndbg.dbg.lldb.repl.proc import LaunchResultSuccess
from pwndbg.dbg.lldb.repl.proc import ProcessDriver
show_tip = pwndbg.config.add_param(
"show-tips", True, "whether to display the tip of the day on startup"
)
# We only allow certain commands to be executed in LLDB mode. This list contains
# tuples made up of the full name of the command and functions that check if a
# given command matches it.
LLDB_EXCLUSIVE = [
("script", lambda cmd: cmd.startswith("sc") and "script".startswith(cmd)),
("expression", lambda cmd: cmd.startswith("e") and "expression".startswith(cmd)),
]
def lex_args(args: str) -> List[str]:
"""
Splits the arguments, respecting quotation marks.
"""
args = args.strip()
result = []
while len(args) > 0:
first = re.match("\\s*(\".*\"|'.*'|\\S+)", args)
sl = first[1]
# Handle single and double quotes, we could do some escaping for the
# double quotes case, but we don't, yet.
sl = sl.strip('"')
sl = sl.strip("'")
result.append(sl)
args = args[first.end() :]
return result
class EventRelay(EventHandler):
"""
The event system that is sensible for the REPL process driver to use isn't
an exact match with the one used by the rest of Pwndbg. They're close, but
there's a bit of work we have to do to properly convey certain events.
"""
def __init__(self, dbg: LLDB):
self.dbg = dbg
self.ignore_resumed = 0
def _set_ignore_resumed(self, count: int):
"""
Don't relay next given number of resumed events.
"""
self.ignore_resumed += count
@override
def created(self):
self.dbg._trigger_event(EventType.START)
@override
def suspended(self, event: lldb.SBEvent):
# The event might have originated from a different source than the user
# currently has selected. Move focus to the where the event happened.
#
# state-changed events have no thread associated with them, and so
# SBThread::GetThreadFromEvent does not work. Interrogate each thread in
# the process and look for the most interesting one.
proc = lldb.SBProcess.GetProcessFromEvent(event)
for thread in proc.threads:
# Currently the one considered most interesting is simply the first
# that has any reason at all to be stopped.
if thread.stop_reason == lldb.eStopReasonNone:
continue
if proc.GetSelectedThread().idx != thread.idx:
print(message.notice(f"[Switched to Thread {thread.id}]"))
assert proc.SetSelectedThread(thread)
break
self.dbg._trigger_event(EventType.STOP)
@override
def resumed(self):
if self.ignore_resumed > 0:
self.ignore_resumed -= 1
return
self.dbg._trigger_event(EventType.CONTINUE)
@override
def exited(self):
self.dbg._trigger_event(EventType.EXIT)
@override
def modules_loaded(self):
self.dbg._trigger_event(EventType.NEW_MODULE)
def show_greeting() -> None:
"""
Show the Pwndbg greeting, the same way the GDB version of Pwndbg would. This
one is considerably simpler than the GDB version, however, as we control the
lifetime of the program, we know exactly when the greeting needs to be shown,
so we don't bother with any of the lifetime checks.
"""
hint_lines = ("loaded %i pwndbg commands commands." % len(pwndbg.commands.commands),)
for line in hint_lines:
print(message.prompt("pwndbg: ") + message.system(line))
if show_tip:
colored_tip = color_tip(get_tip_of_the_day())
print(
message.prompt("------- tip of the day (some of these don't work in LLDB yet!)")
+ message.system(" (disable with %s)" % message.notice("set show-tips off"))
+ message.prompt(" -------")
)
print(colored_tip)
class YieldExecDirect:
"""
Execute the given command directly, on behalf of the user.
"""
def __init__(self, command: str, capture: bool, prompt_silent: bool):
self._command = command
self._capture = capture
self._prompt_silent = prompt_silent
class YieldInteractive:
"""
Prompt the user for the next command.
"""
pass
class PwndbgController:
"""
Class providing interfaces for a client to control the behavior of Pwndbg
asynchronously.
"""
def interactive(self) -> Awaitable[None]:
"""
Runs a single interactive round, in which the user is prompted for a
command from standard input and `readline`, and whatever command they
type in is executed.
"""
return OneShotAwaitable(YieldInteractive())
def execute(self, command: str) -> Awaitable[None]:
"""
Runs the given command, and displays its output to the user.
# Interactivity
Some commands - such as `lldb` and `ipi` - start interactive prompts
when they are run, and issuing them through this command will not change
that behavior.
"""
return OneShotAwaitable(YieldExecDirect(command, False, False))
def execute_and_capture(self, command: str) -> Awaitable[bytes]:
"""
Runs the given command, and captures its output as a byte string.
# Interactivity
Same caveats apply as in `execute`.
# Reliabily of Capture
Some Pwndbg commands currently do not have their outputs captured, even
when run through this command. It is expected that this will be improved
in the future, but, as as general rule, clients should not rely on the
output of the command being available.
"""
return OneShotAwaitable(YieldExecDirect(command, True, False))
@wrap_with_history
def run(
controller: Callable[..., Coroutine[Any, Any, None]],
*args,
debug: bool = False,
) -> None:
"""
Runs the Pwndbg CLI through the given asynchronous controller.
"""
assert isinstance(pwndbg.dbg, LLDB)
dbg: LLDB = pwndbg.dbg
if HAS_FZF:
session = get_prompt_session(dbg)
else:
enable_readline(dbg)
# We're gonna be dealing with process events ourselves, so we'll want to run
# LLDB in asynchronous mode.
dbg.debugger.SetAsync(True)
# This is the driver we're going to be using to handle the process.
relay = EventRelay(dbg)
driver = ProcessDriver(debug=debug, event_handler=relay)
# Set ourselves up to respond to SIGINT by interrupting the process if it is
# running, and doing nothing otherwise.
def handle_sigint(_sig, _frame):
driver.cancel()
if driver.has_process():
driver.interrupt()
print()
signal.signal(signal.SIGINT, handle_sigint)
show_greeting()
last_command = ""
coroutine = controller(PwndbgController(), *args)
last_result: Any = None
last_exc: Exception | None = None
while True:
# Execute the prompt hook.
dbg._fire_prompt_hook()
try:
if last_exc is not None:
action = coroutine.throw(last_exc)
else:
action = coroutine.send(last_result)
except StopIteration:
# Nothing else for us to do.
break
except asyncio.CancelledError:
# We requested a cancellation that wasn't overwritten.
break
finally:
last_exc = None
last_result = None
if isinstance(action, YieldInteractive):
if debug:
print("[-] REPL: Prompt next command from user interactively")
try:
if HAS_FZF:
try:
line = session.prompt(message=PROMPT)
except KeyboardInterrupt:
continue
else:
line = input(PROMPT)
# If the input is empty (i.e., 'Enter'), use the previous command
if line:
last_command = line
else:
line = last_command
except EOFError:
# Exit the REPL if there's nothing else to run.
last_exc = asyncio.CancelledError()
continue
if not exec_repl_command(line, sys.stdout, dbg, driver, relay):
last_exc = asyncio.CancelledError()
continue
elif isinstance(action, YieldExecDirect):
if debug:
print(
f"[-] REPL: Executing command '{action._command}' {'with' if action._capture else 'without'} output capture"
)
last_command = action._command
if not action._prompt_silent:
print(f"{PROMPT}{action._command}")
try:
if action._capture:
with TextIOWrapper(BytesIO(), write_through=True) as output:
should_continue = exec_repl_command(
action._command, output, dbg, driver, relay
)
last_result = output.buffer.getvalue()
else:
should_continue = exec_repl_command(
action._command, sys.stdout, dbg, driver, relay
)
except BaseException as e:
last_exc = e
continue
if not should_continue:
last_exc = asyncio.CancelledError()
continue
def exec_repl_command(
line: str,
output_to,
dbg: LLDB,
driver: ProcessDriver,
relay: EventRelay,
) -> bool:
"""
Parses and runs the given command, returning whether the event loop should continue.
"""
stdout = None
stderr = None
lldb_out = None
lldb_err = None
try:
stdout = sys.stdout
stderr = sys.stderr
lldb_out = dbg.debugger.GetOutputFile()
lldb_err = dbg.debugger.GetErrorFile()
sys.stdout = output_to
dbg.debugger.SetOutputFile(
lldb.SBFile.Create(output_to, borrow=True, force_io_methods=True)
)
dbg.debugger.SetErrorFile(lldb.SBFile.Create(output_to, borrow=True, force_io_methods=True))
return _exec_repl_command(line, output_to.buffer, dbg, driver, relay)
finally:
if stdout is not None:
sys.stdout = stdout
if stderr is not None:
sys.stderr = stderr
if lldb_out is not None:
dbg.debugger.SetOutputFile(lldb_out)
if lldb_err is not None:
dbg.debugger.SetErrorFile(lldb_err)
def _exec_repl_command(
line: str,
lldb_out_target: BinaryIO,
dbg: LLDB,
driver: ProcessDriver,
relay: EventRelay,
) -> bool:
"""
Implementation for exec_repl_command
"""
bits = lex_args(line)
if len(line) == 0:
return True
# Let the user get an LLDB prompt if they so desire.
if bits[0] == "lldb":
print_warn("You are now entering LLDB mode. To exit, type 'quit', 'exit' or Ctrl-D.")
print_warn(
"In this mode, certain commands may cause Pwndbg to break. Proceed with caution."
)
dbg.debugger.RunCommandInterpreter(
True, False, lldb.SBCommandInterpreterRunOptions(), 0, False, False
)
return True
# Not allowed to have this be a regular command, because of LLDB.
if "version".startswith(line) and line.startswith("ve"):
pwndbg.commands.version.version_impl()
return True
# There are interactive commands that `SBDebugger.HandleCommand` will
# silently ignore. We have to implement them manually, here.
if "quit".startswith(line) and line.startswith("q"):
return False
if "exit".startswith(line) and line.startswith("exi"):
return False
# `script` is a little weird. Unlike with the other commands we're
# emulating, we actually need LLDB to spawn it for it to make sense
# from the perspective of the user. This means we have to make
# special arrangements for it.
#
# There is a way to get LLDB to properly handle interactive commands,
# and that is to start an interactive session with
# `SBDebugger.RunCommandInterpreter`, but that comes with its own
# challenges:
# (1) Starting an interactive session on standard input is the
# best option from the perspective of the user, as they get
# full access to the Python interpreter's readline functions.
# However, we can't start a session running a command, which
# means we open up the possibility of the user breaking
# Pwndbg completely if they type in any process or target
# management commands.
# (2) Setting an input file up for the debugger to use, having
# that input file start the Python interpreter, and piping
# `sys.stdin` to it while the interpreter is running. This
# option is better in that it avoids the possibility of the
# user breaking Pwndbg by mistake, but it breaks both
# readline and input in general for the user.
#
# While neither option is ideal, both can be partially mitigated.
# Option (1) by adding an extra command that drops down to LLDB and
# prints a warning to make the user aware of the risk of breaking
# Pwndbg, and option (2) by making a TextIOBase class that uses input()
# at the REPL level before piping that to the Python interpreter running
# under LLDB.
#
# Currently, we go with the mitigated version of option (1), but option
# (2) might still be on the table for the near future.
#
# Likewise for the other commands we barr here.
found_barred = False
for name, test in LLDB_EXCLUSIVE:
if not test(bits[0]):
continue
print_error(
f"The '{name}' command is not supported. Use the 'lldb' command to enter LLDB mode and try again."
)
found_barred = True
if found_barred:
return True
# Because we need to capture events related to target setup and process
# startup, we handle them here, in a special way.
if bits[0].startswith("pr") and "process".startswith(bits[0]):
if len(bits) > 1 and bits[1].startswith("la") and "launch".startswith(bits[1]):
# This is `process launch`.
process_launch(driver, relay, bits[2:], dbg)
return True
if len(bits) > 1 and bits[1].startswith("a") and "attach".startswith(bits[1]):
# This is `process attach`.
process_attach(driver, relay, bits[2:], dbg)
return True
if len(bits) > 1 and bits[1].startswith("conn") and "connect".startswith(bits[1]):
# This is `process connect`.
process_connect(driver, relay, bits[2:], dbg)
return True
# We don't care about other process commands..
if (bits[0].startswith("at") and "attach".startswith(bits[0])) or (
bits[0].startswith("_regexp-a") and "_regexp-attach".startswith(bits[0])
):
# `attach` is an alias for `_regexp-attach`
# (it is NOT an alias for `process attach` even if it may seem so!)
attach(driver, relay, bits[1:], dbg)
return True
if bits[0].startswith("ta") and "target".startswith(bits[0]):
if len(bits) > 1 and bits[1].startswith("c") and "create".startswith(bits[1]):
# This is `target create`
target_create(bits[2:], dbg)
return True
if len(bits) > 1 and bits[1].startswith("de") and "delete".startswith(bits[1]):
# This is `target delete`
#
# Currently, this check is here but it does nothing. We might
# need to check for this, but I can't figure out what kind of
# processing we should do for its arguments, so we do nothing.
pass
if bits[0].startswith("r") and "run".startswith(bits[0]):
# `run` is an alias for `process launch -X true --`
process_launch(driver, relay, ["-X", "true", "--"] + bits[1:], dbg)
return True
if bits[0] == "c" or (bits[0].startswith("con") and "continue".startswith(bits[0])):
# Handle `continue` manually. While `ProcessDriver.run_lldb_command`
# is more than capable of handling this command itself, there's no
# need for it to. We know what the user wants, so we can fast-track
# their request.
continue_process(driver, bits[1:], dbg)
return True
if bits[0].startswith("gd") and "gdb-remote".startswith(bits[0]):
# `gdb-remote` is almost the same as `process launch -p gdb-remote`,
# but it does some additional changes to the URL, by prepending
# "connect://" to it. So, from our pespective, it is a separate
# command, even though it will also end up calling process_launch().
gdb_remote(driver, relay, bits[1:], dbg)
return True
if bits[0] == "set":
# We handle `set` as a command override. We do this so that users
# may change Pwndbg-specific settings in the same way that they
# would in GDB Pwndbg.
#
# The alternatives to this are either (1) use a proper command,
# but that requires the process to already be running, and needs us
# to use a name other than "set", or (2) add our settings to the
# standard debugger settings mechanism, like we do in GDB, but LLDB
# doesn't support that.
warn = False
if len(bits) != 3:
print("Usage: set <name> <value>")
warn = True
else:
param = pget(bits[1])
if param is None:
print_error(f"unknown setting '{bits[1]}'")
warn = True
else:
try:
pset(param, bits[2])
except InvalidParse as e:
print_error(f"invalid value '{bits[2]}' for setting '{bits[1]}': {e}")
warn = True
if warn:
print_hint(
"Use the 'config', 'theme' and 'help set' commands to see what settings are available."
)
print_hint(
"Use the 'help set <name>' command to see information about a specific setting."
)
print_hint(
"If you meant to change LLDB settings, use the fully spelled-out 'settings' command, instead."
)
return True
if (
(bits[0] == "h" or (bits[0].startswith("hel") and "help".startswith(bits[0])))
and len(bits) >= 2
and bits[1] == "set"
):
# This is 'help set'
#
# We override this command to provide help for the 'set' command
# override.
warn = False
if len(bits) > 3:
print("Usage: help set [name]")
warn = True
elif len(bits) == 2:
# In LLDB style, list all valid settings.
print("Set a Pwndbg configuration parameter.")
print()
print("Syntax: set <name> <value>")
for scope in pwndbg.lib.config.Scope:
print()
print(f"Configuration parameters - {scope._name_}:")
pwndbg.commands.config.display_config("", scope, show_hints=False)
else:
# Show information about a single parameter.
param = pget(bits[2])
if param is None:
print_error(f"unknown setting '{bits[2]}'")
warn = True
else:
print(f"Set {param.set_show_doc}.")
print()
print(f"Syntax: set {param.name} <value>")
print()
if param.help_docstring:
print(param.help_docstring)
if warn:
print_hint("Use the 'help set' command to see what settings are available for Pwndbg.")
print_hint(
"If you meant to see help for LLDB settings, see the fully spelled-out 'settings' command, instead."
)
return True
if bits[0] == "ipi":
# Spawn IPython shell, easy for debugging
run_ipython_shell()
return True
if (
bits[0] == pwndbg.commands.start.entry.command_name
or bits[0] in pwndbg.commands.start.entry.aliases
):
# 'entry' is actually a Pwndbg command. For convenience, we launch the
# process on its behalf, before letting it run.
#
# In the LLDB back-end, there is no proper mechanism to make a process
# start from inside of a command, as there is in GDB. Ideally, we'd
# rework `ProcessDriver` so that it lets us do that with an execution
# controller, but that is quite a bit of work to fix a single command,
# when using an override is enough to achieve the same goal.
#
# In any case, we should consider doing it if this proves to be too janky.
if not driver.has_process():
process_launch(driver, relay, ["-s"], dbg)
# This intentionally falls through. We want LLDB to do the rest of the
# work of processing 'entry'.
# The command hasn't matched any of our filtered commands, just let LLDB
# handle it normally. Either in the context of the process, if we have
# one, or just in a general context.
if driver.has_process():
driver.run_lldb_command(line, lldb_out_target)
dbg.relay_exceptions()
else:
ret = lldb.SBCommandReturnObject()
dbg.debugger.GetCommandInterpreter().HandleCommand(line, ret)
if ret.IsValid():
# LLDB can give us strings that may fail to encode.
out = ret.GetOutput().strip()
if len(out) > 0:
lldb_out_target.write(out.encode(sys.stdout.encoding, errors="backslashreplace"))
lldb_out_target.write(b"\n")
out = ret.GetError().strip()
if len(out) > 0:
lldb_out_target.write(out.encode(sys.stdout.encoding, errors="backslashreplace"))
lldb_out_target.write(b"\n")
dbg.relay_exceptions()
# At this point, the last command might've queued up some execution
# control procedures for us to chew on. Run them now.
coroutine_fail_warn = False
for process, coroutine in dbg.controllers:
assert driver.has_process()
assert driver.process.GetUniqueID() == process.process.GetUniqueID()
try:
driver.run_coroutine(coroutine)
except Exception:
# We treat exceptions coming from the execution controllers the
# same way we treat exceptions coming from commands.
pwndbg.exception.handle()
coroutine_fail_warn = True
dbg.controllers.clear()
if coroutine_fail_warn:
print_warn(
"Exceptions occurred execution controller processing. Debugging will likely be unreliable going forward."
)
return True
def _bool_of_string(val: str) -> bool:
"""
Convert a string to a boolean value.
For use with ArgumentParser.
"""
if val.lower() in ("true", "1", "yes"):
return True
elif val.lower() in ("false", "0", "no"):
return False
else:
raise ValueError(f"{val} is not a recognized boolean value")
def parse(
args: List[str],
parser: argparse.ArgumentParser,
unsupported: List[str],
raw_marker: str | None = None,
) -> Any | None:
"""
Parses a list of string arguments into an object containing the parsed
data.
If `raw_marker` is not `None`, the argument list will be split in
two, with all arguments before the split being fed to the argument parser,
and all arguments after the split being returned as-is. In this case the
return value is a tuple.
"""
raw = None
if raw_marker is not None:
# Always return something, even if we match nothing.
raw = []
try:
index = args.index(raw_marker)
raw = args[index + 1 :]
args = args[:index]
except ValueError:
# Ugly, but avoids going over the list twice.
pass
try:
args = parser.parse_args(args)
except SystemExit:
# Ugly, but need to keep ArgumentParser from terminating the process.
return None
# Reject any arguments we don't know how to handle yet.
#
# We'd like this list to grow over time, but we don't strictly need to
# support all of these right away.
varsargs = vars(args)
for unsup in unsupported:
if varsargs[unsup.replace("-", "_")]:
print_error(f"Pwndbg does not support --{unsup} yet")
return None
if raw is not None:
# If called with `raw_marked`, return a tuple.
return args, raw
return args
class AutoTarget:
"""
During the execution of some commands, the LLDB CLI automatically creates an
empty target and selects it before the command is executed.
"""
def __init__(self, dbg: LLDB):
self.error = lldb.SBError()
self._dbg = dbg
self._created_target = False
count = dbg.debugger.GetNumTargets()
if count == 0:
# Create the target.
self.target = dbg.debugger.CreateTarget(None, None, None, True, self.error)
if not self.error.success:
return
# On success, select it and remember that it has been created.
dbg.debugger.SetSelectedTarget(self.target)
self._created_target = True
elif count == 1:
# Just use the current target.
self.target = dbg.debugger.GetTargetAtIndex(0)
assert self.target, f"SBDebugger::GetNumTargets() is 1, but SBDebugger::GetTargetAtIndex(0) is {self.target}"
else:
raise AssertionError(
f"Pwndbg does not support multiple targets, so SBDebugger::GetNumTargets() must always be 0 or 1, but is {count}"
)
def __bool__(self):
return self.error.success
def close(self):
if self._created_target:
assert self._dbg.debugger.DeleteTarget(
self.target
), "Could not delete the target we've just created. What?"
def run_ipython_shell():
@contextmanager
def switch_to_ipython_env():
saved_excepthook = sys.excepthook
try:
saved_ps = sys.ps1, sys.ps2
except AttributeError:
saved_ps = None
yield
# Restore Python's default `ps1`, `ps2`, and `excepthook`
# to ensure proper behavior of the LLDB `script` command.
if saved_ps is not None:
sys.ps1, sys.ps2 = saved_ps
else:
del sys.ps1
del sys.ps2
sys.excepthook = saved_excepthook
def start_ipi():
import IPython
import jedi # type: ignore[import-untyped]
jedi.Interpreter._allow_descriptor_getattr_default = False
IPython.embed(
colors="neutral", banner1="", confirm_exit=False, simple_prompt=False, user_ns=globals()
)
with switch_to_ipython_env():
start_ipi()
target_create_ap = argparse.ArgumentParser(add_help=False, prog="target create")
target_create_ap.add_argument("-S", "--sysroot")
target_create_ap.add_argument("-a", "--arch")
target_create_ap.add_argument("-b", "--build")
target_create_ap.add_argument("-c", "--core")
target_create_ap.add_argument("-d", "--no-dependents")
target_create_ap.add_argument("-p", "--platform")
target_create_ap.add_argument("-r", "--remote-file")
target_create_ap.add_argument("-s", "--symfile")
target_create_ap.add_argument("-v", "--version")
target_create_ap.add_argument("filename")
target_create_unsupported = [
"build",
"core",
"no-dependents",
"remote-file",
"symfile",
"version",
]
def _get_target_triple(debugger: lldb.SBDebugger, filepath: str) -> str | None:
# The triple is the "architecture-vendor-OS[-ABI]" of the target binary.
# Examples:
# - "arm--linux-eabi"
# - "aarch64--linux"
# - "x86_64-apple-macosx11.7.0"
# - "arm64-apple-macosx11.7.0"
# - "aarch64-pc-windows-msvc"
target: lldb.SBTarget = debugger.CreateTarget(filepath)
if not target.IsValid():
return None
triple = target.triple
debugger.DeleteTarget(target)
return triple
def target_create(args: List[str], dbg: LLDB) -> None:
"""
Creates a new target, registers it with the Pwndbg LLDB implementation, and
sets up listeners for it.
"""
args = parse(args, target_create_ap, target_create_unsupported)
if not args:
return
if dbg.debugger.GetNumTargets() > 0:
print_error(
"Pwndbg does not support multiple targets. Please remove the current target with 'target delete' and try again."
)
return
if args.platform and args.platform not in {"qemu-user"}:
print_error("Pwndbg does currently support platforms: qemu-user")
return
if args.arch:
dbg.debugger.SetDefaultArchitecture(args.arch)
if args.sysroot:
dbg.debugger.SetCurrentPlatformSDKRoot(args.sysroot)
# Create the target with the debugger.
error = lldb.SBError()
if args.platform:
dbg.debugger.SetCurrentPlatform(args.platform)
# Having the platform specified requires that we specify the triple.
triple = _get_target_triple(dbg.debugger, args.filename)
if not triple:
print_error(f"could not detect triple for '{args.filename}'")
return
if args.platform == "qemu-user":
arch = triple.split("-")[0]
# Without setting it qemu-user don't work ;(
dbg._execute_lldb_command(f"settings set platform.plugin.qemu-user.architecture {arch}")
target: lldb.SBTarget = dbg.debugger.CreateTarget(
args.filename, triple, args.platform, True, error
)
else:
# Let LLDB figure out both the triple and the platform automatically.
target = dbg.debugger.CreateTarget(args.filename, None, None, True, error)
if not error.success or not target.IsValid():
print_error(f"could not create target for '{args.filename}': {error.description}")
return
dbg.debugger.SetSelectedTarget(target)
print(f"Current executable set to '{args.filename}' ({target.triple.split('-')[0]})")
return
process_launch_ap = argparse.ArgumentParser(add_help=False, prog="process launch")
process_launch_ap.add_argument("-A", "--disable-aslr", type=_bool_of_string, default=False)
process_launch_ap.add_argument("-C", "--script-class")
process_launch_ap.add_argument("-E", "--environment", action="append")
process_launch_ap.add_argument("-P", "--plugin")
process_launch_ap.add_argument("-X", "--shell-expand-args", type=_bool_of_string)
process_launch_ap.add_argument("-a", "--arch")
process_launch_ap.add_argument("-c", "--shell")
process_launch_ap.add_argument("-e", "--stderr")
process_launch_ap.add_argument("-i", "--stdin")
process_launch_ap.add_argument("-k", "--structured-data-key")
process_launch_ap.add_argument("-n", "--no-stdio")
process_launch_ap.add_argument("-o", "--stdout")
process_launch_ap.add_argument("-s", "--stop-at-entry", action="store_true")
process_launch_ap.add_argument("-t", "--tty")
process_launch_ap.add_argument("-v", "--structured-data-value")
process_launch_ap.add_argument("-w", "--working-dir")
process_launch_ap.add_argument("run-args", nargs="*")
process_launch_unsupported = [
"script-class",
"plugin",
"arch",
"shell",
"stderr",
"stdin",
"structured-data-key",
"no-stdio",
"stdout",
"tty",
"structured-data-value",
"working-dir",
]
def process_launch(driver: ProcessDriver, relay: EventRelay, args: List[str], dbg: LLDB) -> None:
"""
Launches a process with the given arguments.
"""
result = parse(args, process_launch_ap, process_launch_unsupported, raw_marker="--")
if result is None:
return
args, raw = result
launch_args = getattr(args, "run-args", []) + raw
if args.shell_expand_args:
# Perform shell expansion.
launch_args = [os.path.expanduser(os.path.expandvars(arg)) for arg in launch_args]
targets = dbg.debugger.GetNumTargets()
assert targets < 2
if targets == 0:
print_error("no target, create one using the 'target create' command")
return
if driver.has_process():
print_error("a process is already being debugged")
return
target: lldb.SBTarget = dbg.debugger.GetTargetAtIndex(0)
# Make sure LLDB knows the correct remote or local status of this launch.
dbg._current_process_is_gdb_remote = driver.has_connection()
if target.GetPlatform().GetName() == "qemu-user":
# Force qemu-user as remote, pwndbg depends on that, eg: for download procfs files
dbg._current_process_is_gdb_remote = True
io_driver = get_io_driver()
result = driver.launch(
target,
io_driver,
[f"{name}={value}" for name, value in os.environ.items()]
+ (args.environment if args.environment else []),
launch_args,
os.getcwd(),
args.disable_aslr,
)
match result:
case LaunchResultError(what, disconnected):
print_error(f"could not launch process: {what.description}")
if disconnected:
print_warn("disconnected")
return
case LaunchResultEarlyExit():
print_warn("process exited early")
return
# Continue execution if the user hasn't requested for a stop at the entry
# point of the process. And handle necessary events.
if not args.stop_at_entry:
# The relay has already sended a START event at this point. Continuing
# normally will send a CONTINUE event, which is incorrect, as START
# already implies the program is about to start running. So we avoid
# sending the next CONTINUE event.
relay._set_ignore_resumed(1)
driver.cont()
else:
# Tell the debugger that the process was suspended.
#
# The event system in the process driver can't natively represent the
# START event type exactly. It knows only when a process has been
# created and when a process changed it state to running from suspended,
# and vice versa.
#
# This means that we have to relay an extra event here to convey that
# the process stopped at entry, even though what's going on, in reality,
# is that we're simply not resuming the process.
dbg._trigger_event(EventType.STOP)
process_attach_ap = argparse.ArgumentParser(add_help=False, prog="process attach")
process_attach_ap.add_argument("-C", "--python-class")
process_attach_ap.add_argument("-P", "--plugin")
process_attach_ap.add_argument("-c", "--continue", action="store_true")
process_attach_ap.add_argument("-i", "--include-existing", action="store_true")
process_attach_ap.add_argument("-k", "--structured-data-key")
process_attach_ap.add_argument("-n", "--name")
process_attach_ap.add_argument("-p", "--pid", type=int)
process_attach_ap.add_argument("-v", "--structured-data-value")
process_attach_ap.add_argument("-w", "--waitfor", action="store_true")
process_attach_unsupported = [
"python-class",
"plugin",
"structured-data-key",
"structured-data-value",
]
def _attach_with_info(
driver: ProcessDriver, relay: EventRelay, dbg: LLDB, info: lldb.SBAttachInfo, cont=False
):
"""
Attaches to a process based on SBAttachInfo information
"""
assert dbg.debugger.GetNumTargets() < 2
# TODO/FIXME: This should ask:
# 'There is a running process, detach from it and attach?: [Y/n]'
if driver.has_process():
print_error("a process is already being debugged")
return
auto = AutoTarget(dbg)
if not auto:
print_error(f"could not create empty target for attaching: {auto.error.description}")
auto.close()
return
# Make sure LLDB knows the correct remote or local status of this attach.
dbg._current_process_is_gdb_remote = driver.has_connection()
result = driver.attach(
auto.target,
info,
)
match result:
case LaunchResultError(what, disconnected):
print_error(f"could not attach to process: {what.description}")
if disconnected:
print_warn("disconnected")
auto.close()
return
case LaunchResultEarlyExit():
print_warn("process exited early")
auto.close()
return
# Continue execution if the user has requested it.
if cont:
# Same logic applies here as in `process_launch`.
relay._set_ignore_resumed(1)
driver.cont()
else:
# Same logic applies here as in `process_launch`.
dbg._trigger_event(EventType.STOP)
def process_attach(driver: ProcessDriver, relay: EventRelay, args: List[str], dbg: LLDB) -> None:
"""
Attaches to a process with the given arguments.
"""
args = parse(args, process_attach_ap, process_attach_unsupported)
if not args:
return
# The first two arguments - executable name and wait_for_launch - don't
# matter, we set them later. The third one is required, as it tells LLDB the
# attach should be asynchronous.
info = lldb.SBAttachInfo(None, False, True)
if args.name is not None:
info.SetExecutable(args.name)
if args.pid is not None:
info.SetProcessID(args.pid)
info.SetWaitForLaunch(args.waitfor)
do_continue = getattr(args, "continue", False)
if do_continue:
info.SetResumeCount(1)
info.SetIgnoreExisting(not args.include_existing)
_attach_with_info(driver, relay, dbg, info, cont=do_continue)
def attach(driver: ProcessDriver, relay: EventRelay, args: List[str], dbg: LLDB) -> None:
"""
Attaches to a process with the given name or pid based on regex match.
Used for `_regexp-attach <pid|name>` (alias for `attach <pid|name>`)
Note: for some reason, `attach` does not really take a regex for process name.
"""
if len(args) != 1:
print_error("expected 1 argument: <pid> or <name>")
return
arg = args[0]
# exec name - None, we set it later (or pid)
# wait_for_launch - False, since we don't wait
# third arg - tell LLDB to attach asynchronously
info = lldb.SBAttachInfo(None, False, True)
# Argument is pid
if arg.isdigit():
info.SetProcessID(int(arg))
else:
info.SetExecutable(arg)
_attach_with_info(driver, relay, dbg, info)
process_connect_ap = argparse.ArgumentParser(add_help=False, prog="process connect")
process_connect_ap.add_argument("-p", "--plugin")
process_connect_ap.add_argument("remoteurl")
def process_connect(driver: ProcessDriver, relay: EventRelay, args: List[str], dbg: LLDB) -> None:
"""
Connects to the given remote process.
"""
args = parse(args, process_connect_ap, [])
if not args:
return
if "plugin" not in args or args.plugin != "gdb-remote":
print_error(
"Pwndbg only supports the gdb-remote plugin for 'process connect'. Please specify it with the '-p gdb-remote' argument."
)
return
if driver.has_connection():
print_error("debugger is already connected")
return
# Make sure the LLDB driver knows that this is a remote process.
dbg._current_process_is_gdb_remote = True
auto = AutoTarget(dbg)
if not auto:
print_error(f"could not create empty target for connection: {auto.error.description}")
auto.close()
return
io_driver = get_io_driver()
result = driver.connect(auto.target, io_driver, args.remoteurl, "gdb-remote")
match result:
case LaunchResultError(what, disconnected):
print_error(f"could not connect to remote: {what.description}")
if disconnected:
print_warn("disconnected")
auto.close()
return
case LaunchResultEarlyExit():
print_warn("remote exited early")
auto.close()
return
# Tell the debugger that the process was suspended, if there is a process.
if driver.has_process():
dbg._trigger_event(EventType.STOP)
gdb_remote_ap = argparse.ArgumentParser(add_help=False, prog="gdb-remote")
gdb_remote_ap.add_argument("remoteurl")
def gdb_remote(driver: ProcessDriver, relay: EventRelay, args: List[str], dbg: LLDB) -> None:
"""
Like `process_connect`, but more lenient with the remote URL format.
"""
args = parse(args, gdb_remote_ap, [])
if not args:
return
parts = args.remoteurl.split(":")
if len(parts) == 1:
url = None
port = parts[0]
elif len(parts) == 2:
url = parts[0]
port = parts[1]
else:
print_error(f"unknown URL format '{args.remoteurl}'")
return
try:
port = int(port, 10)
except ValueError:
print_error(f"could not interpret '{port}' as port number")
return
if url is None:
print_warn("hostname not given, using 'localhost'")
url = "localhost"
process_connect(driver, relay, ["-p", "gdb-remote", f"connect://{url}:{port}"], dbg)
continue_ap = argparse.ArgumentParser(add_help=False, prog="continue")
continue_ap.add_argument("-i", "--ignore-count")
continue_unsupported = ["ignore-count"]
def continue_process(driver: ProcessDriver, args: List[str], dbg: LLDB) -> None:
"""
Continues the execution of a process.
"""
args = parse(args, continue_ap, continue_unsupported)
if not args:
return
if not driver.has_process():
print_error("no process")
return
driver.cont()