┌───────────────────────┐ ▄▄▄▄▄ ▄▄▄▄▄ ▄▄▄▄▄ │ │ █ █ █ █ █ █ │ │ █ █ █ █ █▀▀▀▀ │ │ █ █ █ █ ▄ │ │ ▄▄▄▄▄ │ │ █ █ │ │ █ █ │ │ █▄▄▄█ │ │ ▄ ▄ │ │ █ █ │ │ █ █ │ │ █▄▄▄█ │ │ ▄▄▄▄▄ │ │ █ │ halfexec: Assembly x64 ELF Linux Loader │ █ │ ~ TMZ └───────────────────█ ──┘ ┌────────────────────────────────────────────┐ │ ET_EXEC • ET_DYN • PT_INTERP • TLS │ │ half an execve, all the fun parts │ └────────────────────────────────────────────┘ halfexec │ ├── halfexec.asm ├── utils.inc ├── Makefile │ ├── tests │ │ │ ├── exec_static.c │ ├── nolib.c │ ├── exec_asm.asm │ └── argv_nolib.c │ └── runtime │ ├── validate.inc ├── reloc.inc ├── interp.inc ├── stack.inc ├── rwdata.inc ├── rodata.inc └── trampoline.inc Download halfexec.tgz -= The Half-Loader Series: 1/3 This article is the first part of a series for tmp.0ut volume 5: 1. halfexec (you are here!) Rebuild enough of execve in userspace to load familiar ELF blobs. 2. halfshelf Restrict the input to one static PIE, then remove its head. 3. phork Shove the stripped image, its metadata and its loader into one file. The articles work on their own, but the intended reading order is as numbered above. Keep in mind that the codebase for the projects is quite dense as expected from ASM. -= Introduction ELF is one of those formats that quietly gets away with a lot. You run a file, Linux nods, something eventually lands in _start, and most of the interesting machinery disappears behind the curtain. Convenient, but also a little unsatisfying. What actually has to happen before control reaches user code? How much of that path can we reproduce from userspace? And how far can we go before the whole thing stops being a fun loader and starts turning into a (very) sad knockoff of execve(2)? Here's my answer to that. A small x64 Linux ELF loader written in FASM. (of course it is). It took me almost 4 years (!!!) to "finish" it, but during this time, I changed it a lot and used it to create new projects too. Here's an image I posted on Twitter when I started coding it, in late 2022: https://x.com/guitmz/status/1589057858985156609 https://x.com/guitmz/status/1589057858985156609 I named it halfexec because: - it is not the kernel - it is not a full dynamic linker - it is not the whole execution path I guess one can say I was lacking creativity at that point. The goal was to build something small enough to read, but enough to run mundane ELFs. The initial versions were simple and worked fine for super specific small executables but then a rabbit hole formed and, with each commit, I was adding more features, supporting more ELFs, getting mad deep diving into the the internals of this amazing file format. That means supporting cases like: - ET_EXEC - ET_DYN / PIE - static and dynamic libc binaries - tiny no-libc samples - PT_INTERP handoff for normal dynamically linked binaries and BusyBox (which was essentially my main target during the development of the loader) Oh and I really wanted something in Assembly that I could use for other projects. If you want to skip ahead and just run the thing:

