GNU IFUNC and Indirect Functions

A GNU indirect function (IFUNC) is a symbol whose final address is not chosen by the linker or written into the file, but is computed at load time by a small resolver function that the toolchain attaches to it. When the dynamic linker processes the binary, it calls the resolver, and whatever pointer the resolver returns becomes the symbol’s address for the rest of the program’s life. This one-time indirection is how a single libc.so can ship a dozen hand-tuned versions of memcpy and pick the fastest one for the CPU it happens to be running on — without any per-call branch. IFUNC is a GNU extension to ELF built on a dedicated symbol type, STT_GNU_IFUNC, and a dedicated relocation, R_*_IRELATIVE; it has been supported since binutils 2.20.1 and glibc 2.11.1 (JasonCC). It is also, as this note explains, one of the sharpest edges in the whole userspace runtime: resolvers run in a hostile, half-relocated environment, and getting that wrong crashes the process before main.

For the general machinery this note sits on — how the Global Offset Table (GOT) and Procedure Linkage Table (PLT) work, how relocations are applied, and how ld.so resolves symbols — see those notes. This note is specifically about the indirect case.

The one sentence to keep

R_X86_64_IRELATIVE is the only relocation type in the x86-64 psABI whose calculation is not arithmetic — it is a function call. Every other entry in the psABI’s relocation table computes a value from the symbol address S, the base B, the addend A, the GOT and the place P; IRELATIVE’s calculation column reads simply indirect (B + A), glossed in the spec’s prose as “the value used in this relocation is the program address returned by the function, which takes no arguments, at the address of the result of the corresponding R_X86_64_RELATIVE relocation” (x86-64 psABI, §Relocation). Applying it runs your code, inside ld.so, while relocation is still in progress. Everything difficult about IFUNC follows from that one property: the resolver executes in a process that is half-built — before the module’s own PLT is usable in the general case, before thread-local storage is set up, and, crucially, before RELRO has write-protected the GOT. That last point is not theoretical: it is the window the XZ/liblzma backdoor (CVE-2024-3094) climbed through. The Procedure Linkage Table tells that story from the PLT’s side; this note tells it from the resolver’s.

Mental Model

Think of an ordinary exported function as a fixed address: the linker knows (or the dynamic linker resolves) exactly where memcpy lives, and every caller ends up pointing there. An IFUNC replaces that fixed address with a promise to compute an address later. The symbol table entry does not say “the function is here”; it says “run this resolver, and use whatever it hands back.” The resolver is ordinary code — it can read CPU feature flags, environment, or anything else available at load time — and it returns a plain function pointer. The dynamic linker invokes it once, stores the result in the GOT, and from then on every call goes straight to the chosen implementation with zero extra overhead.

flowchart TB
  subgraph LOAD["Load time (inside ld.so, before main)"]
    IREL["R_*_IRELATIVE reloc<br/>on a GOT slot"] --> CALL["ld.so CALLS the resolver<br/>(elf_ifunc_invoke)"]
    CALL --> RES["resolver():<br/>read CPU features<br/>(AVX2? SSE4.1?)"]
    RES --> PICK["return &amp;memcpy_avx2<br/>(chosen impl pointer)"]
    PICK --> GOT["write pointer into<br/>GOT slot"]
  end
  subgraph RUN["Run time (every later call)"]
    C1["call memcpy@plt"] --> J["jmp *GOT slot"]
    J --> IMPL["memcpy_avx2<br/>(direct, no dispatch)"]
  end
  GOT -. "slot now holds<br/>chosen address" .-> J

What it shows: the resolver runs once, inside the dynamic linker; its return value is baked into the GOT so that every subsequent call is a single indirect jump to the selected implementation. The insight to take: IFUNC moves a dispatch decision from every call site to a single event, trading a tiny bit of startup work for zero steady-state cost — but it also means arbitrary user code runs inside ld.so while the program is still being relocated. (“At load time” is the common case, not a guarantee: a preemptible IFUNC under lazy binding runs its resolver on the first call instead, which may be long after main. The next section explains which case you are in.)

Mechanical Walk-through

The symbol type. An IFUNC is marked with the ELF symbol type STT_GNU_IFUNC (value 10), which distinguishes it from a regular function STT_FUNC (MaskRay). Because this is a GNU extension rather than base ELF, a file that uses it advertises the fact in its ELF header’s OS/ABI byte: “On Linux, GNU tools set ELFOSABI_GNU when ifunc is present” (MaskRay). At the assembly level the marking is .type foo, @gnu_indirect_function followed by .set foo, resolver, and compilers expose it through a function attribute — void *ifunc(void) __attribute__((ifunc("resolver"))) — where resolver is the name of a local function returning the real implementation’s address.

