Introduction Link to heading

Binary loading and analysis are essential in reverse engineering, debugging, and security research. While Linux uses ELF as the standard binary format, manually parsing it is tedious and error-prone.

This project implements a custom binary loader, following the book Practical Binary Analysis, with fixes to the original code to account for changes in function signatures. It uses libbfd, the GNU Binary File Descriptor library, to handle low-level parsing. The loader only loads function symbols and code/data sections, providing a structured and reusable interface.

The complete code for this project is available in this GitHub repository .


A Primer on the ELF Format Link to heading

On Linux, the Executable and Linkable Format (ELF) is the standard for binaries, shared libraries, and object files. Understanding ELF is essential when building a loader.

An ELF file has four main components:

  1. ELF Header – at the very beginning, describes file type, architecture, entry point (e_entry), and offsets to tables.
  2. Program Header Table – describes segments (memory chunks the OS loads), used at runtime.
  3. Sections – logical divisions like .text, .data, .bss, .rodata, used mainly at link time.
  4. Section Header Table – describes all sections.

Key Sections Link to heading

  • .text – main executable code, including _start.
  • .data / .bss / .rodata – program data: initialized, uninitialized, and read-only constants.
  • .init / .fini – code run before and after main.
  • .init_array / .fini_array – arrays of constructor/destructor function pointers.
  • .plt / .got / .got.plt – support dynamic linking with lazy symbol resolution.
  • .rel.* / .rela.* – relocation entries for addresses that need adjustment.
  • .dynamic – tells the dynamic linker which libraries, tables, and init/fini functions to use.

Segments vs Sections Link to heading

  • Sections = for linkers & static analysis
  • Segments = for runtime loading by the OS

A segment may contain multiple sections. The loader maps segments into memory and then jumps to e_entry.


A Primer on libbfd Link to heading

libbfd (Binary File Descriptor library) is part of GNU Binutils and provides a uniform interface to read, inspect, and manipulate binary files across architectures and formats (ELF, PE, Mach-O, etc.).

Key points:

  • Abstracts away file format details — you don’t need to parse ELF headers manually.

  • Provides functions to access sections, symbols, relocations, and segments.

  • Central objects:

    • bfd → represents an open binary file.
    • asection → represents a section in the binary.
    • symbol_table → contains symbols (functions, globals).
  • Typical workflow:

    1. Open a file with bfd_openr.
    2. Check format using bfd_check_format.
    3. Read sections with bfd_get_section_by_name or iterate over all.
    4. Read symbols with bfd_canonicalize_symtab.

Essentially, libbfd acts as a cross-format parser, letting you focus on loader logic rather than binary quirks.


Designing the Loader Interface Link to heading

Before implementing the loader, we define a clean API.

This loader is not a dynamic loader like the OS provides. We’re building a loader for static analysis tools, something that parses binaries and exposes their structure in a simple, reusable way, and thus two important design decisions are :

  1. Hide libbfd completely. The interface must not expose any bfd types or functions.
  2. Keep it minimal. Only expose what analysis tools actually need: sections, symbols, entry point, architecture, etc..

The API revolves around three classes: Binary, Section, and Symbol.

Core Abstractions Link to heading

Symbol Link to heading

Represents a function or global symbol.

class Symbol {
    public:
    enum SymbolType {
        SYM_TYPE_UKN = 0,
        SYM_TYPE_FUNC = 1
    };
    Symbol() : type(SYM_TYPE_UKN), name(), addr(0) {}

    SymbolType type;
    std::string name;
    uint64_t addr; /*start address*/
};

Binaries may contain many symbol types, but the loader intentionally simplifies this.

Only function symbols are exposed, each Symbol contains a type (SYM_TYPE_FUNC), name and an addr (function start address).


Section Link to heading

Represents a binary section.

class Section {
    public:
    enum SectionType {
        SEC_TYPE_NONE = 0,
        SEC_TYPE_CODE = 1,
        SEC_TYPE_DATA = 2
    };

    Section() : binary(NULL), type(SEC_TYPE_NONE), vma(0), size(0), bytes(NULL) {}

