Linking Is Optional: Loading and Relocating an Object File at Runtime

Normally an object file is the linker's raw material, transformed into an executable before main() ever runs. But an object file can also be consumed directly from memory by an unconventional loader() function: given the name of a function inside a user-supplied module, it maps that code and executes it. Before any of that works, though, the object file's structure has to be understood well enough to patch its latent references.

ELF covers relocatable objects, executables and shared libraries; for this exercise the interesting flavor is ET_REL, which marks a plain object file. Clang and GCC emit this format, and both .cpp and .c sources work, since Linux binaries and their startup code rely more on C++ runtime support than many people assume. The experiments here stick to libc, plus /usr/bin/perl as a convenient host process for testing the loader [strace attaches to the interpreter, so the analysis below suits it well]. LLVM 9.0.0 is the toolchain under examination.

How to execute an object file: Part 1

Five Regions of a Relocatable Object

The container holds sections, and headings in readelf's output describe them. Five sections carry the semantics that matter, reachable by their well-known names.

First, .text. Its contents are the compiled machine instructions — here hand-crafted bytes rather than compiler output, since a hand-written assembly fragment is as valid as anything clang emits. Second, .data, holding initialized globals with internal-linkage storage. Third, .rodata, the read-only counterpart of .data.

Two more sections run crosswise to the others. A symbol table (often .symtab) describes every symbol in the section: its name, its position within the object, sometimes its size. And each operation that needs an address resolved instead of baked in gets recorded in a relocation table.

Symbols and relocations are therefore two independent channels of information: the symbol table answers "where is this?" and the relocation table answers "what must change now, given where that is?"

Symbols Without Storage

The fourth section type is the exception to the rule that sections always hold storage. .symtab is such an exception — it carries no data. The layout works on 64-bit Linux with ELF64 as laid out in Intel and System V psABI documents [the psABI manual is also referenced by elf(5)], and mirrors GLIBC's definition.

} Elf64_Sym;
  
typedef struct {
};

  unsigned char   st_other;
  unsigned short  st_shndx;
  Elf64_Addr      st_value;
  Elf64_Xword     st_size;

  
  unsigned char   st_info;
  unsigned int    st_name;
  struct {
    unsigned char st_bind;
    unsigned char st_type;
  };
} Elf64_Sym;

Name, value, size and the index of the section it belongs to are self-descriptive. Less so is the object's ELF header, which supplies one crucial index of its own.

The scattered sections that should be treated as one unit across a module are tied together by an unnamed (SHN_UNDEF) section index of 0 in gABI.

Symbol Type and Binding: The sh_info Interpretation

The shorthand derives from a sequence of bytes. Every symbol has both a type and a binding, where type distinguishes a function from a data object or an unqualified name, and binding distinguishes local symbols — confined to the object file and usually dropped on static linking — from global ones visible across models like a dynamic symbol table. Local symbols keep their scope; globals are the ones an external linker may pick up.

A few more practical observations follow. A single symbol can occupy both worlds when it names a function whose address must also be reachable outside the module. Information about the symbol belongs to the object's symbol table. In a well-formed object file, local symbols generally precede globals, and the symbol table's sh_info field points at the first global index, letting consumers split the ranges in one shot.

The second half of the pairing, relocation, must eventually happen for anything the assembler could not resolve. Every single symbol that needs resolving points at program text, and undefined symbols (SHN_UNDEF) are precisely those a normal object file cannot access locally.

At the tool level, GNU readelf's full command suite is the natural instrument for inspecting the output we've been discussing.

Combining a marked undefined symbol (SHN_UNDEF) with relocation information forms a basis for relocations of any architecture.

ELF Section Names in Practice