Two relocation flavours, chosen by preemptibility. The whole point of a symbol is what relocation refers to it, and IFUNC uses two (MaskRay):

  • A non-preemptible IFUNC — one whose definition is fixed within the module, typically defined in the same executable or .so — is referenced by R_*_IRELATIVE. This relocation has no associated symbol; the location it patches simply holds the resolver’s address. The x86-64 psABI defines R_X86_64_IRELATIVE as being like R_X86_64_RELATIVE (which computes base-plus-addend, B + A) except that the value stored is “the program address returned by the function, which takes no arguments, at the address of the result” — i.e. the linker fills the slot by calling B + A and storing what it returns. MaskRay renders it compactly as “call (B + A) as a function”. Its stated purpose is “to avoid name lookup for locally defined STT_GNU_IFUNC symbols at load-time” — no symbol string, no hash lookup, just call and store.
  • A preemptible IFUNC — one that could be overridden by another module (see Symbol Interposition and LD_PRELOAD) — is referenced through the ordinary PLT with R_*_JUMP_SLOT. When ld.so resolves that slot, “the relocation resolver checks whether the R_*_JUMP_SLOT relocation refers to an ifunc. If it does, instead of filling the GOT entry with the target address, the resolver calls the target address as an indirect function” (MaskRay).

The IPLT. Call sites emit ordinary instructions like call foo, so even a local IFUNC needs a PLT-like stub to forward through. Ian Lance Taylor, who implemented the feature in the GNU linker, notes that all IFUNC references “go through the PLT” unconditionally, “even for a local symbol, although local symbols normally do not require PLT entries” (Taylor). The linker collects these into a synthetic .iplt section with R_*_IRELATIVE relocations in .rela.plt (or a dedicated range).

flowchart TB
  S["an IFUNC symbol<br/>STT_GNU_IFUNC"] --> Q{"can another module<br/>interpose this definition?<br/>(exported from a DSO,<br/>default visibility)"}
  Q -->|"no - hidden, or in an<br/>executable, or -Bsymbolic"| NP["<b>non-preemptible</b>"]
  Q -->|"yes - the usual case<br/>for an exported DSO symbol"| P["<b>preemptible</b>"]
  NP --> NPR["reloc: <b>R_X86_64_IRELATIVE</b> (type 37)<br/>symbol index 0, addend = resolver addr<br/>stub prints as *ABS*+0xADDR@plt"]
  P --> PR["reloc: <b>R_X86_64_JUMP_SLOT</b> (type 7)<br/>ordinary named PLT entry<br/>resolved by symbol lookup first"]
  NPR --> NPW["applied LAST within the module,<br/>during relocation - always eager,<br/>even under lazy binding"]
  PR --> PW{"lazy binding?"}
  PW -->|"yes (default)"| LZ["resolver runs on FIRST CALL<br/>via _dl_fixup -> elf_ifunc_invoke<br/>- may be mid-program, any thread"]
  PW -->|"no (-z now / LD_BIND_NOW)"| EA["resolver runs during relocation<br/>of the CALLING module"]
  NPW --> G["GOT slot now holds the<br/>chosen implementation's address;<br/>every later call is one indirect jmp"]
  LZ --> G
  EA --> G

What it shows: the single question — “can this definition be interposed?” — that decides which relocation the toolchain emits, and how that choice changes when your resolver runs. The insight: the two paths converge on the same GOT slot and the same zero-cost steady state, but they differ completely in timing, and the timing is what makes resolvers dangerous. A non-preemptible IFUNC always runs during relocation; a preemptible one may not run until the first call, which is why a resolver has to be thread-safe and async-signal-safe if the function it selects is. Interposition itself is covered in Symbol Interposition and LD_PRELOAD.

Where the return value goes. During relocation, “the return value of the resolver will be placed in the GOT entry and the PLT entry will load the address” (MaskRay). glibc’s actual dispatch point is elf_ifunc_invoke: in elf/dl-runtime.c’s _dl_fixup, once the symbol is looked up, “If the symbol is indirect function type (STT_GNU_IFUNC), the code invokes value = elf_ifunc_invoke (DL_FIXUP_VALUE_ADDR (value))” — that is, it calls the resolver and uses the returned pointer as the real target (see Lazy Binding for the surrounding _dl_fixup flow).

Ordering is the crux. IRELATIVE relocations are processed last, after R_*_RELATIVE and R_*_GLOB_DAT (MaskRay). This is deliberate: a resolver may legitimately read a global whose GOT slot was filled by an earlier GLOB_DAT, so those must be done first. But it is also fragile: within a module glibc processes RELATIVE → other relocations → IRELATIVE last, and between modules “if A has an ifunc referencing B, generally B needs to be relocated before A.” When that ordering cannot be guaranteed — for instance an executable’s IFUNC called from a shared object whose resolver runs while “the executable’s relocations haven’t been resolved” — you get a load-time crash.

Static and static-PIE binaries. With no dynamic linker present, a static executable has to apply its own IRELATIVE relocations. The linker brackets them with the symbols __rela_iplt_start / __rela_iplt_end (or __rel_iplt_* on 32-bit REL targets), and the C startup code walks that range and calls each resolver (Taylor). For -static-pie, glibc’s _dl_relocate_static_pie “takes care of R_*_IRELATIVE” and expects the __rela_iplt_start/end pair to be empty so the relocations are not applied twice (MaskRay). This connects to Position-Independent Code and PIE.

Code and Specification

The canonical use is CPU-dispatched string/memory routines. Here is a self-contained IFUNC with the modern attribute syntax:

#include <stddef.h>
 
/* The two concrete implementations. */
static void *memcpy_generic(void *d, const void *s, size_t n) { /* portable */ }
static void *memcpy_avx2(void *d, const void *s, size_t n)    { /* uses AVX2 */ }
 