    bool contains(uint64_t addr) {
        return (addr >= vma) && (addr- vma < size);
    }

    Binary *binary;
    std::string name;
    SectionType type;
    uint64_t vma; /*start address*/
    uint64_t size;
    uint8_t *bytes;
};

It stores the following fields : name, type (SEC_TYPE_CODE or SEC_TYPE_DATA), vma (starting virtual address), size, bytes (raw contents) and a pointer back to its parent Binary.

A key helper method:

bool contains(uint64_t addr);

This checks whether an address falls inside the section, which proves useful during disassembly and analysis when resolving instruction addresses.


Binary Link to heading

This is the main container.

class Binary {
    public:
    enum BinaryType {
        BIN_TYPE_AUTO = 0,
        BIN_TYPE_ELF = 1,
        BIN_TYPE_PE = 2
    };
    enum BinaryArch {
        ARCH_NONE = 0,
        ARCH_X86 = 1
    };

    Binary() : type(BIN_TYPE_AUTO), arch(ARCH_NONE), bits(0), entry(0) {}

    Section *get_text_section() {
        for(auto &s : sections) if(s.name == ".text") 
            return &s; 
        return NULL;
    }

    std::string filename;
    BinaryType type;
    std::string type_str;
    BinaryArch arch;
    std::string arch_str;
    unsigned bits;
    uint64_t entry;
    std::vector<Section> sections;
    std::vector<Symbol> symbols;
};

It stores:

  • Architecture
  • Bitness
  • Entry point
  • Sections
  • Symbols

There’s also a convenience function:

Section *get_text_section();

Which simplifies access to executable code.


Loader Entry Points Link to heading

Two functions complete the interface:

int load_binary(std::string &fname, Binary *bin, Binary::BinaryType type);
void unload_binary(Binary *bin);

These abstract away:

  • Opening the file
  • Parsing format details
  • Extracting sections and symbols
  • Cleaning up memory

This loader interface is declared in loader.h, while load_binary() and unload_binary() are implemented in loader.cc.


Writing the binary loader Link to heading

With the interface out of the way, we start implementing the load_binary() and unload_binary() functions, the two entry point functions exposed by the loader interface.

int load_binary(std::string &fname, Binary *bin, Binary::BinaryType type) {
    return load_binary_bfd(fname, bin, type);
}

void unload_binary(Binary *bin) {
    size_t i;
    Section *sec;

    for(i = 0; i < bin->sections.size(); i++) {
        sec = &bin->sections[i];
        if(sec->bytes) {
            free(sec->bytes);
        }
    }
}

load_binary() parses a binary file and fills a Binary object, but it delegates the actual work to load_binary_bfd(). Conversely, unload_binary() simply frees any dynamically allocated memory, which in practice is just the bytes arrays of each Section. Loading is more complex, so it’s handled separately with libbfd.

Before parsing and loading the binary, we have to open it and so we implement open_bfd()

// Used to open a binary
static bfd* open_bfd(std::string &fname) {
    static int bfd_inited = 0;
    bfd *bfd_h;

    if(!bfd_inited) {
        // A function used to initialize bfd's "magical" internal data structure
        // This initialization is tracked with the variable bfd_inited
        bfd_init();
        bfd_inited = 1;
    }

    /*A function used to open the binary file by name, returns a pointer to the file handle of type
    bfd which is libbfd's root data structure */
    bfd_h = bfd_openr(fname.c_str(), NULL);

    if(!bfd_h) {
        fprintf(stderr, "failed to open binary '%s' (%s)\n",
        /*bfd_get_error() gets the most recent error and returns a bfd_error_type object, which is 
        translated to a string by bfd_errmsg()*/
        fname.c_str(), bfd_errmsg(bfd_get_error()));
        return NULL;
    }

    /*bfd_check_format() checks the format of the given file -object, core or archive- 
    a bfd_object is either a binary, relocatable object or shared library*/
    if(!bfd_check_format(bfd_h, bfd_object)) {
        fprintf(stderr, "file '%s' does not look like an executable (%s)\n",
        fname.c_str(), bfd_errmsg(bfd_get_error()));
        return NULL;
    }

    /* Some versions of bfd_check_format pessimistically set a wrong_format
    * error before detecting the format and then neglect to unset it once
    * the format has been detected. We unset it manually to prevent problems.
    */

    bfd_set_error(bfd_error_no_error);

    /*The flavour in this function is simply the type of the binary -ELF,PE...-
    if the flavour is unknown it exists with an error message*/
    if(bfd_get_flavour(bfd_h) == bfd_target_unknown_flavour) {
        fprintf(stderr, "unrecognized format for binary '%s' (%s)\n",
        fname.c_str(), bfd_errmsg(bfd_get_error()));
        return NULL;
    }

    return bfd_h;
}

