← writeups

// writeup

Practical Binary Analysis, Ch. 4: Building a Binary Loader with libbfd

08 July 2026 · binary-analysis · libbfd · reverse-engineering · cpp

Notes on Chapter 4 of Practical Binary Analysis by Dennis Andriesse, plus my solutions to the exercises. This is the first chapter that builds something: a reusable binary-loading framework on top of libbfd, the library objdump, readelf and gdb are all built on. Everything below is compiled and run against the exact binaries from Chapter 2 and Chapter 3, which turns the loader into an independent check on both.

build + run
# libbfd lives in binutils-dev; the header is /usr/include/bfd.h
sudo apt install binutils-dev

g++ -std=c++11 -Wall -I inc loader_demo.cc inc/loader.cc \
    -o loader_demo -lbfd -liberty -lz -ldl

./loader_demo a.out                 # sections + symbols
./loader_demo hello.exe             # the PE from ch. 3, same code path
./loader_demo a.out .rodata         # exercise 1: dump a section

Why libbfd

The Binary File Descriptor library gives one API for every binary format GNU supports: ELF, PE, COFF, a.out, across dozens of architectures. Write against libbfd and you get PE support without ever reading the PE spec.

your analysis tool loader_demo.cc · knows nothing about ELF or PE the loader API · inc/loader.h Binary Section Symbol load_binary() · unload_binary() libbfd · /usr/include/bfd.h bfd asection asymbol bfd_openr · bfd_check_format · bfd_canonicalize_symtab bfd_section_vma · bfd_get_section_contents · bfd_close libbfd back-ends · one per target elf64-x86-64 pei-x86-64 elf32-i386 a.out (ELF64) · hello.exe (PE32+) · /bin/ls · … THE ABSTRACTION BOUNDARY no bfd type crosses this line, loader.h includes no bfd.h generic structures: the same asection describes an ELF section and a PE section a new format = a new back-end here; nothing above changes ↓ each layer only talks to the one below it ↓
Why libbfd is worth the awkward API: it collapses every binary format into one set of structures. The loader copies what it needs into its own classes, so everything above the teal line stays format-agnostic, and gets PE support for free.

The chapter’s real lesson is the abstraction boundary, not the library. loader.h deliberately exposes no libbfd type: no bfd*, no asection, no asymbol. The loader copies out what it needs into its own Binary / Section / Symbol classes and closes the handle. Everything built on top in later chapters stays independent of libbfd, which matters because, as I found out, libbfd’s API is not stable across releases.

The interface

Three classes and two functions. Symbol is a type, a name and an address. Section adds a virtual address, a size, a raw byte array and a back-pointer to its owner. Binary is the root: filename, type, architecture, bit width, entry point, and vectors of the other two.

class Binary std::string filename BinaryType type std::string type_str BinaryArch arch unsigned bits uint64_t entry vector<Section> sections vector<Symbol> symbols class Section Binary *binary back-pointer to the owner std::string name SectionType type uint64_t vma, size uint8_t *bytes the only malloc() in the loader class Symbol SymbolType type std::string name uint64_t addr all by-value, nothing to free 1 : N 1 : N unload_binary() for each Section: free(sec->bytes) that is the entire body. The vectors and strings free themselves when the Binary goes out of scope. two conveniences worth keeping bin.get_text_section() → finds .text sec.contains(addr) → address in range? the whole design in one line: copy what you need out of libbfd, then close the handle no bfd type appears in loader.h, which is what makes the API format-agnostic
Binary owns vectors of Section and Symbol by value, so the only heap allocation in the entire loader is each section's bytes array, which is exactly what unload_binary frees.
inc/loader.h: the parts that matter
class Section {
public:
  enum SectionType { SEC_TYPE_NONE = 0, SEC_TYPE_CODE = 1, SEC_TYPE_DATA = 2 };

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

  Binary       *binary;
  std::string   name;
  SectionType   type;
  uint64_t      vma;
  uint64_t      size;
  uint8_t      *bytes;      // the loader's only malloc()
};

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 };

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

  std::string          type_str;   // "elf64-x86-64" / "pei-x86-64"
  BinaryArch           arch;
  unsigned             bits;       // 32 or 64
  uint64_t             entry;
  std::vector<Section> sections;
  std::vector<Symbol>  symbols;
};

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

Note the dual representation: type is an enum for switching on, type_str the human-readable string straight from libbfd. Same for arch / arch_str. And note that x86 and x86-64 are both ARCH_X86; the bits field is what separates them.

Implementing it