/* The resolver: ordinary code that returns a function POINTER.
   __builtin_cpu_init() must be called before __builtin_cpu_supports()
   when you use GCC's built-in CPU detection. */
static void *resolve_memcpy(void)
{
    __builtin_cpu_init();
    if (__builtin_cpu_supports("avx2"))
        return memcpy_avx2;
    return memcpy_generic;
}
 
/* The public symbol. Its type is that of the IMPLEMENTATIONS, not the
   resolver. Calling my_memcpy(...) transparently reaches the chosen impl. */
void *my_memcpy(void *d, const void *s, size_t n)
    __attribute__((ifunc("resolve_memcpy")));

Line by line: resolve_memcpy is declared static and takes no arguments in this portable form; it returns void * (a pointer to whichever implementation it selects). __builtin_cpu_init() populates GCC’s internal CPU-feature record from CPUID, and __builtin_cpu_supports("avx2") — available since GCC 4.8 (GCC manual) — tests one feature. The __attribute__((ifunc("resolve_memcpy"))) on my_memcpy is what makes the compiler emit my_memcpy as STT_GNU_IFUNC with resolve_memcpy as its resolver. A subtlety the toolchain enforces: the type of my_memcpy as seen by callers is the implementation’s type, so if the resolver and the implementations disagree you get confusing errors — Will Newton’s example deliberately defines the caller-visible function “in a separate file to prevent type confusion” (willnewton).

The resolver’s argument, per architecture. On AArch64 and 32-bit ARM the resolver receives the hardware-capability bitmask directly:

/* 32-bit ARM style: resolver gets HWCAP as an argument. */
void *resolve_func1(unsigned long int hwcap)
{
    if (hwcap & HWCAP_ARM_NEON) return func1_neon;
    if (hwcap & HWCAP_ARM_VFP)  return func1_vfp;
    return func1_arm;
}

This is the exact shape from willnewton, which also warns that “on some architectures, like x86_64, it [hwcap] is not passed at all, and you have to determine hardware capabilities by hand.” On x86-64 the historical convention is therefore to use __builtin_cpu_supports (as above) or glibc’s internal __get_cpu_features(). glibc’s own resolvers read a process-wide record GLRO(dl_x86_cpu_features) populated at startup from CPUID.

Resolved: what a resolver is actually handed, per architecture

An earlier revision of this note flagged the x86-64 resolver calling convention as unverified. It is now settled by reading glibc 2.43’s dl-irel.h for each architecture — the header that contains elf_ifunc_invoke, the single function through which every IRELATIVE resolver call passes. On x86-64 it is three lines long and takes no arguments at all:

/* sysdeps/x86_64/dl-irel.h, glibc 2.43 */
static inline ElfW(Addr) __attribute ((always_inline))
elf_ifunc_invoke (ElfW(Addr) addr)
{
  return ((ElfW(Addr) (*) (void)) (addr)) ();     /* cast to "void" -> zero arguments */
}

The cast is (ElfW(Addr) (*) (void)) — a function of no parameters. The x86-64 psABI agrees independently, describing the resolver as “the function, which takes no arguments”. AArch64’s version of the same header is where the interesting ABI lives:

/* sysdeps/aarch64/dl-irel.h, glibc 2.43 */
#define _IFUNC_ARG_SIZE_VER0 24 /* sizeof 1st published __ifunc_arg_t */
#define _IFUNC_ARG_SIZE_VER1 40 /* sizeof 2nd published __ifunc_arg_t */
...
  arg._size   = sizeof (arg);
  arg._hwcap  = GLRO(dl_hwcap);
  arg._hwcap2 = GLRO(dl_hwcap2);
  arg._hwcap3 = GLRO(dl_hwcap3);
  arg._hwcap4 = GLRO(dl_hwcap4);
  return ((ElfW(Addr) (*) (uint64_t, const __ifunc_arg_t *)) (addr))
	 (GLRO(dl_hwcap) | _IFUNC_ARG_HWCAP, &arg);

Two arguments: the raw AT_HWCAP value with a flag bit (_IFUNC_ARG_HWCAP) set to tell the resolver that a second argument is present at all, and a pointer to a versioned __ifunc_arg_t struct. The _size field is a forward-compatibility handshake — a resolver compiled against the 24-byte VER0 layout must check _size before reading _hwcap3, which only exists in the 40-byte VER1 layout. That is how the AArch64 resolver ABI grew from two capability words to four without breaking binaries.

x86-64AArch6432-bit ARM
Resolver signaturevoid *resolver (void)void *resolver (uint64_t hwcap, const __ifunc_arg_t *)void *resolver (unsigned long hwcap)
Arguments passed by ld.sononeAT_HWCAP | _IFUNC_ARG_HWCAP, plus a struct carrying _size, _hwcap, _hwcap2, _hwcap3, _hwcap4AT_HWCAP
Where the kernel data comes from—GLRO(dl_hwcap)…dl_hwcap4, filled from the ELF auxiliary vector (AT_HWCAP, AT_HWCAP2) the kernel wrote onto the initial stackGLRO(dl_hwcap)
How a resolver detects features insteadCPUID, via __builtin_cpu_supports() in user code or __get_cpu_features() / CPU_FEATURE_USABLE inside glibcthe arguments (though getauxval also works)the argument
Struct size versioningn/a_IFUNC_ARG_SIZE_VER0 = 24 bytes, _IFUNC_ARG_SIZE_VER1 = 40 bytes; check _size before reading new fieldsn/a

