Sitemap
Dor Grosglick

Dor Grosglick

Large Celery 🥬 >> Large Salary 💸

How a function call reaches a shared library

16 min read5 days ago

--

Press enter or click to view image in full size

Calling a function before we know its address

When we call printf, we usually care about what it prints. The function comes from a library, but using it feels much like calling a function we wrote ourselves. We pass the arguments and expect it to do its job.

But the location of the function in memory can be different each time we run the program. How can we compile a call before we know where it needs to go? Let’s follow a C program whose entire job is to print one line. Even here, reaching the library takes more than the call we wrote in the source.

Following our program into the executable

Our example has two source files. main.c calls a helper named hello:

void hello(void);

int main(void)
{
hello();
return 0;
}

In hello.c, we define the helper:

#include <stdio.h>

void hello(void)
{
printf("Hello!\n");
}

The helper is ours, while printf comes from the C standard library. We can build the program with GCC:

gcc -Wall -Wextra -O2 -c main.c hello.c
gcc -s main.o hello.o -o hello

The first command produces object files — machine code with information the linker needs to connect it to other code. -c stops before linking, -O2 enables optimizations, and -Wall and -Wextra enable groups of compiler warnings.

The second command links those object files into the executable hello.
-s strips information that isn't needed to run it, including function names that would otherwise help us inspect the program. The information required for dynamic linking stays in place.

Keeping the helper in a separate source file lets us follow the call between the two functions without ordinary compilation folding the helper into main. I inspected a build of this two-function program using Ubuntu's GCC 13.3 on x86-64 Linux. The screenshots show that build. Different compilers and platform defaults can produce different instructions.

I opened the executable in IDA, which displays its machine code as assembly instructions, and let codex add names and comments without access to the original source. print_hello is the name it gave our hello helper:

Press enter or click to view image in full size

Figure 1. main calls the helper labeled print_hello, then returns zero. The names and comments were added by codex during analysis.

There is already a small difference between the source and the compiled program. GCC turned printf("Hello!\n") into puts("Hello!"). We don't use any formatting here, so puts can do the job and add the newline itself. On this Linux system, both functions come from glibc, the C library most debian based distros use. We will follow the call to puts, because that is the call our executable actually makes.

Leaving a place for the library address

The call from main to our helper is straightforward. The build-time linker has the machine code for both, so it can put them in the executable and connect the call between them. The implementation of puts stays in a shared library, a separate compiled library used at runtime. From our executable's perspective, puts is an imported function - we call it, but its implementation comes from elsewhere.

When we start the program, the loader maps the executable and its libraries into memory. The dynamic linker connects the program’s references to library functions with their implementations. On Linux, these runtime jobs are closely integrated. This is when the program can learn where glibc’s puts is in that process. Library versions and address-space randomization (loading code at varying addresses) can change where it ends up.

We can’t put the final address of puts into our code yet, but we can leave a place to store it - a pointer slot that the dynamic linker fills in later:

puts_pointer_slot = runtime address of glibc's puts

In Linux’s ELF executable format (Executable and Linkable Format), these slots live in the GOT (Global Offset Table). The table holds several kinds of addresses, and our pointer to puts is one of them. The dynamic linker finds the function and writes its address into the slot. This is what we mean by resolving the function.

Once the address is there, the CPU can follow it without looking up the name puts every time we print. For now, let's assume the dynamic linker has already filled in the slot.

For example, we can write this simplified x86–64 instruction:

call [puts_pointer_slot]

The brackets mean “read the value stored in memory here”. The CPU reads the function’s address from the slot, saves a return address, and starts executing the function. When the function executes ret, we return to the instruction after the call. We call this an indirect call because the destination comes from a pointer.

If we already have a local destination to call, we can use a direct call:

call local_function

But the compiler can also generate a direct call to a function whose final destination is still unknown. When the linker finds that the function comes from a shared library, it can give that call a local target — a small piece of code that forwards it through the GOT pointer.

