A call is easy—once you know the address.
Imagine a service that calls scan(7). Today the implementation lives in libwork.so, a Linux shared-library file (.so means shared object); tomorrow you want to put admission control around it, without rebuilding the service. A small shared library could define another scan, acquire a permit, call the original, and release the permit. The unresolved question is how execution ever reaches this new definition.
Start with the ordinary call. A CPU executes addresses, not names. Yet the application can be built before its library has a runtime address. Address-space layout randomization can put that library somewhere different on every execution. Copying the library’s current address into the executable would make the next execution wrong.
The solution has two stages. The build-time linker leaves a usable call route and a description of what is missing. Later, a user-space dynamic linker maps the required objects, chooses definitions, and writes the addresses into designated locations. Once this preparation has happened, most calls need no further name lookup.
/* libwork.so */
__attribute__((noinline))
int scan(int x) { return x + 1; }
/* app: a different compilation unit */
extern int scan(int);
int result = scan(7);
We will follow this exact exported function. A second function, local_scan, has the same arithmetic but is static inside the application. That small difference will expose the boundary between replacing a symbol and replacing code.
Keep three objects separate: the name scan, the address chosen for that name, and the machine instructions at that address. Symbol lookup chooses an address. A GOT update changes a stored address. An entry patch changes instructions. These act at different points in the journey.
What does the executable know?
Our application and its library use ELF—the Executable and Linkable Format. This file format stores machine code together with descriptions of that code: what architecture it runs on, where its contents are, and which addresses still need to be filled in.
First, what kind of ELF file is this?
The ELF header at the beginning of the file contains a numeric field named e_type. Constants whose names begin with ET_ name the possible values: think ELF type. ET_DYN is the name for value 3, whose specified meaning is “shared object file”; DYN is the dynamic mnemonic. These names are labels for numbers, not components that run. [18]
| Constant | Value | Role to remember |
|---|---|---|
ET_NONE | 0 | No file type specified. |
ET_REL | 1 | Relocatable object: typically a compiler’s .o, an input to linking. |
ET_EXEC | 2 | Executable with a fixed link-time virtual-address layout. |
ET_DYN | 3 | Shared object; also the type Linux uses for position-independent executables. |
ET_CORE | 4 | Core dump: a saved process image for debugging. |
A position-independent executable (PIE) is a program built so its image can be loaded at different base addresses. A shared library needs that ability too. Linux therefore uses ET_DYN for both. The type alone does not tell you which role the file serves. Also, ET_EXEC does not mean “statically linked”: a fixed-address executable can still use shared libraries.
$ readelf -hW app
# Selected output: readelf renders e_type=3 as DYN, with a PIE description.
Type: DYN (Position-Independent Executable file)
$ readelf -hW libwork.so
# Same e_type value, but this file serves as a shared library.
Type: DYN (Shared object file)
The small model: REL = link input; EXEC = fixed-layout program; DYN = shared-object / PIE form; CORE = saved process. File type and static-versus-dynamic linking are separate questions.
ELF offers two views. Sections organize content for the toolchain: instructions in .text, names in string tables, symbols in symbol tables, relocations in relocation sections. Program headers describe what a running process needs: which file ranges to map, at which relative virtual addresses, with which permissions. One loadable segment can contain many sections. The loader does not need the section-header table to find the runtime metadata. [1]
| Inspect | What it answers in this lab |
|---|---|
readelf -lW app | Which segments get mapped? Which interpreter starts the process? |
readelf -dW app | Which libraries are needed, and where are the dynamic tables? |
readelf --dyn-syms -W app | Which names participate in dynamic linking? |
readelf -rW app | Which locations still need runtime address fixups? |
objdump -d app | What route does a call instruction actually take? |
What is a symbol table—and why are there two?
A symbol is a name-associated record about something in the program, often a function or variable. Its record includes a reference to the name, a value (such as an address), a size, and attributes describing its kind and visibility. A defined entry describes something this file supplies; an undefined entry records a name this file uses but needs another object to supply. A symbol table is an array of these records, not the functions’ machine code. [3]
ELF commonly has two such arrays. .symtab is the broader table for build-time linking and tools such as debuggers; it can include local names and can be removed from a shipped executable. .dynsym is the smaller table used by the dynamic linker for runtime symbol resolution: it includes exported definitions and unresolved imports. Calling the first one the “static symbol table” describes its role, not whether the executable is statically linked. A dynamically linked executable can contain both.
$ nm app | grep scan
# nm reads the ordinary symbol table here: t = local code, U = undefined.
0000000000001480 t local_scan
U scan
$ nm -D app | grep scan
# -D selects the dynamic symbol table. local_scan is absent.
U scan
So the application has code for local_scan, but it exposes no dynamic symbol for that local name. Its scan entry instead records an unresolved import. The library’s dynamic symbol table will supply a defined scan entry. This distinction will matter when we try to intercept the calls.
Read the prefixes by their container. ET_ labels a file’s type; PT_ labels a program-header entry’s type; DT_ labels an entry’s tag in the dynamic table. A leading dot, as in .dynsym, is part of a section’s name. These are different kinds of metadata, not different actors.
A dependency is a library name; an import is a symbol name.
The build-time linker records the executable’s runtime-linking metadata in an array called the dynamic table, conventionally stored in the .dynamic section. Each entry has a numeric tag identifying what kind of information it contains, and an associated value. DT_ names these dynamic-table tags. For example, DT_NEEDED is tag 1: a dependency-name entry. The loader is the actor that reads these records and acts on them.
$ readelf -dW app
# Selected rows. readelf prints decoded tag names without the DT_ prefix.
Tag Type Name/Value
0x0000000000000001 (NEEDED) Shared library: [libwork.so]
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
0x000000000000001d (RUNPATH) Library runpath: [$ORIGIN]
0x0000000000000006 (SYMTAB) 0x3e8
0x0000000000000005 (STRTAB) 0x670
0x0000000000000017 (JMPREL) 0x900
0x0000000000000003 (PLTGOT) 0x3fe8
Take the first row literally. The linker wrote an entry whose tag is DT_NEEDED and whose value is an offset into the dynamic string table, .dynstr. At that offset is the string libwork.so. At startup, the loader reads the entry, retrieves the name, finds the library file, and loads it if needed. The record says that this executable depends on that library; the loader performs the work. “Shared library: […]” is readelf’s human-readable interpretation of those bytes. [2]
$ readelf -p .dynstr app
# Selected strings. The bracketed numbers are hexadecimal byte offsets.
[ 55] scan
[ ed] libwork.so
[ f8] libc.so.6
[ 119] $ORIGIN
In this build the first dependency entry stores 0xed; the string beginning at offset 0xed is libwork.so. The DT_STRTAB entry identifies the string table’s location. The DT_SYMTAB entry identifies the dynamic symbol table’s location—even though its tag says SYMTAB, the runtime table here is .dynsym. The later JMPREL and PLTGOT rows locate structures we will follow when the first call runs.
Loading a dependency and resolving an import are separate operations. The loader first has the library available; it must also choose which definition satisfies the application’s undefined scan entry. For that second operation, it searches an ordered set of eligible objects, called a lookup scope, and checks their dynamic symbol tables. The scope determines which objects are candidates and the order in which they are searched; it is not their order in virtual memory. The dependency entry does not tie every scan reference exclusively to libwork.so. That flexibility allows another loaded object’s definition to be chosen—a behavior called symbol interposition.
The library’s exported symbol entry says:
$ readelf --dyn-syms -W libwork.so
# Selected entry: this file defines scan, rather than importing it.
Value Size Type Bind Vis Ndx Name
0000000000001100 4 FUNC GLOBAL DEFAULT 9 scan
Read the row as: FUNC identifies a function; GLOBAL makes it eligible to satisfy references from other objects; DEFAULT is its normal visibility setting; Ndx=9 identifies the section containing its definition. An import would show UND (undefined) instead of a defining section index. Here 0x1100 is an ELF virtual address, not a final process address. The four-byte implementation is simply lea 1(%rdi), %eax; ret on this build. The name itself lives in .dynstr; the symbol entry points into that string table. A loader hash table helps find candidates without disassembling functions. [3]
A relocation is a deferred edit.
The application also contains this record:
$ readelf -rW app
# Selected entry, retaining the location, relocation type, and name.
Offset Type Symbol
0000000000004058 R_X86_64_JUMP_SLOT scan + 0
Read it as: “when binding this function reference, put the chosen target address in the slot at this object’s ELF virtual address 0x4058.” A relocation identifies a location and an operation; the symbol is one possible input to that operation. In ELF64 RELA records, the key fields are r_offset, r_info—encoding type and symbol index—and r_addend. Other relocations need no lookup: a relative relocation can initialize a pointer by adding the object’s load bias to an addend. You need not memorize the numeric relocation encodings here: the important information is the location to update, the target name when one is involved, and the operation the loader performs. [4]
The executable therefore contains both an operational route for the CPU and a description for the dynamic linker. The CPU never reads the name “scan.” It will follow an address the linker arranges.
The file is not poured into one contiguous blob.
A process’s virtual address space is the whole collection of addresses it can use. A Linux VMA, or virtual memory area, describes one range with common mapping properties. A single shared object commonly occupies several VMAs: executable instructions, read-only constants, writable state, and sometimes anonymous zero-filled extensions. Neither a function nor a library needs its own process.
In /proc/self/maps, each row gives an address range, permissions, a backing-file offset, device and inode, and a pathname. r-xp means readable, executable, private; rw-p means readable, writable, private. “Private” describes how writes affect the mapping, not whether clean physical pages can be shared. File-backed code pages can be shared through the page cache; writes to private data are private to the process. [5]
Several objects. Several mappings each.
$ ./app
# The app prints its own /proc/self/maps; with the agent loaded:
$ LD_PRELOAD="$PWD/libagent.so" ./app
# The mapping bands below regroup selected rows from this second run.Open a band to inspect its VMAs. Gaps are compressed; heights do not represent byte counts. Paths are shortened from the captured run.
app bias 0x55e0f9a9a000
55e0f9a9a000–55e0f9a9b000 r--p off 0000
55e0f9a9b000–55e0f9a9c000 r-xp off 1000
55e0f9a9c000–55e0f9a9d000 r--p off 2000
55e0f9a9d000–55e0f9a9e000 r--p off 2000
55e0f9a9e000–55e0f9a9f000 rw-p off 3000libc.so.6 bias 0x7fe42604e000
7fe42604e000–7fe426076000 r--p off 000000
7fe426076000–7fe4261d9000 r-xp off 028000
7fe4261d9000–7fe42622f000 r--p off 18b000
7fe42622f000–7fe426233000 r--p off 1e0000
7fe426233000–7fe426235000 rw-p off 1e4000
7fe426235000–7fe426242000 rw-p anonymouslibwork.so bias 0x7fe426265000
7fe426265000–7fe426266000 r--p off 0000
7fe426266000–7fe426267000 r-xp off 1000 ← scan
7fe426267000–7fe426268000 r--p off 2000
7fe426268000–7fe426269000 r--p off 2000
7fe426269000–7fe42626a000 rw-p off 3000libagent.so bias 0x7fe42626a000
7fe42626a000–7fe42626b000 r--p off 0000
7fe42626b000–7fe42626c000 r-xp off 1000 ← wrapper
7fe42626c000–7fe42626d000 r--p off 2000
7fe42626d000–7fe42626e000 r--p off 2000
7fe42626e000–7fe42626f000 rw-p off 3000ld-linux-x86-64.so.2 bias 0x7fe426279000
7fe426279000–7fe42627a000 r--p off 00000
7fe42627a000–7fe4262a2000 r-xp off 01000
7fe4262a2000–7fe4262ad000 r--p off 29000
7fe4262ad000–7fe4262af000 r--p off 34000
7fe4262af000–7fe4262b0000 rw-p off 36000Observation, not a required ordering. ASLR, architecture, loader, environment, and later mappings change the layout. Symbol search order is not address order.
Three coordinates for the same byte.
To navigate from a disassembler to memory, distinguish a file offset, an ELF virtual address, and a runtime virtual address. An entry with program-header type PT_LOAD describes a segment to map into memory. Its p_offset is the start in the file, and its p_vaddr is the corresponding start in ELF virtual-address coordinates. For a byte inside its file-backed portion:
ELF VA = p_vaddr + (file_offset − p_offset)
runtime VA = load_bias + ELF VA
The load bias is the adjustment from link-time virtual addresses to this process’s addresses. It is often the first mapping address for a PIE or dynamic shared object (DSO) whose lowest loadable virtual address is zero, but that is not its definition. For many fixed-address executables the bias is zero. dl_iterate_phdr() exposes dlpi_addr and each program header, so an agent can compute dlpi_addr + p_vaddr directly. [6]
Follow a byte from disk into memory.
All three locations use bias 0x55e0f9a9a000. The last two illustrate why adding a file offset directly to the first mapping address can fail.
Our last writable PT_LOAD has p_offset=0x2db0 and p_vaddr=0x3db0. The dynamic table begins at file offset 0x2dc0, hence ELF VA 0x3dc0. The same segment contains the GOT slot: file offset 0x3058, ELF VA 0x4058. Segment boundaries and page alignment explain why the fourth app VMA can show file offset 0x2000 again.
Uninitialized storage adds another wrinkle. When the segment’s memory size p_memsz exceeds its file-backed size p_filesz, the extra memory is zero-filled; it has no corresponding payload bytes on disk. The section conventionally called .bss holds this zero-initialized storage; a large one can extend into anonymous pages. And after relocations the loader can make part of an initially writable segment read-only—one reason the VMA list need not match the PT_LOAD list one-for-one. [1]
In the captured run, the library’s scan is at 0x7fe426265000 + 0x1100 = 0x7fe426266100. The application’s GOT slot lives at 0x55e0f9a9a000 + 0x4058 = 0x55e0f9a9e058. The slot belongs to the caller; the instructions it points to belong to the provider.
Who turns these files into a running process?
The build-time linker, usually reached through gcc or g++, has already produced the executable. It is not still running in the application. At execution time a different component takes over: the dynamic linker/loader, here /lib64/ld-linux-x86-64.so.2, supplied by glibc (the GNU C Library). The program-header entry with type PT_INTERP (interpreter) identifies where the file stores that path.
On a normal Linux ELF execve(), the kernel maps the main executable and its interpreter, prepares the initial stack with arguments, environment, and an auxiliary vector, and transfers control to the interpreter’s entry point. The auxiliary vector supplies information such as the executable’s program headers and entry point. The kernel does not recursively resolve scan itself. [7]
The loader bootstraps itself, finds dependencies, maps shared objects, establishes thread-local storage (variables for which each thread has its own instance), and performs the relocations required before execution can proceed. Applicable initialization routines run before main; startup work is shared between the loader and the C runtime—the library startup code that prepares the environment for C/C++ code. The internal thread-local-storage layout is not needed to follow this call. The executable’s entry point is normally _start, which eventually reaches main through runtime startup. Some function bindings may be postponed until their first use.
In the lab, a constructor in libagent.so writes agent constructor: before main before the application prints its first message. No separate “agent process” is handling the call. The constructor and wrapper execute in the application’s address space. Preloading is a way to get code loaded at startup; that code must still arrange whatever hooks or control loop it needs.
Finding the file comes before finding the symbol.
-L. tells the build-time linker where to look; it does not by itself teach the runtime loader where to look. Our -Wl,-rpath,'$ORIGIN' stores a runtime search path relative to the executable’s directory. Quoting keeps the shell from expanding the token. For ordinary no-slash dependency names, glibc considers legacy RPATH under its conditions, LD_LIBRARY_PATH, RUNPATH, the loader cache, and default directories. RUNPATH applies to direct dependencies, not automatically to all descendants. [8]
A file can be found successfully and still fail symbol lookup. A symbol can also be found in an object other than the one you expected. Diagnose these as two separate questions: which files were loaded? and which definition satisfied this reference?
One stable call site, two different first steps.
Binding means choosing a definition for a symbol reference and establishing its target address. Lazy binding postpones that work for a function reference until its first call; eager binding does it before normal execution. Here is the actual x86-64 call instruction in our lazy-binding build:
$ objdump -d app
# Selected call site and the PLT stub it calls.
11b6: e8 25 ff ff ff call 10e0 <scan@plt>
10e0 <scan@plt>:
ff 25 72 2f 00 00 jmp *0x2f72(%rip) # slot at 4058
68 0b 00 00 00 push $0xb
e9 30 ff ff ff jmp 1020 # common PLT entry
The procedure linkage table is a set of little executable stubs in the caller. The global offset table stores addresses in the caller’s data mappings. This PLT stub jumps through a GOT slot using an instruction-pointer-relative address. In the assembly, %rip is the instruction pointer and jmp * means an indirect jump: read a target pointer from memory, then jump to that target. Both stub and slot slide by the same load bias, so the displacement remains usable after ASLR.
Initially, lazy-binding setup makes this slot lead to the stub’s fallback instructions at scan@plt + 6. Those instructions identify the relocation and reach the common resolver route. The dynamic linker chooses the definition, writes its address into the slot, and arranges to continue into the chosen function. Later calls use the same call instruction and PLT stub but jump directly to the resolved target. The application binary interface (ABI) specifies machine-level agreements between compiled components, including call conventions and relocation formats. This particular push/index arrangement is x86-64 ABI machinery; other architectures and modern PLT variants encode the route differently.
Step through a binding.
call scan@plt
one target pointer
function instructions
The model shows one imported reference. Each importing object can have its own slot for the same symbol.
Resolve early, and the first call becomes ordinary.
The lab’s app-now uses -Wl,-z,now. LD_BIND_NOW=1 also requests eager function binding. The loader fills the relevant targets before normal execution, so the first call does not need this lazy resolver detour. The cost moves into startup; there is no claim here about how many nanoseconds it costs.
RELRO means relocation read-only: the loader can protect a designated region after fixing it up. A common “full RELRO” build combines -z relro with -z now, allowing the GOT area that lazy binding would need to mutate to become read-only. In our lazy build, the GNU-specific program-header entry PT_GNU_RELRO identifies the range to protect; that range ends at ELF VA 0x4000; scan’s slot at 0x4058 remains writable. Inspect the actual layout rather than inferring permissions from the section name. [17]
Position-independent code is the broader idea: keep instructions usable when the object moves, using relative addressing and indirection where necessary. -fPIC is the usual shared-library compilation mode; -fPIE plus -pie builds a position-independent executable. PIC does not mean “no relocations.” Our PIC library and PIE application still need runtime fixups for some stored addresses. Keeping most fixups out of executable text helps code pages remain shareable. [9]
Removing the PLT does not remove dynamic lookup.
Our app-noplt, built with GCC’s -fno-plt, has this call instead:
$ objdump -d app-noplt
# Selected call site: an indirect call through the GOT.
1097: ff 15 1b 2f 00 00 call *0x2f1b(%rip) # GOT at 3fb8
$ readelf -rW app-noplt
# Selected relocation for that slot:
0000000000003fb8 R_X86_64_GLOB_DAT scan + 0
The application calls through the GOT without taking a PLT stub first. The address is resolved at load time. Our preload experiment still changes its result from 8 to 108. -fno-plt changes call generation, not the general eligibility of a default-visible external symbol for interposition. GCC can also optimize calls known to bind locally. [9]
dlsym() is the lookup operation made explicit.
An ordinary imported call delegates address preparation to relocation machinery. Sometimes a program wants to ask for an address itself: a plugin loader knows only a function name; a wrapper needs the next implementation; an agent needs an exported entry point. dlsym(handle, "scan") performs a symbol lookup and returns its address. It does not disassemble the process to discover arbitrary functions, and it does not globally redirect calls.
dlerror(); /* clear a previous error */
void *p = dlsym(handle, "scan");
const char *error = dlerror();
if (error) { fprintf(stderr, "%s\n", error); abort(); }
/* Linux/POSIX example: the ABI signature must be exactly right. */
int (*fn)(int) = (int (*)(int))p;
/* For a callable function, also reject an unusable null target. */
if (!fn) abort();
int result = fn(7);
Check dlerror(): in unusual cases a successful symbol lookup can yield a null value. A function address also carries no type checking. Calling with the wrong argument, return type, calling convention, or C++ ABI is not repaired by a cast. [10]
| Lookup handle | The question it asks |
|---|---|
A real dlopen handle | Find the name in this object and its dependency search scope. |
RTLD_DEFAULT | Find the name using the default global lookup scope. |
RTLD_NEXT | Find the next definition after the object containing this lookup call. Useful inside a wrapper. |
RTLD_NEXT means “next,” not “the pristine libc version.” A second preload layer can be next. Moving the lookup into another shared object can change what “after me” means. The public symbol name is a lookup key; it is not a unique process-wide identity for an implementation. [10]
A returned pointer does not rewrite existing call routes.
The lab includes a small x86-64/glibc-only inspection program that finds the main executable’s scan relocation and reads its slot around a lookup and a call. Here is one measured run:
$ make slots
# Build output omitted.
$ ./slots
# Selected output; addresses come from this one execution.
before: 0x55a308ddc086 ← app’s unresolved PLT route
dlsym result: 0x7ff08f4f3100 ← libwork’s implementation
after dlsym: 0x55a308ddc086 ← unchanged
after call: 0x7ff08f4f3100 ← now bound by the call
result: 8; lookup left slot unchanged: yes
The two mechanisms can discover the same target while updating different state. Calling the returned pointer would invoke that target directly. It would not require the application’s scan@plt to become bound first. Conversely, changing a GOT slot later does not update copies of a function pointer already stored elsewhere.
Loading is a separate operation again.
dlopen("libwork.so", RTLD_NOW | RTLD_LOCAL) obtains a handle, loading the object and dependencies if needed and performing initialization before returning. RTLD_LOCAL does not generally publish the new object’s definitions into the global lookup scope; RTLD_GLOBAL does. Reopening an already loaded object normally reuses it and increments a reference count. dlclose drops a reference, but unloading is subject to other references and loader rules. Keep an object alive while its code or data pointers can be used. [11]
A late dlopen() of a new wrapper is therefore not a universal “replace this function” command. Already bound slots and cached function pointers retain their targets. Even a future lookup depends on scope and precedence; merely loading another definition does not guarantee that it wins.
Get a wrapper chosen before the original.
Run the application with:
$ LD_PRELOAD="$PWD/libagent.so" ./app
The loader brings the agent in at startup and makes its definitions available for interposition. In the usual glibc global startup lookup scope, eligible definitions in the main executable come before preloaded libraries, which precede ordinary dependencies. Multiple preload entries are considered in their specified order. Exact lookup remains reference- and scope-dependent. Secure-execution rules restrict environment-based preloading. [8]
Our agent exports scan. It resolves the next implementation once and adds 100 to its result. The added arithmetic makes the route visible without requiring a real semaphore.
static int (*next_scan)(int);
static pthread_once_t once = PTHREAD_ONCE_INIT;
static void resolve(void) {
dlerror();
next_scan = (int (*)(int))dlsym(RTLD_NEXT, "scan");
const char *e = dlerror();
if (e || !next_scan) abort();
}
int scan(int x) {
pthread_once(&once, resolve);
return next_scan(x) + 100;
}
Excerpt; the downloadable source includes headers and diagnostics. The once-control avoids a data race during concurrent initialization. It is suitable for this narrow example, not a general allocator-hook bootstrap.
The compiled lab reports:
$ LD_PRELOAD="$PWD/libagent.so" ./app
# Selected output; local paths are shortened below.
agent constructor: before main
scan(7)=108
scan(7)=108
local_scan(7)=8
DEFAULT scan belongs to …/libagent.so
handle scan belongs to …/libwork.so
DEFAULT call=108; handle call=8
The imported calls choose the agent. Its RTLD_NEXT lookup reaches the library. The executable’s direct local call stays local. And the explicit libwork.so handle lookup returns that object’s definition in this example, while the default-scope lookup returns the wrapper.
No instructions in libwork.so were changed. Both implementations coexist in executable VMAs in the same process. What changed is which implementation the dynamic lookup selected for an eligible reference.
Startup is part of the hook design. Real hooks for allocation, logging, synchronization, or loader functions may run while those facilities are initializing. Lookup and diagnostics can recursively invoke what you are hooking. A pthread_once resolver that re-enters the same wrapper can deadlock. Keep bootstrap dependencies explicit; do not transplant this teaching wrapper into malloc interception.
A symbol is not every execution of a function.
Suppose runtime feedback says that scan is creating memory-bandwidth contention. You want to turn a permit check on for a changing subset of functions, with as little inactive cost as possible. Dynamic linking gives you an injection route and some indirection points. It does not guarantee a universal interception point.
In our application the local route is literal:
$ objdump -d app
# Selected direct call and its local implementation.
11e0: e8 9b 02 00 00 call 1480 <local_scan>
1480 <local_scan>:
8d 47 01 lea 0x1(%rdi), %eax
c3 ret
No dynamic lookup or GOT slot exists on this route. A preload library cannot win a lookup that never happens. Inlining goes further: there may be no function call left to redirect at all. Separate translation units (source files compiled independently), link-time optimization (LTO, which can optimize across them), visibility, local binding, and linker options affect which routes survive. Treat the final binary as the evidence.
| Intervention | What changes | Which routes it reaches |
|---|---|---|
| Preload interposition | Definition selected by eligible dynamic lookups | Imports and lookups that choose the wrapper; misses direct local calls and inlined copies. |
| GOT-slot update | A pointer slot in a particular importing object | Calls reading that slot; misses other objects’ slots, direct calls, and cached pointer copies. |
| Application dispatch pointer | A pointer the program intentionally calls through | Every route obeying that dispatch contract; costs an indirect call while inactive too. |
| Entry detour / reserved patch site | Instructions at a chosen entry address | Calls and pointers reaching that entry; misses inlining, alternate entries, and bypass routes. |
My engineering recommendation for a compiler-controlled experiment is to use LD_PRELOAD to start the agent, then make the interception contract explicit. If inactive per-call indirection is unacceptable, reserve and identify patch sites during compilation, and verify that every intended call reaches them. Dynamic linking explains how the agent arrives; a patching protocol must explain how calls are redirected safely.
Knowing an address is the start of patching, not its correctness proof.
mprotect() can change page permissions, with page-aligned ranges; successful permission changes only establish access rights. They do not stop another thread from executing the bytes you are changing. A complete detour design must address instruction boundaries, concurrent instruction fetch, a thread already inside the displaced range, branch reach, relocation of displaced instructions, and instruction-cache synchronization on the target architecture. A reserved NOP area reduces decoding work; it does not supply the synchronization protocol. [12]
A GOT update avoids moving instructions but still needs a defined concurrency and lifetime strategy, and may need to change read-only page permissions. An aligned hardware pointer store is not automatically a complete, language-level protocol for racing arbitrary generated call sites. If an old target can be unloaded or freed, a successful store does not prove no thread is still using it.
The unknowns worth finding before a benchmark.
Can the agent find the intended function?
.symtab, the full toolchain symbol table, and .dynsym, the dynamic interface, are different. A function visible to nm app may be absent from nm -D app. -rdynamic can export executable globals, but it cannot make a static function interposable or resurrect an inline call. C++ adds mangled names, overloaded signatures, thunks, clones, and exception semantics. Prefer a compiler-generated address/ID manifest with build identity over guessing from printable names. [3]
Does the address name the implementation you think it does?
GNU indirect-function (IFUNC) symbols designate a resolver whose returned address selects an implementation, often based on CPU features. A raw symbol-table value can therefore point to a resolver rather than the code ultimately called. Symbol versions add another identity dimension; dlvsym() asks for a particular version. A useful experimental target identity is a tuple: object/build, function ID or symbol/version, and observed entry address. [13]
Are lookup scopes hiding a second copy?
A local plugin scope, RTLD_DEEPBIND, or a separate dlmopen() namespace can defeat an assumption that every lookup sees one global set of definitions. Hidden/protected visibility and -Bsymbolic can make intra-library references bind locally. For a first research prototype, deliberately constrain the loading model and document those constraints. Expand coverage only when the experiment demands it. [11] [17]
Can you discover mappings safely?
Use dl_iterate_phdr() for loaded objects and segment information. dladdr() maps a code address back to object/symbol information when available; it is not a complete debug-symbol or source-line service. dlinfo() exposes documented handle information. Avoid treating an opaque dlopen handle as a public link_map*, or walking loader lists unsynchronized. Discovery also needs a lifetime policy if other threads can unload objects. [14] [15]
What if a signal requests the change?
A signal handler may interrupt loader or allocation state halfway through an update. Safe for concurrent calls by multiple threads (MT-safe) does not mean safe when called from an interrupting signal handler (async-signal-safe): do not run dlsym(), dlopen(), or general logging from an arbitrary signal handler. Notify a normal control thread through a suitable async-signal-safe mechanism, or arrange synchronous signal handling with sigwait. Let that thread perform discovery and coordinate the patch. [16]
What does the permit wrapper promise?
The call ABI comes first: preserve arguments, return values, stack alignment, and any exception/unwind contract. Then define permit ownership on early return, exception, thread cancellation, nested guarded calls, and recursion. A C++ RAII guard helps on ordinary exception paths; it does not make arbitrary cancellation or nonlocal exits safe by itself. Make the cancellation contract explicit before treating a wrapper as transparent.
Which overhead are you actually measuring?
Separate first-use lookup, steady-state call route, permit operation, and activation/deactivation. Compare a direct-call baseline, normal imported call, preloaded pass-through, explicit pointer dispatch, and a detoured entry with the same workload. Warm the intended binding state before steady-state timings, retain a cold case separately, inspect generated instructions, and prevent the compiler from deleting the work. These lab results establish routes and behavior; they are not timing measurements.
The important design question is now concrete: which instruction or pointer must every relevant execution pass through, and what protocol lets the agent change it? The loader can answer where an object landed and which symbol it selected. Your instrumentation contract must answer the rest.
Open the binary yourself.
Download the source lab, or read its instructions. It contains the application, original library, preload agent, three build variants, and the optional GOT inspection program. No BPF runtime, root access, or patching framework is needed.
unzip lab.zip
cd lab
make
./app
LD_PRELOAD="$PWD/libagent.so" ./app
LD_PRELOAD="$PWD/libagent.so" ./app-noplt
readelf -lW app # segments and interpreter
readelf -dW app # dependencies and tables
readelf --dyn-syms -W libwork.so # exported scan
readelf -rW app # scan's relocation
objdump -d app # imported and direct call sites
# x86-64 + glibc only: inspect the slot around lookup and call
make slots
./slots
LD_BIND_NOW=1 ./slots
For loader decisions, run LD_DEBUG=libs,bindings ./app. The trace is noisy; search for scan. For a live process, use ./app --wait in one terminal and inspect its PID’s /proc/PID/maps in another. The application also prints its own mappings and load biases.
In GDB, starti, info files, info sharedlibrary, info proc mappings, and disassemble /r main connect the file and runtime views. In the lab build shown here, the scan GOT slot is bias + 0x4058, so x/gx at that address reveals its target. Recompute the offset from your own readelf -rW; a different compiler or build can move it.
A useful next experiment: add an internal library caller of scan. Compare its disassembly and preloaded behavior before and after -Wl,-Bsymbolic-functions. Then test a call through an explicitly obtained function pointer. Predict which address each route reads before running it.
You now have a way to turn “the hook did not fire” into a sequence of testable questions. Was the object loaded? Was the function exported? Which definition won lookup? Did this call use that lookup at all? Did it reach the patched entry? Those questions lead from the binary to the actual execution, one observable boundary at a time.
Sources & further paths
The disassembly, relocation offsets, mappings, and behavioral outputs above were produced from the included lab using GCC 14.2.0 on x86-64 Linux with glibc. ASLR addresses are snapshots, not constants. General mechanisms and API contracts are grounded in the following primary references.
- System V ABI: program loading — program headers, loadable segments, file/memory sizes.
- System V ABI: dynamic linking — the interpreter, dynamic table, dependencies, lookup machinery.
- System V ABI: symbol table — symbol values, binding, visibility, and names.
- System V ABI: relocation — relocation entries and deferred address computation.
- Linux man-pages: proc_pid_maps(5) — interpreting mappings.
- Linux man-pages: dl_iterate_phdr(3) — loaded objects, program headers, address calculation.
- Linux man-pages: getauxval(3) and Linux ELF loader source — the kernel side of startup.
- Linux man-pages: ld.so(8) — paths, preload, binding flags, diagnostics, secure execution.
- GCC: code generation options — PIC, PIE, visibility, and
-fno-plt. - Linux man-pages: dlsym(3) — scopes, NEXT, error handling, versions.
- Linux man-pages: dlopen(3) — loading, reference counts, scope flags, namespaces.
- Linux man-pages: mprotect(2) and GCC: instrumentation options — page permissions and reserved function-entry sites.
- glibc: indirect functions — resolver symbols and chosen implementations.
- glibc: dynamic linker introspection — load bias, link maps, documented introspection.
- Linux man-pages: dladdr(3) — translating addresses back to objects and symbols.
- Linux man-pages: signal-safety(7) — signal-handler constraints.
- System V ABI: ELF header — the numeric e_type field and ET_ constants.
- GNU ld: options — eager binding, RELRO, symbolic binding, exports.
Continue into the bpftime series for the next layer: getting an agent and a hook runtime into the application, then tracing a detoured call.