To re-run the earlier example, clang relocates an archive-style object of 64-bit code on ELF64 systems, or an ARM32 object on a 64-bit Linux host, using one of the size fields already in mind.

  • Extract an empty section: To build from an existing object, clang uses -ffreestanding leaving out builtins that link against undefined symbols and have constants folded. (Constant folding also applies to simple ones.)
  • Top-level entry with a large body: Section names like .L are immediately resolvable, so .ident gives a value of 0 with no relocation.
  • The type grouping change: Here it refers to type descriptors inside a linked list at different levels of data structure member calculations, shifted by a few bits for different relocation settings and different fields assembled in the module throughout.

We end with .LC0 repeated below in Example 2 to indicate that .LC0 exists as a symbol that was referenced but never accessed — it's a module's internal constant data.

An object file on its own is a pile of code the operating system has no plans to run. Turning it into something callable means recreating, by hand, the piece of work the linker and dynamic loader would normally do: find the machine code, move it somewhere executable, and jump to it.

What still separates source from a running process

Compilation turns source files into object files, one per translation unit. Those objects may reference each other, and a linker stitches them into a single executable, binding cross-object references along the way. That executable is often still incomplete: most Linux binaries are dynamically linked, borrowing part of their functionality at runtime from shared libraries.

compile and link
dynamic loader

The dynamic loader handles that last step — locating the needed libraries, mapping their code into the process, and resolving dependencies — when the program starts. Since machine code originates at the compiler stage, but nothing after a failed link or load step could prevent execution only by finishing those steps, the obvious question is whether an object file can be run directly, without ever linking it. Reasons to try range from validating an open-source reimplementation against a proprietary driver, to code from an obscure compiler, missing build- or runtime-time dependencies, malware analysis, or binaries from another operating system.

A minimal artifact to work with

The test case is deliberately tiny: two functions, add5 and add10, each adding a constant to its single argument.

obj.c:

int add5(int num)
{
    return num + 5;
}

int add10(int num)
{
    return num + 10;
}

Compiling it produces the object file the rest of the exercise consumes.

$ gcc -c obj.c 
$ ls
obj.c  obj.o

Because a process can only execute code that already sits in RAM, object files cannot simply be invoked. They are used the way a library is used, and loading them is our job rather than the dynamic loader's. That means writing a wrapper:

loader.c:

#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>

static void load_obj(void)
{
    /* load obj.o into memory */
}

static void parse_obj(void)
{
    /* parse an object file and find add5 and add10 functions */
}

static void execute_funcs(void)
{
    /* execute add5 and add10 with some inputs */
}

int main(void)
{
    load_obj();
    parse_obj();
    execute_funcs();

    return 0;
}

The functions above are stubs; the rest of this post fills them in.

Mapping the file instead of reading it

Not all of the object file's bytes will be needed, and real objects can be large, so loading the whole thing into a buffer is wasteful. mmap is the better fit: the OS then reads only the pages actually touched, when they are touched.

loader.c:

...
/* for open(2), fstat(2) */
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

/* for close(2), fstat(2) */
#include <unistd.h>

/* for mmap(2) */
#include <sys/mman.h>

/* parsing ELF files */
#include <elf.h>

/* for errno */
#include <errno.h>

typedef union {
    const Elf64_Ehdr *hdr;
    const uint8_t *base;
} objhdr;

/* obj.o memory address */
static objhdr obj;