That forwarding code is a PLT entry, part of the PLT (Procedure Linkage Table). The linker knows where to put the entry inside our executable, so it can finish the direct call. The entry then reads the function’s address from the GOT and jumps to it:

; In the caller:
call puts_plt

; Elsewhere in the same executable:
puts_plt:
jmp [puts_pointer_slot]

The stub uses jmp, which transfers execution without saving a new return address. The return address from the original call is still there, so the library function returns straight to the caller when it finishes. The linker creates this stub as part of the executable we inspect in IDA.

In our executable, the compiler made one more optimization. Printing is the helper’s last action, so there is no reason to call the printing function, return to the helper, and immediately return again. The helper can jump to the printing function and let it return to main. We call this a tail call, and it gives us this path:

main --call--> print_hello --jmp--> puts_plt --jmp--> glibc puts
^ |
+--------------------------ret---------------------------+

The call from main creates the return address. Both jumps leave it in place, so glibc's puts can return straight to main.

Press enter or click to view image in full size

Figure 2. The forwarding stub in our executable. Codex named it puts_plt during analysis. IDA's jmp cs:puts_ptr reads the pointer stored at puts_ptr and jumps to that address.

We can read the forwarding instruction as:

jmp qword ptr [puts_ptr]

The cs: notation doesn't change the fact that this is an indirect jump. The other instruction, endbr64, is a security marker. This instruction is not important for our explanation, though we will cover it at the end for any one interested

When does the pointer get its value?

So far, we have assumed that the pointer already holds the function’s address. But it can also point to code that finds the address for us. This is how the PLT supports lazy binding. The stub doesn’t need to check whether the function has been found. It just follows the pointer, and changing that pointer changes where we go.

With eager binding, the loader finds the function’s address before application execution:

Before main:
puts_ptr = address of glibc puts

Every invocation:
caller -> puts_plt -> glibc puts

With lazy binding, the slot initially points to a helper. Following the layout in our executable, the code looks like this:

puts_plt:
endbr64
jmp [puts_ptr]

puts_lazy_resolver:
endbr64
push 0
jmp plt_resolve

plt_resolve:
endbr64
push [loader_object_information]
jmp [loader_resolver_address]

The names in this example describe each piece’s job. push 0 tells the resolver which function we need: entry zero in the executable's list of relevant relocation records, which describes the puts import. A relocation record tells the linker about an address that needs to be filled in. We are passing the resolver a way to identify puts, not passing zero to puts as an argument.

The shared plt_resolve entry adds information identifying the executable or library that requested the function, then jumps into the dynamic linker. The dynamic linker reads the import metadata, finds puts, and writes its address into the GOT slot. It also preserves the function arguments and arranges for the real function to run and return properly.

First lazy call:
caller -> puts_plt -> puts_lazy_resolver -> plt_resolve -> dynamic linker -> glibc puts
^ |
+---------------------------------------ret---------------------------------------+

Later calls:
caller -> puts_plt -> glibc puts
^ |
+---------ret--------------+

Every call still goes to the same PLT entry. On the first call, the pointer sends us through the resolver. After that, the updated pointer sends us straight to the function. We don’t need to change any of the callers, and PLT imports we never call don’t need the lookup. Lazy binding usually postpones finding the function’s address, not loading the library. The library can already be in memory while its function pointers remain unresolved. Oracle’s linker documentation describes this first-call resolution.

The executable in the screenshots doesn’t use this lazy path. Its loading metadata includes BIND_NOW, which requests eager resolution. The function addresses are filled in before our application code runs. The forwarding stubs still do their job, while the lazy resolver stubs remain in the executable without being used during normal execution.

This explains why we can find resolver code in IDA even when the program never needs to execute it. The build-time linker created a PLT layout that includes resolver code, but the binding policy determines whether execution reaches it. We can require eager binding at link time, as this executable does, or request it through the runtime environment.

Get Dor Grosglick’s stories in your inbox

Join Medium for free to get updates from this writer.