We won’t go too much into the details of the functions and code, but essentially, open_bfd uses libbfd to open a binary and return a handle for further processing. It initializes libbfd (once per program), opens the file with bfd_openr, and checks for errors using bfd_get_error and bfd_errmsg. The function then verifies that the file is a valid object (bfd_check_format) and that its binary “flavor” is recognized (ELF or PE) using bfd_get_flavour. If all checks pass, it returns a valid bfd handle ready for extracting metadata, symbols, and sections.

Extracting binary metadata Link to heading

Now with the binary open, it’s time to start parsing and loading it. The implementation of the load_binary_bfd() function follows, which is the function that handles all the actual parsing and loading work on behalf of the load_binary() function.

static int load_binary_bfd(std::string &fname, Binary *bin, Binary::BinaryType type) {
    int ret;
    bfd *bfd_h;
    const bfd_arch_info_type *bfd_info;

    bfd_h = NULL;
    /*Binary is opened using the previously defined open_bfd function*/
    bfd_h = open_bfd(fname);
    if(!bfd_h) {
        goto fail;
    }

    /*Some of the binary's properties are set*/
    bin->filename = std::string(fname);
    bin->entry = bfd_get_start_address(bfd_h);
    bin->type_str = std::string(bfd_h->xvec->name);

    /*The xvec field gives us access to the bfd_target strcuture which includes
    data about the type of the binary*/
    switch(bfd_h->xvec->flavour){
        case bfd_target_elf_flavour:
            bin->type = Binary::BIN_TYPE_ELF;
            break;
        case bfd_target_coff_flavour:
            bin->type = Binary::BIN_TYPE_PE;
            break;
        case bfd_target_unknown_flavour:
        default:
            fprintf(stderr, "unsupported binary type (%s)\n", bfd_h->xvec->name);
            goto fail;
    }

    bfd_info = bfd_get_arch_info(bfd_h);
    bin->arch_str = std::string(bfd_info->printable_name);

    /*We here use the structure returned by bfd_get_arch_info to get data about
    the architecture and then decide the binary's arch*/
    switch(bfd_info->mach){
        case bfd_mach_i386_i386:
            bin->arch = Binary::ARCH_X86;
            bin->bits = 32;
            break;
        case bfd_mach_x86_64:
            bin->arch = Binary::ARCH_X86;
            bin->bits = 64;
            break;
        default:
            fprintf(stderr, "unsupported architecture (%s)\n",
                bfd_info->printable_name); 
            goto fail;
    }
    /* Symbol handling is best effort only as they may not be present*/
    load_symbols_bfd(bfd_h, bin);
    load_dynsym_bfd(bfd_h, bin);

    if(load_sections_bfd(bfd_h, bin) < 0) goto fail;

    ret = 0;
    goto cleanup;

    fail:
        ret = -1;

    cleanup:
        if(bfd_h) bfd_close(bfd_h);

    return ret;
}

load_binary_bfd() starts by opening the binary with open_bfd() and obtaining a bfd handle. It then fills the Binary object with basic information: filename, entry point (bfd_get_start_address), binary type (ELF or PE via bfd_h->xvec->flavour), and architecture (using bfd_get_arch_info and the mach field to distinguish 32- vs 64-bit x86). After setting these properties, the loader delegates symbol and section extraction to specialized functions load_symbols_bfd(), load_dynsym_bfd(), and load_sections_bfd(), which we’ll implement in a bit. Once all data is copied into the Binary object, the bfd handle is closed with bfd_close, even if an error occurs during loading.