static void load_obj(void)
{
    struct stat sb;

    int fd = open("obj.o", O_RDONLY);
    if (fd <= 0) {
        perror("Cannot open obj.o");
        exit(errno);
    }

    /* we need obj.o size for mmap(2) */
    if (fstat(fd, &sb)) {
        perror("Failed to get obj.o info");
        exit(errno);
    }

    /* mmap obj.o into memory */
    obj.base = mmap(NULL, sb.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
    if (obj.base == MAP_FAILED) {
        perror("Maping obj.o failed");
        exit(errno);
    }
    close(fd);
}
...

On success, load_obj leaves the start of obj.o in the global obj. That variable has a dedicated union type because the address will be read two ways: as Elf64_Ehdr, the ELF header structure from C, and as a byte pointer, since parsing ELF data means computing byte offsets from the start of the file. Object files are, in fact, ELF files — the same container format used by Linux executables and shared libraries.

Inside the ELF container

Linux ships the elf.h header with the necessary structure definitions, so parsing is not the hard part; knowing which structures matter is. Segments and sections are the two families to keep straight.

Sections hold the payload: executable code, constant data, global variables, and so on. Segments describe nothing themselves — they tell the operating system how to get sections into RAM with the right access pattern. A segment does not contain sections; it points at where a section should be placed and whether that memory is readable, writable or executable.

segments and sections

Object files contain no segments at all, because they are never handed to the OS directly — segments are a linker's output, produced once the object is combined with other code. readelf makes this visible:

$ readelf --segments obj.o

There are no program headers in this file.

The sections worth knowing

The same tool enumerates the sections:

$ readelf --sections obj.o
There are 11 section headers, starting at offset 0x268:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .text             PROGBITS         0000000000000000  00000040
       000000000000001e  0000000000000000  AX       0     0     1
  [ 2] .data             PROGBITS         0000000000000000  0000005e
       0000000000000000  0000000000000000  WA       0     0     1
  [ 3] .bss              NOBITS           0000000000000000  0000005e
       0000000000000000  0000000000000000  WA       0     0     1
  [ 4] .comment          PROGBITS         0000000000000000  0000005e
       000000000000001d  0000000000000001  MS       0     0     1
  [ 5] .note.GNU-stack   PROGBITS         0000000000000000  0000007b
       0000000000000000  0000000000000000           0     0     1
  [ 6] .eh_frame         PROGBITS         0000000000000000  00000080
       0000000000000058  0000000000000000   A       0     0     8
  [ 7] .rela.eh_frame    RELA             0000000000000000  000001e0
       0000000000000030  0000000000000018   I       8     6     8
  [ 8] .symtab           SYMTAB           0000000000000000  000000d8
       00000000000000f0  0000000000000018           9     8     8
  [ 9] .strtab           STRTAB           0000000000000000  000001c8
       0000000000000012  0000000000000000           0     0     1
  [10] .shstrtab         STRTAB           0000000000000000  00000210
       0000000000000054  0000000000000000           0     0     1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
  L (link order), O (extra OS processing required), G (group), T (TLS),
  C (compressed), x (unknown), o (OS specific), E (exclude),
  l (large), p (processor specific)

The Linux manpages for elf.5 document both each section's purpose and its matching structure from elf.h, which makes them a single reference for writing a parser. The most common sections:

  • .text — compiled machine code. For this post it is the section that matters, because add5 and add10 live there.
  • .data and .bss — global and static local variables, split by whether an initializer exists: .data holds definitions like int foo = 5;, .bss merely reserves space for definitions like int bar;.
  • .rodata — constant data, mostly strings and byte arrays, such as string literals passed to printf or used in error messages. It is absent from the output above because obj.c contains neither.
  • .symtab — symbols defined in the file (functions, globals, constants) plus the external symbols it depends on.
  • .strtab and .shstrtab — packed string tables. These are not the program's own strings, which belong in .rodata; they hold names of ELF structures, such as symbol names in .symtab or the section names in the table itself. To keep structures fixed-size and compact, the names live in one place and the structures reference them by offset.

Picking functions out of .text

Knowing the code is in .text is not enough, since .text is one undifferentiated blob holding two functions. Resolving individual functions is the symbol table's job, and it has a flag of its own in readelf:

$ readelf --symbols obj.o

Symbol table '.symtab' contains 10 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND
     1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS obj.c
     2: 0000000000000000     0 SECTION LOCAL  DEFAULT    1
     3: 0000000000000000     0 SECTION LOCAL  DEFAULT    2
     4: 0000000000000000     0 SECTION LOCAL  DEFAULT    3
     5: 0000000000000000     0 SECTION LOCAL  DEFAULT    5
     6: 0000000000000000     0 SECTION LOCAL  DEFAULT    6
     7: 0000000000000000     0 SECTION LOCAL  DEFAULT    4
     8: 0000000000000000    15 FUNC    GLOBAL DEFAULT    1 add5
     9: 000000000000000f    15 FUNC    GLOBAL DEFAULT    1 add10

The last two entries carry the names we want. Beyond the name, each entry has useful metadata: Ndx gives the index of the section containing the symbol, and cross-checking against the section table above confirms section 1, .text. Type set to FUNC confirms these are functions. Size, Bind and Vis are of little use here. The valuable column is Value, whose name misleads: in this context it is an offset from the start of the containing section, so add5 begins at offset zero of .text and add10 at byte 15.

Executing the code

That gives a workable plan: locate the section header table and .shstrtab so sections can be looked up by name; locate .symtab and .strtab so symbols can be looked up by name; find .text and copy it into RAM with execute permission; read the offsets of add5 and add10 from .symtab; call them.

More globals, plus parse_obj, get us started:

loader.c:

...

/* sections table */
static const Elf64_Shdr *sections;
static const char *shstrtab = NULL;

/* symbols table */
static const Elf64_Sym *symbols;
/* number of entries in the symbols table */
static int num_symbols;
static const char *strtab = NULL;

...

static void parse_obj(void)
{
    /* the sections table offset is encoded in the ELF header */
    sections = (const Elf64_Shdr *)(obj.base + obj.hdr->e_shoff);
    /* the index of `.shstrtab` in the sections table is encoded in the ELF header
     * so we can find it without actually using a name lookup
     */
    shstrtab = (const char *)(obj.base + sections[obj.hdr->e_shstrndx].sh_offset);

...
}

...

With the section table and .shstrtab in hand, a helper can resolve any section by name:

loader.c:

...

static const Elf64_Shdr *lookup_section(const char *name)
{
    size_t name_len = strlen(name);

    /* number of entries in the sections table is encoded in the ELF header */
    for (Elf64_Half i = 0; i < obj.hdr->e_shnum; i++) {
        /* sections table entry does not contain the string name of the section
         * instead, the `sh_name` parameter is an offset in the `.shstrtab`
         * section, which points to a string name
         */
        const char *section_name = shstrtab + sections[i].sh_name;
        size_t section_name_len = strlen(section_name);

        if (name_len == section_name_len && !strcmp(name, section_name)) {
            /* we ignore sections with 0 size */
            if (sections[i].sh_size)
                return sections + i;
        }
    }

    return NULL;
}

...

Which immediately finds .symtab and .strtab:

loader.c:

...

static void parse_obj(void)
{
...

    /* find the `.symtab` entry in the sections table */
    const Elf64_Shdr *symtab_hdr = lookup_section(".symtab");
    if (!symtab_hdr) {
        fputs("Failed to find .symtab\n", stderr);
        exit(ENOEXEC);
    }

    /* the symbols table */
    symbols = (const Elf64_Sym *)(obj.base + symtab_hdr->sh_offset);
    /* number of entries in the symbols table = table size / entry size */
    num_symbols = symtab_hdr->sh_size / symtab_hdr->sh_entsize;

    const Elf64_Shdr *strtab_hdr = lookup_section(".strtab");
    if (!strtab_hdr) {
        fputs("Failed to find .strtab\n", stderr);
        exit(ENOEXEC);
    }

    strtab = (const char *)(obj.base + strtab_hdr->sh_offset);
    
...
}

...

Why .text cannot just be mapped in place

Locating .text is not sufficient. A separate copy has to be made, and three reasons explain why:

  • Many CPU architectures either forbid executing code that is unaligned in memory (4 kilobytes on x86) or pay a performance penalty for it, and an ELF file on disk is packed for compactness, so its .text makes no promise of landing on a page boundary.
  • Relocations may require patching bytes inside .text — not needed in this particular example, but unavoidable later on. Mapping the file without MAP_PRIVATE would let those writes reach the file on disk and corrupt it.
  • Sections need different permissions at runtime: .text readable and executable but not writable (writable-and-executable memory is a security liability), .data and .bss readable and writable for globals, .rodata read-only. Permissions can only be set per page, so each section has to be copied onto a page boundary of its own.

Hardcoding 4096 would be correct on most x86 systems and wrong on others, such as arm64, so the page size is queried instead:

loader.c:

...

static uint64_t page_size;

static inline uint64_t page_align(uint64_t n)
{
    return (n + (page_size - 1)) & ~(page_size - 1);
}

...

static void parse_obj(void)
{
...

    /* get system page size */
    page_size = sysconf(_SC_PAGESIZE);

...
}

...

page_align is a small convenience that rounds a value up to the next page boundary. The copy itself is four steps: find the .text section header, allocate memory sized for it, copy the bytes, and make the result executable.

loader.c:

...

/* runtime base address of the imported code */
static uint8_t *text_runtime_base;

...

static void parse_obj(void)
{
...

    /* find the `.text` entry in the sections table */
    const Elf64_Shdr *text_hdr = lookup_section(".text");
    if (!text_hdr) {
        fputs("Failed to find .text\n", stderr);
        exit(ENOEXEC);
    }

    /* allocate memory for `.text` copy rounding it up to whole pages */
    text_runtime_base = mmap(NULL, page_align(text_hdr->sh_size), PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (text_runtime_base == MAP_FAILED) {
        perror("Failed to allocate memory for .text");
        exit(errno);
    }

    /* copy the contents of `.text` section from the ELF file */
    memcpy(text_runtime_base, obj.base + text_hdr->sh_offset, text_hdr->sh_size);

    /* make the `.text` copy readonly and executable */
    if (mprotect(text_runtime_base, page_align(text_hdr->sh_size), PROT_READ | PROT_EXEC)) {
        perror("Failed to make .text executable");
        exit(errno);
    }
}

...

A helper then turns a symbol name into a callable address:

loader.c:

...

static void *lookup_function(const char *name)
{
    size_t name_len = strlen(name);

    /* loop through all the symbols in the symbol table */
    for (int i = 0; i < num_symbols; i++) {
        /* consider only function symbols */
        if (ELF64_ST_TYPE(symbols[i].st_info) == STT_FUNC) {
            /* symbol table entry does not contain the string name of the symbol
             * instead, the `st_name` parameter is an offset in the `.strtab`
             * section, which points to a string name
             */
            const char *function_name = strtab + symbols[i].st_name;
            size_t function_name_len = strlen(function_name);

            if (name_len == function_name_len && !strcmp(name, function_name)) {
                /* st_value is an offset in bytes of the function from the
                 * beginning of the `.text` section
                 */
                return text_runtime_base + symbols[i].st_value;
            }
        }
    }

    return NULL;
}

...

And execute_funcs ties the sequence together:

loader.c:

...

static void execute_funcs(void)
{
    /* pointers to imported add5 and add10 functions */
    int (*add5)(int);
    int (*add10)(int);

    add5 = lookup_function("add5");
    if (!add5) {
        fputs("Failed to find add5 function\n", stderr);
        exit(ENOENT);
    }

    puts("Executing add5...");
    printf("add5(%d) = %d\n", 42, add5(42));

    add10 = lookup_function("add10");
    if (!add10) {
        fputs("Failed to find add10 function\n", stderr);
        exit(ENOENT);
    }

    puts("Executing add10...");
    printf("add10(%d) = %d\n", 42, add10(42));
}

...

Compiling and running the loader confirms the import and execution work end to end.

$ gcc -o loader loader.c 
$ ./loader 
Executing add5...
add5(42) = 47
Executing add10...
add10(42) = 52

Caveats

The example is a favourable one: the object's code is self-contained, referencing no globals, no constants and no external symbols. More involved cases, including relocations, are handled later in the series.

It is also worth being explicit that parsing a file from disk is processing untrusted input. The loader.c shown here skips most bounds and integrity checks — deliberate simplification for readability, not a characteristic of production-ready parsing. Maliciously crafted input can likely exploit it, so it belongs in educational use only.

The full source for this post is available at the linked repository.