YOUR CODE WHAT LIBBFD DOES load_binary() the only entry point the API exposes load_binary_bfd() does the real work open_bfd() 1 bfd_init() once per process 2 bfd_openr(fname, NULL) NULL = autodetect 3 bfd_check_format(bfd_object) 4 bfd_set_error(no_error) ← the workaround parse basic properties bfd_get_start_address() → entry bfd_h->xvec->flavour → ELF or PE bfd_get_arch_info()->mach → 32/64 load_symbols_bfd() + load_dynsym_bfd() bfd_canonicalize_symtab() best-effort return value ignored, symbols may not exist load_sections_bfd() failure here IS fatal, goto fail returns bfd *bfd_h libbfd's root handle. Everything else in the API takes this as its first argument. bfd_check_format() can leave a stale wrong_format error behind even on success, hence step 4. bfd_h->xvec = the bfd_target xvec->name gives "elf64-x86-64" or "pei-x86-64"; flavour gives the family. mach distinguishes i386 from x86_64. two-step reads libbfd never hands you a sized array. You ask for the upper bound, malloc that many bytes, then ask libbfd to fill it in: n = bfd_get_symtab_upper_bound(h); nsyms = bfd_canonicalize_symtab(h, tab); bfd_close(): no libbfd state escapes
Every libbfd call the loader makes, in order. Note the asymmetry at the bottom: missing symbols are tolerated, missing sections are fatal. And once bfd_close runs, the Binary object owns everything, no libbfd pointer outlives the call.

Opening the file

open_bfd initialises the library once, opens the file, and runs three checks before handing back a handle.

inc/loader.cc: open_bfd
static bfd*
open_bfd(std::string &fname)
{
  static int bfd_inited = 0;
  bfd *bfd_h;

  if(!bfd_inited) {          // "initialize magical internal data structures"
    bfd_init();
    bfd_inited = 1;
  }

  bfd_h = bfd_openr(fname.c_str(), NULL);   // NULL target = autodetect
  if(!bfd_h) {
    fprintf(stderr, "failed to open binary '%s' (%s)\n",
            fname.c_str(), bfd_errmsg(bfd_get_error()));
    return NULL;
  }

  if(!bfd_check_format(bfd_h, bfd_object)) { /* … */ 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. */
  bfd_set_error(bfd_error_no_error);

  if(bfd_get_flavour(bfd_h) == bfd_target_unknown_flavour) { /* … */ return NULL; }

  return bfd_h;
}

bfd_object in libbfd terminology means an executable, a relocatable object or a shared library, as opposed to bfd_archive or bfd_core. Errors come back through a global: bfd_get_error() returns a bfd_error_type, and bfd_errmsg() turns it into a printable string.

Parsing type and architecture

Once open, the interesting fields hang off the handle. bfd_h->xvec is a bfd_target describing the format; bfd_get_arch_info() returns a bfd_arch_info_type describing the machine.

inc/loader.cc: load_binary_bfd (abridged)
bin->entry    = bfd_get_start_address(bfd_h);
bin->type_str = std::string(bfd_h->xvec->name);

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;   // PE is COFF-flavoured
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);

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: /* … */ goto fail;
}

load_symbols_bfd(bfd_h, bin);        // best-effort: return value ignored
load_dynsym_bfd(bfd_h, bin);         // best-effort
if(load_sections_bfd(bfd_h, bin) < 0) goto fail;   // fatal

PE arrives as bfd_target_coff_flavour, not a PE-specific flavour. That is the PE/COFF lineage from Chapter 3 showing up in the API. And notice the asymmetry in the last three lines: symbol loading may fail silently, section loading may not. A stripped binary is still perfectly loadable; a binary whose sections won’t read is not.

Loading symbols

libbfd never hands you a sized array. You ask how many bytes to reserve, malloc that, then ask it to fill the array in, the “canonicalize” step.

inc/loader.cc: load_symbols_bfd
n = bfd_get_symtab_upper_bound(bfd_h);        // bytes, not symbols
if(n < 0)      { /* error */ goto fail; }
else if(n) {
  bfd_symtab = (asymbol**)malloc(n);
  nsyms = bfd_canonicalize_symtab(bfd_h, bfd_symtab);   // now it's populated
  for(i = 0; i < nsyms; i++) {
    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]);
    }
  }
}

load_dynsym_bfd is the same function with two names changed: bfd_get_dynamic_symtab_upper_bound and bfd_canonicalize_dynamic_symtab. Both static and dynamic symbols are asymbol, so the loop body is identical, a nice payoff from Chapter 2’s .symtab vs .dynsym distinction being a type difference, not a structural one.

Loading sections