Loading symbols Link to heading

Now we’ll discuss loading both static and dynamic symbols, the following function load_symbols_bfd() loads the static symbol table

/*Function used to load the symbols, a symbol is represented by the asymbol struct
a symbol table is a asymbol** which is an array of pointers to symbols
This function populates the binary's array of pointers to symbols*/

static int load_symbols_bfd(bfd *bfd_h, Binary *bin) {
    int ret;
    long n, nsyms, i;
    asymbol **bfd_symtab;
    Symbol *sym;

    bfd_symtab = NULL;
    /*This function returns the necessary number of bytes to store all the symbol pointers
    a neg value is an error, and 0 means the binary is stripped -no symbols-*/
    n = bfd_get_symtab_upper_bound(bfd_h);
    if(n < 0) {
        fprintf(stderr, "failed to read symtab (%s)\n",
        bfd_errmsg(bfd_get_error()));
        goto fail;
    } else if(n) {
        bfd_symtab = (asymbol**)malloc(n);
        if(!bfd_symtab) {
            fprintf(stderr, "out of memory\n");
            goto fail;
        }

        /*This function is the most important here as it populates the array bfd_symtab
        with the bfd's symbols and returns how many symbols it populated*/
        nsyms = bfd_canonicalize_symtab(bfd_h, bfd_symtab);
        if(nsyms < 0) {
            fprintf(stderr, "failed to read symtab (%s)\n",
            bfd_errmsg(bfd_get_error()));
            goto fail;
        }
        /*Using the populated array bfd_symtab, we now create Symbol entries in our binary
        and fill it with the data of the array's entries*/
        for(i = 0; i < nsyms; i++) {
            /*As we are only interested in function symbols here, we only check if the function flag is set*/
            if(bfd_symtab[i]->flags & BSF_FUNCTION) {
                bin->symbols.push_back(Symbol());
                sym = &bin->symbols.back();
                sym->type = Symbol::SYM_TYPE_FUNC;
                sym->name = std::string(bfd_symtab[i]->name);
                sym->addr = bfd_asymbol_value(bfd_symtab[i]);
            }
        }
    }

    ret = 0;
    goto cleanup;

    fail:
        ret = -1;

    cleanup:
        if(bfd_symtab) free(bfd_symtab);

    return ret;
}

In libbfd, symbols are stored as asymbol pointers, and a symbol table is an array of these pointers. load_symbols_bfd() allocates space for the table using bfd_get_symtab_upper_bound() and populates it with bfd_canonicalize_symtab(). The loader then iterates over all symbols, keeping only function symbols (BSF_FUNCTION), and copies their names and addresses into the Binary object’s Symbol vector. Once done, the temporary libbfd symbol table is freed. The dynamic symbol table is loaded in a nearly identical way.

As for loading the dynamic symbols table, we have the function load_dynsym_bfd() which is identical to the static function with minor differences in function, I prefer not putting it here to avoid redundance but the complete code is available in the GitHub repo.


Loading sections Link to heading

After loading the symbols, all that remains is loading the binary’s sections, the following function load_sections_bfd() does that

