From 832a0098640103cf5cec46694ab53f156baf8719 Mon Sep 17 00:00:00 2001 From: jackmisbach Date: Tue, 9 Dec 2025 19:24:34 -0600 Subject: [PATCH] Retrieve debug info when present to show stack variables (#3451) * Add stack variable lookup from DWARF debug info * Add get_stack_var_name to Process API * Implement get_stack_var_name for GDB * Implement get_stack_var_name for LLDB * Display stack variable names in memory view * Fix linter issue * Catch RuntimeError for stripped binaries * Skip stack vars without address * check prefix is not None instead of truthy * fix accidental spacing * address review feedback, adjust test regex, and introduce new test * fix newlines * fix lldb init * facepalm * Remove frame caching, remove import cache, and remove frame_pc parameter * Remove frame caching, remove import cache, and remove frame_pc parameter * Remove test from gdb * Add caching for get_stack_var_name * Remove unneeded variable * Add comments explaining errors * Add comments, not using the web editor... * Update test for PR #3457 * revert test name * fix test, match new schema to work on all architectures * update test name back * lldb tests fixy * just rerunning tests, had http 500 errors --------- Co-authored-by: Disconnect3d --- pwndbg/aglib/stack.py | 20 ++++++++++ pwndbg/color/memory.py | 10 +++-- pwndbg/dbg/__init__.py | 9 +++++ pwndbg/dbg/gdb/__init__.py | 35 +++++++++++++++++ pwndbg/dbg/lldb/__init__.py | 27 +++++++++++++ tests/binaries/host/stack_vars.native.c | 23 +++++++++++ .../dbg/tests/test_context_commands.py | 38 ++++++++++++++++++- .../gdb/tests/test_context_commands.py | 4 +- 8 files changed, 159 insertions(+), 7 deletions(-) create mode 100644 tests/binaries/host/stack_vars.native.c diff --git a/pwndbg/aglib/stack.py b/pwndbg/aglib/stack.py index d31b48b80..ab912085f 100644 --- a/pwndbg/aglib/stack.py +++ b/pwndbg/aglib/stack.py @@ -189,3 +189,23 @@ def callstack() -> List[int]: frame = frame.parent() return addresses + + +@pwndbg.lib.cache.cache_until("stop", "start") +def get_stack_var_name(address: int) -> str | None: + """ + Get the name of the stack variable covering the given address. + + Returns the variable name with optional offset (e.g., "buf+0x8") if the address + is within a variable but not at its start. Returns None if no variable is found. + """ + frame = pwndbg.dbg.selected_frame() + if frame is None: + return None + + for start, end, name in frame.stack_variables(): + if start <= address < end: + offset = address - start + return name if offset == 0 else f"{name}+{offset:#x}" + + return None diff --git a/pwndbg/color/memory.py b/pwndbg/color/memory.py index 2bd910c9f..1da9d6e91 100644 --- a/pwndbg/color/memory.py +++ b/pwndbg/color/memory.py @@ -35,7 +35,9 @@ def get_address_and_symbol(address: int) -> str: else: page = pwndbg.aglib.vmmap.find(address) if page and "[stack" in page.objfile: - var = pwndbg.integration.provider.get_stack_var_name(address) + var = pwndbg.aglib.stack.get_stack_var_name(address) + if not var: + var = pwndbg.integration.provider.get_stack_var_name(address) if var: symbol = f"{address:#x} {{{var}}}" return get(address, symbol) @@ -58,7 +60,9 @@ def attempt_colorized_symbol(address: int) -> str | None: else: page = pwndbg.aglib.vmmap.find(address) if page and "[stack" in page.objfile: - var = pwndbg.integration.provider.get_stack_var_name(address) + var = pwndbg.aglib.stack.get_stack_var_name(address) + if not var: + var = pwndbg.integration.provider.get_stack_var_name(address) if var: return get(address, f"{{{var}}}") return None @@ -108,7 +112,7 @@ def get( if text is None: text = pwndbg.lib.pretty_print.int_to_string(address) - if prefix: + if prefix is not None: # Prepend the prefix and a space before the existing text text = f"{prefix} {text}" diff --git a/pwndbg/dbg/__init__.py b/pwndbg/dbg/__init__.py index 6f725f64e..4115bd24c 100644 --- a/pwndbg/dbg/__init__.py +++ b/pwndbg/dbg/__init__.py @@ -253,6 +253,15 @@ class Frame: """ raise NotImplementedError() + def stack_variables(self) -> Tuple[Tuple[int, int, str], ...]: + """ + Get all stack variables (local variables and arguments) in current frame. + + Returns a tuple of (start_address, end_address, name) for each variable. + Returns an empty tuple if no debug information is available or on error. + """ + raise NotImplementedError() + def __eq__(self, rhs: object) -> bool: """ Whether this frame is the same as the given frame. Two frames are the diff --git a/pwndbg/dbg/gdb/__init__.py b/pwndbg/dbg/gdb/__init__.py index 4919290ae..5b411f326 100644 --- a/pwndbg/dbg/gdb/__init__.py +++ b/pwndbg/dbg/gdb/__init__.py @@ -92,6 +92,37 @@ def parse_and_eval(expression: str, global_context: bool) -> gdb.Value: return gdb.parse_and_eval(expression) +def _get_frame_stack_variables(frame: gdb.Frame) -> Tuple[Tuple[int, int, str], ...]: + try: + block = frame.block() + except (gdb.error, RuntimeError): + # gdb.error: No frame selected (no active inferior) + # RuntimeError: Frame exists and selected, + # But no DWARF info, such as in the case of stripped binaries + return () + + if not block: + return () + + variables = [] + while block: + for sym in block: + if not (sym.is_variable or sym.is_argument): + continue + + try: + value = sym.value(frame) + addr = int(value.address) + size = value.type.sizeof + variables.append((addr, addr + size, sym.name)) + except (gdb.error, AttributeError, TypeError): + continue + + block = block.superblock + + return tuple(variables) + + class GDBRegisters(pwndbg.dbg_mod.Registers): def __init__(self, frame: GDBFrame): self.frame = frame @@ -204,6 +235,10 @@ class GDBFrame(pwndbg.dbg_mod.Frame): return sal.symtab.fullname(), sal.line + @override + def stack_variables(self) -> Tuple[Tuple[int, int, str], ...]: + return _get_frame_stack_variables(self.inner) + @override def __eq__(self, rhs: object) -> bool: assert isinstance(rhs, GDBFrame), "tried to compare GDBFrame to other type" diff --git a/pwndbg/dbg/lldb/__init__.py b/pwndbg/dbg/lldb/__init__.py index 6882e3852..02c4920c2 100644 --- a/pwndbg/dbg/lldb/__init__.py +++ b/pwndbg/dbg/lldb/__init__.py @@ -78,6 +78,29 @@ class LLDBRegisters(pwndbg.dbg_mod.Registers): return None +def _get_frame_stack_variables(frame: lldb.SBFrame) -> Tuple[Tuple[int, int, str], ...]: + try: + # GetVariables(arguments, locals, statics, in_scope_only) + variables = frame.GetVariables(True, True, False, True) + + result = [] + for i in range(variables.GetSize()): + var = variables.GetValueAtIndex(i) + if not var.IsValid(): + continue + + addr = var.GetLoadAddress() + if addr == lldb.LLDB_INVALID_ADDRESS: + continue + + size = var.GetType().GetByteSize() + result.append((int(addr), int(addr) + size, var.GetName())) + + return tuple(result) + except Exception: + return () + + class LLDBFrame(pwndbg.dbg_mod.Frame): inner: lldb.SBFrame proc: LLDBProcess @@ -262,6 +285,10 @@ class LLDBFrame(pwndbg.dbg_mod.Frame): return None + @override + def stack_variables(self) -> Tuple[Tuple[int, int, str], ...]: + return _get_frame_stack_variables(self.inner) + @override def __eq__(self, rhs: object) -> bool: assert isinstance(rhs, LLDBFrame), "tried to compare LLDBFrame to other type" diff --git a/tests/binaries/host/stack_vars.native.c b/tests/binaries/host/stack_vars.native.c new file mode 100644 index 000000000..e2aeec7e1 --- /dev/null +++ b/tests/binaries/host/stack_vars.native.c @@ -0,0 +1,23 @@ +#include +#include + +void inner_function(void) { + char buffer[64]; + int local_var = 42; + + strcpy(buffer, "Hello from inner function!"); + + printf("local_var = %d\n", local_var); + printf("buffer = %s\n", buffer); +} + +void outer_function() { + inner_function(); +} + +int main() { + printf("Starting test program...\n"); + outer_function(); + printf("Done\n"); + return 0; +} diff --git a/tests/library/dbg/tests/test_context_commands.py b/tests/library/dbg/tests/test_context_commands.py index 35abfc4aa..2b149eb0b 100644 --- a/tests/library/dbg/tests/test_context_commands.py +++ b/tests/library/dbg/tests/test_context_commands.py @@ -15,6 +15,7 @@ USE_FDS_BINARY = get_binary("use-fds.native.out") TABSTOP_BINARY = get_binary("tabstop.native.out") SYSCALLS_BINARY = get_binary("syscalls.x86-64.out") MANGLING_BINARY = get_binary("symbol_1600_and_752.native.out") +STACK_VARS_BINARY = get_binary("stack_vars.native.out") @pwndbg_test @@ -54,7 +55,7 @@ async def test_context_disasm_show_fd_filepath(ctrl: Controller) -> None: ) line_buf = line_buf.strip() - assert re.match(r"buf:\s+0x[0-9a-f]+ ◂— 0", line_buf) + assert re.match(r"buf:\s+0x[0-9a-f]+(?: \{buf\})? ◂— 0", line_buf) line_nbytes = line_nbytes.strip() assert re.match(r"nbytes:\s+0", line_nbytes) @@ -78,7 +79,7 @@ async def test_context_disasm_show_fd_filepath(ctrl: Controller) -> None: assert re.match(r"fd:\s+3\s+\(.*?/tests/binaries/host/use-fds.native.out\)", line_fd) line_buf = line_buf.strip() - assert re.match(r"buf:\s+0x[0-9a-f]+ ◂— 0", line_buf) + assert re.match(r"buf:\s+0x[0-9a-f]+(?: \{buf\})? ◂— 0", line_buf) line_nbytes = line_nbytes.strip() assert re.match(r"nbytes:\s+0x10", line_nbytes) @@ -622,3 +623,36 @@ async def test_context_output_redirection(ctrl: Controller) -> None: assert "STACK" not in receive_output.context_output pwndbg.commands.context.resetcontextoutput("regs") + + +@pwndbg_test +async def test_stack_variable_names_from_dwarf(ctrl: Controller) -> None: + """ + Test that stack variable names from DWARF debug info are displayed correctly + """ + import pwndbg.aglib.stack + import pwndbg.commands.context + import pwndbg.dbg + + # Launch directly to inner_function where the variables are + await launch_to(ctrl, STACK_VARS_BINARY, "inner_function") + + # Test direct API: pwndbg.aglib.stack.get_stack_var_name() + # Get addresses of local variables + frame = pwndbg.dbg.selected_frame() + buffer_addr = int(frame.evaluate_expression("&buffer")) + local_var_addr = int(frame.evaluate_expression("&local_var")) + + # Test that get_stack_var_name returns correct names + assert pwndbg.aglib.stack.get_stack_var_name(buffer_addr) == "buffer" + assert pwndbg.aglib.stack.get_stack_var_name(local_var_addr) == "local_var" + + # Test offset notation for addresses within variables + # buffer is 64 bytes, so buffer+0x10 should show "buffer+0x10" + buffer_offset_addr = buffer_addr + 0x10 + offset_result = pwndbg.aglib.stack.get_stack_var_name(buffer_offset_addr) + assert offset_result == "buffer+0x10" + + # Test that telescope shows variable names + telescope_out = await ctrl.execute_and_capture(f"telescope {buffer_addr:#x} 1") + assert "{buffer}" in telescope_out diff --git a/tests/library/gdb/tests/test_context_commands.py b/tests/library/gdb/tests/test_context_commands.py index 7e6173b81..04c6effd5 100644 --- a/tests/library/gdb/tests/test_context_commands.py +++ b/tests/library/gdb/tests/test_context_commands.py @@ -52,7 +52,7 @@ def test_context_disasm_show_fd_filepath(start_binary): ) line_buf = line_buf.strip() - assert re.match(r"buf:\s+0x[0-9a-f]+ ◂— 0", line_buf) + assert re.match(r"buf:\s+0x[0-9a-f]+(?: \{buf\})? ◂— 0", line_buf) line_nbytes = line_nbytes.strip() assert re.match(r"nbytes:\s+0", line_nbytes) @@ -74,7 +74,7 @@ def test_context_disasm_show_fd_filepath(start_binary): assert re.match(r"fd:\s+3 \(.*?/tests/binaries/host/use-fds.native.out\)", line_fd) line_buf = line_buf.strip() - assert re.match(r"buf:\s+0x[0-9a-f]+ ◂— 0", line_buf) + assert re.match(r"buf:\s+0x[0-9a-f]+(?: \{buf\})? ◂— 0", line_buf) line_nbytes = line_nbytes.strip() assert re.match(r"nbytes:\s+0x10", line_nbytes)