The build command doesn’t explicitly ask for eager binding. In the Ubuntu GCC 13.3 setup used for this example, the toolchain adds -pie -z now -z relro during linking. -pie produces a position-independent executable, which can be loaded at different addresses. -z now requests eager binding.

There is a security reason for filling in the pointers early. -z relro allows selected memory regions to become read-only after the loader has finished updating them. This protection is called RELRO (Relocation Read-Only). Once eager binding has filled in the GOT, those pointers can be protected against later writes. Ubuntu's compiler and linker documentation explains these defaults.

If the pointer is already filled in before our application code runs, why go through the stub at all? We can read the GOT pointer at the call site. GCC’s -fno-plt requests exactly that:

call [puts_pointer_slot]

Our tail-calling helper would use jmp in place of call, but the idea is the same. We still use a shared library and a GOT pointer. Removing the forwarding step doesn't copy glibc into the executable. In the ordinary -fno-plt scheme, the function pointers are resolved at load time. GCC's documentation describes this behavior.

There are two choices here. One is when to fill in the pointer — before the application runs, or when it first calls the function. The other is how the call reaches that pointer — through a PLT stub, or from the call site itself. Using a PLT doesn’t force lazy binding, as our eager-bound executable shows. GCC’s ordinary -fno-plt calls skip the stub and have their pointers resolved at load time.

Windows uses the same forwarding idea

This idea is older than Linux. The PLT already appears in Sun’s 1988 manual and became part of the System V ELF linking conventions. Windows uses much the same forwarding mechanism.

On Windows, our shared library is a DLL (Dynamic-Link Library). The executable keeps its imported function and data pointers in the IAT (Import Address Table), which the loader fills during normal loading. It serves the role of the import pointer slots we used in the GOT. The table is part of Windows’s PE (Portable Executable) format.

For a DLL function named foo, we can tell the compiler that the function is imported:

__declspec(dllimport) void foo(void);

The compiler can then generate a call through the IAT pointer:

call [__imp_foo]

Here, __imp_foo names the pointer slot. The function's code lives at the address stored in that slot. Without the import annotation, the compiler may generate a direct call to foo. When the linker finds that foo comes from a DLL, it can provide a local forwarding stub - an import thunk:

; Already-compiled caller:
call foo_thunk

; Linker-generated forwarding stub:
foo_thunk:
jmp [__imp_foo]

The thunk does essentially the same forwarding job as a PLT entry. We don’t need lazy binding for this to be useful. The caller was compiled with a direct call, and the thunk lets that call reach a function whose address will only be known at runtime. As Microsoft explains:

If you tell the compiler the function is in a DLL, it can generate an indirect call for you.

Microsoft’s explanation of __declspec(dllimport) and import thunks shows both instruction forms.

Windows also has delay-load support. A helper can load a DLL and find the required function when we first need it, then update the import pointer. Here we can postpone loading the DLL itself, which goes further than ordinary ELF lazy binding. A normal eager import thunk doesn’t give us this behavior on its own. Microsoft’s delay-load documentation.

Why keep the extra jump?

We have seen that the compiler can skip the thunk when we declare a DLL import. But it could also start with an indirect call whenever the destination is uncertain, then leave the linker to turn it into a direct call when a local definition is available. Nothing in Windows or the PE format fundamentally prevents that approach.

I tested the corresponding case on Linux. With an unresolved declaration of foo, GCC's -fno-plt produced an indirect GOT call in the object file. When I linked it with a local definition of foo, the linker replaced the indirect call with a direct one. This is linker relaxation. The linker can do this when the call must reach that local definition. If runtime linking is allowed to select another implementation, it has to preserve that possibility.

The flag didn’t tell GCC which functions came from a library. It told GCC to use pointers for calls that might need dynamic resolution, giving the linker room to simplify them later. Windows’s dllimport instead tells the compiler something specific about the function - it comes from a DLL. These are different ways for the compiler and linker to cooperate. The CPU can execute either form.