Sections are a linked list off bfd_h->sections, walked via next. For each one the loader checks the flags, skips anything that is neither code nor data, then copies name, address, size and raw bytes.

inc/loader.cc: load_sections_bfd
for(bfd_sec = bfd_h->sections; bfd_sec; bfd_sec = bfd_sec->next) {
  bfd_flags = bfd_section_flags(bfd_sec);

  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;                        // ← everything else is dropped

  vma     = bfd_section_vma(bfd_sec);
  size    = bfd_section_size(bfd_sec);
  secname = bfd_section_name(bfd_sec);

  bin->sections.push_back(Section());
  sec = &bin->sections.back();
  sec->binary = bin;  sec->name = secname;  sec->type = sectype;
  sec->vma = vma;     sec->size = size;
  sec->bytes = (uint8_t*)malloc(size);

  if(!bfd_get_section_contents(bfd_h, bfd_sec, sec->bytes, 0, size)) { /* … */ return -1; }
}

Then bfd_close, and the Binary owns everything. unload_binary is four lines because a Section’s bytes array is the only heap allocation in the whole loader.

Testing it

The demo program loads a binary, prints its properties, sections and symbols, then unloads it. Run against the ELF from Chapter 2:

./loader_demo a.out: the ELF from chapter 2
loaded binary '../elf/a.out' elf64-x86-64/i386:x86-64 (64 bits) entry@0x0000000000001060
  0x0000000000001000 27       .init                CODE
  0x0000000000001020 32       .plt                 CODE
  0x0000000000001040 16       .plt.got             CODE
  0x0000000000001050 16       .plt.sec             CODE
  0x0000000000001060 274      .text                CODE
  0x0000000000001174 13       .fini                CODE
  0x0000000000002000 18       .rodata              DATA
  0x0000000000003dc8 496      .dynamic             DATA
  0x0000000000003fb8 72       .got                 DATA
  0x0000000000004000 16       .data                DATA
scanned symbol tables
  frame_dummy                              0x0000000000001140 FUNC
  _start                                   0x0000000000001060 FUNC
  main                                     0x0000000000001149 FUNC
  puts@GLIBC_2.2.5                         0x0000000000000000 FUNC
  __cxa_finalize@GLIBC_2.2.5               0x0000000000000000 FUNC WEAK

And the PE from Chapter 3, through the identical code path:

./loader_demo hello.exe: the PE from chapter 3
failed to read dynamic symtab (invalid operation)
loaded binary '../elf/hello.exe' pei-x86-64/i386:x86-64 (64 bits) entry@0x00000001400012e4
  0x0000000140001000 85328    .text                CODE
  0x0000000140016000 42930    .rdata               DATA
  0x0000000140021000 3072     .data                DATA
  0x0000000140023000 4824     .pdata               DATA
  0x0000000140025000 256      .fptable             DATA
  0x0000000140026000 1664     .reloc               DATA

Entry 0x1400012e4 is exactly the ImageBase + AddressOfEntryPoint computed by hand in Chapter 3, and the sizes convert straight back to the header values: 85328 = 0x14d50, 42930 = 0xa7b2, 1664 = 0x680. Not one line of PE-specific code was written to get this.

Exercises

1. Dumping section contents

For brevity, the current version of the loader_demo program doesn’t display section contents. Expand it with the ability to take a binary and the name of a section as input. Then dump the contents of that section to the screen in hexadecimal format.

The bytes are already sitting in sec->bytes, so this is presentation only. I added an optional second argument and a classic hex+ASCII dump:

loader_demo.cc: dump_section
static void
dump_section(Binary *bin, const std::string &secname)
{
  for(auto &sec : bin->sections) {
    if(sec.name != secname) continue;

    printf("dump of section '%s' (0x%016jx, %ju bytes)\n",
           sec.name.c_str(), sec.vma, sec.size);

    for(uint64_t i = 0; i < sec.size; i += 16) {
      printf("  0x%016jx  ", sec.vma + i);               // address column
      for(uint64_t j = 0; j < 16; j++) {                  // hex column
        if(i + j < sec.size) printf("%02x ", sec.bytes[i + j]);
        else                 printf("   ");               // pad the last row
        if(j == 7) printf(" ");                           // split at 8 bytes
      }
      printf(" |");
      for(uint64_t j = 0; j < 16 && i + j < sec.size; j++) {
        uint8_t c = sec.bytes[i + j];
        printf("%c", isprint(c) ? c : '.');               // ASCII column
      }
      printf("|\n");
    }
    return;
  }
  fprintf(stderr, "no section named '%s'\n", secname.c_str());
}
./loader_demo a.out .rodata
dump of section '.rodata' (0x0000000000002000, 18 bytes)
  0x0000000000002000  01 00 02 00 48 65 6c 6c  6f 2c 20 77 6f 72 6c 64  |....Hello, world|
  0x0000000000002010  21 00                                             |!.|

