How ltrace Works to Trace Dynamic Library Calls in Linux

This article provides an in-depth explanation of how the Linux operating system implements the ltrace utility to intercept and display dynamic library calls. It covers the core mechanisms powering the tool, including the role of the Executable and Linkable Format (ELF), the Procedure Linkage Table (PLT), the ptrace system call, software breakpoint injection, and symbol resolution during process execution.

The Role of ELF, PLT, and Dynamic Linking

To understand how ltrace intercepts function calls, one must first understand dynamic linking on Linux. When a dynamically linked program calls a shared library function (such as printf() from libc.so), the application does not contain the target memory address at compile time. Instead, it relies on two ELF sections:

When code calls an external function, it calls the function's corresponding PLT entry (e.g., printf@plt). ltrace leverages this predictable structure to identify where dynamic library calls are dispatched.

Process Attachment via ptrace

ltrace operates as a debugger. When you execute a command through ltrace or attach to an existing process using -p <PID>, it utilizes the Linux ptrace system call (PTRACE_ATTACH or PTRACE_TRACEME).

The ptrace API allows ltrace to:

Inspecting Dynamic Symbols

Before the target process runs its main execution loop, ltrace parses the target binary's ELF structures. Specifically, it inspects the dynamic symbol table (.dynsym) and relocation entries (.rel.plt or .rela.plt).

By mapping the symbols to their respective PLT offsets, ltrace builds an internal dictionary of:

Injecting Breakpoints into the PLT

Once the PLT addresses are known, ltrace intercepts calls by inserting software breakpoints:

  1. Reading Original Bytes: ltrace uses ptrace(PTRACE_PEEKTEXT, ...) to read the instruction bytes at the beginning of each PLT entry.
  2. Writing Breakpoints: It replaces the first byte of each entry with a breakpoint instruction using ptrace(PTRACE_POKETEXT, ...). On x86 and x86_64 architectures, this is the single-byte int 3 instruction (0xCC).

Because PLT stubs reside in the memory space of the executable, replacing their entry points ensures that any dynamic call made by the binary executes the breakpoint before jumping to the actual shared library.

Handling the Trap and Inspecting Arguments

When the tracee executes an intercepted library call, the CPU encounters the int 3 instruction. This causes a software trap, transitioning execution to the Linux kernel:

  1. Signal Delivery: The kernel halts the tracee and sends a SIGTRAP signal to the tracer (ltrace).
  2. Identification: ltrace intercepts the signal via waitpid(). It reads the tracee’s instruction pointer (e.g., RIP on x86_64) to determine which address triggered the trap.
  3. Argument Decoding: By querying the process registers using ptrace(PTRACE_GETREGS, ...), ltrace reads the arguments passed to the function according to the target architecture's calling convention (System V AMD64 ABI on standard x86_64 Linux, which uses registers RDI, RSI, RDX, RCX, R8, and R9).

Capturing Return Values and Resuming

To capture the return value of a library function, ltrace cannot simply let the program run freely after logging the entry:

  1. Return Address Detection: ltrace inspects the top of the stack to identify the return address where the dynamic library will jump after finishing execution.
  2. Return Breakpoint: It places a temporary breakpoint at that return address.
  3. Restoring and Continuing: ltrace temporarily restores the original byte at the PLT entry, sets the instruction pointer back by one byte to point to the restored instruction, and uses ptrace(PTRACE_SINGLESTEP, ...) or ptrace(PTRACE_CONT, ...) to resume execution into the dynamic library.
  4. Logging the Return: When the library function completes, it hits the return breakpoint. ltrace receives another SIGTRAP, reads the return value from the architecture's return register (such as RAX), cleans up the temporary breakpoint, and prints the result to standard error.