Why x86-64 passes nothing: on x86 the capability data is not the kernel’s to give. CPUID is an unprivileged instruction that any code can execute directly, so there is nothing for the auxiliary vector to convey that the resolver could not read itself, and glibc’s own resolvers call __get_cpu_features() — a pointer into a process-wide cpu_features record that _dl_x86_init_cpu_features() fills in “very early in _dl_sysdep_start” (per the DL_PLATFORM_INIT comment in sysdeps/x86_64/dl-machine.h). On ARM and AArch64 the equivalent feature bits live in privileged system registers, so the kernel must publish them through AT_HWCAP/AT_HWCAP2 (see The Auxiliary Vector and The Initial Process Stack); passing them to the resolver saves each resolver a getauxval call and, more importantly, works in contexts where getauxval — itself a libc function — may not be callable yet.

Kernel pin

AT_HWCAP and AT_HWCAP2 are written by the kernel’s ELF loader into the auxiliary vector at execve time; on Linux v6.12 (this vault’s LTS pin) that is fs/binfmt_elf.c’s create_elf_tables() (line 156), which emits NEW_AUX_ENT(AT_HWCAP, ELF_HWCAP) at line 241 and, guarded by #ifdef ELF_HWCAP2, NEW_AUX_ENT(AT_HWCAP2, ELF_HWCAP2) at line 259 — ELF_HWCAP/ELF_HWCAP2 being per-architecture macros, which is why x86-64 has nothing useful to put there and AArch64 has a great deal. Everything else in this note happens entirely in userspace — the kernel has no notion of STT_GNU_IFUNC or R_*_IRELATIVE for dynamically linked programs, and never calls a resolver. (It does apply relocations for statically linked binaries it loads directly, but IRELATIVE in a static binary is handled by glibc’s own startup code walking __rela_iplt_start, not by the kernel.)

Measured: One IFUNC, End to End

Everything above is easier to trust after watching it happen. What follows was built and run on Fedora 44 with GCC 16.2.1, binutils 2.46.1 and glibc 2.43. The program defines one IFUNC whose resolver prints a marker before returning, so the timing of the resolver call is directly observable:

/* ifdemo.c */
static size_t len_generic (const char *s) { const char *p=s; while (*p) p++; return p-s; }
static size_t len_fast    (const char *s) { const char *p=s; while (*p) p++; return p-s; }
 
static void *resolve_len (void)
{
  static const char msg[] = "[resolver ran]\n";
  /* raw write(2): NOT a libc call, because a resolver must not depend on one */
  __asm__ volatile ("syscall" :: "a"(1), "D"(2), "S"(msg), "d"(sizeof msg - 1)
                    : "rcx", "r11", "memory");
  unsigned long hwcap2 = getauxval (AT_HWCAP2);
  return (hwcap2 & (1UL << 1)) ? (void *) len_fast : (void *) len_generic;
}
 
size_t my_strlen (const char *) __attribute__((ifunc ("resolve_len")));

The inline syscall is deliberate: printing from a resolver with printf would be a libc call from inside ld.so, which is the exact thing the contract forbids. Writing the bytes with the raw write syscall (%rax = 1, %rdi = 2 for stderr) needs no relocation, no GOT slot, and no libc state. The getauxval call is there on purpose too, as a probe — it is a preemptible libc function, so whether it survives tells us something.

$ gcc -O2 -c -fPIC ifdemo.c -o ifdemo.o && gcc -O2 -o ifdemo main.c ifdemo.o
$ ./ifdemo
[resolver ran]
main: my_strlen=11

The marker prints before main. Now the ELF-level artifacts.

The symbol is IFUNC, and it is not in .dynsym. Because this IFUNC is not exported, it appears only in the static symbol table — and note that the IFUNC symbol’s value is the resolver’s address, 0x400520, identical to resolve_len:

$ readelf -sW ifdemo | grep -E 'my_strlen|resolve_len|len_generic|len_fast'
  13: 00000000004004c0 35 FUNC  LOCAL  DEFAULT 4 len_generic
  14: 00000000004004f0 35 FUNC  LOCAL  DEFAULT 4 len_fast
  15: 0000000000400520 63 FUNC  LOCAL  DEFAULT 4 resolve_len
  23: 0000000000400520 63 IFUNC GLOBAL DEFAULT 4 my_strlen     <- same address as the resolver
$ readelf -hW ifdemo | grep -i 'OS/ABI'
  OS/ABI:  UNIX - GNU                                          <- ELFOSABI_GNU, set because of the ifunc

The relocation carries no symbol. R_X86_64_IRELATIVE is relocation type 37 in the x86-64 psABI — visible here as 0x25 in the low half of the Info field, with the symbol-index half zeroed:

$ readelf -rW ifdemo
Relocation section '.rela.dyn' at offset 0x1168 contains 2 entries:
  Offset           Info             Type                 Symbol's Name + Addend
0000000000402fd8 0000000100000006 R_X86_64_GLOB_DAT     __libc_start_main@GLIBC_2.34 + 0
0000000000402fe0 0000000300000006 R_X86_64_GLOB_DAT     __gmon_start__ + 0

