mirror of https://github.com/pwndbg/pwndbg
Add history of context output (#2439)
* Add history of context output Every context section is cached individually to allow to display older output again. You can scroll through the old context output using `contextprev` and `contextnext`. This allows to "scroll" in TUI mode. * Add button TUI window to control context history * Simplify history status output generation
This commit is contained in:
parent
6e9cdac13a
commit
5553a93a3e
|
@ -21,6 +21,8 @@ The output of the context may be redirected to a file (including other tty) by u
|
|||
|
||||
![](caps/context.png)
|
||||
|
||||
A history of previous context output is kept which can be accessed using the `contextprev` and `contextnext` commands.
|
||||
|
||||
### Splitting / Layouting Context
|
||||
|
||||
The context sections can be distributed among different tty by using the `contextoutput` command.
|
||||
|
@ -73,7 +75,7 @@ The context sections are available as native gdb TUI windows as well as `pwndbg_
|
|||
|
||||
Try creating a layout and selecting it:
|
||||
```
|
||||
tui new-layout pwndbg {-horizontal { { -horizontal { pwndbg_code 2 pwndbg_disasm 8 } 2 { pwndbg_legend 1 pwndbg_regs 6 pwndbg_stack 6 } 3 } 7 cmd 3 } 3 { pwndbg_backtrace 1 } 1 } 1 status 1
|
||||
tui new-layout pwndbg {-horizontal { { -horizontal { pwndbg_code 2 pwndbg_disasm 8 } 2 { { -horizontal pwndbg_legend 8 pwndbg_control 2 } 1 pwndbg_regs 6 pwndbg_stack 6 } 3 } 7 cmd 3 } 3 { pwndbg_backtrace 1 } 1 } 1 status 1
|
||||
layout pwndbg
|
||||
```
|
||||
|
||||
|
|
|
@ -2,15 +2,21 @@ from __future__ import annotations
|
|||
|
||||
import argparse
|
||||
import ast
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from typing import Any
|
||||
from typing import Callable
|
||||
from typing import DefaultDict
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
from typing import Tuple
|
||||
|
||||
from typing_extensions import ParamSpec
|
||||
|
||||
import pwndbg
|
||||
import pwndbg.aglib.arch
|
||||
import pwndbg.aglib.disasm
|
||||
|
@ -40,6 +46,10 @@ if pwndbg.dbg.is_gdblib_available():
|
|||
import pwndbg.gdblib.symbol
|
||||
import pwndbg.ghidra
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
P = ParamSpec("P")
|
||||
|
||||
theme.add_param("backtrace-prefix", "►", "prefix for current backtrace label")
|
||||
|
||||
# TODO: Should namespace be "context.backtrace"?
|
||||
|
@ -267,6 +277,186 @@ def resetcontextoutput(section):
|
|||
}
|
||||
|
||||
|
||||
# Context history
|
||||
context_history: DefaultDict[str, List[List[str]]] = defaultdict(list)
|
||||
selected_history_index: Optional[int] = None
|
||||
|
||||
context_history_size = pwndbg.config.add_param(
|
||||
"context-history-size", 50, "number of context history entries to store"
|
||||
)
|
||||
|
||||
|
||||
@pwndbg.config.trigger(context_history_size)
|
||||
def history_size_changed() -> None:
|
||||
if context_history_size <= 0:
|
||||
context_history.clear()
|
||||
else:
|
||||
for section in context_history:
|
||||
context_history[section] = context_history[section][-int(context_history_size) :]
|
||||
|
||||
|
||||
def serve_context_history(function: Callable[P, List[str]]) -> Callable[P, List[str]]:
|
||||
@functools.wraps(function)
|
||||
def _serve_context_history(*a: P.args, **kw: P.kwargs) -> List[str]:
|
||||
global selected_history_index
|
||||
assert "context_" in function.__name__
|
||||
section_name = function.__name__.replace("context_", "")
|
||||
|
||||
# If the history is disabled, just return the current output
|
||||
if context_history_size <= 0:
|
||||
return function(*a, **kw)
|
||||
|
||||
# Add the current section to the history if it is not already there
|
||||
current_output = []
|
||||
if pwndbg.aglib.proc.alive:
|
||||
current_output = function(*a, **kw)
|
||||
if (
|
||||
len(context_history[section_name]) == 0
|
||||
or context_history[section_name][-1] != current_output
|
||||
):
|
||||
context_history[section_name].append(current_output)
|
||||
selected_history_index = None
|
||||
# Show the history if the process is not running anymore
|
||||
elif context_history[section_name] and selected_history_index is None:
|
||||
selected_history_index = len(context_history[section_name]) - 1
|
||||
|
||||
# Truncate the history to the configured size
|
||||
context_history[section_name] = context_history[section_name][-int(context_history_size) :]
|
||||
history = context_history[section_name]
|
||||
|
||||
if selected_history_index is None:
|
||||
return current_output or function(*a, **kw)
|
||||
if not history or selected_history_index >= len(history):
|
||||
return []
|
||||
return history[selected_history_index]
|
||||
|
||||
return _serve_context_history
|
||||
|
||||
|
||||
def history_handle_unchanged_contents() -> None:
|
||||
longest_history = max(len(h) for h in context_history.values())
|
||||
for section_name, history in context_history.items():
|
||||
# Duplicate the last entry if it is the same as the previous one
|
||||
# and wasn't added when the history was updated
|
||||
if len(history) == longest_history - 1:
|
||||
context_history[section_name].append(history[-1])
|
||||
# Prepend empty entries to the history to make all sections have the same length
|
||||
elif len(history) < longest_history - 1:
|
||||
context_history[section_name] = [
|
||||
[] for _ in range(longest_history - 1 - len(history))
|
||||
] + history
|
||||
|
||||
|
||||
parser = argparse.ArgumentParser(description="Select previous entry in context history.")
|
||||
parser.add_argument(
|
||||
"count",
|
||||
type=int,
|
||||
nargs="?",
|
||||
default=1,
|
||||
help="The number of entries to go back in history",
|
||||
)
|
||||
|
||||
|
||||
@pwndbg.commands.ArgparsedCommand(parser, aliases=["ctxp"], category=CommandCategory.CONTEXT)
|
||||
def contextprev(count) -> None:
|
||||
global selected_history_index
|
||||
longest_history = max(len(h) for h in context_history.values())
|
||||
if selected_history_index is None:
|
||||
if not context_history:
|
||||
print(message.error("No context history captured"))
|
||||
return
|
||||
new_index = longest_history - count - 1
|
||||
else:
|
||||
new_index = selected_history_index - count
|
||||
selected_history_index = max(0, new_index)
|
||||
context()
|
||||
|
||||
|
||||
parser = argparse.ArgumentParser(description="Select next entry in context history.")
|
||||
parser.add_argument(
|
||||
"count",
|
||||
type=int,
|
||||
nargs="?",
|
||||
default=1,
|
||||
help="The number of entries to go forward in history",
|
||||
)
|
||||
|
||||
|
||||
@pwndbg.commands.ArgparsedCommand(parser, aliases=["ctxn"], category=CommandCategory.CONTEXT)
|
||||
def contextnext(count) -> None:
|
||||
global selected_history_index
|
||||
longest_history = max(len(h) for h in context_history.values())
|
||||
if selected_history_index is None:
|
||||
if not context_history:
|
||||
print(message.error("No context history captured"))
|
||||
return
|
||||
new_index = longest_history - 1
|
||||
else:
|
||||
new_index = selected_history_index + count
|
||||
selected_history_index = min(longest_history - 1, new_index)
|
||||
context()
|
||||
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Search for a string in the context history and select that entry."
|
||||
)
|
||||
parser.add_argument(
|
||||
"needle",
|
||||
type=str,
|
||||
help="The string to search for in the context history",
|
||||
)
|
||||
parser.add_argument(
|
||||
"section",
|
||||
type=str,
|
||||
nargs="?",
|
||||
default=None,
|
||||
help="The section to search in. If not provided, search in all sections",
|
||||
)
|
||||
|
||||
|
||||
@pwndbg.commands.ArgparsedCommand(parser, aliases=["ctxsearch"], category=CommandCategory.CONTEXT)
|
||||
def contextsearch(needle, section) -> None:
|
||||
if not section:
|
||||
sections = context_history.keys()
|
||||
else:
|
||||
if section not in context_history:
|
||||
print(message.error(f"Section '{section}' not found in context history."))
|
||||
return
|
||||
sections = [section]
|
||||
|
||||
matches: List[Tuple[str, int]] = []
|
||||
for section in sections:
|
||||
for i, entry in enumerate(context_history[section]):
|
||||
if not any(m[1] == i for m in matches) and any(needle in line for line in entry):
|
||||
matches.append((section, i))
|
||||
matches.sort(key=lambda m: m[1], reverse=True)
|
||||
|
||||
if not matches:
|
||||
print(message.error(f"String '{needle}' not found in context history."))
|
||||
return
|
||||
|
||||
# Select first match before currently selected entry
|
||||
global selected_history_index
|
||||
if selected_history_index is None:
|
||||
next_match = matches[0]
|
||||
else:
|
||||
for match in matches:
|
||||
if match[1] < selected_history_index:
|
||||
next_match = match
|
||||
break
|
||||
else:
|
||||
next_match = matches[0]
|
||||
print(message.warn("No more matches before the current entry. Starting from the top."))
|
||||
|
||||
selected_history_index = next_match[1]
|
||||
print(
|
||||
message.info(
|
||||
f"Found {len(matches)} match{'es' if len(matches) > 1 else ''}. Selected entry {next_match[1] + 1} for match in section '{next_match[0]}'."
|
||||
)
|
||||
)
|
||||
context()
|
||||
|
||||
|
||||
# Watches
|
||||
expressions = []
|
||||
|
||||
|
@ -317,6 +507,7 @@ def contextunwatch(num) -> None:
|
|||
expressions.pop(int(num) - 1)
|
||||
|
||||
|
||||
@serve_context_history
|
||||
def context_expressions(target=sys.stdout, with_banner=True, width=None):
|
||||
if not expressions:
|
||||
return []
|
||||
|
@ -352,6 +543,7 @@ config_context_ghidra = pwndbg.config.add_param(
|
|||
)
|
||||
|
||||
|
||||
@serve_context_history
|
||||
def context_ghidra(target=sys.stdout, with_banner=True, width=None):
|
||||
"""
|
||||
Print out the source of the current function decompiled by ghidra.
|
||||
|
@ -404,13 +596,17 @@ parser.add_argument(
|
|||
|
||||
|
||||
@pwndbg.commands.ArgparsedCommand(parser, aliases=["ctx"], category=CommandCategory.CONTEXT)
|
||||
@pwndbg.commands.OnlyWhenRunning
|
||||
def context(subcontext=None, enabled=None) -> None:
|
||||
"""
|
||||
Print out the current register, instruction, and stack context.
|
||||
|
||||
Accepts subcommands 'reg', 'disasm', 'code', 'stack', 'backtrace', 'ghidra', 'args', 'threads', 'heap_tracker', 'expressions', and/or 'last_signal'.
|
||||
"""
|
||||
# Allow to view history after the program has exited
|
||||
if not pwndbg.aglib.proc.alive and (context_history_size <= 0 or not context_history):
|
||||
log.error("context: The program is not being run.")
|
||||
return None
|
||||
|
||||
if subcontext is None:
|
||||
subcontext = []
|
||||
args = subcontext
|
||||
|
@ -418,7 +614,15 @@ def context(subcontext=None, enabled=None) -> None:
|
|||
if len(args) == 0:
|
||||
args = config_context_sections.split()
|
||||
|
||||
sections = [("legend", lambda *args, **kwargs: [M.legend()])] if args else []
|
||||
sections = []
|
||||
if args:
|
||||
if selected_history_index is None:
|
||||
sections.append(("legend", lambda *args, **kwargs: [M.legend()]))
|
||||
else:
|
||||
longest_history = max(len(h) for h in context_history.values())
|
||||
history_status = f" (history {selected_history_index + 1}/{longest_history})"
|
||||
sections.append(("legend", lambda *args, **kwargs: [M.legend() + history_status]))
|
||||
|
||||
sections += [(arg, context_sections.get(arg[0], None)) for arg in args]
|
||||
|
||||
result = defaultdict(list)
|
||||
|
@ -441,6 +645,8 @@ def context(subcontext=None, enabled=None) -> None:
|
|||
)
|
||||
)
|
||||
|
||||
history_handle_unchanged_contents()
|
||||
|
||||
for target, res in result.items():
|
||||
settings = result_settings[target]
|
||||
if len(res) > 0 and settings.get("banner_bottom", True):
|
||||
|
@ -539,6 +745,7 @@ def compact_regs(regs, width=None, target=sys.stdout):
|
|||
return result
|
||||
|
||||
|
||||
@serve_context_history
|
||||
def context_regs(target=sys.stdout, with_banner=True, width=None):
|
||||
regs = get_regs()
|
||||
if pwndbg.config.show_compact_regs:
|
||||
|
@ -552,6 +759,7 @@ def context_regs(target=sys.stdout, with_banner=True, width=None):
|
|||
return banner + regs if with_banner else regs
|
||||
|
||||
|
||||
@serve_context_history
|
||||
def context_heap_tracker(target=sys.stdout, with_banner=True, width=None):
|
||||
if not pwndbg.gdblib.heap_tracking.is_enabled():
|
||||
return []
|
||||
|
@ -643,6 +851,7 @@ disasm_lines = pwndbg.config.add_param(
|
|||
)
|
||||
|
||||
|
||||
@serve_context_history
|
||||
def context_disasm(target=sys.stdout, with_banner=True, width=None):
|
||||
flavor = pwndbg.dbg.x86_disassembly_flavor()
|
||||
syntax = pwndbg.aglib.disasm.CapstoneSyntax[flavor]
|
||||
|
@ -768,6 +977,7 @@ should_decompile = pwndbg.config.add_param(
|
|||
)
|
||||
|
||||
|
||||
@serve_context_history
|
||||
def context_code(target=sys.stdout, with_banner=True, width=None):
|
||||
filename, formatted_source, line = get_filename_and_formatted_source()
|
||||
|
||||
|
@ -796,6 +1006,7 @@ stack_lines = pwndbg.config.add_param(
|
|||
)
|
||||
|
||||
|
||||
@serve_context_history
|
||||
def context_stack(target=sys.stdout, with_banner=True, width=None):
|
||||
result = [pwndbg.ui.banner("stack", target=target, width=width)] if with_banner else []
|
||||
telescope = pwndbg.commands.telescope.telescope(
|
||||
|
@ -814,6 +1025,7 @@ backtrace_frame_label = theme.add_param(
|
|||
)
|
||||
|
||||
|
||||
@serve_context_history
|
||||
def context_backtrace(with_banner=True, target=sys.stdout, width=None):
|
||||
result = []
|
||||
|
||||
|
@ -863,6 +1075,7 @@ def context_backtrace(with_banner=True, target=sys.stdout, width=None):
|
|||
return result
|
||||
|
||||
|
||||
@serve_context_history
|
||||
def context_args(with_banner=True, target=sys.stdout, width=None):
|
||||
args = pwndbg.arguments.format_args(pwndbg.aglib.disasm.one())
|
||||
|
||||
|
@ -896,6 +1109,7 @@ def get_thread_status(thread):
|
|||
return "unknown"
|
||||
|
||||
|
||||
@serve_context_history
|
||||
def context_threads(with_banner=True, target=sys.stdout, width=None):
|
||||
try:
|
||||
original_thread = gdb.selected_thread()
|
||||
|
@ -1011,6 +1225,7 @@ if pwndbg.dbg.is_gdblib_available():
|
|||
gdb.events.exited.connect(save_signal)
|
||||
|
||||
|
||||
@serve_context_history
|
||||
def context_last_signal(with_banner=True, target=sys.stdout, width=None):
|
||||
if not last_signal:
|
||||
return []
|
||||
|
|
|
@ -81,6 +81,7 @@ def prompt_hook(*a: Any) -> None:
|
|||
cur = new
|
||||
|
||||
if pwndbg.gdblib.proc.alive and pwndbg.gdblib.proc.thread_is_stopped and not context_shown:
|
||||
pwndbg.commands.context.selected_history_index = None
|
||||
pwndbg.commands.context.context()
|
||||
context_shown = True
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pwndbg.gdblib.tui.context
|
||||
import pwndbg.gdblib.tui.control
|
||||
|
|
|
@ -0,0 +1,47 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import gdb
|
||||
|
||||
|
||||
class ControlTUIWindow:
|
||||
_tui_window: "gdb.TuiWindow"
|
||||
_button_text: str = "[←] [→]"
|
||||
# Map from command to the span of the button in the _button_text.
|
||||
# The span is represented as (start, end) where start is the index
|
||||
# of the first character of the button and end is the index of the
|
||||
# last character of the button to react to on mouse click.
|
||||
_button_spans = {"contextprev": (0, 2), "contextnext": (5, 7)}
|
||||
|
||||
def __init__(self, tui_window: "gdb.TuiWindow") -> None:
|
||||
self._tui_window = tui_window
|
||||
self._tui_window.title = "history"
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
def render(self) -> None:
|
||||
self._tui_window.write(self._button_text, True)
|
||||
|
||||
def hscroll(self, num: int) -> None:
|
||||
pass
|
||||
|
||||
def vscroll(self, num: int) -> None:
|
||||
pass
|
||||
|
||||
def click(self, x: int, y: int, button: int) -> None:
|
||||
# button specifies which mouse button was used, whose values can be 1 (left), 2 (middle), or 3 (right).
|
||||
if button != 1:
|
||||
return
|
||||
|
||||
for command, (start, end) in self._button_spans.items():
|
||||
start_x = start % self._tui_window.width
|
||||
end_x = end % self._tui_window.width
|
||||
start_y = start // self._tui_window.width
|
||||
end_y = end // self._tui_window.width
|
||||
if start_x <= x <= end_x and start_y <= y <= end_y:
|
||||
gdb.execute(command, to_string=True)
|
||||
break
|
||||
|
||||
|
||||
if hasattr(gdb, "register_window_type"):
|
||||
gdb.register_window_type("pwndbg_control", ControlTUIWindow)
|
|
@ -46,6 +46,7 @@ TIPS: List[str] = [
|
|||
"Use `track-got enable|info|query` to track GOT accesses - useful for hijacking control flow via writable GOT/PLT",
|
||||
"Need to `mmap` or `mprotect` memory in the debugee? Use commands with the same name to inject and run such syscalls",
|
||||
"Use `hi` to see if a an address belongs to a glibc heap chunk",
|
||||
"Use `contextprev` and `contextnext` to display a previous context output again without scrolling",
|
||||
]
|
||||
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@ import pwndbg.commands
|
|||
import pwndbg.commands.canary
|
||||
import tests
|
||||
|
||||
REFERENCE_BINARY = tests.binaries.get("reference-binary.out")
|
||||
USE_FDS_BINARY = tests.binaries.get("use-fds.out")
|
||||
TABSTOP_BINARY = tests.binaries.get("tabstop.out")
|
||||
SYSCALLS_BINARY = tests.binaries.get("syscalls-x64.out")
|
||||
|
@ -457,3 +458,76 @@ def test_context_hide_sections(start_binary):
|
|||
out = gdb.execute("context", to_string=True)
|
||||
assert "REGISTERS" in out
|
||||
assert "DISASM" in out
|
||||
|
||||
|
||||
def test_context_history_prev_next(start_binary):
|
||||
start_binary(LONG_FUNCTION_X64_BINARY)
|
||||
|
||||
# Add two context outputs to the history
|
||||
first_ctx = gdb.execute("ctx", to_string=True)
|
||||
gdb.execute("si")
|
||||
second_ctx = gdb.execute("ctx", to_string=True)
|
||||
assert first_ctx != second_ctx
|
||||
|
||||
# Go back to the first context
|
||||
gdb.execute("contextprev")
|
||||
history_ctx = gdb.execute("ctx", to_string=True)
|
||||
assert first_ctx == history_ctx.replace(" (history 1/2)", "")
|
||||
assert "(history 1/2)" in history_ctx
|
||||
|
||||
# Go to the second context again
|
||||
gdb.execute("contextnext")
|
||||
history_ctx = gdb.execute("ctx", to_string=True)
|
||||
assert second_ctx == history_ctx.replace(" (history 2/2)", "")
|
||||
assert "(history 2/2)" in history_ctx
|
||||
|
||||
# Make sure new events are displayed right away
|
||||
# and disable the history scroll.
|
||||
gdb.execute("si")
|
||||
# Execute twice since the prompt hook isn't installed in tests
|
||||
# which causes the legend to still have the (history 2/2) string at first.
|
||||
gdb.execute("ctx", to_string=True)
|
||||
third_ctx = gdb.execute("ctx", to_string=True)
|
||||
assert history_ctx != third_ctx
|
||||
assert "(history " not in third_ctx
|
||||
|
||||
|
||||
def test_context_history_search(start_binary):
|
||||
start_binary(REFERENCE_BINARY)
|
||||
|
||||
gdb.execute("break main")
|
||||
gdb.execute("break break_here")
|
||||
|
||||
gdb.execute("starti")
|
||||
gdb.execute("context")
|
||||
gdb.execute("continue")
|
||||
gdb.execute("context")
|
||||
gdb.execute("continue")
|
||||
gdb.execute("context")
|
||||
|
||||
for _ in range(5):
|
||||
gdb.execute("ni")
|
||||
gdb.execute("context")
|
||||
|
||||
# Search for something in the past
|
||||
search_result = gdb.execute("contextsearch puts@plt", to_string=True)
|
||||
assert "Found 1 match. Selected entry 2 for match in section 'disasm'." in search_result
|
||||
|
||||
# Search for something that happened later and have the search wrap around
|
||||
search_result = gdb.execute("contextsearch 'Hello World'", to_string=True)
|
||||
assert "No more matches before the current entry. Starting from the top." in search_result
|
||||
assert "Found 7 matches. Selected entry 8 for match in section " in search_result
|
||||
search_result = gdb.execute("contextsearch 'Hello World'", to_string=True)
|
||||
assert "Found 7 matches. Selected entry 7 for match in section " in search_result
|
||||
|
||||
# Select a section to search in
|
||||
search_result = gdb.execute("contextsearch 'Hello World' disasm", to_string=True)
|
||||
assert "Found 1 match. Selected entry 2 for match in section 'disasm'." in search_result
|
||||
|
||||
# Search for something that doesn't exist
|
||||
search_result = gdb.execute("contextsearch 'nonexistent'", to_string=True)
|
||||
assert "String 'nonexistent' not found in context history." in search_result
|
||||
|
||||
# Search in non-existing section
|
||||
search_result = gdb.execute("ctxsearch 'Hello World' nonexistent", to_string=True)
|
||||
assert "Section 'nonexistent' not found in context history." in search_result
|
||||
|
|
Loading…
Reference in New Issue