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:
peace-maker 2024-09-19 15:08:05 +02:00 committed by GitHub
parent 6e9cdac13a
commit 5553a93a3e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 344 additions and 3 deletions

View File

@ -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
```

View File

@ -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 []

View File

@ -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

View File

@ -1,3 +1,4 @@
from __future__ import annotations
import pwndbg.gdblib.tui.context
import pwndbg.gdblib.tui.control

View File

@ -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)

View File

@ -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",
]

View File

@ -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