Repository: pwndbg/pwndbg
Pull request: Add a command that tracks function calls through the Global Offset Table
Fixes: #1958

This PR adds a command (`enable-got-tracking`) that allows users to track and collect information about accesses made to dynamically linked functions, by analyzing the instances of a Global Offset Table (GOT) present in the running process's memory space.

## Functioning
Seeing as GDB has little to no support for directly querying information about the dynamic linkage of a process, achieving this requires a multi-stage process, that can be roughly summarized by the following steps, that must be performed in order:
1. **Library Discovery**: The binary images in the current process must be enumerated. We must have a way to find where the main executable has been mapped to in the process's memory space, as well as where all of the dynamic libraries it depends on have been mapped to. Additionally, it's not enough that we do this only once, as the running process may request additional dynamic libraries be mapped into its memory space though `dlopen()`, and so we must be able to catch those requests and respond to them.
2. **PLT/GOT Relocation Parsing**: Having discovered the location of the process's main executable and of all of its dynamic libraries, we must be able to enumerate all of the relocations belonging to them that refer to function calls made through addresses stored in the PLT/GOT. Additionally, we must be able to calculate the addresses of the relocation targets[^1] after the binary images have been mapped to memory at their real locations.
3. **Trap Call**: Calls made though the addresses present in the GOT must be trapped, or instrumented, such that a snapshot of the call stack can be taken when the call is made, for later analysis.

All of these tasks can get fairly involved, and have their own thorns that must be dealt with carefully. Thankfully, there is a single method that can solve both (1) and (2), and (3) can be tackled fairly independently from the other two. I will now go into more detail about the specifics of how they are implemented.

