Skip to content

Memory Access

In libdebug, memory access is performed via the memory attribute of the Debugger object or the Thread Context. When reading from memory, a bytes-like object is returned. The following methods are available:

Access a single byte of memory by providing the address as an integer.

d.memory[0x1000]

Access a range of bytes by providing the start and end addresses as integers.

d.memory[0x1000:0x1010]

Access a range of bytes by providing the base address and length as integers.

d.memory[0x1000, 0x10]

Access memory using a symbol name.

d.memory["function", 0x8]

When specifying a symbol, you can also provide an offset. Contrary to what happens in GDB, the offset is always interpreted as hexadecimal.

d.memory["function+a8"]

Access a range of bytes using a symbol name.

d.memory["function":"function+0f"]
Please note that contrary to what happens in GDB, the offset is always interpreted as hexadecimal.


Accessing memory with symbols

Please note that, unless otherwise specified, symbols are resolved in the debugged binary only. To resolve symbols in shared libraries, you need to indicate it in the third parameter of the function.

d.memory["__libc_start_main", 0x8, "libc"]

Writing to memory works similarly. You can write a bytes-like object to memory using the same addressing methods:

d.memory[d.rsp, 0x10] = b"AAAAAAABC"
d.memory["main_arena", 16, "libc"] = b"12345678"

Length/Slice when writing

When writing to memory, slices and length are ignored in favor of the length of the specified bytes-like object.

In the following example, only 4 bytes are written:

d.memory["main_arena", 50] = b"\x0a\xeb\x12\xfc"

Absolute and Relative Addressing

Just like with symbols, memory addresses can also be accessed relative to a certain file base. libdebug uses "hybrid" addressing by default. This means it first attempts to resolve addresses as absolute. If the address does not correspond to an absolute one, it considers it relative to the base of the binary.

You can use the third parameter of the memory access method to select the file you want to use as base (e.g., libc, ld, binary). If you want to force libdebug to use absolute addressing, you can specify "absolute" instead.

Examples of relative and absolute addressing

# Absolute addressing
d.memory[0x7ffff7fcb200, 0x10, "absolute"]

# Hybrid addressing
d.memory[0x1000, 0x10, "hybrid"]

# Relative addressing
d.memory[0x1000, 0x10, "binary"]
d.memory[0x1000, 0x10, "libc"]

Searching inside Memory

The memory attribute of the Debugger object also allows you to search for specific values in the memory of the process. You can search for integers, strings, or bytes-like objects.

Function Signature

d.memory.find(
    value: int | bytes | str,
    file: str = "all",
    start: int | None = None,
    end: int | None = None,
) -> list[int]:

Parameters:

Argument Type Description
value int | bytes | str The value to search for.
file str The backing file to search in (e.g, binary, libc, stack).
start int (optional) The start address of the search (works with both relative and absolute).
end int (optional) The end address of the search (works with both relative and absolute).

Returns:

Return Type Description
Addresses list[int] List of memory addresses where the value was found.

Usage Example

binsh_string_addr = d.memory.find("/bin/sh", file="libc")

value_address = d.memory.find(0x1234, file="stack", start=d.regs.rsp)

Searching Pointers

The memory attribute of the Debugger object also allows you to search for values in a source memory map that are pointers to another memory map. One use case for this would be identifying potential leaks of memory addresses when libdebug is used for exploitation tasks.

Function Signature

def find_pointers(
        where: int | str = "*",
        target: int | str = "*",
        step: int = 1,
    ) -> list[tuple[int, int]]:

Parameters:

Argument Type Description
where int | str The memory map where we want to search for references. Defaults to "*", which means all memory maps.
target int | str The memory map whose pointers we want to find. Defaults to "*", which means all memory maps.
step int The interval step size while iterating over the memory buffer. Defaults to 1.

Returns:

Return Type Description
Pointers list[tuple[int, int]] A list of tuples containing the address where the pointer was found and the pointer itself.

Usage Example

pointers = d.memory.find_pointers("stack", "heap")

for src, dst in pointers:
    print(f"Heap leak to {dst} found at {src} points")

Telescope

The memory attribute of the Debugger object also allow you to traverse a chain of pointers in memory, starting from a given address. This is particularly useful for exploring complex data structures or following pointers through multiple levels of indirection.

For example, let's say the return value of telescope(0x7ffff7fcb200) is:

[0x7ffff7fcb200, 0x7ffff7fcb208, 0x7ffff7fcb210, "provola"]

This means that 0x7ffff7fcb200 (input address) points to 0x7ffff7fcb208, which points to 0x7ffff7fcb210, which contains the string "provola".

Function Signature

def telescope(
    address: int,
    max_depth: int = 10,
    min_str_len: int = 3,
    max_str_len: int = 0x100,
) -> list[int | str]:

Parameters:

Argument Type Description
address int The address to telescope.
max_depth int The maximum depth of the telescope. Defaults to 10.
min_str_len int The minimum length of a string to be resolved, if the found element is not a valid address. If -1, the element will never be resolved as a string. Defaults to 3.
max_str_len int The maximum length of a string to be resolved, if the found element is not a valid address. Defaults to 0x100.

Returns:

Return Type Description
Chain list[int | str] The telescope chain. The last element might be both an integer or a string, depending on the arguments provided and the content of the memory. The first element is always the address provided as argument.

Usage Example

address = d.regs.rsp
chain = d.memory.telescope(address, max_depth=5, min_str_len=6, max_str_len=0x100)

Fast and Slow Memory Access

libdebug supports two different methods to access memory on Linux, controlled by the fast_memory parameter of the Debugger object. The two methods are:

  • fast_memory=False uses the ptrace system call interface, requiring a context switch from user space to kernel space for each architectural word-size read.
  • fast_memory=True reduces the access latency by relying on Linux's procfs, which contains a virtual file as an interface to the process memory.

As of version 0.8 🍣 Chutoro Nigiri 🍣, fast_memory=True is the default. The following examples show how to change the memory access method when creating the Debugger object or at runtime.

d = debugger("test", fast_memory=False)
d.fast_memory = False