Relocation section '.rela.plt' at offset 0x1198 contains 3 entries:
0000000000403008 0000000200000007 R_X86_64_JUMP_SLOT    printf@GLIBC_2.2.5 + 0
0000000000403010 0000000400000007 R_X86_64_JUMP_SLOT    getauxval@GLIBC_2.16 + 0
0000000000403000 0000000000000025 R_X86_64_IRELATIVE                       400520

Read the Info column carefully — it is the whole point. 0000000200000007 is “symbol index 2, type 7 (JUMP_SLOT)”. The IRELATIVE entry is 0000000000000025: symbol index 0, type 0x25 = 37. There is no symbol, no name, no hash lookup; the addend 400520 is the resolver’s address and the loader’s entire job is to call it. That is why the psABI says the relocation exists “to avoid name lookup for the locally defined STT_GNU_IFUNC symbols at load-time.”

The PLT stub has no name. Because the relocation has no symbol, objdump has nothing to label the stub with and falls back to printing the absolute address the stub serves:

$ objdump -d -j .plt ifdemo
0000000000400370 <*ABS*+0x400520@plt>:            <- the IFUNC's stub; 0x400520 is the resolver
  400370: ff 25 8a 2c 00 00   jmp  *0x2c8a(%rip)  # 403000  <- the IRELATIVE'd GOT slot
  400376: 68 02 00 00 00      push $0x2
  40037b: e9 e0 ff ff ff      jmp  400360 <_init+0x24>

0000000000400380 <printf@plt>:
  400380: ff 25 82 2c 00 00   jmp  *0x2c82(%rip)  # 403008 <printf@GLIBC_2.2.5>

*ABS*+0x400520@plt is one of the most recognisable sights in a disassembly, and now it reads plainly: “the PLT entry for the nameless thing at absolute address 0x400520.” If you see it in a binary you did not expect to contain IFUNCs, something in your link brought one in — and in one famous case, that was the whole attack (below).

The ordering, observed. LD_DEBUG=bindings writes to stderr, and so does the resolver, so the marker lands in the trace at exactly the point it ran:

$ LD_DEBUG=bindings ./ifdemo 2>dbg.txt
$ grep -n -A1 -B1 'resolver ran' dbg.txt
88: binding file /lib64/libc.so.6 [0] to /lib64/ld-linux-x86-64.so.2 [0]: `_dl_audit_preinit' [GLIBC_PRIVATE]
89: binding file ./ifdemo   [0] to /lib64/libc.so.6 [0]: `__libc_start_main' [GLIBC_2.34]
90: [resolver ran]
91: binding file ./ifdemo   [0] to /lib64/libc.so.6 [0]: `getauxval' [GLIBC_2.16]
92: binding file ./ifdemo   [0] to /lib64/libc.so.6 [0]: `calloc'    [GLIBC_2.2.5]

Line 89 is the executable’s .rela.dyn being processed — __libc_start_main is a GLOB_DAT, applied eagerly. Line 90 is the resolver, running immediately afterwards when .rela.plt’s IRELATIVE is reached. Line 91 is the resolver’s own getauxval call being lazily bound from inside the resolver. So the sequence is exactly the documented one: RELATIVE → GLOB_DAT → IRELATIVE, last within the module, and the module’s own lazy PLT machinery is live by then.

sequenceDiagram
    autonumber
    participant K as kernel (execve)
    participant L as ld.so
    participant LC as libc.so.6
    participant R as your resolver
    participant M as main()
    K->>L: map exe + interp, push auxv<br/>(AT_HWCAP, AT_HWCAP2, AT_BASE...)
    L->>L: self-relocate, then map DT_NEEDED tree
    L->>LC: relocate libc.so.6 (dependencies first)
    Note over LC: libc is now fully relocated -<br/>this is why calling into it<br/>from a resolver usually works on x86-64
    L->>L: executable: R_X86_64_RELATIVE
    L->>L: executable: R_X86_64_GLOB_DAT
    rect rgba(200,60,60,0.18)
        Note over L,R: IRELATIVE is processed LAST within the module
        L->>R: elf_ifunc_invoke(B + A) - your code runs HERE
        Note over R: NOT yet initialised: TLS via __libc_setup_tls,<br/>the module's own constructors, RELRO mprotect.<br/>ALREADY initialised: GLRO(dl_x86_cpu_features),<br/>the auxv, and every DT_NEEDED module's relocations
        R-->>L: return &chosen_impl
        L->>L: *got_slot = chosen_impl
    end
    L->>L: mprotect GOT read-only (RELRO closes here)
    L->>LC: run initialisers (DT_INIT_ARRAY)
    L->>M: transfer control to _start -> __libc_start_main -> main

What it shows: the resolver’s slot in the startup sequence, with the red band marking the window in which it executes and the note listing exactly what does and does not exist yet. The insight: the resolver is scheduled after every dependency has been relocated but before RELRO makes the GOT read-only — which is simultaneously why it can safely read CPU features and why it is the single most attractive place in the ELF runtime to plant hostile code.