Byte-for-byte identical to the readelf -x .rodata output in Chapter 2, including the four bytes of _IO_stdin_used before the string, which is why main’s lea pointed at 0x2004 and not 0x2000.

2. Overriding weak symbols

Some symbols are weak, which means that their value may be overridden by another symbol that isn’t weak. Currently, the binary loader doesn’t take this into account and simply stores all symbols. Expand the binary loader so that if a weak symbol is later overridden by another symbol, only the latest version is kept.

The flag to test is BSF_WEAK. I added an is_weak field to Symbol and routed every insertion through one helper, so the same rule applies to static and dynamic symbols:

inc/loader.cc: push_symbol
static void
push_symbol(Binary *bin, const char *name, uint64_t addr,
            Symbol::SymbolType type, bool weak)
{
  for(auto &s : bin->symbols) {
    if(s.name == name) {
      if(s.is_weak && !weak) {     // a strong definition displaces a weak one
        s.addr    = addr;
        s.type    = type;
        s.is_weak = false;
      }
      return;                      // already known: never store it twice
    }
  }
  bin->symbols.push_back(Symbol());
  Symbol *sym  = &bin->symbols.back();
  sym->type    = type;
  sym->name    = std::string(name);
  sym->addr    = addr;
  sym->is_weak = weak;
}

Two weak symbols show up in my test binary, both __cxa_finalize, once from .symtab and once from .dynsym:

the weak entries
  __cxa_finalize@GLIBC_2.2.5               0x0000000000000000 FUNC WEAK
  __cxa_finalize                           0x0000000000000000 FUNC WEAK

3. Printing data symbols

Expand the binary loader and the loader_demo program so that they can handle local and global data symbols as well as function symbols. […] Note that data items are called objects in symbol terminology.

Function symbols carry BSF_FUNCTION; data symbols carry BSF_OBJECT. So it is one extra branch per symbol loop, plus a new enum value:

inc/loader.cc + loader.h
// loader.h
enum SymbolType { SYM_TYPE_UKN = 0, SYM_TYPE_FUNC = 1, SYM_TYPE_DATA = 2 };

// loader.cc, in both load_symbols_bfd and load_dynsym_bfd
bool weak = (bfd_symtab[i]->flags & BSF_WEAK) != 0;
if(bfd_symtab[i]->flags & BSF_FUNCTION) {
  push_symbol(bin, bfd_symtab[i]->name, bfd_asymbol_value(bfd_symtab[i]),
              Symbol::SYM_TYPE_FUNC, weak);
} else if(bfd_symtab[i]->flags & BSF_OBJECT) {          // ← data symbols
  push_symbol(bin, bfd_symtab[i]->name, bfd_asymbol_value(bfd_symtab[i]),
              Symbol::SYM_TYPE_DATA, weak);
}
./loader_demo a.out: data symbols now appear
  __abi_tag                                0x000000000000038c DATA
  completed.0                              0x0000000000004010 DATA
  __do_global_dtors_aux_fini_array_entry   0x0000000000003dc0 DATA
  __frame_dummy_init_array_entry           0x0000000000003db8 DATA
  __FRAME_END__                            0x00000000000020f0 DATA
  _DYNAMIC                                 0x0000000000003dc8 DATA
  _GLOBAL_OFFSET_TABLE_                    0x0000000000003fb8 DATA
  __dso_handle                             0x0000000000004008 DATA
  _IO_stdin_used                           0x0000000000002000 DATA

Chapter 2 territory, confirmed independently: _DYNAMIC at 0x3dc8 is the .dynamic section’s address, _GLOBAL_OFFSET_TABLE_ at 0x3fb8 is .got, __frame_dummy_init_array_entry at 0x3db8 is the single .init_array slot, and _IO_stdin_used at 0x2000 is the four bytes sitting in front of the "Hello, world!" string.

Summary

The chapter is nominally about libbfd, but the durable idea is the boundary. libbfd is awkward: a global error variable that survives success, byte counts masquerading as element counts, bfd_h->xvec->flavour, and an API that renamed four functions out from under the book. None of that leaked past loader.cc, so adapting to binutils 2.38 was four one-line edits.

What comes out the other side is a Binary object that reads an ELF and a PE through the same call, reproduces Chapter 2’s and Chapter 3’s numbers exactly, and degrades gracefully on stripped input. Part III uses it as the foundation for real analysis tools.