/*Function used to load sections, sections are represented by the struct asection and are
included in a linked list*/
static int load_sections_bfd(bfd *bfd_h, Binary *bin) {
    int bfd_flags;
    uint64_t vma, size;
    const char *secname;
    asection* bfd_sec;
    Section *sec;
    Section::SectionType sectype;

    /*We iterate over all sections in the asection linked list starting with the first one
    stored in bfd_h->sections*/
    /*bfd_get_section_flags() has been deleted and thus is replaced with bfd_section_flags()*/
    for(bfd_sec = bfd_h->sections; bfd_sec; bfd_sec = bfd_sec->next) {
        bfd_flags = bfd_section_flags(bfd_sec); /*get all section flags*/

        /*get section type*/
        sectype = Section::SEC_TYPE_NONE;
        if(bfd_flags & SEC_CODE) {
            sectype = Section::SEC_TYPE_CODE;
        } else if(bfd_flags & SEC_DATA) {
            sectype = Section::SEC_TYPE_DATA;
        } else {
            continue;
        }

        /*Get data of the section using libbfd functions and copy them into the Binary object's elements
        in the sections vector*/
        /*Signature for these functions changed compared to the book's*/
        vma = bfd_section_vma(bfd_sec);
        size = bfd_section_size(bfd_sec);
        secname = bfd_section_name(bfd_sec);
        if(!secname) secname = "<unnamed>";

        bin->sections.push_back(Section());
        sec = &bin->sections.back();
        sec->binary = bin;
        sec->name = std::string(secname);
        sec->type = sectype;
        sec->vma = vma;
        sec->size = size;
        
        
        sec->bytes = (uint8_t*)malloc(size); /*Allocate enough space to copy the section's content into the Binary object*/
        if(!sec->bytes) {
            fprintf(stderr, "out of memory\n");
            return -1;
        }
        /*copy the contents of the section into the Binary's section*/
        if(!bfd_get_section_contents(bfd_h, bfd_sec, sec->bytes, 0, size)) {
            fprintf(stderr, "failed to read section '%s' (%s)\n",
            secname, bfd_errmsg(bfd_get_error()));
            return -1;
        }
    }

    return 0;
}

In libbfd, sections are represented by asection structures linked in a list. The loader iterates over this list and only processes code (SEC_CODE) and data (SEC_DATA) sections. For each relevant section, it copies the type, virtual address, size, name, and raw bytes into a Section object in the Binary. The section bytes are allocated and filled using bfd_get_section_contents(). Once all code and data sections are copied, the section-loading process is complete.

Testing the loader Link to heading

Now with the interface loader.h and the binary loader loader.cc both implemented, all that remains is testing the binary loader.

I have included the code for the helper binary that runs the loader and the necessary Makefile to build it in the GitHub repo.

Running the binary loader on /bin/sh for example gives us :

loaded binary '/bin/ls' elf64-x86-64/i386:x86-64 (64 bits) entry@0x00000000000054f0
 0x0000000000000350 80       .note.gnu.property   DATA
 0x00000000000003a0 36       .note.gnu.build-id   DATA
 0x00000000000003c4 28       .interp              DATA
 0x00000000000003e0 156      .gnu.hash            DATA
 0x0000000000000480 3168     .dynsym              DATA
 0x00000000000010e0 1590     .dynstr              DATA
 0x0000000000001716 264      .gnu.version         DATA
 0x0000000000001820 208      .gnu.version_r       DATA
 0x00000000000018f0 2928     .rela.dyn            DATA
 0x0000000000002460 88       .relr.dyn            DATA
 0x0000000000003000 27       .init                CODE
 0x0000000000003040 103171   .text                CODE
 0x000000000001c344 13       .fini                CODE
 0x000000000001d000 21608    .rodata              DATA
 0x0000000000022468 2676     .eh_frame_hdr        DATA
 0x0000000000022ee0 11216    .eh_frame            DATA
 0x0000000000025ab0 32       .note.ABI-tag        DATA
 0x0000000000026d10 8        .init_array          DATA
 0x0000000000026d18 8        .fini_array          DATA
 0x0000000000026d20 3384     .data.rel.ro         DATA
 0x0000000000027a58 496      .dynamic             DATA
 0x0000000000027c48 952      .got                 DATA
 0x0000000000028000 608      .data                DATA
scanned symbol tables
 __ctype_toupper_loc                      0x0000000000000000 FUNC
 getenv                                   0x0000000000000000 FUNC
 cap_to_text                              0x0000000000000000 FUNC
 sigprocmask                              0x0000000000000000 FUNC
 __snprintf_chk                           0x0000000000000000 FUNC
 raise                                    0x0000000000000000 FUNC
 free                                     0x0000000000000000 FUNC
 __vfprintf_chk                           0x0000000000000000 FUNC
...