The failure that lazy binding hides. glibc detects one IFUNC ordering hazard explicitly. In sysdeps/x86_64/dl-machine.h’s elf_machine_rela, if a relocation refers to an STT_GNU_IFUNC symbol whose defining module has not been relocated yet (sym_map != map && !sym_map->l_relocated), glibc either warns — Relink '%s' with '%s' for IFUNC symbol '%s' — or, if the definer is the executable, dies:

if (sym_map->l_type == lt_executable)
  _dl_fatal_printf ("%s: IFUNC symbol '%s' referenced in '%s' is defined in the "
                    "executable and creates an unsatisfiable circular dependency.\n", ...);

That is reproducible in ten lines. Define the IFUNC in the executable, reference it from a shared library the executable links against:

$ cat circ_app.c
static int a (void) { return 7; }
static void *r (void) { return (void *) a; }
int pick (void) __attribute__((ifunc ("r")));      /* IFUNC in the EXECUTABLE */
extern int use (void);                             /* defined in libcirc.so  */
int main (void) { printf ("use=%d\n", use ()); }

$ gcc -shared -fPIC -O2 -o libcirc.so circ_lib.c      # circ_lib.c just calls pick()
$ gcc -O2 -rdynamic -o circ circ_app.c -L. -lcirc -Wl,-rpath,'$ORIGIN'

$ ./circ                                              # lazy binding (the default)
use=7                                                 # ... works fine
$ LD_BIND_NOW=1 ./circ                                # eager binding
./circ: IFUNC symbol 'pick' referenced in '.../libcirc.so' is defined in the executable
        and creates an unsatisfiable circular dependency.
$ echo $?
127

This is worth sitting with, because it inverts a common intuition. Under lazy binding the resolver is not reached during libcirc.so’s relocation at all — the JUMP_SLOT for pick is deferred until use() actually calls it, long after everything is relocated, so the circularity never materialises. Under -z now or LD_BIND_NOW=1, ld.so must resolve pick while relocating libcirc.so, the executable is not relocated yet, and glibc refuses. Hardening the link is what exposes the bug. A program that has worked for years can die on a distro rebuild that adds -Wl,-z,now, and the error message will name a library that did nothing wrong.

Uncertain

Verify on other architectures and with other resolver hazards. Reason: two hazards the literature describes — a resolver calling a preemptible libc function, and a resolver in a non-preemptible (IRELATIVE) shared-object IFUNC calling into libc — did not reproduce here on glibc 2.43/x86-64: both ran correctly under lazy binding and LD_BIND_NOW=1, because libc is relocated before its dependents and the loader’s lazy-resolution path is functional by then. The glibc wiki’s own IFUNC page anticipates exactly this: “This may work on x86_64 where the R_*_IRELATIVE relocations happen in DT_JMPREL after the DT_REL* relocations, but that is no guarantee that it will work on AArch64, PPC64, or other architectures that are slightly different.” The TLS hazard could not be tested at all: glibc-static is not installed on this host, so no -static IFUNC binary could be built. Treat “it worked on my x86-64 box” as evidence about x86-64 only. To resolve: repeat on AArch64 and PPC64, and with glibc-static installed for the static/TLS case. uncertain

The Resolver Contract

Pulling the restrictions together, because they are scattered across the GCC manual, the glibc wiki and the psABI. The authoritative statement of most of them is the glibc project’s own GNU_IFUNC wiki page, last edited by Carlos O’Donell, which is unusually frank that “there are actually a lot of restrictions placed on IFUNC usage which aren’t entirely clear and the documentation needs to be updated.”

A resolver mayA resolver must notWhy
Execute CPUID (x86) or read its hwcap arguments (ARM/AArch64)Call a function in another module, including libcThe callee’s module may not be relocated yet; on x86-64 it happens to work, on other ports it is not guaranteed
Read the cpu_features record via __get_cpu_features() (inside glibc) or __builtin_cpu_supports() (user code)Access thread-local storageTLS is set up by __libc_setup_tls, which in a static binary runs after IRELATIVE processing
Issue a raw syscall with inline asmBe a weak definitionglibc wiki, “Requirement (b): Cannot be weakly defined functions”
Return any of the implementations defined in its own translation unitLive in a different translation unit from the implementationsglibc wiki, “Requirement (a): Resolver must be defined in the same translation unit as the implementations”
Run more than once, or on any thread, if you are unluckyBe compiled with -fstack-protector-all, ASan, or similarglibc wiki: those “may require early setup which has not yet completed”
—Assume it runs exactly once before mainUnder lazy binding a preemptible IFUNC’s resolver runs on first call, which may be mid-program, on any thread, possibly in a signal handler. The wiki is explicit: “If the implemented function is async-signal safe and thread-safe, then the resolver must also be”

The last row is the one most people get wrong, and this note’s own measurement demonstrates it: in the circular-dependency reproduction above, the same resolver ran during relocation under LD_BIND_NOW=1 and would have run on first call under lazy binding. When a resolver runs is not a property of the resolver; it is a property of how the program was linked and who calls the symbol first. Writing one that is only safe at load time is writing a latent bug.

The practical distillation is short. A resolver should read a capability bit, branch, and return a pointer from its own translation unit — and do nothing else. Every restriction above is satisfied automatically by a resolver of that shape, and violating any of them buys, at most, a slightly nicer dispatch decision.

Which glibc Functions Actually Use IFUNC on x86-64

