Building a Custom ELF Loader from Scratch, in C
In Part 1, we built a tool that opens an ELF binary and reads its metadata: header, sections, string tables. That’s reading. This time we will be doing something harder, making the binary run.
We’re going to write a program that loads another program into memory and jumps to its entry point. No execve. No dynamic linker. Just mmap, a stack we build by hand and a jmp instruction.
This is what the kernel does when you type ./program. Except we’re doing it from userspace, in about 300 lines of C.
The full code is an elf-loader. This post covers loading and executing a static ELF binary.
What actually happens when you run ./program
You type the command, your shell calls execve and the kernel takes over. Here’s what it does, stripped to the essentials:
- opens the binary, checks the ELF magic
- reads the program headers (not section headers, the kernel doesn’t care about sections)
- for each
PT_LOADsegment, maps it into the process’s virtual address space with the right permissions - builds the initial stack: argc, argv, environment variables and a blob of metadata called the auxiliary vector.
- sets the instruction pointer to the entry point and returns to userspace
That’s it. The binary doesn’t know or care that a kernel just assembled its entire address space. It sees a stack, some memory-mapped segments and a CPU ready to execute.
Our loader does the same thing, minus the kernel part. We open the file, map the segments, build the stack and jump. The binary can’t tell the difference.
What we’re building
We’re targeting static binaries only, no shared libraries, no ld.so. That keeps the loader simple while still teaching the core mechanics. The binary has to be compiled with gcc -static and -Wl, -z, norelro (more on that later)
The test binary is written in raw assembly. No libc, no startup code, no hidden complexity:
.section .text.globl _start_start: mov $1, %rax mov $1, %rdi lea msg(%rip), %rsi mov $30, %rdx syscall
mov $60, %rax xor %rdi, %rdi syscall
.section .rodatamsg: .ascii "Hello from custom ELF loader!\n"Two syscalls: write and exit. That’s the smallest program that produces visible output. We’ll come back to why we wrote it that way instead of using printf
The loader in 4 steps
Step 1: mmap the binary and parse the header
Same trick from Part 1. Open the file, mmap it, cast the pointer.
void *file_map = mmap(NULL, st.st_size, PROT_READ, MAP_PRIVATE, fd, 0);Elf64_Ehdr *ehdr = (Elf64_Ehdr *)file_map;Check the magic, check it’s ELF64, check it’s ET_EXEC (a statically linked executable, not a shared object or a relocatable). Then read the program header, the array of Elf64_Phdr structs that describe how to load the binary:
Elf64_Phdr *phdrs = (Elf64_Phdr *)((char *)ehdr + ehdr->e_phoff);Same overlay trick as Part 1. e_phoff is the byte offset of the program header table. We cast it to an Elf64_Phdr * and iterate e_phnum times. No parsing, just pointer arithmetic.
Step 2: map PT_LOAD segments into memory
The kernel doesn’t load the whole file into one flat region. It loads it in segments, chunks with different permissions. A typical binary has 3 or 4:
PT_LOAD R-- 0x400000 .rodata, ELF headerPT_LOAD R-X 0x401000 .text (code)PT_LOAD R-- 0x480000 .rodata, debug infoPT_LOAD RW- 0x4b5000 .data, .bssThe kernel maps each one at its requested virtual address (p_vaddr) with the requested permissions(p_flags). Code gets R-X, data gets RW-. That’s W^X, write XOR execute, enforced by the MMU at the hardware level.
Our loader does the same thing. First, find the total address range:
for (int i = 0; i < ehdr->e_phnum; i++) { if (phdrs[i].p_type != PT_LOAD) continue; uintptr_t start = PAGE_DOWN(phdrs[i].p_vaddr); uintptr_t end = PAGE_ALIGN(phdrs[i].p_vaddr + phdrs[i].p_memsz); if (start < min_vaddr) min_vaddr = start; if (end > max_vaddr) max_vaddr = end;}Then allocate the whole range as anonymous memory:
void *base = mmap((void *)min_vaddr, total, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED_NOREPLACE, -1, 0);MAP_FIXED_NOREPLACE asks the kernel to place the mapping at exactly that address, failing if something’s already there. This matters for static binaries whose addresses are hardcoded, a call 0x401234 instruction would jump to the wrong place if the code loaded elsewhere.
Then for each segment, read its data from the file into the right place in memory:
void *dst = (char *)base + (PAGE_DOWN(vaddr) - min_vaddr);lseek(fd, offset, SEEK_SET);read(fd, dst, filesz);And apply permissions with mprotect:
int prot = 0;if (phdrs[i].p_flags & PF_R) prot |= PROT_READ;if (phdrs[i].p_flags & PF_W) prot |= PROT_WRITE;if (phdrs[i].p_flags & PF_X) prot |= PROT_EXEC;mprotect(dst, memsz, prot);One detail that trips up everyone: p_filesz vs p_memsz. The file only contains filesz bytes, but the segment needs memsz bytes in memory. The gap is .bss, uninitialized data that must be zeroed. Our loader handles this:
if (filesz < memsz) memset((char *)dst + filesz, 0, memsz - filesz);That’s the whole memory setup. The binary’s code and data now live at the virtual addresses the linker originally chose.
Step 3: build the initial stack
This is the part nobody talks about and it’s the hardest.
When a Linux process starts, its stack doesn’t contain just argc and argv. The kernel packs a precise layout of data that the C runtime reads during startup. If any byte is wrong, __libc_start_main segfaults in a way that’s extremely hard to debug.
The layout, from low addresses to high:
sp+0: argc <- _start pops thissp+8: argv[0] argv[1] ... NULLsp+?: envp[0] envp[1] ... NULLsp+?: auxv type, value, ..., AT_NULL, 0sp+?: [padding to 16-byte alignment]sp+?: [env strings]sp+?: [arg strings]sp+?: [16 bytes of "random" data for AT_RANDOM]The _start function (the real entry point, not main) does exactly this:
pop %rsi ; argcmov %rsp, %rdx ; argv = rsp (pointing at argv[0])Then it calls __libc_start_main(main, argc, argv, ...). That function derives envp by walking past argc entries in argv, past the NULL terminator and treating the next pointers as the environment.
The auxiliary vector (auxv) is a list of key-value pairs that tell the runtime about the binary.
Our stack builder writes strings at the top of a new mmap’d region (256 KB, growing downward), then writes the arrays below them:
/* mmap a separate region for the new stack */void *stack_top = mmap(NULL, STACK_SIZE, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);char *sp = (char *)stack_top + STACK_SIZE;
/* Write strings at the top (high addresses) */ sp -= strlen("LOADED_BY=custom-loader") + 1;memcpy(sp, "LOADED_BY=custom-loader", ...);uintptr_t env1_addr = (uintptr_t)sp;
/* Align to 16 bytes */sp = (char *)((uintptr_t)sp & ~0xFULL);
/* Write arrays below (low addresses, growing up) */ *(uintptr_t *)p = argc; p += 8;/* argv, envp, auxv follow... */Why mmap a separate region? Because build_stack runs on our own stack. If we wrote the new stack data starting from a fixed address, we’d clobber our own function’s local variables. Learned that the hard way, the loader segfaulted with garbage values in argc and argv because we were overwriting the function’s own stack frame.
Step 4: jump to the entry point
The final step is the smallest and the scariest. We switch the stack pointer to our new stack and jump to the binary’s entry point:
uintptr_t entry = info.entry;uintptr_t sp = (uintptr_t)stack;
__asm__ volatile ( "mov %[new_sp], %%rsp\n\t" "jmp *%[target]\n\t" : : [new_sp] "r" (sp), [target] "r" (entry) : "memory", "cc");Just 2 instructions.
Why inline assembly? Because we need to change rsp(the stack pointer) to point at our new stack, then jump to the binary. You can’t do that from C, the compiler assumes rsp is valid throughout the function. The moment you change it, every local variable, every return address, every callee-saved register is gone. The binary gets a fresh stack and our loader never comes back.
Why jmp and not call? call pushes a return address onto the stack. But we just carefully built the stack, we don’t want garbage on it. jmp just changes rip. The binary’s _start function will pop argc from the new stack and take it from there.
After the jump, we’re running someone else’s code on someone else’s stack. Our process is the same, but everything below rsp belongs to the binary now.
Running it
$ make$ ./elf-loader test/minimal[+] Loaded: test/minimal (9072 bytes)[+] Entry: 0x401000[+] Base: 0x400000 (0x3000 bytes)[+] Segment: vaddr=0x400000 dst=0x400000 size=0x1b4 R--[+] Segment: vaddr=0x401000 dst=0x401000 size=0x2a R-X[+] Segment: vaddr=0x402000 dst=0x402000 size=0x50 R--[+] Stack: 0x7f9363e97f08 argc=1[+] Entry: 0x401000[+] Stack: 0x7f9363e97f08[+] ========================================Hello from custom ELF loader!It works. The loader opened the binary, mapped three segments (code at 0x401000, read-only data above and below it), built a stack with argc=1 and jumped to 0x401000. The binary’s _start popped argc, called write(1, msg, 30), called exit(0), and we saw the output.
The binary has no idea it wasn’t loaded by the kernel.
Bugs I hit so you don’t have to
Every one of these crashed the loader with a segfault and no useful error message.
Bug 1: building the stack on top of our own stack
The first version of building_stack wrote stack data starting at a fixed address: 0x7fffffffe000. That address was above the current stack frame, so I assumed it was safe.
It wasn’t. The function’s own local variables, argc, argv, the info struct, live on the same stack. Writing to 0x7fffffffe000 overwrote the function’s parameters before it finished using them.
The argc value came through as 0x726564 (ASCII for “red”), which is part of the string "LOADED_BY=...".
Fix: mmap a separate, anonymous region for the new stack. No overlap, no corruption.
Bug 2: GCC doesn’t honor __asm__("rsp")
The first version used GCC’s named register variables:
register uintptr_t rsp_val __asm__("rsp") = (uintptr_t)stack;The theory: declare a variable tied to rsp, use it as an input operand and the compiler sets rsp before the jmp. In practice, GCC at -O0 sometimes generates code that uses the stack between setting the register variable and executing the inline asm. The jmp fires, but rsp points at the old stack, not the new one.
Fix: don’t ask the compiler. Just write the two instructions in pure asm:
__asm__ volatile ( "mov %[new_sp], %%rsp\n\t" "jmp *%[target]\n\t" ...);Bug 3: RELRO makes the GOT read-only before the binary finishes
Even with the RELRO segment removed (-Wl, -z, norelro), the glibc-linked binary still crashed inside __libc_start_main. The problem: glibc’s own startup code reads the program headers from auxv and applies mprotect(PROT_READ) to the GOT region. This happens before the binary’s global constructors run, so any code that writes to the GOT during initialization segfaults.
This is why the test binary uses raw syscalls instead of printf. The printf path goes through glibc’s initialization, which walks the auxiliary vector, finds the program headers, and applies RELRO protections, all before main() runs. With a minimal binary that skips libc entirely, none of that happens.
The -Wl, -z, norelro flag tells the linker to omit the PT_GNU_RELRO segment. Without it, the kernel (or in our case, glibc’s startup) makes the GOT region read-only.
What this teaches
This is the shape of everything that comes in systems programming.
How the kernel loads binaries: Our loader does in 300 lines what the kernel does in 30, 000. The algorithm is the same: mmap segments, build a stack, jump to entry. The kernel adds ASLR, security policies, cgroups, namespaces, but the ELF loading part is this.
Why address space layout matters: Every pointer in the binary is a virtual address. When we mmap at 0x400000, we’re choosing where the code lives. The linker hardcodes call 0x401234 instructions and those only work if .text lands at 0x401000. PIE binaries (position-independent executables) solve this by using relative addressing, which is why modern distros compile everything as ET_DYN, but our static binary is ET_EXEC with fixed addresses.
What the auxiliary vector is for. The kernel can’t just hand the binary a memory dump and hope for the best. The auxv tells the runtime where the program headers are, what the page size is, where the entry point is. Without it, __libc_start_main has no way to find its own initialization data.
Why writing an ELF Loader is a security skill: Understanding how the binaries get loaded is the first step to understanding how they get exploited. Buffer overflows, ROP chains, return-to-libc, all of them depend on knowing where code lives in memory and what permissions it has. When you see mprotect calls in an exploit chain, you’ll know exactly what they’re doing: changing page permissions so shellcode can run.
Where this is going
Part 3, symbols: .symtab vs .dynsym, and what function names really are in the binary
Part 4, relocations: how PIE binaries fix up their addresses at load time.
Part 5, a mini-readelf: glue it all together into a tool that reads headers, sections, symbols and relocations.
Each one builds on the last. By part 5 you’ll have a complete picture of what’s inside an ELF binary, not because a tool told you, but because you built the tool.
Next reads
View all →4 Sept
Symbol Tables: What Function Names Actually Are
Function names in a binary are just entries in a table. Two tables actually: .symtab and .dynsym. Here's what each is for, how the struct works, and how to resolve a name from an address.
9 Sept
Mini-Readelf: Gluing It All Together
The capstone. Four parts of pieces, headers, sections, symbols, relocations, joined into one tool that reads any ELF. The only new mechanic is the sh_link chain: offset into a table that holds offsets into a table that holds strings.
5 Sept
Relocations: How PIE Binaries Fix Their Addresses
A PIE binary can't write final addresses because ASLR moves it. The linker leaves placeholders and the loader patches them after mapping. That's a relocation: R_X86_64_RELATIVE, GLOB_DAT and JUMP_SLOT.
Get posts by email
One email when I publish, not a drip, not weekly. Sign up and I'll only write when there's something new.
You won't get mail just for signing up. Unsubscribe any time.