There is also a practical reason to keep a thunk available when the caller has already been compiled. It comes from how the instructions represent their destinations. A direct x86–64 call normally stores a distance from the instruction that follows it to the target. This is a relative address. An indirect call through the GOT or IAT can also use a relative distance, but that distance leads to the pointer slot. The slot then holds the function’s absolute virtual address — its address in the process’s address space.

Direct and indirect describe whether we get the destination from a pointer. Relative and absolute describe how an address is represented. A direct call doesn’t have to contain an absolute address.

The size of these instructions helps explain why a thunk is useful. On x86–64, RIP is the instruction pointer. A typical indirect RIP-relative call (reading a pointer at an offset from RIP) takes six bytes, while a direct relative call takes five. We can replace the six-byte instruction with a five-byte instruction and padding. Going the other way is harder. If the compiler already gave us a five-byte direct call, we might not have room for a six-byte indirect call. A thunk lets us keep the existing call, including one supplied in a precompiled library.

The thunk solves a problem for a call that was already compiled as direct. But that doesn’t make the extra jump desirable. If we can call through the IAT pointer ourselves, we should skip the thunk. Microsoft explicitly describes the resulting code as smaller and faster, and explains that the extra thunk code can hurt cache performance. This is why it recommends __declspec(dllimport) for calls into a DLL.

Microsoft’s whole-program optimization can remove the thunk even without that annotation. It gives the toolchain enough information to eliminate the forwarding step. We still call the same library function through the same import pointer, but we no longer take an unnecessary jump to get there.

How does the call find the pointer?

Moving the function’s address into a pointer slot leaves the compiler with another address it doesn’t know yet — the location of that slot. It can leave part of the instruction unfinished and tell the linker what belongs there. For an indirect GOT call, the object file can contain something like this:

call [rip + 0]          ; Zero is a placeholder displacement

Alongside the instruction, a relocation record identifies foo and the calculation needed to complete the reference. Linux x86-64 can use a relocation named R_X86_64_GOTPCRELX. Windows object files use the COFF format (Common Object File Format), where IMAGE_REL_AMD64_REL32 commonly describes the corresponding RIP-relative reference to an IAT symbol. These long names identify the rules for calculating the reference. The CPU doesn't execute these records. They tell the linker how to finish the code. AMD64 ABI, Microsoft's relocation definitions.

The linker lays out the code and pointer table, then fills in the distance between them. For example:

Instruction address:       0x1000
Address after instruction: 0x1006
GOT/IAT slot address: 0x3000

Displacement = 0x3000 - 0x1006 = 0x1FFA

Now the completed instruction is:

call [rip + 0x1FFA]

In this addressing mode, the calculation uses the address immediately after the instruction. We measure the distance from there to the pointer slot, not from the beginning of the function.

We now have two separate relationships to keep track of:

Instruction --relative distance--> pointer slot
Pointer slot --absolute virtual address--> library function

If the executable moves, its instructions and pointer slots move together. The distance between them stays the same, so the loader normally doesn’t need to patch this instruction again. The library can move independently. Its function’s address still needs to be written into the pointer slot.

This is why we have both build-time and runtime relocations. The build-time linker fixes references between locations it lays out and creates the import information needed later. At runtime, ELF uses dynamic relocations such as GLOB_DAT and JUMP_SLOT to fill these external pointers. Windows uses its import metadata. Absolute addresses pointing inside a module may also need adjustment when that module moves, through ELF dynamic relocations or the PE base-relocation table. But resolving an import requires finding a target in a separately loaded library. Adding our executable's load offset isn't enough.

Relative addressing can avoid the need to patch code at runtime, help code pages remain shareable, and fit compact instructions. It also has limits. A normal x86–64 direct relative call or jump reaches roughly ±2 GiB (one byte for the instruction and four for the offset), and there is no ordinary direct call or jump encoding that contains an arbitrary 64-bit absolute destination. To reach other addresses, we can use registers or memory pointers. Relative addressing solves one part of our problem, while the absolute pointer in the GOT or IAT solves another.

