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:
- Procedure Linkage Table (PLT): Executable stubs
that jump to target addresses or invoke the dynamic linker
(
ld.so) to resolve them. - Global Offset Table (GOT): A table of absolute addresses populated at runtime by the dynamic linker.
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:
- Control the execution state of the traced process (tracee).
- Read and write the tracee’s memory space.
- Inspect and modify CPU registers.
- Receive notification signals whenever the tracee changes state or encounters an exception.
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:
- The memory address of each PLT stub.
- The human-readable name of the function (e.g.,
malloc,fopen). - The expected function signatures (argument types and return types),
typically read from configuration files in
/etc/ltrace.confor~/.ltrace.conf.
Injecting Breakpoints into the PLT
Once the PLT addresses are known, ltrace intercepts
calls by inserting software breakpoints:
- Reading Original Bytes:
ltraceusesptrace(PTRACE_PEEKTEXT, ...)to read the instruction bytes at the beginning of each PLT entry. - 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-byteint 3instruction (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:
- Signal Delivery: The kernel halts the tracee and
sends a
SIGTRAPsignal to the tracer (ltrace). - Identification:
ltraceintercepts the signal viawaitpid(). It reads the tracee’s instruction pointer (e.g.,RIPon x86_64) to determine which address triggered the trap. - Argument Decoding: By querying the process
registers using
ptrace(PTRACE_GETREGS, ...),ltracereads 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 registersRDI,RSI,RDX,RCX,R8, andR9).
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:
- Return Address Detection:
ltraceinspects the top of the stack to identify the return address where the dynamic library will jump after finishing execution. - Return Breakpoint: It places a temporary breakpoint at that return address.
- Restoring and Continuing:
ltracetemporarily restores the original byte at the PLT entry, sets the instruction pointer back by one byte to point to the restored instruction, and usesptrace(PTRACE_SINGLESTEP, ...)orptrace(PTRACE_CONT, ...)to resume execution into the dynamic library. - Logging the Return: When the library function
completes, it hits the return breakpoint.
ltracereceives anotherSIGTRAP, reads the return value from the architecture's return register (such asRAX), cleans up the temporary breakpoint, and prints the result to standard error.