$ make
$ HALFEXEC_DEBUG=1 ./halfexec /bin/true
make run builds and exercises the whole test matrix in one go, if you would rather see every supported shape pass at once. -= What The Loader Actually Does At a high level: 1. parse its own startup stack to find the target path 2. open the target and stage the whole file in memory 3. validate the ELF header 4. decide whether it is dealing with ET_EXEC or ET_DYN 5. map the main image 6. optionally load the interpreter from PT_INTERP 7. optionally apply a small relocation subset for direct entry ET_DYN 8. optionally build a minimal TLS runtime state 9. rebuild a fresh stack with argc, argv, envp and auxv 10. jump to the right entry point (I hope) At first glance, it sounds almost civilized, but of course, the details are where all the funny bugs live. -= A Tiny Map Of ELF Loading Before getting into halfexec's choices, it helps to separate an ELF file from the process image it describes. An ELF is not copied wholesale to one address and executed. The loader reads the ELF header, follows the program header table, and treats each program header as an instruction about how to construct the new address space. Sections such as .text, .data, .dynsym and .rela.dyn are mostly the linker's view of the file. At execution time, the important table is the program header table: entries such as PT_LOAD, PT_INTERP, PT_DYNAMIC and PT_TLS describe what the runtime really needs. ELF file process memory ┌──────────────────┐ ┌──────────────────────┐ │ ELF header │── e_phoff ──────>│ loader reads PHDRs │ ├──────────────────┤ ├──────────────────────┤ │ program headers │── PT_LOAD RX ───>│ code mapping │ │ │── PT_LOAD RW ───>│ data + zeroed BSS │ │ │── PT_INTERP ────>│ dynamic linker image │ ├──────────────────┤ ├──────────────────────┤ │ segment bytes │──── copy/map ───>│ mapped segment bytes │ └──────────────────┘ └──────────────────────┘ For every PT_LOAD, the loader combines two kinds of information: - p_offset and p_filesz: which bytes come from the file - p_vaddr and p_memsz: where they live and how much memory exists - p_flags: whether the final pages are readable, writable or executable When p_memsz is larger than p_filesz, the remaining bytes are filled with zeros. That is how uninitialized data such as .bss appears in memory even though those zeroes do not occupy space in the file. The last part is process startup state. Mapping code is not enough: the entry point expects a stack in a specific kernel layout. map segments │ v relocate / prepare TLS if needed │ v ┌──────────────── initial stack ────────────────┐ │ argc │ argv[] │ NULL │ envp[] │ NULL │ auxv │ └───────────────────────────────────────────────┘ │ entry = load_bias + e_entry │ v _start For a static direct entry image, control can go to the executable's entry after relocations and stack construction. If PT_INTERP is present, the named dynamic linker is mapped too. Control goes to its entry point while auxiliary vector entries such as AT_ENTRY, AT_PHDR and AT_PHNUM still describe the main executable. The dynamic linker then finishes symbol resolution before eventually transferring control to the program. That is the broad job normally hidden inside execve(2). halfexec does not reproduce every kernel and dynamic linker feature, but it follows this same skeleton closely enough. -= First Things First One design decision was made early and it shaped everything else: the loader maps the whole file into memory first. Could this be done in a more elegant or more scalable way? Yeah. But I wanted to avoid yet another complexity in this already dense code to avoid a staging buffer. It is, however, a totally valid improvement to be done at some point. Anyway, reading the full target into memory keeps a lot of things pleasantly simple: - header validation is straightforward - program header iteration is easy - PT_INTERP path extraction is trivial (I see this as "trivial" now, but definitely not back then) - segment population can be done by copying bytes from buffer to mapping - the ET_EXEC child path can duplicate the staged image into scratch memory The tradeoff: files must fit in the fixed buffer. For a small loader meant to be read by humans, that feels like a fair deal, I suppose. -= ET_DYN And ET_EXEC Are Not the Same Problem This is the most important split in the loader. It would be tempting to think of ET_EXEC and ET_DYN as mostly the same thing with a few small differences. In practice, that mindset just makes the code worse. --[ ET_DYN ET_DYN is the "nice" case. Since the image is relocatable, the loader can: - scan all PT_LOAD segments - compute the lowest and highest virtual address span - reserve one adjacent anonymous mapping - derive a load bias from that reservation - place each PT_LOAD at base + p_vaddr That is nice and cute. That is the kind of ELF shape that wants to cooperate. --[ ET_EXEC ET_EXEC is a different story. Its virtual addresses are fixed, that is fine when the kernel creates a fresh address space for the process but much less fine when a userspace loader is already running inside the same process and tries to map the target right on top of itself. To deal with that, we cheat (respectably). The loader prepares everything it can in the parent, then forks. The child copies a small trampoline plus the staged ELF into a scratch mapping and jumps there. From that scratch code, it is finally safe to MAP_FIXED the target segments at their real ET_EXEC addresses without smashing the running loader text it still depends on. That was one of the most satisfying parts of the whole project to get working. It is also the kind of thing that makes you appreciate why the kernel normally does this in a brand new process image and calls it a day. The fork/trampoline split, trimmed down to the interesting part:

forkTransformExec:
    mov rax, SYS_FORK
    syscall
    test rax, rax
    js forkFailed
    test rax, rax                 ; zero means we are now executing in the child process
    jz .child
    ; parent: wait4() the child and mirror its exit status, then we are done
    ...
    .child:
    ; Scratch mapping layout:
    ;   [EXEC_CTX][trampoline code][padding][staged ELF file image]
    ;
    ; The trampoline runs entirely from this high scratch area while it remaps
    ; the ET_EXEC PT_LOAD segments to their final fixed virtual addresses
    ...