Does calculating the address cost another cycle?

We still have to add the distance to the address after the instruction. Calculating that address takes work, but it doesn’t necessarily cost an extra instruction or clock cycle. The CPU already has hardware for calculating branch and memory addresses, and the addition can fit into the time available for that operation. It still takes energy and time, but not necessarily harm computation speed, as this is measured in cycles.

Imagine two workers waiting for a bell ring. Every ring they move on to the next task. Let’s say the bell rings every ten seconds. One worker writes a line to a report file. This takes them two seconds. Now the second worker read a line from the file, then writes a new one. The reading takes them one second. Combined with the write they finish their task in three seconds. Both workers finished their work before the next ring, so they complete the same amount of tasks. There is a limit to the complexity of a task. If the combined time was more then the ring interval, then it can’t be a single task anymore. The CPU’s timing is more complicated, but the distinction is the same: doing a calculation and adding another cycle aren’t necessarily the same thing.

Relative branches appear across different processor designs, including x86–64, ARM, and RISC-V. The CPU’s branch or address-generation hardware does the calculation. The MMU (Memory Management Unit) has a separate job — translating the program’s virtual addresses into locations in physical memory. Relative and absolute addressing both produce virtual addresses that use this translation system. And reading a GOT or IAT slot still means reading a pointer from memory. Its cost goes beyond the address addition.

Bonus: What does endbr64 protect?

We can now follow the call all the way to the library, but there is one instruction in the screenshot we haven’t explained: endbr64. It addresses a separate problem - controlling where indirect branches can land. It is an Indirect Branch Tracking (IBT) marker, part of Intel's Control-flow Enforcement Technology (CET), a set of processor security features. When this protection is active, an ordinary tracked indirect call or jump must land on an appropriate marker. Direct calls and jumps aren't subject to this particular check.

Each marker applies to the location where it appears:

puts_plt:
endbr64 ; Permits indirect entry into this stub
jmp [puts_ptr] ; Transfers to a different destination

; Elsewhere, in an IBT-compatible libc:
puts:
endbr64 ; Permits indirect entry into puts
; actual implementation follows

The marker at puts_plt allows an incoming indirect branch to enter that stub. The stub's outgoing jump goes to a different location, which needs its own marker. An endbr64 before a jump doesn't approve wherever that jump goes. Intel's IBT documentation.

Even if many functions have this marker, the restriction still removes choices an attacker would otherwise have. A corrupted pointer could send execution into the middle of a function, or even into bytes inside an instruction. IBT limits these indirect branches to marked locations, but it doesn’t prove that we called the right function. Another marked function can still pass the check. CET’s shadow stack handles a different part of the problem — protecting return addresses.

In the build I inspected, both the executable and glibc were marked as compatible with IBT and shadow stacks (SHSTK). But the Intel Core i7-8700K running the program reported neither hardware feature through CPUID (the instruction for querying processor capabilities). The process also had no active shadow-stack features. The instructions were there, but on that processor they didn't enforce protection. The markers tell us how the binary was built. They don't tell us that the protection is active. Linux's CET documentation distinguishes executable markings from runtime enablement.

Conclusion: a pointer connects our call to the library

Our program still only prints one line. main calls the helper, the helper jumps to the PLT entry, and the entry follows a GOT pointer to glibc's puts. In this build, the loader has already filled in the pointer, so the lazy resolver code stays unused. When puts finishes, it returns to main.

The program doesn’t need the library function’s final address when we compile it. It needs a way to reach that address once it is known. The GOT on Linux and the IAT on Windows provide a place to store it. The PLT entry or import thunk gives a direct call a local destination that can forward through that pointer. If the caller can read the pointer itself, we can skip the forwarding step. These pieces each solve part of the same problem — connecting the call we wrote to code that will be loaded later.

--

--

Dor Grosglick
Dor Grosglick
0 followers

Large Celery 🥬 >> Large Salary 💸