The usual claim is “glibc uses IFUNC for the string and memory functions.” That is right, but it is worth enumerating rather than hand-waving, because the list is both larger and stranger than the claim suggests. Counted on this machine (readelf --dyn-syms -W <lib> | grep -c IFUNC, glibc 2.43, x86-64):

LibraryIFUNC symbolsWhat they are
libc.so.665The string/memory/wide-string families, the _chk fortify variants, and three surprises (below)
libm.so.696Vector and scalar math routines dispatched on AVX2 / AVX-512 availability
ld-linux-x86-64.so.20The loader resolves IFUNCs; it contains none, because there is nothing to resolve them with
libcrypt.so.20—

The 65 in libc.so.6, grouped:

  • Byte-string (28): strlen, strnlen, strcpy, strncpy, stpcpy, stpncpy, strcat, strncat, strcmp, strncmp, strcasecmp, strncasecmp, strcasecmp_l, strncasecmp_l, strchr, strrchr, strchrnul, strstr, strspn, strcspn, strpbrk, index, rindex, plus the __-prefixed internal aliases __stpcpy, __stpncpy, __strcasecmp, __strcasecmp_l, __strncasecmp_l.
  • Memory (12): memcpy, memmove, memset, memcmp, memchr, memrchr, mempcpy, __mempcpy, __rawmemchr, rawmemchr, bcmp, __memcmpeq.
  • Wide-string (15): wcslen, wcsnlen, wcscpy, wcsncpy, wcpcpy, wcpncpy, wcscat, wcsncat, wcscmp, wcsncmp, wcschr, wcsrchr, wmemchr, wmemcmp, wmemset.
  • Fortify variants (5): __memcpy_chk, __memmove_chk, __mempcpy_chk, __memset_chk, __wmemset_chk — the _FORTIFY_SOURCE entry points get their own dispatch so that hardened builds are not slower.
  • The five that are not string functions at all (5): gettimeofday, __gettimeofday, and time are IFUNCs, and modf/modff are too. The time functions dispatch between a vDSO implementation and a plain syscall wrapper — the resolver checks whether the kernel supplied a vDSO with the symbol and returns the fast path if so (see vDSO Symbols and How libc Uses Them). This is IFUNC used for environment dispatch rather than CPU dispatch, and it is the reason a gettimeofday call costs a few nanoseconds rather than a syscall.

Two things follow. First, memcpy is the famous one but it is 1 of 65 — and on x86-64 it is also the only one carrying a second version node, because it is the only one whose contract ever changed (see Versioned Symbols in glibc for that incident; the rest are pure implementation swaps under one @@GLIBC_2.2.5 node). Second, libm’s 96 is larger than libc’s 65, which is not what the folklore suggests; the vectorised math routines are now the heaviest user of the mechanism in the whole system.

Failure Modes

The resolver runs in a half-relocated world. The single most common bug: a resolver that calls another function or reads a global that is not yet relocated. The guidelines are blunt — resolvers “must not access global data or call functions in other modules including the libc” and “must not access tls” (MaskRay). Concretely, with lazy binding enabled “calling a preemptible function in an ifunc resolver will crash due to accessing an unresolved GOTPLT entry,” and with static linking a resolver that touches thread-local storage “attempts to access TLS before it’s properly set up by __libc_setup_tls.” The symptom is a SIGSEGV inside ld.so or __libc_start_main before your main ever runs, often with a backtrace deep in _dl_relocate_object / elf_machine_rela.

Lazy binding makes it worse — or -z now masks it. Because a resolver might indirectly call an unresolved PLT entry, whether a buggy resolver crashes can depend on the binding mode. Linking with -z now (or running with LD_BIND_NOW=1) forces eager relocation, which changes the ordering hazards — MaskRay notes FreeBSD’s loader is more robust here because it runs multiple phases that “initialize states needed by ifunc resolvers” before dependent relocations. The practical lesson: keep resolvers pure — read CPU flags via a built-in, branch, return. Nothing else.

Cross-module ordering. An executable that defines an IFUNC referenced by a shared library it links against can crash because the library’s relocations run before the executable’s are complete. There is no general fix in the GNU loader beyond keeping resolvers self-contained; MaskRay’s article catalogues the corner cases.

Weaponization: the resolver runs before RELRO closes. Because a resolver is arbitrary code that runs automatically inside the linker before main, it is an attractive place to hide malicious behaviour — and the xz/liblzma backdoor (CVE-2024-3094, 2024) is the case study. The Procedure Linkage Table covers what the backdoor did to the PLT and GOT; the piece that belongs here is why an IFUNC resolver was the right tool for the job rather than an ordinary __attribute__((constructor)), which is simpler and also runs before main.

The answer is timing. Constructors (DT_INIT_ARRAY) run after the loader has finished relocating and, on any modern distro, after it has called mprotect to make the GOT read-only. An IFUNC resolver runs during relocation, strictly before that mprotect. LWN’s analysis states the consequence directly: “When the dynamic linker finalizes the locations of those functions, it calls the backdoor’s resolver functions. At this point, dynamic linking is still in progress, so many of the linker’s internal data structures have not yet been made read-only. This would let the backdoor manipulate libraries that had already been loaded by overwriting entries in the procedure linkage table (PLT) or global offset table (GOT).” (LWN, “How the XZ backdoor works”). The red band in the sequence diagram above is exactly that window, and RELRO Relocation Read-Only is the mitigation that closes it — a mitigation IFUNC is structurally required to run ahead of. glibc’s elf/dl-reloc.c makes the ordering explicit in four lines:

void _dl_relocate_object (struct link_map *l, ...)
{
  if (l->l_relocated) return;
  _dl_relocate_object_no_relro (l, scope, reloc_mode, consider_profiling);
  _dl_protect_relro (l);            /* mprotect(PROT_READ) - strictly AFTER the relocations */
}

Worth being precise about what this does and does not give an attacker, because the popular framing overstates it. RELRO is applied per module, immediately after that module’s own relocations, and modules are relocated dependencies-first — so by the time a library’s resolver runs, its dependencies’ GOTs are already read-only. What is not yet protected is the resolver’s own module, every module relocated after it (the main executable is relocated last), and the loader’s own bookkeeping. That is still a large enough surface, and it is strictly larger than what a constructor gets.

The camouflage was equally deliberate. LWN notes that the backdoor “provides its own version of the indirect function resolvers crc32_resolve() and crc64_resolve()”, and that “liblzma does not usually use indirect functions, but using faster functions to calculate checksums does sound like a plausible use of the feature. This plausible deniability is probably why the exploit itself lives in a file called liblzma_la-crc64-fast.o.” A CRC routine dispatched on CPU support for carry-less multiply is the single most ordinary-looking reason to add an IFUNC to a compression library.

One detail rounds out the picture and is worth knowing because it shows the limits of the technique: the resolver ran too early for its own purposes. Per LWN, “liblzma is loaded fairly early in the link order of OpenSSH, which means that the OpenSSL cryptography functions that are the backdoor’s ultimate target may not have been loaded yet. To deal with that, the backdoor adds an audit hook” — an rtld-audit callback — “to wait until it sees the RSA_public_decrypt@got.plt symbol being resolved.” So the IFUNC bought early enough to write but not late enough to know what to write, and a second mechanism was needed to bridge the gap. The ifuncd-up write-up argues the general point that “GNU IFUNC is the real culprit behind CVE-2024-3094” (robertdfrench); the more precise statement is that IFUNC supplied the one thing nothing else in the ELF runtime does — arbitrary code execution inside the relocation pass — and everything else the backdoor needed was built on top of that foothold.

The defensive takeaway is concrete rather than philosophical: an unexpected *ABS*+0x...@plt stub, an unexpected R_*_IRELATIVE in readelf -r, or an unexpected IFUNC-typed symbol in a library that has no plausible need for CPU dispatch is a signal worth chasing. readelf --dyn-syms <lib> | grep IFUNC on a library you did not expect to contain any is a thirty-second check.

Alternatives and When to Choose Them

  • Function multi-versioning (__attribute__((target_clones(...))) / target). GCC and Clang can auto-generate multiple versions of a function and dispatch among them. Under the hood on GNU systems this is implemented with IFUNC, so it shares the load-time-resolution model but hides the resolver boilerplate. Choose this when you just want “compile this hot function for SSE4.2 and AVX2 and pick automatically.”
  • A plain function pointer initialized once. A static void (*impl)(void) set in a constructor or on first call is simpler, portable to non-GNU toolchains, and avoids the resolver-runs-in-ld.so hazards — at the cost of one indirect load per call (the pointer is not baked into the GOT the way an IFUNC target is) and a branch on first use. Choose this when portability matters more than shaving the last cycle, or when the decision needs data not available during relocation.
  • Explicit dispatch at the call site. For a single hot loop, an ordinary if (have_avx2) ... else ... around two code paths is transparent and debuggable. IFUNC wins only when the same routine is called from many sites and you want the dispatch cost amortized to zero.

Because IFUNC is “an extension to the ELF standard and therefore will not be portable to other toolchains or systems” and is “not possible in Windows” (JasonCC), cross-platform libraries usually keep a portable function-pointer fallback.

Production Notes

In production, IFUNC’s flagship user is glibc itself. glibc “uses this feature to implement multiple versions of a few memory and string functions, including memmove, memset, memcpy, strcmp, strstr” (JasonCC); it also resolves things like __gettimeofday between a vDSO implementation and a syscall implementation. This is why straceing a fresh process shows the loader touching CPU features, and why the same libc.so.6 binary can be fast on both a 2012 Sandy Bridge and a 2024 server: the AVX-512 memcpy and the SSE2 memcpy are both in the file, and the resolver picks per-boot. The micro-architecture “levels” that distros now target (x86-64-v2, -v3, -v4) interact with this: a resolver may pick an implementation that requires a level the CPU lacks, which is one path to the “CPU does not support x86-64-v2” class of fatal loader errors when a distro’s glibc is built for a baseline the hardware does not meet.

Operationally, if a program crashes before main on some machines but not others, suspect an IFUNC resolver making an unsafe assumption (calling into another module, or selecting an instruction set the CPU does not have). LD_DEBUG=reloc and LD_DEBUG=symbols (see The Dynamic Linker) show relocation processing, and gdb with a breakpoint on the resolver — set via its local name, since the public symbol is the IFUNC — is the standard way to inspect what a resolver returns.

See Also