The parent never touches MAP_FIXED at all. Only the child, running from scratch memory it does not depend on, is allowed to overwrite the addresses the original loader text still lives at. -= Where The Trouble Starts Most of the real work in any ELF loader is around PT_LOAD. Once a segment is identified as loadable, the loader has to: - round the segment start down to a page boundary - account for any in page offset between that page boundary and p_vaddr - round the total mapping length up to a full page size - mmap writable memory at the final destination - copy the file bytes - zero the memsz - filesz tail for .bss - translate ELF PF_* flags into Linux PROT_* - mprotect the segment to its final permissions The kind of thing a hobbyist computer virus writer like me enjoys and is familiar with. Nothing there is individually exotic, but there are many small ways to get it wrong (even more so when writing the damn thing in Assembly): - confusing file offsets with runtime addresses - forgetting that filesz can be smaller than memsz - forgetting page alignment - assuming ELF permission bits match Linux protection bits directly For the last item, they do not, by the way (why would they). PF_R/PF_W/PF_X and PROT_READ/PROT_WRITE/PROT_EXEC do NOT share the same bit positions. That is exactly the kind of stupid little detail that steals hours of your life if you are not paying attention. Concretely: PF_X = 0x1, PF_W = 0x2, PF_R = 0x4, while PROT_READ = 0x1, PROT_WRITE = 0x2, PROT_EXEC = 0x4. Read and execute sit on swapped bits between the two sets, write only lines up by coincidence. The loader's ELF_FLAGS_TO_PROT macro exists purely to paper over that mismatch, one test/or pair per bit:

macro ELF_FLAGS_TO_PROT flags_reg, out_reg
{
  local .no_read, .no_write, .done
    xor out_reg, out_reg
    test flags_reg, PF_R
    jz .no_read
    or out_reg, PROT_READ
  .no_read:
    test flags_reg, PF_W
    jz .no_write
    or out_reg, PROT_WRITE
  .no_write:
    test flags_reg, PF_X
    jz .done
    or out_reg, PROT_EXEC
  .done:
}
Nothing clever, just the one place in the whole loader that is allowed to know both bit layouts at once. -= PT_INTERP: Knowing When To Stop One thing I did not want to do here was to let this become a full dynamic linker. So when a target has PT_INTERP, the loader does not go down the path of just implementing the rest of ld-linux too. That way lies madness! Instead, it does something much more reasonable: 1. validate the interpreter path in the staged target image 2. open and stage the interpreter too 3. map the interpreter as another ET_DYN image 4. rebuild the stack 5. patch AT_BASE to the interpreter base 6. jump to the interpreter entry point The main executable is still the main executable. AT_ENTRY still describes its entry point. But final control transfers to the interpreter, because that is the component responsible for finishing the job in that case. "It ain't much but it's honest work." --- Farmer dude from the interwebz memes -= A Little Relocation Support Goes A Long Way For direct entry ET_DYN, the loader intentionally handles only a small subset of relocations: - R_X86_64_RELATIVE - R_X86_64_IRELATIVE I wanted enough relocation logic to support the real test matrix, but not so much that the project would turn into a relocation petting zoo. For a loader like this, the smaller subset brings more value. It also makes the two pass structure very clear: - first fix plain RELATIVE relocations - then run IRELATIVE resolvers If you reverse that order, bad things can happen. Resolver code likes running inside a world where the obvious pointers were already fixed first. -= Rebuilding The Stack By Hand Another thing I wanted to make explicit is process entry state. The loaded program should not keep pointers into the loader's original stack. That is sloppy and also a very good way to manufacture confusing bugs. So halfexec creates its own fresh stack mapping and rebuilds: - argc - argv[] - envp[] - auxv[] It also copies: - all argument strings - all environment strings - AT_RANDOM - AT_PLATFORM - AT_BASE_PLATFORM Then it patches the auxv entries that need to match the new reality: - AT_PHDR - AT_PHENT - AT_PHNUM - AT_BASE - AT_ENTRY - AT_EXECFN This was one of those areas where any mistake will have very large consequences, like: - forgetting to copy a payload string - leaving AT_EXECFN pointed at the old stack - misplacing the NULL terminators between argv, envp, and auxv - getting stack alignment wrong All of those bugs are deeply educational when they happen to somebody else and very much less so when they happen to you at 2AM (and often). -= TLS: Just Enough To Be Useful I also wanted PT_TLS binaries to work, at least for the supported matrix. I had to do a lot of research on this, it was kinda of an unexplored area of ELF to me until then. That means the loader has to build a minimal TLS layout itself. In this case it uses: - the TLS block - a TCB - a tiny DTV Then it points %fs at the synthetic TCB with arch_prctl. This is not a full TLS runtime implementation. It is just enough x64 TLS setup to make the supported cases work and to keep the mechanism visible in the code instead of waving it away. -= Testing Real Shapes Matters One thing I am happy about is that halfexec did not stop at a single toy binary. It now covers a small but useful matrix of recognizable shapes: - no-libc ET_EXEC in C - no-libc ET_DYN in C - no-libc ET_EXEC in Assembly - argv focused no-libc regression probes - libc static ET_EXEC - libc dynamic ET_EXEC - libc dynamic PIE ET_DYN - libc static PIE ET_DYN - PT_INTERP binaries Your mileage may vary. You'll likely encounter bugs if you run this code with random ELFs that need more than this loader supports. Every new shape stresses a different assumption (and stressed my nerves too). If you only test one "Hello World", almost everything looks fine. The moment you start mixing ET_EXEC, PIE, PT_INTERP, libc startup, TLS and argv/envp usage, the loader starts telling (usually very loudly) you where your shortcuts were. -= How Does It Look Like Running a ET_DYN binary with halfexec in debug mode looks like this:

$ uname -r
6.8.0-64-generic

$ file -b /bin/whoami | cut -d, -f1-4
ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked

$ readelf -h /bin/whoami
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              DYN (Position-Independent Executable file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x2890
  Start of program headers:          64 (bytes into file)
  Start of section headers:          41512 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         14
  Size of section headers:           64 (bytes)
  Number of section headers:         30
  Section header string table index: 29

$ HALFEXEC_DEBUG=1 ./halfexec /bin/whoami
halfexec v0.0.1 by TMZ (c) 2026
halfexec: debug tracing enabled
halfexec: target type ET_DYN
halfexec: reserving ET_DYN load window
halfexec: load bias = 0x000077279c35e000
halfexec: synthetic stack base = 0x000077279ba00000
halfexec: mapping PT_LOAD at 0x000077279c35e000
halfexec: mapping PT_LOAD at 0x000077279c360000
halfexec: mapping PT_LOAD at 0x000077279c365000
halfexec: mapping PT_LOAD at 0x000077279c367000
halfexec: loading PT_INTERP image
halfexec: interpreter base = 0x000077279c326000
halfexec: mapping interpreter PT_LOAD at 0x000077279c326000
halfexec: mapping interpreter PT_LOAD at 0x000077279c327000
halfexec: mapping interpreter PT_LOAD at 0x000077279c34f000
halfexec: mapping interpreter PT_LOAD at 0x000077279c35a000
halfexec: rebuilding argc/argv/envp/auxv
halfexec: jumping to interpreter entry = 0x000077279c3432c0
root
Implementing the debug mode in ASM was tedious work, very tedious. It takes a lot in low level just to print some information to the screen. But I enjoyed working with the code way more after having it done. Beats endless gdb sessions and obscure code comments anytime. -= How The Code Is Organized To keep things readable, the loader was split by phase: - halfexec.asm keeps the main control flow - runtime/validate.inc handles ELF validation and early dispatch - runtime/interp.inc handles interpreter loading - runtime/reloc.inc handles the direct-entry relocation subset - runtime/stack.inc handles TLS and stack rebuilding (I am still a bit traumatized by this one, not gonna lie) - runtime/trampoline.inc handles the ET_EXEC child transform path - runtime/rwdata.inc keeps mutable runtime state - runtime/rodata.inc keeps messages and other read only loader data - utils.inc keeps constants, structs and macros That split helped a lot. Assembly gets unreadable very quickly when everything lives in one giant file and every label tries to do two things at the same time. There is still a lot going on, but at least now the project reads more like a guided tour, as much as it can be I guess, and less like a wall of opcodes. I also tried to add comments to most sections where I thought they would help (if you read some of my ASM code before you know what to expect). Without them, it was hard to remember why that "mov rax" was added there 3 months ago, things like that. -= Could halfexec Be A Library? Yes. The phase split above is already most of the conceptual work needed. The reusable surface could expose validation, address reservation, segment mapping, relocation, TLS and stack construction as separate entry points over an explicit loader structure. A caller could select the phases it needs and provide the policy: accepted ELF types, number of segments, interpreter support and final handoff. The current project is not packaged that way because it's not a stable library ABI. Its includes share one global state layout and assume labels from the main loader. Turning that into a real library would mean defining ownership, inputs, clobbers, errors and feature boundaries instead of merely giving the same labels a nicer directory name. That refactor is possible and I want to do it. The next articles will reuse the small phases that fit in order to be self contained. -= Why This Loader Exists In This Shape There are plenty of ways to make a loader like this more... ambitious. - It could support more relocation types - It could try to validate more of the world - It could grow into something closer to a full runtime environment - It could be part of some nefarious business? All of that would be interesting, to me particularly the last one the most. -= Final Thoughts This is not meant to be the last word on ELF loading. It is not hardened. It does not aim to support every relocation. It is not a new dynamic linker. It is not trying to compete with the kernel. What it is meant to do is much simpler: - show how real ELF loading works - make ABI details visible - support common executable shapes people recognize - stay readable enough that future me, and hopefully other people, can still learn from it - use 100% Assembly (duh!) Sometimes the best systems projects are the ones that make familiar things look slightly suspicious again for a while. -= References [1] System V ABI: ELF Object File Format [2] System V AMD64 ABI [3] elf(5) [4] execve(2) [5] mmap(2) [6] getauxval(3) [7] ld.so(8) [8] Linux fs/binfmt_elf.c --[ PREV | HOME | NEXT ]--