### Libraries and Relocations
There are many, many ways one can go about finding out which binaries are mapped to the address space of a process. In fact, pwndbg has an [implementation of this very thing already](https://github.com/pwndbg/pwndbg/blob/dev/pwndbg/commands/got.py). However, the mechanisms it uses have several quirks and edge cases that I judged would cause problems further down the line if it got incorporated into `gdblib` for other parts of pwndbg to use. They are:

- **Reliant on ELF sections**: In order to find the base addresses of the shared libraries, the offset of the `.text` section, as it is in the file, is compared to that reported by GDB's `info sharedlibraries` command. ELF sections aren't needed during program loading by either [Linux](https://elixir.bootlin.com/linux/v6.6.6/source/fs/binfmt_elf.c) or [`ld.so`](https://elixir.bootlin.com/glibc/glibc-2.38/source/elf/rtld.c). This means that one may use tools such as [`sstrip`](https://github.com/aunali1/super-strip) to completely remove the section headers from an ELF file, and that file will, nonetheless, continue to function as intended. Because of this, by relying on section headers, there are processes that will function correctly that cannot be analyzed this way.
- **Reliant on the `vmmap`**: The virtual memory map is used as part of figuring out the base address of the main executable. The way this is done is by comparing the names of the files attached to the pages of the vmmap to the value of `pwndbg.gdblib.proc.exe`, and picking the first entry that matches as the base address. However, it is often the case - particularly for QEMU, and other remote targets - that the vmmap reported has incorrect addresses or doesn't match the expectation that there will be at least one entry whose filename is equal to the value of `pwndbg.gdblib.proc.exe`.
- **Must download the ELF files from remote targets**: It's fairly hard to work your way back from the address of the `.text` section to reach the relocations without going through the ELF header, and the current way to get around this is by opening the entire file - or downloading it, in the case of a remote target. Ideally, we'd like to have to pull only as much data as is required to find and read the relocations.

[^1]:  For example, an ELF file that is of type `DYN` - often used for ASLR-compatible executables and shared libraries - initially has all of the addresses of its relocation targets be relative to the start of the file.

Fortunately, there is a way to do this reliably. 

First, the SystemV R4 ABI defines [a set of data structures](https://www.sco.com/developers/gabi/latest/ch5.dynamic.html) for use by the dynamic linker that contains all of the information needed for dynamic linking. It's pointed to by the program header that has type `PT_DYNAMIC`, and contains pointers to the symbol table, the strings table and the relocation tables. Crucially, as it is used by `ld.so`, these structures can't be removed from the program without breaking loading, unlike the section headers.

Second, `ld.so` has [a protocol](https://elixir.bootlin.com/glibc/glibc-2.38/source/include/link.h#L95) that we can use to both find the objects that are currently loaded, as well as where their dynamic segments are mapped to in memory. This means that, by leveraging [`r_debug`](https://elixir.bootlin.com/glibc/glibc-2.38/source/elf/link.h#L40), we can gather this information directly from `ld.so`. Additionally, the protocol defines a simple way for us to be notified of changes to the set of currently loaded dynamic libraries, though changes in the link map.

The code in this PR makes use of both of these mechanisms to find the relocations in the current process.

### Trapping on Calls
Seeing as for most, if not all, psABIs entries for jump are function pointers, we can't just put a breakpoint on them, seeing as execution may never actually pass through the address it's at at all. Additionally, we can't rely on breaking on the trampolines, because, even if there is one - which is not required -, it might not be in the form we expect it to be. Ideally, we'd be able to put a watchpoint on each of the function pointers, and break on reads to them. Unfortunately, this is impractical, seeing as it requires hardware watchpoints, and those are often very limited in number.

The way the current implementation of this PR gets around this is by leveraging the `pwndbg.gdblib.shellcode.exec_syscall` function. It `mmap`'s blocks of `PROT_READ | PROT_EXEC` pages into the memory space of the process and replaces the function pointers in the PLT/GOT with pointers to addresses contained in those pages, that then have breakpoints attached to them. These breakpoints, in turn, take note of the call stack and redirect execution to the intended location.

## Open Questions
The approach taken by the current implementation has some rough edges and limitations, especially when it comes to actually performing the tracking, and it's probably interesting to discuss alternatives that can help file them down. They are:

- [ ] **Continues**: GDB technically [doesn't allow for state changes to be made to the inferior or to GDB during `gdb.Breakpoint.stop()`](https://sourceware.org/gdb/current/onlinedocs/gdb.html/Breakpoints-In-Python.html#Breakpoints-In-Python), which becomes an issue when shellcode might be involved. Currently this PR gets around this by using [the event system](https://sourceware.org/gdb/current/onlinedocs/gdb.html/Events-In-Python.html) and performing those state changes during the event handlers, which seems to be allowed. This approach has one severe drawback, however, in that commands like `next` will behave incorrectly if they step over any calls that happen to be made through the GOT. The best way I could figure out to resume the program in some form after the breakpoint was hit, was running `continue` at the end of the stop event, which lets execution continue past where `next` would've stopped. Obviously, this is not ideal. Once we figure out a better way to do this, though, making changes should be easy enough, as this behavior is controlled entirely by the new `pwndbg.gdblib.bpoint.BreakpointEvent` class.
- [ ] **Testing**: The behavior needs further testing.
- [ ] **Data collection**: Call stacks made sense intuitively as a first pick for what kind of analysis data to collect, but it would be interesting to hear what else could be collected, and how it should be displayed.


Problem statement:
Support tracking function calls made through the PLT/GOT
## Motivation
When analyzing the behavior of dynamically linked programs, it's often interesting to know which parts of it interact with functions provided by library code[^1], as well as if the entry points to these external functions may be changed in some way. For many binaries, especially those built to be position independent[^2], this information may be acquired by inspecting and keeping track of accesses to entries in the Procedure Linkage Table (PLT) and the Global Offset Table (GOT).

[^1]: And how code inside libraries interacts with functions provided by the libraries they themselves depend on, and so on.
[^2]: Including, but is not limited to, most shared libraries, as well as executables built to make use of ASLR.

The PLT and the GOT are sections designed to allow efficient dynamic symbol resolution, at runtime or at load time, for position independent code. The technique that makes use of them does this by moving dynamic symbol references out of the text sections and into the tables, where each entry corresponds to a different symbol, and making it so that code in the text sections references the entries in the tables instead of the symbols directly[^3]. The actual runtime addresses of each of the dynamic symbols and the branch targets for the function calls based on them are stored in the tables.

[^3]: Referencing data in the tables can be done by means of relative addressing, and thus the instructions in the text sections need not be changed to be able to reference external symbols. This also allows for the text sections to be shared between all running instances of the program, which is one of the reasons why this technique is interesting in terms of memory efficiency.

Combined with this, it used to be common practice that the binding of symbols for function calls was done lazily, such that the function pointer would be initialized to the dynamic linker resolver, and, at the first call, the symbol would be resolved and that function pointer would be overwritten with the real address of the function. Predictably, using this method requires that the pages on which these function pointers reside be kept writable for at least as long as there are symbols still left to resolve, which, in practice, almost always means these pages are kept writable for the whole lifetime of the program. This means that, when being used by programs or libraries that do lazy binding, the tables can provide an attacker with an interesting vector with which to hijack execution.

While lazy binding has been falling out of favor, there should still be plenty of programs and libraries in which it is still used. And, so, I think implementing this might be interesting.

## Proposed Solution
Add two new commands that enable and disable tracking, and one or more commands to query information, though I'm not entirely sure how these should look like. Tracking could be done by means of watching for accesses to the addresses of entries in the PLT/GOT that correspond to branch target addresses. The location of which, for ELF, can be retrieved by reading the data from the segment marked with `PT_DYNAMIC`, more specifically, the dynamic array entries marked `DT_PLTGOT` and `DT_JMPREL`.

Watching accesses may be slow, so another possibility that can be considered for the mechanism by which accesses may be logged - although more experimental in nature and certainly not as simple to implement - would be to map executable pages owned by pwndbg to the address space of the executing program and change the entries in the PLT to branch to snippets of code that would write information about the running context into a memory region that could later be read from by pwndbg. These snippets would be JIT compiled for the current architecture by pwndbg and written to the page.

## Additional Context
There already is code in pwndbg - specifically, in `pwndbg/commands/got.py` - that deals with the problem of discovering the GOT for a given ELF image. However, the code has a major shortcoming in that it derives that information from the entries in the section header, rather than the ones in the `ST_DYNAMIC` segment, that `ld.so` uses. Meaning that there are valid binaries with a GOT that the existing code will nonetheless fail to read the it from. 

While there is an option that makes `readelf` use the `ST_DYNAMIC` segment that could be used to get around this, I'm not sure how much better that is as opposed to just parsing these fields from the ELF image ourselves.

Hints from issue discussion:
I plan to have a PR up, or at least a draft, implementing this, in a few days at most.
Interestingly, even the existing implementation in `pwndbg/commands/got.py`, that goes out of its way to support cases where the `vmmap` is invalid - specifically mentioning QEMU -, will still break if QEMU reports a `vmmap` where no entries have the same name as the original program image. This is the case for the main docker image, where QEMU calls it `main.exe`, which makes the following excerpt always fail:

```py
    # The following code is inspired by the "got" command of https://github.com/bata24/gef/blob/dev/gef.py by @bata24, thank you!
    # TODO/FIXME: Maybe a -v option to show more information will be better        
    outputs = []                                                                   
    if path == pwndbg.gdblib.proc.exe:                                             
        bin_base_offset = pwndbg.gdblib.proc.binary_base_addr if "PIE enabled" in pie_status else 0
    else:                                                                          
        # TODO/FIXME: Is there a better way to get the base address of the loaded shared library?
        # I guess parsing the vmmap result might also work, but what if it's not reliable or not available? (e.g. debugging with qemu-user)
        text_section_addr = pwndbg.gdblib.info.parsed_sharedlibrary()[path][0]  
        with open(local_path, "rb") as f:                                          
            bin_base_offset = (                                                    
                text_section_addr - ELFFile(f).get_section_by_name(".text").header["sh_addr"]
            )                                                                      
            print(bin_base_offset)
```

This is due to the fact that the method implementing the `pwndbg.gdblib.proc.binary_base_addr` property will end up raising an exception when the `vmmap` does not contain an entry whose name is the same as `pwndbg.gdblib.proc.exe`:

```
pwndbg> pi pwndbg.gdblib.proc.binary_base_addr
Traceback (most recent call last):
  File "<string>", line 1, in <module>
  File "/pwndbg/pwndbg/lib/cache.py", line 138, in decorator
    value = func(*a, **kw)
  File "/pwndbg/pwndbg/gdblib/proc.py", line 100, in binary_base_addr
    return self.binary_vmmap[0].start
IndexError: tuple index out of range
Error while executing Python code.
```

I'm wondering if for my implementation I should look for a way that `vmmap`s could be avoided entirely, or if we should just embrace them, since the current GOT-related code already requires a somewhat well-formed one to be available anyway.

Commit message: Disable broken r_brk hooks, for now.

Patch to review:
diff --git a/pwndbg/commands/__init__.py b/pwndbg/commands/__init__.py
index ae3f5623906..5bfd0dbff0d 100644
--- a/pwndbg/commands/__init__.py
+++ b/pwndbg/commands/__init__.py
@@ -626,6 +626,7 @@ def load_commands() -> None:
     import pwndbg.commands.flags
     import pwndbg.commands.ghidra
     import pwndbg.commands.got
+    import pwndbg.commands.got_tracking
     import pwndbg.commands.heap
     import pwndbg.commands.hexdump
     import pwndbg.commands.ida
@@ -638,6 +639,7 @@ def load_commands() -> None:
     import pwndbg.commands.killthreads
     import pwndbg.commands.kversion
     import pwndbg.commands.leakfind
+    import pwndbg.commands.linkmap
     import pwndbg.commands.memoize
     import pwndbg.commands.misc
     import pwndbg.commands.mmap
diff --git a/pwndbg/commands/got_tracking.py b/pwndbg/commands/got_tracking.py
new file mode 100644
index 00000000000..e1e6ddbd27b
--- /dev/null
+++ b/pwndbg/commands/got_tracking.py
@@ -0,0 +1,237 @@
+from __future__ import annotations
+
+import argparse
+import re
+
+import pwndbg.color.message as message
+import pwndbg.gdblib.dynamic
+import pwndbg.gdblib.got
+import pwndbg.gdblib.proc
+from pwndbg.commands import CommandCategory
+
+
+def columns(rows, colors=None):
+    """
+    Print data formatted into distinct columns.
+    """
+    if len(rows) == 0:
+        # Nothing to print.
+        return
+
+    col_max = [0 for _ in range(len(rows[0]))]
+    for i in range(len(rows)):
+        if len(rows[i]) == 0:
+            continue
+        for j in range(len(col_max)):
+            if len(rows[i][j]) > col_max[j]:
+                col_max[j] = len(rows[i][j])
+
+    for i in range(len(rows)):
+        if len(rows[i]) == 0:
+            print()
+            continue
+
+        for j in range(len(col_max)):
+            color = colors[j] if colors is not None else lambda x: x
+            print(f"{color(rows[i][j].ljust(col_max[j]))} ", end="")
+        print()
+
+
+# Subcommand that enables the tracker.
+parser = argparse.ArgumentParser(
+    description="Controls GOT tracking",
+)
+subparsers = parser.add_subparsers(
+    required=True, description="Used to disable and query information about the tracker"
+)
+
+# Subcommand that enables the tracker.
+enable = subparsers.add_parser("enable", help="Enable GOT parsing")
+enable.set_defaults(mode="enable")
+
+# Subcommand that disables the tracker.
+disable = subparsers.add_parser("disable", help="Disable GOT tracking")
+disable.set_defaults(mode="disable")
+
+# Subcommand that produces a report.
+report = subparsers.add_parser("info", help="Give an overview of the GOT tracker")
+report.add_argument(
+    "-s",
+    "--so-name",
+    type=str,
+    dest="soname",
+    default=".*",
+    help="Selects objects whose names matche the given expression",
+)
+report.add_argument(
+    "-w",
+    "--writable",
+    dest="writable",
+    action="store_true",
+    help="Only show functions whose GOT entry is in a writable region of memory",
+)
+report.add_argument(
+    "-f",
+    "--function-name",
+    type=str,
+    dest="fnname",
+    default=".*",
+    help="Selects functions whose names match the given expression",
+)
+report.set_defaults(mode="report")
+
+# Subcommand that queries for information about a specific tracker.
+status = subparsers.add_parser(
+    "query", help="Queries detailed tracking information about a single entry in the GOT"
+)
+status.add_argument(
+    "address",
+    type=str,
+    help="The address of the GOT entry being tracked",
+)
+status.set_defaults(mode="status")
+
+
+@pwndbg.commands.ArgparsedCommand(parser, category=CommandCategory.LINUX, command_name="track-got")
+@pwndbg.commands.OnlyWhenRunning
+def track_got(mode=None, soname=None, writable=False, fnname=None, address=None):
+    if mode == "enable":
+        # Enable the tracker.
+        if pwndbg.gdblib.got.GOT_TRACKING:
+            print("GOT tracking is already enabled. Did you mean to use a flag?")
+            return
+        pwndbg.gdblib.got.enable_got_call_tracking()
+    elif mode == "disable":
+        # Disable the tracker.
+        if not pwndbg.gdblib.got.GOT_TRACKING:
+            print("GOT tracking is already disabled. Did you mean to enable it with `track-got`?")
+            return
+        pwndbg.gdblib.got.disable_got_call_tracking()
+    elif mode == "report":
+        # Delegate to the report function.
+        got_report(soname=soname, writable=writable, fnname=fnname)
+    elif mode == "status":
+        # Delegate to the status function.
+        got_tracking_status(address=address)
+    else:
+        raise AssertionError(f"track-got must never have invalid mode '{mode}'. this is a bug")
+
+
+def got_report(soname=".*", writable=False, fnname=".*"):
+    """
+    Prints out a report of the current status of the GOT tracker.
+    """
+    if not pwndbg.gdblib.got.GOT_TRACKING:
+        print(message.error("GOT call tracking is not enabled"))
+        return
+
+    soname = re.compile(soname)
+    fnname = re.compile(fnname)
+
+    print(
+        f"Showing {'writable' if writable else 'all'} GOT function entries and how many times they were called."
+    )
+    print()
+
+    per_object = {}
+    for _, (tracker, patcher) in pwndbg.gdblib.got.all_tracked_entries():
+        objname = tracker.link_map_entry.name()
+        if objname == b"":
+            objname = pwndbg.gdblib.proc.exe
+        else:
+            objname = pwndbg.gdblib.got.display_name(objname)
+
+        # Filter out objects we're not interested in.
+        if soname.match(objname) is None:
+            continue
+
+        if objname not in per_object:
+            per_object[objname] = []
+        per_object[objname].append((tracker, patcher))
+
+    for objname, trackers in per_object.items():
+        print(f"Calls from {objname}:")
+        rows = [["Address in GOT", "Function Address", "Symbol", "Call Count"]]
+        for tracker, patcher in trackers:
+            # If requested, filter out entries that are not in a writable
+            # portion of memory.
+            if writable and not pwndbg.gdblib.vmmap.find(patcher.entry).write:
+                continue
+
+            dynamic = tracker.dynamic_section
+            sym_index = tracker.relocation_fn(tracker.relocation_index, "r_sym")
+            sym_name = dynamic.symtab_read(sym_index, "st_name")
+            sym_name = pwndbg.gdblib.got.display_name(dynamic.string(sym_name))
+
+            # Filter out symbols we're not interested in.
+            if fnname.match(sym_name) is None:
+                continue
+
+            if sym_name == "":
+                sym_name = "<Empty>"
+
+            hits = tracker.total_hits
+            hits = f"{hits} hit{'s' if hits != 1 else ''}"
+            rows.append([f"{tracker.target:#x}", f"{patcher.entry:#x}", sym_name, hits])
+        columns(rows)
+        print()
+
+
+def got_tracking_status(address):
+    """
+    Prints out information about a single GOT tracking entry.
+    """
+    if not pwndbg.gdblib.got.GOT_TRACKING:
+        print(message.error("GOT call tracking is not enabled"))
+        return
+
+    try:
+        address = int(address, 0)
+    except ValueError as e:
+        print(message.error(f"Invalid address {address}: {e}"))
+        return
+
+    result = pwndbg.gdblib.got.tracked_entry_by_address(address)
+    if result is None:
+        print(message.error(f"No entry at address {address:#x}"))
+        print("Hint: This command expects the address of the entry in the GOT. So, consider")
+        print("using the address from the 'Address in GOT' column of the `track-got info`")
+        print("command.")
+        return
+
+    tracker, patcher = result
+    rows = []
+
+    dynamic = tracker.dynamic_section
+    sym_index = tracker.relocation_fn(tracker.relocation_index, "r_sym")
+    raw_sym_name = dynamic.symtab_read(sym_index, "st_name")
+    sym_name = pwndbg.gdblib.got.display_name(dynamic.string(raw_sym_name))
+
+    if sym_name == "":
+        sym_name = "<Empty>"
+
+    objname = tracker.link_map_entry.name()
+    if objname == b"":
+        objname = pwndbg.gdblib.proc.exe
+    else:
+        objname = pwndbg.gdblib.got.display_name(objname)
+
+    print(f"Tracking details for {sym_name}")
+    print()
+    print(f"Dynamic object name: {objname}")
+    print(f"Jump target address: {tracker.target:#x}")
+    print(f"GOT entry address:   {address:#x}")
+    print(f"Relocation index:    {tracker.relocation_index}")
+    print(f"Symbol index:        {sym_index}")
+    print()
+
+    callers = sorted(tracker.hits.items(), key=lambda x: x[1])
+    for stack, hits in callers:
+        print(f"Called {hits} times from stack:")
+        for entry in stack:
+            print(f"    - {entry:#x} ", end="")
+            symname = pwndbg.gdblib.symbol.get(entry)
+            if symname != "":
+                print(f"<{symname}>", end="")
+            print()
+        print()
diff --git a/pwndbg/commands/linkmap.py b/pwndbg/commands/linkmap.py
new file mode 100644
index 00000000000..71edc43efb5
--- /dev/null
+++ b/pwndbg/commands/linkmap.py
@@ -0,0 +1,42 @@
+from __future__ import annotations
+
+import argparse
+
+import pwndbg.color as color
+import pwndbg.gdblib.dynamic
+import pwndbg.gdblib.proc
+from pwndbg.commands import CommandCategory
+
+parser = argparse.ArgumentParser(
+    description="Show the state of the Link Map",
+)
+
+
+@pwndbg.commands.ArgparsedCommand(parser, category=CommandCategory.LINUX)
+@pwndbg.commands.OnlyWhenRunning
+def linkmap():
+    is_first = True
+    rows = [["Node", "Objfile", "Load Bias", "Dynamic Segment"]]
+    for obj in pwndbg.gdblib.dynamic.link_map():
+        name = obj.name().decode("utf-8")
+        if name == "":
+            name = "<Unknown"
+            if is_first:
+                is_first = False
+                name += f", likely {pwndbg.gdblib.proc.exe}"
+            name += ">"
+        rows.append(
+            [f"{obj.link_map_address:#x}", name, f"{obj.load_bias():#x}", f"{obj.dynamic():#x}"]
+        )
+
+    col_max = [0, 0, 0, 0]
+    for i in range(len(rows)):
+        for j in range(len(col_max)):
+            if len(rows[i][j]) > col_max[j]:
+                col_max[j] = len(rows[i][j])
+
+    colors = [color.light_cyan, color.light_yellow, color.light_red, color.light_purple]
+    for i in range(len(rows)):
+        for j in range(len(col_max)):
+            print(f"{colors[j](rows[i][j].ljust(col_max[j]))} ", end="")
+        print()
diff --git a/pwndbg/gdblib/__init__.py b/pwndbg/gdblib/__init__.py
index e789b35a3a4..2a8ef82ec17 100644
--- a/pwndbg/gdblib/__init__.py
+++ b/pwndbg/gdblib/__init__.py
@@ -31,9 +31,11 @@ def load_gdblib() -> None:
     import pwndbg.gdblib.argv
     import pwndbg.gdblib.bpoint
     import pwndbg.gdblib.ctypes
+    import pwndbg.gdblib.dynamic
     import pwndbg.gdblib.elf
     import pwndbg.gdblib.events
     import pwndbg.gdblib.functions
+    import pwndbg.gdblib.got
     import pwndbg.gdblib.hooks
     import pwndbg.gdblib.kernel
     import pwndbg.gdblib.memory
diff --git a/pwndbg/gdblib/bpoint.py b/pwndbg/gdblib/bpoint.py
index dd64e3f4795..8455782e0aa 100644
--- a/pwndbg/gdblib/bpoint.py
+++ b/pwndbg/gdblib/bpoint.py
@@ -26,3 +26,34 @@ def should_stop(self):
         its return value determines whether the inferior will be stopped.
         """
         return True
+
+
+REGISTERED_BP_EVENTS = {}
+
+
+class BreakpointEvent(gdb.Breakpoint):
+    """
+    Breakpoint class, similar to gdb.Breakpoint, but executes a given callback
+    when, or very shortly after, a the breakpoint is hit, but does not stop
+    the execution of the inferior.
+
+    This allows us to execute code that changes the state of the inferior safely
+    after a breakpoint is hit.
+    """
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        REGISTERED_BP_EVENTS[id(self)] = self
+        self.commands = (
+            f"python pwndbg.gdblib.bpoint.REGISTERED_BP_EVENTS[{id(self)}].on_breakpoint_hit()"
+        )
+
+    def delete(self):
+        REGISTERED_BP_EVENTS.remove(id(self))
+        super().delete()
+
+    def on_breakpoint_hit(self):
+        """
+        This function is called whenever this breakpoint is hit in the code.
+        """
+        pass
diff --git a/pwndbg/gdblib/dynamic.py b/pwndbg/gdblib/dynamic.py
new file mode 100644
index 00000000000..17cbf4adc13
--- /dev/null
+++ b/pwndbg/gdblib/dynamic.py
@@ -0,0 +1,805 @@
+"""
+Dynamic linking interface.
+
+This module provides an interface to analyze various aspects of dynamically
+linked programs.
+
+It also defines a hook that watches for changes to the link map communicated by
+ld.so, and exposes an event that other parts of pwndbg can tap into, but one
+that may have a somewhat obtuse beahvior, due to limitations in GDB. See
+`r_debug_install_link_map_changed_hook` for more information.
+"""
+
+from __future__ import annotations
+
+import gdb
+
+import pwndbg.color.message as message
+import pwndbg.gdblib.bpoint
+import pwndbg.gdblib.memory
+import pwndbg.gdblib.typeinfo
+import pwndbg.lib.cache
+from pwndbg.lib.elftypes import constants as elf
+
+
+def _r_debug():
+    """
+    The easiest entry point into the link map is through the debug structure
+    provided by ld.so. It provides a convenient pointer into the head of the
+    link map list[1], and can be found at the address that corresponds to the
+    `_r_debug` symbol[2].
+
+    [1]: https://elixir.bootlin.com/glibc/latest/source/elf/link.h#L45
+    [2]: https://elixir.bootlin.com/glibc/glibc-2.38/source/elf/dl-debug-symbols.S#L30
+    """
+
+    try:
+        address = gdb.execute("output/x &_r_debug", to_string=True)
+        address = int(address, 0)
+
+        return address
+    except gdb.error:
+        # Symbol is most likely unavailable.
+        return None
+
+
+def is_dynamic():
+    """
+    Returns whether the current inferior is dynamic.
+
+    Not all programs are dynamically linked, or even need the dynamic loader at
+    all. Since this module is entirely reliant on at least the presence of the
+    dynamic loader, and really only makes sense for dynamic programs, it should
+    not be used at all with programs that don't participate in dynamic linkage,
+    or when there is a dynamic linker, but we have no way to talk to it.
+    """
+    return _r_debug() is not None
+
+
+# Reference to our hook in the link map update breakpoint, if it is installed.
+R_DEBUG_LINK_MAP_CHANGED_HOOK = None
+R_DEBUG_LINK_MAP_CHANGED_LISTENERS = set()
+
+
+class RDebugLinkMapChangedHook(pwndbg.gdblib.bpoint.BreakpointEvent):
+    """
+    Hook that gets activated whenever the link map changes.
+
+    The r_debug structure, in addition to having a refence to the head of the
+    link map, also has, in its ABI-stable part, a reference to an address that
+    can have a breakpoint attached to it, such that whenever the contents of the
+    link map change, that breakpoint will be triggered[1].
+
+    We take advantage of that here, by installing our own breakpoint in that
+    location, and watching for trigger events, so that we can notify other bits
+    of pwndbg that the contents of the `link_map()` function will be different.
+
+    [1]: https://elixir.bootlin.com/glibc/glibc-2.37/source/elf/link.h#L52
+    """
+
+    def __init__(self, *args, **kwargs):
+        self.skip_this = True
+        super().__init__(*args, **kwargs)
+
+    def stop(self):
+        # Skip every other trigger, we only care about the completed link map
+        # that is available after the library is loaded.
+        self.skip_this = not self.skip_this
+        return not self.skip_this
+
+    def on_breakpoint_hit(self):
+        # Clear the cache that is tied to link map updates, and signal all of
+        # the interested parties that this event has occurred.
+        for listener in R_DEBUG_LINK_MAP_CHANGED_LISTENERS:
+            listener()
+
+
+# FIXME: Obviously, having consumers call this function is not ideal. We really
+# should find a way to install this automatically at the correct time. What we
+# want is something that can run some arbitrary Python code at the same point
+# in the lifecycle of the inferior as the user would be put in if they were to
+# run `stepi`.
+def r_debug_install_link_map_changed_hook():
+    """
+    Installs the r_debug-based hook to the change event of the link map.
+
+    This function is a bit tricky, because ideally we want it to be run as
+    soon as possible, before even the dynamic linker runs, but after both it and
+    the main binary have been mapped into the address space of the inferior.
+    While doing this manually would be trivial - seeing as there is a command
+    in GDB that gives the user control at the exact place we would like -, there
+    does not seem to be a way of easily doing this from inside Python.
+
+    Because of this, parts of the code that rely on the hook should try calling
+    this function and firing their own listeners manually at least once.
+    """
+    # TODO: Currently, reacting to this event has some unpleasant side effects
+    # on usability. Until this can be fixed, this hook is unavailable.
+    print(
+        message.warn("r_brk hook is disabled. r_debug_install_link_map_changed_hook() does nothing")
+    )
+    return
+
+    global R_DEBUG_LINK_MAP_CHANGED_HOOK
+    if R_DEBUG_LINK_MAP_CHANGED_HOOK is not None:
+        return
+
+    r_debug_address = _r_debug()
+    if r_debug_address is None:
+        print(message.warn("symbol _r_debug is missing, cannot install link map change hook"))
+        return
+
+    r_debug = CStruct.r_debug()
+    r_brk = r_debug.read(r_debug_address, "r_brk")
+
+    bp = RDebugLinkMapChangedHook(f"*{r_brk:#x}", internal=True)
+
+    R_DEBUG_LINK_MAP_CHANGED_HOOK = bp
+
+
+def r_debug_link_map_changed_add_listener(handler):
+    """
+    Install a callback to be called whenever r_debug signal of there being a
+    change in the link map link map is triggered.
+
+    Keep in mind this function may be called before the hook that calls the
+    listeners is installed, and, until it is installed, no listener callbacks
+    will actually be triggered. See `r_debug_install_link_map_changed_hook`.
+    """
+    R_DEBUG_LINK_MAP_CHANGED_LISTENERS.add(handler)
+
+
+def r_debug_link_map_changed_remove_listener(handler):
+    """
+    Removes a listener previously installed with
+    r_debug_link_map_changed_add_listener().
+    """
+    R_DEBUG_LINK_MAP_CHANGED_LISTENERS.remove(handler)
+
+
+def link_map_head():
+    """
+    Acquires a reference to the head entry of the link map.
+    """
+    r_debug_address = _r_debug()
+    if r_debug_address is None:
+        print(message.warn("symbol _r_debug is missing, cannot find link map"))
+        return None
+
+    r_debug = CStruct.r_debug()
+
+    r_version = r_debug.read(r_debug_address, "r_version")
+    r_map = r_debug.read(r_debug_address, "r_map")
+
+    if r_map != 0:
+        return LinkMapEntry(r_map)
+
+
+def link_map():
+    """
+    Iterator over all the entries in the link map.
+    """
+    head = link_map_head()
+    while head is not None:
+        yield head
+        head = head.next()
+
+
+class LinkMapEntry:
+    """
+    An entry in the link map.
+    """
+
+    def __init__(self, address):
+        self.link_map = CStruct.link_map()
+        self.link_map_address = address
+
+    def name(self):
+        """
+        The name of the binary image this entry describes.
+        """
+        ptr = self.link_map.read(self.link_map_address, "l_name")
+        return pwndbg.gdblib.memory.string(ptr)
+
+    def dynamic(self):
+        """
+        The pointer to the memory mapped dynamic segment of the binary image.
+        """
+        return self.link_map.read(self.link_map_address, "l_ld")
+
+    def load_bias(self):
+        """
+        The difference between the addresses in the data structures of the
+        binary image and the actual location of the data being pointed to by
+        them in the address space of the inferior. This number will never be
+        negative.
+
+        Aditionally, for DYN images, such as PIE executables and shared
+        libraries, this value is the same as the base load address of the image.
+
+        The term "load bias" comes from the ELF binary format loading procedure
+        in the Linux Kernel.
+        """
+        return self.link_map.read(self.link_map_address, "l_addr")
+
+    def next(self):
+        """
+        The next entry in the chain, if any.
+        """
+        ptr = self.link_map.read(self.link_map_address, "l_next")
+        if ptr == 0:
+            return None
+        else:
+            return LinkMapEntry(ptr)
+
+    def prev(self):
+        """
+        The previous entry in the chain, if any.
+        """
+        ptr = self.link_map.read(self.link_map_address, "l_prev")
+        if ptr == 0:
+            return None
+        else:
+            return LinkMapEntry(ptr)
+
+    def __repr__(self):
+        return f"<{self.__class__.__name__} node={self.link_map_address:#x} name={self.name()} load_bias={self.load_bias():#x} dynamic={self.dynamic():#x}>"
+
+
+# Normally, only one entry for each tag is allowed to be present in the dynamic
+# array for us to consider the dynamic array to be well-formed. Tags in this
+# set are allowed to appear multiple times.
+DYNAMIC_SECTION_ALLOW_MULTIPLE = set([elf.DT_NEEDED])
+
+# The DynamicSegment class expects some tags to always be present to function
+# correctly. In this set we list them explicitly. Code in that class is allowed
+# to presume these tags are always present after __init__.
+DYNAMIC_SECTION_REQUIRED_TAGS = set(
+    [
+        elf.DT_STRTAB,
+        elf.DT_STRSZ,
+        elf.DT_SYMTAB,
+        elf.DT_SYMENT,
+    ]
+)
+
+
+class DynamicSegment:
+    """
+    Parser for the DYNAMIC segment present in a binary image.
+    """
+
+    strtab_addr = 0
+    strtab_size = 0
+
+    symtab_addr = 0
+    symtab_elem = None
+
+    entries_by_tag = {}
+
+    has_jmprel = False
+    has_rela = False
+    has_rel = False
+
+    jmprel_addr = 0
+    rela_addr = 0
+    rel_addr = 0
+
+    jmprel_elem = None
+    rela_elem = None
+    rel_elem = None
+
+    jmprel_r_sym_fn = None
+    jmprel_r_info_fn = None
+
+    rela_r_sym_fn = None
+    rela_r_info_fn = None
+
+    rel_r_sym_fn = None
+    rel_r_info_fn = None
+
+    def __init__(self, address, load_bias):
+        # Enumerate the ElfNN_Dyn entries.
+        count = 0
+        elf_dyn = CStruct.elfNN_dyn()
+
+        while elf_dyn.read(address + count * elf_dyn.size, "d_tag") != elf.DT_NULL:
+            count += 1
+
+        # Set up the fields used by the methods we use next.
+        self.entries = count
+        self.address = address
+        self.load_bias = load_bias
+        self.elf_dyn = elf_dyn
+
+        # Map the tags we want to find to their respective entires. We don't
+        # allow for repeats on most tags as they should only appear once in a
+        # well-formed dynamic segment.
+        sections = {}
+        for i in range(self.entries):
+            tag = self.dyn_array_read(i, "d_tag")
+            if tag in sections:
+                if tag not in DYNAMIC_SECTION_ALLOW_MULTIPLE:
+                    raise RuntimeError(f"tag {tag:#x} repeated")
+
+                if isinstance(sections[tag], list):
+                    sections[tag].append(i)
+                else:
+                    sections[tag] = [sections[tag], i]
+            else:
+                sections[tag] = i
+        for tag in DYNAMIC_SECTION_REQUIRED_TAGS:
+            if tag not in sections:
+                raise RuntimeError(f"missing requried tag {tag:#x}")
+        self.entries_by_tag = sections
+
+        # Setup the string table reference.
+        self.strtab_addr = self.dyn_array_read_tag_val(elf.DT_STRTAB)
+        self.strtab_size = self.dyn_array_read_tag_val(elf.DT_STRSZ)
+
+        # Find the address of the symbol table and determine the correct version
+        # of the ElfNN_Sym structure to use for this table, based on the size
+        # of the elements given by DT_SYMENT.
+        self.symtab_addr = self.dyn_array_read_tag_val(elf.DT_SYMTAB)
+
+        syment = self.dyn_array_read_tag_val(elf.DT_SYMENT)
+        if syment == 16:
+            self.symtab_elem = CStruct.elf32_sym()
+        elif syment == 24:
+            self.symtab_elem = CStruct.elf64_sym()
+        else:
+            raise RuntimeError(
+                f"unsupported value {syment} for DT_SYMENT, expected either 16 (Elf32_Sym) or 24 (Elf64_Sym)"
+            )
+
+        # Check the relocation sections, and perform some sanity checks.
+        self.has_jmprel = (
+            elf.DT_JMPREL in sections and elf.DT_PLTREL in sections and elf.DT_PLTRELSZ in sections
+        )
+        self.has_rela = (
+            elf.DT_RELA in sections and elf.DT_RELASZ in sections and elf.DT_RELAENT in sections
+        )
+        self.has_rel = (
+            elf.DT_REL in sections and elf.DT_RELSZ in sections and elf.DT_RELENT in sections
+        )
+
+        # Create the CStructs for the entries in each of our relocation sections
+        # and make sure that their size matches the value of their respective
+        # dynamic array element size entry. Additionally, pick selectors for
+        # r_sym and r_type based on the size of the element of each of the
+        # relocation sections.
+        if self.has_rela:
+            self.rela_addr = self.dyn_array_read_tag_val(elf.DT_RELA)
+            self.rela_elem = CStruct.elfNN_rela()
+            relaent = self.dyn_array_read_tag_val(elf.DT_RELAENT)
+            assert self.rela_elem.size == relaent
+
+            if self.dyn_array_read_tag_val(elf.DT_RELASZ) % self.rela_elem.size != 0:
+                raise RuntimeError("DT_RELASZ is not divisible by DT_RELAENT")
+
+            if self.rela_elem.size == 12:
+                self.rela_r_sym = elf32_r_sym
+                self.rela_r_type = elf32_r_type
+            elif self.rela_elem.size == 24:
+                self.rela_r_sym = elf64_r_sym
+                self.rela_r_type = elf64_r_type
+            else:
+                raise RuntimeError(
+                    f"DT_RELAENT is {self.rela_elem}, expected 12 (ELF32) or 24 (ELF64)"
+                )
+
+        if self.has_rel:
+            self.rel_addr = self.dyn_array_read_tag_val(elf.DT_REL)
+            self.rel_elem = CStruct.elfNN_rel()
+            relent = self.dyn_array_read_tag_val(elf.DT_RELENT)
+            assert self.rel_elem.size == relent
+
+            if self.dyn_array_read_tag_val(elf.DT_RELSZ) % self.rel_elem.size != 0:
+                raise RuntimeError("DT_RELSZ is not divisible by DT_RELENT")
+
+            if self.rel_elem.size == 8:
+                self.rel_r_sym = elf32_r_sym
+                self.rel_r_type = elf32_r_type
+            elif self.rel_elem.size == 16:
+                self.rel_r_sym = elf64_r_sym
+                self.rel_r_type = elf64_r_type
+            else:
+                raise RuntimeError(
+                    f"DT_RELENT is {self.rel_elem}, expected 8 (ELF32) or 16 (ELF64)"
+                )
+
+        if self.has_jmprel:
+            self.jmprel_addr = self.dyn_array_read_tag_val(elf.DT_JMPREL)
+            pltrel = self.dyn_array_read_tag_val(elf.DT_PLTREL)
+            if pltrel == elf.DT_RELA:
+                self.jmprel_elem = CStruct.elfNN_rela()
+                if elf.DT_RELAENT not in sections:
+                    raise RuntimeError("DT_PLTREL is DT_RELA, but missing DT_RELAENT")
+                assert self.jmprel_elem.size == self.dyn_array_read_tag_val(elf.DT_RELAENT)
+
+                if self.rela_elem.size == 12:
+                    self.jmprel_r_sym = elf32_r_sym
+                    self.jmprel_r_type = elf32_r_type
+                elif self.rela_elem.size == 24:
+                    self.jmprel_r_sym = elf64_r_sym
+                    self.jmprel_r_type = elf64_r_type
+                else:
+                    raise RuntimeError(
+                        f"DT_RELAENT is {self.rela_elem}, expected 12 (ELF32) or 24 (ELF64)"
+                    )
+
+            elif pltrel == elf.DT_REL:
+                self.jmprel_elem = CStruct.elfNN_rel()
+                if elf.DT_RELENT not in sections:
+                    raise RuntimeError("DT_PLTREL is DT_REL, but missing DT_RELENT")
+                assert self.jmprel_elem.size == self.dyn_array_read_tag_val(elf.DT_RELENT)
+
+                if self.jmprel_elem.size == 8:
+                    self.jmprel_r_sym = elf32_r_sym
+                    self.jmprel_r_type = elf32_r_type
+                elif self.jmprel_elem.size == 16:
+                    self.jmprel_r_sym = elf64_r_sym
+                    self.jmprel_r_type = elf64_r_type
+                else:
+                    raise RuntimeError(
+                        f"DT_RELENT is {self.rel_elem}, expected 8 (ELF32) or 16 (ELF64)"
+                    )
+
+            if self.dyn_array_read_tag_val(elf.DT_PLTRELSZ) % self.jmprel_elem.size != 0:
+                raise RuntimeError("DT_PLTRELSZ is not divisible by the element size")
+
+    def jmprel_has_addend(self):
+        """
+        Returns whether the `r_addend` field is available in entries of JMPREL.
+        """
+        assert self.has_jmprel
+        return self.dyn_array_read_tag_val(elf.DT_PLTREL) == elf.DT_RELA
+
+    def rela_read(self, i, field):
+        """
+        Reads the requested field from the entry of the given index in RELA.
+        """
+        assert self.has_rela
+        count = self.rela_entry_count()
+        if i >= count:
+            raise ValueError(f"tried to read entry {i} in RELA with only {count} entries")
+
+        transform = lambda x: x
+        if field == "r_sym":
+            transform = self.rela_r_sym
+            field = "r_info"
+        elif field == "r_type":
+            transform = self.rela_r_type
+            field = "r_info"
+
+        return transform(self.rela_elem.read(self.rela_addr + i * self.rela_elem.size, field))
+
+    def rel_read(self, i, field):
+        """
+        Reads the requested field from the entry of the given index in REL.
+        """
+        assert self.has_rel
+        count = self.rel_entry_count()
+        if i >= count:
+            raise ValueError(f"tried to read entry {i} in REL with only {count} entries")
+
+        transform = lambda x: x
+        if field == "r_sym":
+            transform = self.rel_r_sym
+            field = "r_info"
+        elif field == "r_type":
+            transform = self.rel_r_type
+            field = "r_info"
+
+        return transform(self.rel_elem.read(self.rel_addr + i * self.rel_elem.size, field))
+
+    def jmprel_read(self, i, field):
+        """
+        Reads the requested field from the entry of the given index in JMPREL.
+        """
+        assert self.has_jmprel
+        count = self.jmprel_entry_count()
+        if i >= count:
+            raise ValueError(f"tried to read entry {i} in JMPREL with only {count} entries")
+
+        transform = lambda x: x
+        if field == "r_sym":
+            transform = self.jmprel_r_sym
+            field = "r_info"
+        elif field == "r_type":
+            transform = self.jmprel_r_type
+            field = "r_info"
+
+        return transform(self.jmprel_elem.read(self.jmprel_addr + i * self.jmprel_elem.size, field))
+
+    def rela_entry_count(self):
+        """
+        Returns the number of RELA entries.
+        """
+        assert self.has_rela
+        relasz = self.dyn_array_read_tag_val(elf.DT_RELASZ)
+        relaent = self.dyn_array_read_tag_val(elf.DT_RELAENT)
+
+        return relasz // relaent
+
+    def rel_entry_count(self):
+        """
+        Returns the number of REL entries.
+        """
+        assert self.has_rel
+        relsz = self.dyn_array_read_tag_val(elf.DT_RELSZ)
+        relent = self.dyn_array_read_tag_val(elf.DT_RELENT)
+
+        return relsz // relent
+
+    def jmprel_entry_count(self):
+        """
+        Returns the number of JMPREL entries.
+        """
+        assert self.has_jmprel
+        pltrelsz = self.dyn_array_read_tag_val(elf.DT_PLTRELSZ)
+        pltrelent = self.jmprel_elem.size
+
+        return pltrelsz // pltrelent
+
+    def string(self, i):
+        """
+        Reads the string at index i from the string table.
+        """
+        if i >= self.strtab_size:
+            raise ValueError(
+                f"tried to read entry {i} in string table with only {self.entries} bytes"
+            )
+        return pwndbg.gdblib.memory.string(self.strtab_addr + i)
+
+    def symtab_read(self, i, field):
+        """
+        Reads the requested field from the entry of given index in the symbol
+        table.
+        """
+        return self.symtab_elem.read(self.symtab_addr + i * self.symtab_elem.size, field)
+
+    def dyn_array_read(self, i, field):
+        """
+        Reads the requested field from the entry of given index in the dynamic
+        array.
+        """
+        if i >= self.entries:
+            raise ValueError(
+                f"tried to read from entry {i} in dynamic array with only {self.entries} entries"
+            )
+        return self.elf_dyn.read(self.address + i * self.elf_dyn.size, field)
+
+    def dyn_array_read_tag_val(self, tag):
+        """
+        Reads the `d_un` field from the entry of given tag in the dynamic
+        array. Must not be a tag that allows multiple entries.
+        """
+        return self.dyn_array_read(self.entries_by_tag[tag], "d_un")
+
+
+def elf32_r_sym(r_info):
+    """
+    Returns the r_sym portion of the r_info relocation field for ELF32.
+    """
+    return (r_info >> 8) & 0xFFFFFF
+
+
+def elf32_r_type(r_info):
+    """
+    Returns the r_type portion of the r_info relocation field for ELF32.
+    """
+    return r_info & 0xFF
+
+
+def elf64_r_sym(r_info):
+    """
+    Returns the r_sym portion of the r_info relocation field for ELF64.
+    """
+    return (r_info >> 32) & 0xFFFFFFFF
+
+
+def elf64_r_type(r_info):
+    """
+    Returns the r_type portion of the r_info relocation field for ELF64.
+    """
+    return r_info & 0xFFFFFFFF
+
+
+class CStruct:
+    """
+    Utility class for reading fields off of C structs.
+
+    Without proper debug information it cannot be guaranteed that the calculated
+    field offsets are correct, therefore, reasonable caution should be exercised
+    when using this class. The assumptions made are:
+     - Padding is added between fields so that all internal members are
+       correctly aligned, as long as the struct itself is correctly aligned.
+     - The alignment of the struct is the same as the alignment of its most
+       strictly aligned member.
+     - Padding is added to the end of the struct so that sequentially laid out
+       instances are always correctly aligned.
+     - Stuct sizes must be greater than or equal to 1 byte.
+
+    While these assumptions do not apply in all cases, they should be good
+    enough for the structs in ld.so and in the ELF program images.
+    """
+
+    types = {}
+    offsets = {}
+    converters = {}
+    size = 0
+    align = 0
+
+    @staticmethod
+    def link_map():
+        """
+        Creates a new instance describing the ABI-stable part of the link_map
+        struct.
+        """
+        return CStruct(
+            [
+                ("l_addr", pwndbg.gdblib.typeinfo.size_t, int),
+                ("l_name", pwndbg.gdblib.typeinfo.char.pointer(), int),
+                ("l_ld", pwndbg.gdblib.typeinfo.pvoid, int),
+                ("l_next", pwndbg.gdblib.typeinfo.pvoid, int),
+                ("l_prev", pwndbg.gdblib.typeinfo.pvoid, int),
+            ]
+        )
+
+    @staticmethod
+    def r_debug():
+        """
+        Creates a new instance describing the ABI-stable part of the r_debug
+        struct.
+        """
+        return CStruct(
+            [
+                ("r_version", pwndbg.gdblib.typeinfo.uint, int),
+                ("r_map", pwndbg.gdblib.typeinfo.pvoid, int),
+                ("r_brk", pwndbg.gdblib.typeinfo.pvoid, int),
+            ]
+        )
+
+    @staticmethod
+    def elfNN_dyn():
+        """
+        Creates a new instance describing the ElfNN_Dyn structure, suitable for
+        the architecture of the inferior.
+        """
+        return CStruct(
+            [
+                ("d_tag", pwndbg.gdblib.typeinfo.size_t, int),
+                ("d_un", pwndbg.gdblib.typeinfo.size_t, int),
+            ]
+        )
+
+    @staticmethod
+    def elfNN_rel():
+        """
+        Creates a new instance describing the ElfNN_Rel structure, suitable for
+        the architecture of the inferior.
+        """
+        return CStruct(
+            [
+                ("r_offset", pwndbg.gdblib.typeinfo.size_t, int),
+                ("r_info", pwndbg.gdblib.typeinfo.size_t, int),
+            ]
+        )
+
+    @staticmethod
+    def elfNN_rela():
+        """
+        Creates a new instance describing the ElfNN_Rela structure, suitable for
+        the architecture of the inferior.
+        """
+        return CStruct(
+            [
+                ("r_offset", pwndbg.gdblib.typeinfo.size_t, int),
+                ("r_info", pwndbg.gdblib.typeinfo.size_t, int),
+                ("r_addend", pwndbg.gdblib.typeinfo.size_t, int),
+            ]
+        )
+
+    @staticmethod
+    def elf32_sym():
+        """
+        Creates a new instance describing the Elf32_Sym srtucture.
+        """
+        # FIXME: ELF types have an exact size. We want our GDB types to match
+        # whatever the platform's exact sized integer types are, but, because of
+        # how these types are resolved, that might not always be the case.
+        #
+        # It's better to fail loudly here than to fail silently later.
+        assert pwndbg.gdblib.typeinfo.uint32.sizeof == 4
+        assert pwndbg.gdblib.typeinfo.uint16.sizeof == 2
+        assert pwndbg.gdblib.typeinfo.uint8.sizeof == 1
+
+        return CStruct(
+            [
+                ("st_name", pwndbg.gdblib.typeinfo.uint32, int),
+                ("st_value", pwndbg.gdblib.typeinfo.uint32, int),
+                ("st_size", pwndbg.gdblib.typeinfo.uint32, int),
+                ("st_info", pwndbg.gdblib.typeinfo.uint8, int),
+                ("st_other", pwndbg.gdblib.typeinfo.uint8, int),
+                ("st_shndx", pwndbg.gdblib.typeinfo.uint16, int),
+            ]
+        )
+
+    @staticmethod
+    def elf64_sym():
+        """
+        Creates a new instance describing the Elf64_Sym structure.
+        """
+
+        # FIXME: Same issue as elf32_sym()
+        assert pwndbg.gdblib.typeinfo.uint64.sizeof == 8
+        assert pwndbg.gdblib.typeinfo.uint32.sizeof == 4
+        assert pwndbg.gdblib.typeinfo.uint16.sizeof == 2
+        assert pwndbg.gdblib.typeinfo.uint8.sizeof == 1
+
+        return CStruct(
+            [
+                ("st_name", pwndbg.gdblib.typeinfo.uint32, int),
+                ("st_info", pwndbg.gdblib.typeinfo.uint8, int),
+                ("st_other", pwndbg.gdblib.typeinfo.uint8, int),
+                ("st_shndx", pwndbg.gdblib.typeinfo.uint16, int),
+                ("st_value", pwndbg.gdblib.typeinfo.uint64, int),
+                ("st_size", pwndbg.gdblib.typeinfo.uint64, int),
+            ]
+        )
+
+    def __init__(self, fields):
+        # Calculate the offset of all of the fields in the struct.
+        current_offset = 0
+        alignment = 1
+        for entry in fields:
+            name = entry[0]
+            ty = entry[1]
+            if len(entry) > 2:
+                conv = entry[2]
+            else:
+                conv = None
+
+            # Pad the offset so that the field is correctly aligned.
+            if current_offset % ty.alignof != 0:
+                current_offset += ty.alignof - (current_offset % ty.alignof)
+
+            # Save the alignment requirements of the strictest element.
+            if ty.alignof > alignment:
+                alignment = ty.alignof
+
+            self.offsets[name] = current_offset
+            self.types[name] = ty
+            self.converters[name] = conv
+
+            current_offset += ty.sizeof
+
+        # We don't consider zero-sized structures to be valid.
+        if current_offset == 0:
+            current_offset = 1
+
+        # Pad the end of the struct so that the next instance of this struct in
+        # an array is correctly aligned.
+        if current_offset % alignment != 0:
+            current_offset += alignment - (current_offset % alignment)
+
+        self.size = current_offset
+        self.align = alignment
+
+    def read(self, address, name):
+        """
+        Reads the field with the given name from the struct instance located at
+        the given address.
+        """
+        val = pwndbg.gdblib.memory.poi(self.types[name], address + self.offsets[name])
+        if self.converters[name] is not None:
+            return self.converters[name](val)
+        else:
+            return val
+
+    def has_field(self, name):
+        """
+        Returns whether a field with the given name exists in this struct.
+        """
+        return name in self.offsets
diff --git a/pwndbg/gdblib/got.py b/pwndbg/gdblib/got.py
new file mode 100644
index 00000000000..6c47ce0f0ee
--- /dev/null
+++ b/pwndbg/gdblib/got.py
@@ -0,0 +1,533 @@
+"""
+Global Offset Table Tracker
+
+Subsystem for tracking accesses to external function calls made through pointers
+in an inferior's Global Offset Table, such as those made by the stubs in the
+Procedure Linkage Table.
+
+Currently, it does this by attatching watchpoints to the entries in the GOT and
+taking note of where the call came from, but it could be done much faster by
+injecting our own code into the program space to track this.
+"""
+
+from __future__ import annotations
+
+import gdb
+
+import pwndbg.color.message as message
+import pwndbg.gdblib.arch
+import pwndbg.gdblib.bpoint
+import pwndbg.gdblib.dynamic
+import pwndbg.gdblib.memory
+import pwndbg.gdblib.shellcode
+import pwndbg.gdblib.typeinfo
+import pwndbg.gdblib.vmmap
+
+
+class RelocTypes:
+    """
+    This class contains all the relocation type constants so that one may
+    interpret the relocations types present in the DYNAMIC segment. These
+    constants are defined in each of the processors' SystemV R4 psABI document,
+    or equivalent, and should stay the same across all implementations of libc
+    on systems that adhere to that ABI, such as Linux.
+
+    Most of these were sourced from GLibc, which conveniently lists all of the
+    relocations types in a single file[1].
+
+    [1]: https://elixir.bootlin.com/glibc/glibc-2.38/source/elf/elf.h
+    """
+
+    R_RISCV_JUMP_SLOT = 5
+    R_X86_64_JUMP_SLOT = 7
+    R_386_JMP_SLOT = 7
+    R_CRIS_JUMP_SLOT = 11
+    R_390_JMP_SLOT = 11
+    R_CKCORE_JUMP_SLOT = 12
+    R_TILEPRO_JMP_SLOT = 12
+    R_MICROBLAZE_JUMP_SLOT = 17
+    R_TILEGX_JMP_SLOT = 18
+    R_OR1K_JMP_SLOT = 20
+    R_68K_JMP_SLOT = 21
+    R_SPARC_JMP_SLOT = 21
+    R_PPC_JMP_SLOT = 21
+    R_PPC64_JMP_SLOT = 21
+    R_ARM_JUMP_SLOT = 22
+    R_MN10300_JMP_SLOT = 22
+    R_ALPHA_JMP_SLOT = 26
+    R_NIOS2_JUMP_SLOT = 38
+    R_NDS32_JMP_SLOT = 41
+    R_METAG_JMP_SLOT = 44
+    R_M32R_JMP_SLOT = 52
+    R_ARC_JMP_SLOT = 55
+    R_MIPS_JUMP_SLOT = 127
+    R_SH_JMP_SLOT = 164
+    R_AARCH64_JUMP_SLOT = 1026
+
+    R_X86_64_IRELATIVE = 37
+    R_386_IRELATIVE = 42
+    R_RISCV_IRELATIVE = 58
+    R_390_IRELATIVE = 61
+    R_ARM_IRELATIVE = 160
+    R_AARCH64_P32_IRELATIVE = 188
+    R_PPC_IRELATIVE = 248
+    R_PPC64_IRELATIVE = 248
+    R_SPARC_IRELATIVE = 249
+    R_AARCH64_IRELATIVE = 1032
+
+
+# Set of all type codes associated with jump slots, by architecture.
+JUMP_SLOTS = {
+    "x86-64": set([RelocTypes.R_X86_64_JUMP_SLOT]),
+    "i386": set([RelocTypes.R_386_JMP_SLOT]),
+    "aarch64": set([RelocTypes.R_AARCH64_JUMP_SLOT]),
+    "mips": set([RelocTypes.R_MIPS_JUMP_SLOT]),
+    "powerpc": set([RelocTypes.R_PPC_JMP_SLOT]),
+    "sparc": set([RelocTypes.R_SPARC_JMP_SLOT]),
+    "arm": set([RelocTypes.R_ARM_JUMP_SLOT]),
+    "armcm": set([RelocTypes.R_ARM_JUMP_SLOT]),
+    "rv32": set([RelocTypes.R_RISCV_JUMP_SLOT]),
+    "rv64": set([RelocTypes.R_RISCV_JUMP_SLOT]),
+}
+
+# Set of all type codes associated with irelative jump slots, by architecture.
+IRELATIVE_SLOTS = {
+    "x86-64": set([RelocTypes.R_X86_64_IRELATIVE]),
+    "i386": set([RelocTypes.R_386_IRELATIVE]),
+    "aarch64": set([RelocTypes.R_AARCH64_P32_IRELATIVE, RelocTypes.R_AARCH64_IRELATIVE]),
+    "mips": set([]),
+    "powerpc": set([RelocTypes.R_PPC_IRELATIVE]),
+    "sparc": set([RelocTypes.R_SPARC_IRELATIVE]),
+    "arm": set([RelocTypes.R_ARM_IRELATIVE]),
+    "armcm": set([RelocTypes.R_ARM_IRELATIVE]),
+    "rv32": set([RelocTypes.R_RISCV_IRELATIVE]),
+    "rv64": set([RelocTypes.R_RISCV_IRELATIVE]),
+}
+
+
+def is_mmap_error(ptr):
+    """
+    Checks whether the return value of an mmap of indicates an error.
+    """
+    err = ((1 << pwndbg.gdblib.arch.ptrsize) - 1) & pwndbg.lib.memory.PAGE_MASK
+    return ptr & pwndbg.lib.memory.PAGE_MASK == err
+
+
+class TrapAllocator:
+    """
+    Utility that allocates and manages executable addresses in the space of the
+    executing program that we can trap.
+    """
+
+    block_capacity = 4096
+    slot_size = 8
+
+    def __init__(self):
+        self._reset()
+
+    def _reset(self):
+        """
+        Reset the internal state of the allocator.
+        """
+        self.blocks = []
+        self.current_block_occupancy = 0
+        self.vacant_slots = []
+        self.occupied_slots = set()
+
+    def alloc(self):
+        """
+        Allocates a new address to where program execution can be diverted.
+        """
+        if len(self.vacant_slots) > 0:
+            # We have an easy vacant slot we can recycle.
+            addr = self.vacant_slots.pop()
+            self.occupied_slots.add(addr)
+            return addr
+
+        if len(self.blocks) > 0 and self.current_block_occupancy < self.block_capacity:
+            # We have a non-full block we can allocate a new slot from.
+            addr = self.blocks[-1] + self.current_block_occupancy * self.slot_size
+            self.current_block_occupancy += 1
+            self.occupied_slots.add(addr)
+            return addr
+
+        # We have to allocate a new block.
+        block_base = pwndbg.gdblib.shellcode.exec_syscall(
+            "SYS_mmap",
+            0,
+            self.block_capacity * self.slot_size,
+            5,  # PROT_READ | PROT_EXEC
+            0x22,  # MAP_PRIVATE | MAP_ANONYMOUS
+            -1,
+            0,
+            disable_breakpoints=True,
+        )
+        if is_mmap_error(block_base):
+            raise RuntimeError(f"SYS_mmap request returned {block_base:#x}")
+
+        self.blocks.append(block_base)
+        self.current_block_occupancy = 1
+        addr = self.blocks[-1]
+
+        self.occupied_slots.add(addr)
+        return addr
+
+    def free(self, address):
+        """
+        Indicates that an address obtained from alloc() can be recycled.
+        """
+        assert address in self.occupied_slots
+        self.occupied_slots.remove(address)
+        self.vacant_slots.append(address)
+
+    def clear(self):
+        """
+        Deletes all memory mappings and frees all addresses.
+        """
+        size = self.block_capacity * self.slot_size
+        while len(self.blocks) > 0:
+            block = self.blocks.pop()
+            result = pwndbg.gdblib.shellcode.exec_syscall(
+                "SYS_munmap", block, self.block_capacity * self.slot_size, disable_breakpoints=True
+            )
+            if result != 0:
+                raise RuntimeError(
+                    f"SYS_munmap({block:#x}, {self.block_capacity * self.slot_size:#x}) failed ({result:#x})"
+                )
+        self._reset()
+
+
+def display_name(name, basename=False):
+    """
+    Return the display name for a symbol or objfile.
+
+    Ideally, we'd like to display all of the names of the symbols as text, but
+    there is really nothing stopping symbol names from being stored in some
+    fairly wacky encoding or really from having names that aren't text at all.
+
+    We should try our best to turn whatever the symbol name is into text, but
+    not so much that non-text entries or entries in unknown encodings become
+    unrecognizable.
+    """
+    if name == b"":
+        return "<Empty>"
+    try:
+        if isinstance(name, bytearray):
+            name = name.decode("ascii")
+        if basename and "/" in name:
+            name = name.split("/")[-1]
+        return name
+    except TypeError:
+        return name
+
+
+# The allocator we use for our trap addresses.
+TRAP_ALLOCATOR = TrapAllocator()
+
+# Whether the GOT tracking is currently enabled.
+GOT_TRACKING = False
+
+# Map describing all of the currently installed analysis watchpoints.
+INSTALLED_WATCHPOINTS = {}
+
+
+class Patcher(pwndbg.gdblib.bpoint.Breakpoint):
+    """
+    Watches for changes made by program code to the GOT and fixes them up.
+
+    This class is paired with Tracker, and instances of both classes always
+    function together.
+    """
+
+    entry = 0
+    tracker = None
+
+    def __init__(self, entry, tracker):
+        super().__init__(
+            f"*(void**){entry:#x}", type=gdb.BP_WATCHPOINT, wp_class=gdb.WP_WRITE, internal=True
+        )
+        self.silent = True
+        self.entry = entry
+        self.tracker = tracker
+        self.init = True
+
+        # Figure out the display names both this class and its corresponding
+        # tracker will use.
+        objfile = self.tracker.link_map_entry.name()
+        if objfile == b"":
+            objfile = pwndbg.gdblib.proc.exe
+        self.tracker.obj_display_name = display_name(objfile, basename=True)
+
+        self.tracker.sym_display_name = display_name(
+            self.tracker.dynamic_section.string(
+                self.tracker.dynamic_section.symtab_read(
+                    self.tracker.relocation_fn(self.tracker.relocation_index, "r_sym"), "st_name"
+                )
+            )
+        )
+
+    def should_stop(self):
+        # Read the new branch target, and update the redirection target of the
+        # tracker accordingly.
+        new_target = pwndbg.gdblib.memory.pvoid(self.entry)
+        if new_target == self.tracker.trapped_address:
+            # The write to this range from within GDB that we do at the end of
+            # this function can cause this watchpoint to trigger again.
+            # Obviously, we don't want to treat our own writes the same way we'd
+            # treat writes made by the inferior.
+            return False
+
+        self.tracker.target = new_target
+
+        # Notify the user about changes to the GOT.
+        if not self.init:
+            print(
+                f"[*] GOT entry {self.entry:#x} ({self.tracker.sym_display_name}@{self.tracker.obj_display_name}) now points to {new_target:#x}"
+            )
+        self.init = False
+
+        # Update the GOT entry so that it points to the trapped address again.
+        #
+        # FIXME: Ideally, we'd use gdb.Value([...]).assign() here, but that is
+        # not always available, so we must do this ugly hack instead.
+        gdb.execute(f"set *(void**){self.entry:#x} = {self.tracker.trapped_address:#x}")
+
+        return False
+
+
+class Tracker(pwndbg.gdblib.bpoint.Breakpoint):
+    """
+    Class that tracks the accesses made to the entries in the GOT.
+
+    This class is paired with Patcher, and instances of both classes always
+    function together.
+    """
+
+    hits = {}
+    total_hits = 0
+    trapped_address = 0
+
+    target = 0
+    dynamic_section = None
+    relocation_fn = None
+    relocation_index = 0
+    link_map_entry = None
+
+    def __init__(self):
+        self.trapped_address = TRAP_ALLOCATOR.alloc()
+        super().__init__(f"*{self.trapped_address:#x}", internal=True)
+        self.hits = {}
+        self.silent = True
+
+    def delete(self):
+        TRAP_ALLOCATOR.free(self.trapped_address)
+        super().delete()
+
+    def should_stop(self):
+        # Notify the user about calls made through this GOT entry.
+        print(f"[*] {self.sym_display_name}@{self.obj_display_name} called via GOT")
+
+        # Collect the stack that accessed this GOT entry.
+        stack = []
+        frame = gdb.newest_frame().older()
+        while frame is not None:
+            stack.append(frame.pc())
+            frame = frame.older()
+        stack = tuple(stack)
+        if stack not in self.hits:
+            self.hits[stack] = 0
+        self.hits[stack] += 1
+        self.total_hits += 1
+
+        # Divert execution back to the real jump target.
+        gdb.execute(f"set $pc = {self.target}")
+        return False
+
+
+def _update_watchpoints():
+    """
+    Internal function responsible for updating the watchpoints that track the
+    accesses to the GOT.
+    """
+    if not GOT_TRACKING:
+        # We don't want to bother anyone.
+        return
+
+    # Remove the watchpoints that are currently enabled.
+    for _, (tracker, patcher) in INSTALLED_WATCHPOINTS.items():
+        patcher.delete()
+        tracker.delete()
+    INSTALLED_WATCHPOINTS.clear()
+
+    # Install new watchpoints to cover all of the jump slots in all GOTs
+    for obj in pwndbg.gdblib.dynamic.link_map():
+        name = obj.name()
+        if name == b"":
+            name = pwndbg.gdblib.proc.exe
+
+        try:
+            dynamic = pwndbg.gdblib.dynamic.DynamicSegment(obj.dynamic(), obj.load_bias())
+        except RuntimeError as e:
+            print(message.warn(f"object {name} has invalid DYNAMIC section: {e}"))
+            continue
+
+        jump_slots = JUMP_SLOTS[pwndbg.gdblib.arch.name]
+        if dynamic.has_rel:
+            for i in range(dynamic.rel_entry_count()):
+                if dynamic.rel_read(i, "r_type") not in jump_slots:
+                    continue
+                target = dynamic.load_bias + dynamic.rel_read(i, "r_offset")
+
+                tracker = Tracker()
+                tracker.dynamic_section = dynamic
+                tracker.link_map_entry = obj
+                tracker.relocation_index = i
+                tracker.relocation_fn = dynamic.rel_read
+                patcher = Patcher(target, tracker)
+                patcher.should_stop()
+
+                INSTALLED_WATCHPOINTS[target] = (tracker, patcher)
+        if dynamic.has_rela:
+            for i in range(dynamic.rela_entry_count()):
+                if dynamic.rela_read(i, "r_type") not in jump_slots:
+                    continue
+                target = (
+                    dynamic.load_bias
+                    + dynamic.rela_read(i, "r_offset")
+                    + dynamic.rela_read(i, "r_addend")
+                )
+
+                tracker = Tracker()
+                tracker.dynamic_section = dynamic
+                tracker.link_map_entry = obj
+                tracker.relocation_index = i
+                tracker.relocation_fn = dynamic.rela_read
+                patcher = Patcher(target, tracker)
+                patcher.should_stop()
+
+                INSTALLED_WATCHPOINTS[target] = (tracker, patcher)
+        if dynamic.has_jmprel:
+            for i in range(dynamic.jmprel_entry_count()):
+                if dynamic.jmprel_read(i, "r_type") not in jump_slots:
+                    continue
+                target = dynamic.load_bias + dynamic.jmprel_read(i, "r_offset")
+                if dynamic.jmprel_elem.has_field("r_addend"):
+                    target += dynamic.jmprel_read(i, "r_addend")
+
+                tracker = Tracker()
+                tracker.dynamic_section = dynamic
+                tracker.link_map_entry = obj
+                tracker.relocation_index = i
+                tracker.relocation_fn = dynamic.jmprel_read
+                patcher = Patcher(target, tracker)
+                patcher.should_stop()
+
+                INSTALLED_WATCHPOINTS[target] = (tracker, patcher)
+
+
+# Set the function so that it's called whenever the link map changes.
+pwndbg.gdblib.dynamic.r_debug_link_map_changed_add_listener(_update_watchpoints)
+
+
+def all_tracked_entries():
+    """
+    Return an iterator over all of the GOT whose accesses are being tracked.
+    """
+    return INSTALLED_WATCHPOINTS.items()
+
+
+def tracked_entry_by_address(address):
+    """
+    Return the tracker associated with the entry at the given address, if any.
+    """
+    return INSTALLED_WATCHPOINTS.get(address)
+
+
+def enable_got_call_tracking(disable_hardware_whatchpoints=True):
+    """
+    Enable the analysis of calls made through the GOT.
+    """
+
+    # Disable hardware watchpoints.
+    #
+    # We don't really know how to make sure that the hardware watchpoints
+    # present in the system have enough capabilities for them to be useful to
+    # us in this module, seeing as what they can do varies considerably between
+    # systems and failures are fairly quiet and, thus, hard to detect[1].
+    # Because of this, we opt to disable them by default for the sake of
+    # consistency and so that we don't have to chase silent failures.
+    #
+    # [1]: https://sourceware.org/gdb/onlinedocs/gdb/Set-Watchpoints.html
+    if disable_hardware_whatchpoints:
+        gdb.execute("set can-use-hw-watchpoints 0")
+        print("Hardware watchpoints have been disabled. Please do not turn them back on until")
+        print("GOT tracking is disabled, as it may lead to unexpected silent errors.")
+        print()
+        print("They may be re-enabled with `set can-use-hw-watchpoints 1`")
+        print()
+    else:
+        print(
+            message.warn("Hardware watchpoints have not been disabled, silent errors may happen.")
+        )
+        print()
+
+    global GOT_TRACKING
+    assert len(INSTALLED_WATCHPOINTS) == 0
+
+    GOT_TRACKING = True
+
+    _update_watchpoints()
+
+    print("Enabled GOT tracking. Calls across dynamic library boundaries are now")
+    print("instumented, and the number of calls and stack traces for every call will be")
+    print("collected. You may check the current call information by using the")
+    print("`track-got info` and `track-got query` commands. Run this command again to")
+    print("diasble tracking.")
+    print()
+
+    # Until we start enablign the r_brk hook again.
+    print("Keep in mind that, currently, the tracker does not update across calls to")
+    print("dlopen(), so, if one of those does happen, the tracker has to be manually")
+    print("disabled and re-enabled in order to update the hooks.")
+
+
+def disable_got_call_tracking():
+    """
+    Disable the analysis of calls made through the GOT.
+    """
+    global GOT_TRACKING
+    GOT_TRACKING = False
+
+    for _, (tracker, patcher) in INSTALLED_WATCHPOINTS.items():
+        patcher.delete()
+        tracker.delete()
+    INSTALLED_WATCHPOINTS.clear()
+    TRAP_ALLOCATOR.clear()
+
+
+def jump_slots_for(dynamic):
+    """
+    Returns the jump slot addresses described by the given dynamic section.
+    """
+    jump_slots = JUMP_SLOTS[pwndbg.gdblib.arch.name]
+    if dynamic.has_rel:
+        for i in range(dynamic.rel_entry_count()):
+            if dynamic.rel_read(i, "r_type") in jump_slots:
+                yield (0, i, dynamic.load_bias + dynamic.rel_read(i, "r_offset"))
+    if dynamic.has_rela:
+        for i in range(dynamic.rela_entry_count()):
+            if dynamic.rela_read(i, "r_type") in jump_slots:
+                yield (
+                    1,
+                    i,
+                    dynamic.load_bias
+                    + dynamic.rela_read(i, "r_offset")
+                    + dynamic.rela_read(i, "r_addend"),
+                )
+    if dynamic.has_jmprel:
+        for i in range(dynamic.jmprel_entry_count()):
+            if dynamic.jmprel_read(i, "r_type") in jump_slots:
+                base = dynamic.load_bias + dynamic.jmprel_read(i, "r_offset")
+                if dynamic.jmprel_elem.has_field("r_addend"):
+                    base += dynamic.jmprel_read(i, "r_addend")
+                yield (2, i, base)
diff --git a/pwndbg/gdblib/shellcode.py b/pwndbg/gdblib/shellcode.py
index 221d7289249..2a6533637f7 100644
--- a/pwndbg/gdblib/shellcode.py
+++ b/pwndbg/gdblib/shellcode.py
@@ -26,7 +26,15 @@ def _get_syscall_return_value():
 
 
 def exec_syscall(
-    syscall, arg0=None, arg1=None, arg2=None, arg3=None, arg4=None, arg5=None, arg6=None
+    syscall,
+    arg0=None,
+    arg1=None,
+    arg2=None,
+    arg3=None,
+    arg4=None,
+    arg5=None,
+    arg6=None,
+    disable_breakpoints=False,
 ):
     """
     Tries executing the given syscall in the context of the inferior.
@@ -37,10 +45,15 @@ def exec_syscall(
     syscall_bin = pwnlib.asm.asm(syscall_asm)
 
     # Run the syscall and pass its return value onward to the caller.
-    return exec_shellcode(syscall_bin, restore_context=True, capture=_get_syscall_return_value)
+    return exec_shellcode(
+        syscall_bin,
+        restore_context=True,
+        capture=_get_syscall_return_value,
+        disable_breakpoints=disable_breakpoints,
+    )
 
 
-def exec_shellcode(blob, restore_context=True, capture=None):
+def exec_shellcode(blob, restore_context=True, capture=None, disable_breakpoints=False):
     """
     Tries executing the given blob of machine code in the current context of the
     inferior, optionally restoring the values of the registers as they were
@@ -87,10 +100,38 @@ def exec_shellcode(blob, restore_context=True, capture=None):
     existing_code = pwndbg.gdblib.memory.read(starting_address, len(blob))
     pwndbg.gdblib.memory.write(starting_address, blob)
 
+    # Disable breakpoints.
+    #
+    # We might not want to hit any breakpoints that aren't the ones that are
+    # strictly necessary for this function to run to completion. So, if
+    # requested, we temporarily disable breakpoints during the shellcode
+    re_enable_list = []
+    for bp in gdb.breakpoints():
+        if bp.enabled and disable_breakpoints:
+            bp.enabled = False
+            re_enable_list.append(bp)
+
+    # The continue we use here will trigger an event that would get the context
+    # prompt to show, regardless of the circumstances. We don't want that, so
+    # we preserve the state of the context skip.
+    would_skip_context = pwndbg.gdblib.prompt.context_shown
+
     # Execute.
-    bp = gdb.Breakpoint(f"*{starting_address+len(blob):#x}", internal=True, temporary=True)
+    target_address = starting_address + len(blob)
+    bp = gdb.Breakpoint(f"*{target_address:#x}", internal=True, temporary=True)
+    bp.silent = True
     gdb.execute("continue")
 
+    # Restore the state of the context skip.
+    pwndbg.gdblib.prompt.context_shown = would_skip_context
+
+    # Re-enable breakpoints.
+    for bp in re_enable_list:
+        bp.enabled = True
+
+    # Make sure we're in the right place.
+    assert pwndbg.gdblib.regs.pc == target_address
+
     # Give the caller a chance to collect information from the environment
     # before any of the context gets restored.
     captured = None


Code review comments:
diff_hunk: @@ -0,0 +1,237 @@
+from __future__ import annotations
+
+import argparse
+import re
+
+import pwndbg.color.message as message
+import pwndbg.gdblib.dynamic
+import pwndbg.gdblib.got
+import pwndbg.gdblib.proc
+from pwndbg.commands import CommandCategory
+
+
+def columns(rows, colors=None):
+    """
+    Print data formatted into distinct columns.
+    """
+    if len(rows) == 0:
+        # Nothing to print.
+        return
+
+    col_max = [0 for _ in range(len(rows[0]))]
path: pwndbg/commands/got_tracking.py
text: @user1:
```suggestion
    col_max = [0] * len(rows[0])
```