Repository: pwndbg/pwndbg
Pull request: Highlight breakpoints in `nearpc` output
Add visual indication for breakpoints in the `nearpc` output. Addresses in the disassembly which have an active breakpoint attached to them are prefixed using by `b+` and highlighted in red by default.

This can be configured using the new `highlight-breakpoints`, `nearpc-breakpoint`, and `nearpc-breakpoint-color` theme config options.

~~I only did proper tests on gdb, sorry.~~ Are there benchmarks to see if this slows `nearpc` down too bad?

Fixes #2783

![grafik](https://github.com/user-attachments/assets/37e9a13b-2550-4893-ab31-769fd57d8a05)
![grafik](https://github.com/user-attachments/assets/6f06ef0c-7c86-4313-b159-cd4f878cfd21)


Problem statement:
Show breakpoints/watchpoints in context
**Is your feature request related to a problem? Please describe.**
Visual debuggers allow you to click next to an assembly line to set a breakpoint at that address. They proceed to highlight that address in the disassembly view in e.g. red to visualize the presence of a breakpoint here. gdb/pwndbg doesn't do that and you'll have to match the list of breakpoints manually.

**Describe the solution you'd like**
I'd love to see some visual indicator in the disassembly context for addresses with breakpoints on them. The current pc address is marked by an => arrow and the address is highlighted with some colour already. The native disassembly TUI marks the line with a `b+` and we could do the same and highlight the address in red too when rendering disassembly.

A similar thing could be done for watchpoints, but I'm not sure where to display them.

Hints from issue discussion:
That's a great idea.

Commit message: Update tests/gdb-tests/tests/test_nearpc.py

Patch to review:
diff --git a/pwndbg/aglib/nearpc.py b/pwndbg/aglib/nearpc.py
index c008dfa940f..921d2feeaa0 100644
--- a/pwndbg/aglib/nearpc.py
+++ b/pwndbg/aglib/nearpc.py
@@ -36,6 +36,7 @@ def ljust_padding(lst):
         ColorParamSpec("symbol", "normal", "color for nearpc command (symbol)"),
         ColorParamSpec("address", "normal", "color for nearpc command (address)"),
         ColorParamSpec("prefix", "none", "color for nearpc command (prefix marker)"),
+        ColorParamSpec("breakpoint", "red", "color for nearpc command (breakpoint marker)"),
         ColorParamSpec("syscall-name", "red", "color for nearpc command (resolved syscall name)"),
         ColorParamSpec("argument", "bold", "color for nearpc command (target argument)"),
         ColorParamSpec(
@@ -57,7 +58,11 @@ def ljust_padding(lst):
     "contiguous branch marker line for nearpc command",
 )
 pwndbg.color.theme.add_param("highlight-pc", True, "whether to highlight the current instruction")
+pwndbg.color.theme.add_param("highlight-breakpoints", True, "whether to highlight breakpoints")
 pwndbg.color.theme.add_param("nearpc-prefix", "►", "prefix marker for nearpc command")
+pwndbg.color.theme.add_param(
+    "nearpc-breakpoint-prefix", "b+", "breakpoint marker for nearpc command"
+)
 pwndbg.config.add_param("left-pad-disasm", True, "whether to left-pad disassembly")
 nearpc_lines = pwndbg.config.add_param(
     "nearpc-lines", 10, "number of additional lines to print for the nearpc command"
@@ -168,11 +173,14 @@ def nearpc(
 
     assembly_strings = D.instructions_and_padding(instructions)
 
+    breakpoint_locations = pwndbg.dbg.breakpoint_locations()
+
     # Print out each instruction
     for i, (address_str, symbol, instr, asm) in enumerate(
         zip(addresses, symbols, instructions, assembly_strings)
     ):
         prefix_sign = pwndbg.config.nearpc_prefix
+        breakpoint_sign = pwndbg.config.nearpc_breakpoint_prefix
 
         # Show prefix only on the specified address and don't show it while in repeat-mode
         # or when showing current instruction for the second time
@@ -180,11 +188,21 @@ def nearpc(
         prefix = " %s" % (prefix_sign if show_prefix else " " * len(prefix_sign))
         prefix = c.prefix(prefix)
 
+        is_breakpoint = not show_prefix and instr.address in breakpoint_locations
+        # If the instruction is not the current instruction and a breakpoint,
+        # show the breakpoint sign
+        if is_breakpoint:
+            prefix = c.breakpoint(breakpoint_sign.ljust(len(prefix_sign)))
+
+        # If this instruction is a breakpoint and not the current pc, highlight it.
+        if is_breakpoint and pwndbg.config.highlight_breakpoints:
+            address_str = c.breakpoint(address_str)
+            symbol = c.breakpoint(symbol)
         # Colorize address and symbol if not highlighted
         # symbol is fetched from gdb and it can be e.g. '<main+8>'
         # In case there are duplicate instances of an instruction (tight loop),
         # ones that the instruction pointer is not at stick out a little, to indicate the repetition
-        if not pwndbg.config.highlight_pc or instr.address != pc or repeat:
+        elif not pwndbg.config.highlight_pc or instr.address != pc or repeat:
             address_str = c.address(address_str)
             symbol = c.symbol(symbol)
         elif pwndbg.config.highlight_pc and i == index_of_pc:
diff --git a/pwndbg/dbg/__init__.py b/pwndbg/dbg/__init__.py
index 3523a0189fc..1feefad9035 100644
--- a/pwndbg/dbg/__init__.py
+++ b/pwndbg/dbg/__init__.py
@@ -142,6 +142,13 @@ class BreakpointLocation:
     def __init__(self, address: int):
         self.address = address
 
+    def __eq__(self, other: object) -> bool:
+        if isinstance(other, BreakpointLocation):
+            return self.address == other.address
+        if isinstance(other, int):
+            return self.address == other
+        return False
+
 
 class WatchpointLocation:
     """
@@ -1145,6 +1152,13 @@ def supports_breakpoint_creation_during_stop_handler(self) -> bool:
         """
         raise NotImplementedError()
 
+    def breakpoint_locations(self) -> List[BreakpointLocation]:
+        """
+        Returns a list of all breakpoint locations that are currently
+        installed and enabled in the focused process.
+        """
+        raise NotImplementedError()
+
     # WARNING
     #
     # These are hacky parts of the API that were strictly necessary to bring up
diff --git a/pwndbg/dbg/gdb/__init__.py b/pwndbg/dbg/gdb/__init__.py
index 8a29f806f75..c8cbd3a41a8 100644
--- a/pwndbg/dbg/gdb/__init__.py
+++ b/pwndbg/dbg/gdb/__init__.py
@@ -1549,6 +1549,36 @@ def set_sysroot(self, sysroot: str) -> bool:
     def supports_breakpoint_creation_during_stop_handler(self) -> bool:
         return False
 
+    @override
+    def breakpoint_locations(self) -> List[pwndbg.dbg_mod.BreakpointLocation]:
+        bps = gdb.breakpoints()
+        locations: List[pwndbg.dbg_mod.BreakpointLocation] = []
+        for bp in bps:
+            if (
+                bp.is_valid()
+                and bp.enabled
+                and bp.type in (gdb.BP_BREAKPOINT, gdb.BP_HARDWARE_BREAKPOINT)
+                and bp.visible
+            ):
+                # GDB 13.1+
+                if hasattr(bp, "locations"):
+                    for location in bp.locations:
+                        locations.append(pwndbg.dbg_mod.BreakpointLocation(location.address))
+                else:
+                    # Num     Type           Disp Enb Address            What
+                    # 1       breakpoint     keep y   0x00007ffff7e90840 in __GI___libc_read at ../sysdeps/unix/sysv/linux/read.c:26
+                    bp_locations = gdb.execute(
+                        f"info breakpoint {bp.number}", to_string=True
+                    ).split("\n")
+                    for line in bp_locations:
+                        try:
+                            address = int(line.split()[4], 16)
+                            locations.append(pwndbg.dbg_mod.BreakpointLocation(address))
+                        except (IndexError, ValueError):
+                            # Ignore lines that don't have an address.
+                            pass
+        return locations
+
     @override
     def name(self) -> pwndbg.dbg_mod.DebuggerType:
         return pwndbg.dbg_mod.DebuggerType.GDB
diff --git a/pwndbg/dbg/lldb/__init__.py b/pwndbg/dbg/lldb/__init__.py
index bbce170e9e6..62e8ed65994 100644
--- a/pwndbg/dbg/lldb/__init__.py
+++ b/pwndbg/dbg/lldb/__init__.py
@@ -2029,6 +2029,20 @@ def set_sysroot(self, sysroot: str) -> bool:
     def supports_breakpoint_creation_during_stop_handler(self) -> bool:
         return True
 
+    @override
+    def breakpoint_locations(self) -> List[pwndbg.dbg_mod.BreakpointLocation]:
+        inferior: LLDBProcess = self.selected_inferior()
+        if inferior is None:
+            return []
+
+        bps: List[lldb.SBBreakpoint] = inferior.target.breakpoints
+        locations: List[pwndbg.dbg_mod.BreakpointLocation] = []
+        for bp in bps:
+            if bp.IsValid() and bp.IsEnabled():
+                for location in bp.locations:
+                    locations.append(location.GetAddress().GetLoadAddress(inferior.target))
+        return locations
+
     @override
     def name(self) -> pwndbg.dbg_mod.DebuggerType:
         return pwndbg.dbg_mod.DebuggerType.LLDB
diff --git a/tests/gdb-tests/tests/test_nearpc.py b/tests/gdb-tests/tests/test_nearpc.py
index 19b84d766ca..634c6e2ba60 100644
--- a/tests/gdb-tests/tests/test_nearpc.py
+++ b/tests/gdb-tests/tests/test_nearpc.py
@@ -184,3 +184,127 @@ def test_nearpc_opcode_invalid_config():
         )
     except gdb.error as e:
         assert expected == str(e)
+
+
+def test_nearpc_highlight_breakpoint(start_binary):
+    start_binary(SYSCALLS_BINARY)
+    gdb.execute("break *_start+5")
+    gdb.execute("break *_start+22")
+    dis = gdb.execute("nearpc", to_string=True)
+    expected = (
+        " ► 0x400080 <_start>       mov    eax, 0                 EAX => 0\n"
+        "b+ 0x400085 <_start+5>     mov    edi, 0x1337            EDI => 0x1337\n"
+        "   0x40008a <_start+10>    mov    esi, 0xdeadbeef        ESI => 0xdeadbeef\n"
+        "   0x40008f <_start+15>    mov    ecx, 0x10              ECX => 0x10\n"
+        "   0x400094 <_start+20>    syscall \n"
+        "b+ 0x400096 <_start+22>    mov    eax, 0xa               EAX => 0xa\n"
+        "   0x40009b <_start+27>    int    0x80\n"
+        "   0x40009d                add    byte ptr [rax], al\n"
+        "   0x40009f                add    byte ptr [rax], al\n"
+        "   0x4000a1                add    byte ptr [rax], al\n"
+        "   0x4000a3                add    byte ptr [rax], al\n"
+    )
+    assert dis == expected
+
+    gdb.execute("stepi")
+    dis = gdb.execute("nearpc", to_string=True)
+    # When we stop on a breakpoint, we only highlight it (and not show the "b+" marker)
+    expected = (
+        "   0x400080 <_start>       mov    eax, 0                 EAX => 0\n"
+        " ► 0x400085 <_start+5>     mov    edi, 0x1337            EDI => 0x1337\n"
+        "   0x40008a <_start+10>    mov    esi, 0xdeadbeef        ESI => 0xdeadbeef\n"
+        "   0x40008f <_start+15>    mov    ecx, 0x10              ECX => 0x10\n"
+        "   0x400094 <_start+20>    syscall \n"
+        "b+ 0x400096 <_start+22>    mov    eax, 0xa               EAX => 0xa\n"
+        "   0x40009b <_start+27>    int    0x80\n"
+        "   0x40009d                add    byte ptr [rax], al\n"
+        "   0x40009f                add    byte ptr [rax], al\n"
+        "   0x4000a1                add    byte ptr [rax], al\n"
+        "   0x4000a3                add    byte ptr [rax], al\n"
+    )
+    assert dis == expected
+
+    gdb.execute("stepi")
+    dis = gdb.execute("nearpc", to_string=True)
+    expected = (
+        "   0x400080 <_start>       mov    eax, 0                 EAX => 0\n"
+        "b+ 0x400085 <_start+5>     mov    edi, 0x1337            EDI => 0x1337\n"
+        " ► 0x40008a <_start+10>    mov    esi, 0xdeadbeef        ESI => 0xdeadbeef\n"
+        "   0x40008f <_start+15>    mov    ecx, 0x10              ECX => 0x10\n"
+        "   0x400094 <_start+20>    syscall \n"
+        "b+ 0x400096 <_start+22>    mov    eax, 0xa               EAX => 0xa\n"
+        "   0x40009b <_start+27>    int    0x80\n"
+        "   0x40009d                add    byte ptr [rax], al\n"
+        "   0x40009f                add    byte ptr [rax], al\n"
+        "   0x4000a1                add    byte ptr [rax], al\n"
+        "   0x4000a3                add    byte ptr [rax], al\n"
+    )
+    assert dis == expected
+
+    gdb.execute("disable breakpoint 1")
+    dis = gdb.execute("nearpc", to_string=True)
+    expected = (
+        "   0x400080 <_start>       mov    eax, 0                 EAX => 0\n"
+        "   0x400085 <_start+5>     mov    edi, 0x1337            EDI => 0x1337\n"
+        " ► 0x40008a <_start+10>    mov    esi, 0xdeadbeef        ESI => 0xdeadbeef\n"
+        "   0x40008f <_start+15>    mov    ecx, 0x10              ECX => 0x10\n"
+        "   0x400094 <_start+20>    syscall \n"
+        "b+ 0x400096 <_start+22>    mov    eax, 0xa               EAX => 0xa\n"
+        "   0x40009b <_start+27>    int    0x80\n"
+        "   0x40009d                add    byte ptr [rax], al\n"
+        "   0x40009f                add    byte ptr [rax], al\n"
+        "   0x4000a1                add    byte ptr [rax], al\n"
+        "   0x4000a3                add    byte ptr [rax], al\n"
+    )
+    assert dis == expected
+
+    gdb.execute("enable breakpoint 1")
+    dis = gdb.execute("nearpc", to_string=True)
+    expected = (
+        "   0x400080 <_start>       mov    eax, 0                 EAX => 0\n"
+        "b+ 0x400085 <_start+5>     mov    edi, 0x1337            EDI => 0x1337\n"
+        " ► 0x40008a <_start+10>    mov    esi, 0xdeadbeef        ESI => 0xdeadbeef\n"
+        "   0x40008f <_start+15>    mov    ecx, 0x10              ECX => 0x10\n"
+        "   0x400094 <_start+20>    syscall \n"
+        "b+ 0x400096 <_start+22>    mov    eax, 0xa               EAX => 0xa\n"
+        "   0x40009b <_start+27>    int    0x80\n"
+        "   0x40009d                add    byte ptr [rax], al\n"
+        "   0x40009f                add    byte ptr [rax], al\n"
+        "   0x4000a1                add    byte ptr [rax], al\n"
+        "   0x4000a3                add    byte ptr [rax], al\n"
+    )
+    assert dis == expected
+
+    gdb.execute("delete breakpoint 1")
+    dis = gdb.execute("nearpc", to_string=True)
+    expected = (
+        "   0x400080 <_start>       mov    eax, 0                 EAX => 0\n"
+        "   0x400085 <_start+5>     mov    edi, 0x1337            EDI => 0x1337\n"
+        " ► 0x40008a <_start+10>    mov    esi, 0xdeadbeef        ESI => 0xdeadbeef\n"
+        "   0x40008f <_start+15>    mov    ecx, 0x10              ECX => 0x10\n"
+        "   0x400094 <_start+20>    syscall \n"
+        "b+ 0x400096 <_start+22>    mov    eax, 0xa               EAX => 0xa\n"
+        "   0x40009b <_start+27>    int    0x80\n"
+        "   0x40009d                add    byte ptr [rax], al\n"
+        "   0x40009f                add    byte ptr [rax], al\n"
+        "   0x4000a1                add    byte ptr [rax], al\n"
+        "   0x4000a3                add    byte ptr [rax], al\n"
+    )
+    assert dis == expected
+
+    gdb.execute("delete breakpoint 2")
+    dis = gdb.execute("nearpc", to_string=True)
+    expected = (
+        "   0x400080 <_start>       mov    eax, 0                 EAX => 0\n"
+        "   0x400085 <_start+5>     mov    edi, 0x1337            EDI => 0x1337\n"
+        " ► 0x40008a <_start+10>    mov    esi, 0xdeadbeef        ESI => 0xdeadbeef\n"
+        "   0x40008f <_start+15>    mov    ecx, 0x10              ECX => 0x10\n"
+        "   0x400094 <_start+20>    syscall \n"
+        "   0x400096 <_start+22>    mov    eax, 0xa               EAX => 0xa\n"
+        "   0x40009b <_start+27>    int    0x80\n"
+        "   0x40009d                add    byte ptr [rax], al\n"
+        "   0x40009f                add    byte ptr [rax], al\n"
+        "   0x4000a1                add    byte ptr [rax], al\n"
+        "   0x4000a3                add    byte ptr [rax], al\n"
+    )
+    assert dis == expected


Code review comments:
diff_hunk: @@ -168,23 +173,36 @@ def nearpc(
 
     assembly_strings = D.instructions_and_padding(instructions)
 
+    breakpoint_locations = pwndbg.dbg.breakpoint_locations()
+
     # Print out each instruction
     for i, (address_str, symbol, instr, asm) in enumerate(
         zip(addresses, symbols, instructions, assembly_strings)
     ):
         prefix_sign = pwndbg.config.nearpc_prefix
+        breakpoint_sign = pwndbg.config.nearpc_breakpoint_prefix
path: pwndbg/aglib/nearpc.py
text: @user1:
Those two variables are re-fetched on each loop iteration. Can we move them out of the loop?

diff_hunk: @@ -168,23 +173,36 @@ def nearpc(
 
     assembly_strings = D.instructions_and_padding(instructions)
 
+    breakpoint_locations = pwndbg.dbg.breakpoint_locations()
+
     # Print out each instruction
     for i, (address_str, symbol, instr, asm) in enumerate(
         zip(addresses, symbols, instructions, assembly_strings)
     ):
         prefix_sign = pwndbg.config.nearpc_prefix
+        breakpoint_sign = pwndbg.config.nearpc_breakpoint_prefix
 
         # Show prefix only on the specified address and don't show it while in repeat-mode
         # or when showing current instruction for the second time
         show_prefix = instr.address == pc and not repeat and i == index_of_pc
         prefix = " %s" % (prefix_sign if show_prefix else " " * len(prefix_sign))
         prefix = c.prefix(prefix)
 
+        is_breakpoint = not show_prefix and instr.address in breakpoint_locations
+        # If the instruction is not the current instruction and a breakpoint,
+        # show the breakpoint sign
+        if is_breakpoint:
+            prefix = c.breakpoint(breakpoint_sign.ljust(len(prefix_sign)))
path: pwndbg/aglib/nearpc.py
text: @user1:
Shall we maybe do:
```py
if show_prefix:

elif instr.address in breakpoint_locations:

else:
```
?