┌───────────────────────┐ ▄▄▄▄▄ ▄▄▄▄▄ ▄▄▄▄▄ │ │ █ █ █ █ █ █ │ │ █ █ █ █ █▀▀▀▀ │ │ █ █ █ █ ▄ │ │ ▄▄▄▄▄ │ │ █ █ │ │ █ █ │ │ █▄▄▄█ │ │ ▄ ▄ │ │ █ █ │ │ █ █ │ │ █▄▄▄█ │ │ ▄▄▄▄▄ │ │ █ │ halfshelf: Loading ELF After The Header Is Gone │ █ │ ~ TMZ └───────────────────█ ──┘ halfshelf │ ├── halfshelf.asm ├── headshelf.asm ├── utils.inc ├── Makefile │ ├── loader │ │ │ ├── validate.inc │ ├── reloc.inc │ ├── stack.inc │ ├── rwdata.inc │ └── rodata.inc │ ├── tests │ │ │ ├── test.c │ ├── test_argv_tls.c │ └── static-pie.ld │ └── strip_headers │ └── strip_headers.asm Download halfshelf.tgz -= The Half-Loader Series: 2/3 This article is the second part of a three part series for tmp.0ut volume 5: 1. halfexec Broad userspace ELF loading and the process state normally built by Linux. 2. halfshelf (you are here) One direct entry static PIE, with normal and headless input paths. 3. phork The headless representation packaged behind its own loader stub. The series gets narrower as it goes. This part is the hinge: it keeps the ELF runtime obligations from part one, but makes the accepted image shape small enough that part three can package it cleanly. -= Introduction An ELF loader is even more appreciated when it is allowed to be a bit crazy. Do not accept every architecture! Do not negotiate with an interpreter! Do not map six subtly different load segments! Pick one executable form, write down its scope and reject everything outside it. That is halfshelf (yay). It is a Linux x64 userspace loader for a narrow SHELF shaped static PIE: ET_DYN, exactly one PT_LOAD, no PT_INTERP and a direct jump to the payload entry point. There are two front ends for the same runtime problem. - halfshelf reads an ordinary ELF from a path - headshelf reads a stripped load image plus detached metadata The first asks how little loader is needed for a deliberately friendly ELF. The second asks a stranger question: after the ELF and Program Header prefixes are removed, which facts must survive for the program to wake up normally. There are load biases, dynamic relocations, TLS, a fresh initial stack and an auxiliary vector full of promises normally made by the kernel. -= Where SHELF Comes From The starting point is Introducing SHELF Loading by @ulexec and @Anonymous_. An alternate copy lives on ulexec's site. SHELF combines two properties that are usually discussed separately: - static linking, so there is no runtime dependency on ld-linux or a set of shared objects - position independent code, so the complete image can live at a base chosen at runtime The result is a static position independent executable: an ET_DYN file that contains its own runtime but can still be placed under ASLR. Static does not mean structurally simple. A glibc static PIE can still contain PT_DYNAMIC, RELA tables, indirect function resolvers and PT_TLS. glibc itself performs early static PIE relocation before normal startup. The interesting opportunity is that these records describe work a small loader can perform directly. SHELF then makes the load geometry boring by linking the image into one PT_LOAD. If that segment begins at file offset zero and virtual address zero, every payload address follows one equation:

    runtime_address = load_bias + image_virtual_address
There is no per segment translation puzzle as the chosen mapping base is the load bias. Calling this raw shellcode would undersell it. It is still a real linked C program with libc startup, relocations, TLS and process entry expectations. It has simply been shaped to travel as one position independent image. -= Why A More Restricted Loader Than halfexec halfexec handles ET_EXEC, normal PIE, interpreted executables, multiple PT_LOAD ranges and the awkward handoff to a dynamic linker. halfshelf treats the bigger loader as its source and trims choices: halfexec halfshelf ----------------------------- ----------------------------- ET_EXEC and ET_DYN ET_DYN only one or many PT_LOAD segments exactly one PT_LOAD optional PT_INTERP PT_INTERP rejected direct or interpreter entry direct entry only general ELF ish mapping p_offset = 0, p_vaddr = 0 The reusable runtime phases remain shared, but their source files are vendored into this release. As mentioned in the part one of this series, halfexec could become a library at some point. -= The Payload The normal loader checks that the input is: - ELF64, little-endian and x64 - current ELF version with the native 64 byte ELF header - ET_DYN - described by native 56 byte Elf64_Phdr records - exactly one non empty PT_LOAD - loaded from p_offset == 0 at p_vaddr == 0 - free of PT_INTERP It also records optional PT_DYNAMIC, PT_TLS and PT_GNU_STACK information. The one load rule comes from the linker script. GNU ld's PHDRS command lets a script describe the output Program Header Table explicitly. The test script creates one load segment and assigns all allocatable sections to it:

    PHDRS
    {
      shelf   PT_LOAD    FILEHDR PHDRS FLAGS(7);
      dynamic PT_DYNAMIC FLAGS(6);
      tls     PT_TLS     FLAGS(4);
    }

    SECTIONS
    {
      PROVIDE(__ehdr_start = 0);
      . = SIZEOF_HEADERS;
      ...
      .text    : { *(.text*) }    :shelf
      .rodata  : { *(.rodata*) }  :shelf
      .dynamic : { *(.dynamic) }  :shelf :dynamic
      .data    : { *(.data*) }    :shelf
      .bss     : { *(.bss*) }     :shelf
      .tdata   : { *(.tdata*) }   :shelf :tls
    }
FILEHDR PHDRS places the ELF and Program Header bytes inside the loadable segment. That is important twice: the normal loader can produce a valid AT_PHDR, and the headless loader knows exactly which prefix it must grow back later. The obvious cost is permissions. Code and mutable data share one segment, so the sample linker emits one RWX PT_LOAD. This is a teaching trade, not a W^X hardening technique. The linker warning is correct and the article should not pretend otherwise. -= Front End One: A Normal ELF From Disk halfshelf parses its own initial stack, makes the target pathname become the future payload's argv[0], opens the file, uses fstat to bound it, reads the ELF header and stages the file in a fixed buffer. Validation pre scans the Program Header Table before committing to a mapping. It rejects an interpreter and a second load segment, records the dynamic and TLS ranges, rounds the one memory span to pages and reserves an anonymous window with mmap. Later, the segment mapping replaces that range with MAP_FIXED. Keeping the reservation avoids choosing an address, unmapping it and hoping no intervening allocation steals it. For the single load segment the loader: 1. checks that p_filesz <= p_memsz 2. checks that the file range stays inside the staged input 3. maps the rounded range read write 4. copies p_filesz bytes from the staged file 5. zeroes the tail through p_memsz 6. applies the Program Header permissions with mprotect Anonymous mappings arrive zero filled, but the explicit model is still useful: file bytes end at p_filesz and BSS like memory ends at p_memsz. There is no execve after this point. The loader is building a new runtime image inside its own process, then replacing its stack pointer and branching to the new entry. -= Front End Two: Taking The Head Off headshelf starts from the same linked image but stores it differently. The stripping tool computes: phdr_bytes = e_phnum * e_phentsize header_span = e_phoff + phdr_bytes payload_len = PT_LOAD.p_filesz - header_span It preserves bytes from header_span through p_filesz as the stripped payload. Section headers, debug sections and other bytes beyond the load segment are not runtime payload and are not copied. The result looks like this:

    original ELF

    0                                                p_filesz
    ┌──────────┬───────────────┬───────────────────────────┐
    │ Elf64_Ehdr│ Elf64_Phdr[] │ bytes needed by PT_LOAD   │
    └──────────┴───────────────┴───────────────────────────┘
    └────── header_span ───────┘

    detached representation

    ┌──────────────────────┐    ┌───────────────────────────┐
    │ metadata + phdr bytes│    │ stripped payload bytes    │
    └──────────────────────┘    └───────────────────────────┘
Nothing inside the logical image is rebased by stripping. A byte originally at image offset header_span is copied back to base + header_span. Linked virtual addresses remain valid after the load bias is added. This is the important distinction between deleting bytes and moving bytes. The on disk representation is shorter and the reconstructed in-memory layout is not. -= Two Metadata Encodings The metadata sidecar has two encodings with the same meaning. SHELFMETA1 is text. It is verbose, friendly to od, sed and human eyes, and useful while debugging a new field. A real one, generated by strip_headers off the included test.c sample, looks like this:

SHELFMETA1
entry 0000000000006cc0
phoff 0000000000000040
phnum 0000000000000003
phentsize 0000000000000038
header_span 00000000000000e8
load_offset 0000000000000000
load_vaddr 0000000000000000
load_filesz 00000000000afab8
load_memsz 00000000000afab8
load_flags 0000000000000007
dynamic_vaddr 00000000000a44b8
dynamic_memsz 00000000000001a0
tls_vaddr 00000000000afa88
tls_filesz 0000000000000030
tls_memsz 0000000000000058
tls_align 0000000000000008
phdr_hex 010000000700000000000000...
One key/value pair per line, fixed width hex values, no parser juggling required to eyeball whether a field looks right. SHELFBN1 is a fixed width binary record. It begins with eight magic bytes, followed by seventeen little-endian 64 bit values: 0 magic "SHELFBN1" 8 entry original e_entry 16 phoff original e_phoff 24 phnum original e_phnum 32 phentsize original e_phentsize 40 header_span end of removed prefix 48 load_offset required to be zero 56 load_vaddr required to be zero 64 load_filesz original PT_LOAD.p_filesz 72 load_memsz original PT_LOAD.p_memsz 80 load_flags mmap R/W/X bits 88 dynamic_vaddr optional PT_DYNAMIC address 96 dynamic_memsz optional PT_DYNAMIC size 104 tls_vaddr optional PT_TLS address 112 tls_filesz initialized TLS byte count 120 tls_memsz complete TLS byte count 128 tls_align requested TLS alignment 136 phdr_blob_size bytes of saved Program Headers 144 phdr_blob original Program Header bytes The fixed portion is 144 bytes. The current test image has three 56 byte Program Headers, so its binary metadata is 144 + 168 = 312 bytes. The loader autodetects the leading magic. It then verifies the zero-based load geometry, bounds the Program Header blob to its 4096 byte scratch area and requires:

    phdr_blob_size = phnum * phentsize
The text format optimizes for inspection and the binary format optimizes for an unambiguous compact record. Keeping both is useful because serialization is part of the loader design here. -= Growing The ELF Header Back After metadata and payload validation, headshelf allocates one anonymous read write mapping rounded from load_memsz. It writes a synthetic Elf64_Ehdr at the mapping base. Stable identity fields such as ELF64, little-endian, x64 and ET_DYN are supplied by the loader. Entry point and Program Header geometry come from the sidecar. The raw saved Program Header bytes go to base + phoff. The stripped payload goes to base + header_span. base + 0 synthetic Elf64_Ehdr base + phoff restored Elf64_Phdr array base + header_span first stripped payload byte base + load_filesz end of file initialized image base + load_memsz end of zero filled image Then mprotect applies the original load flags, and phdrRuntime = base + phoff becomes the future AT_PHDR value. Why rebuild the headers if the executable code was already preserved? Because the bytes are not the whole ABI. Static libc startup inspects the auxiliary vector, uses the Program Header Table to discover structures such as PT_TLS, and may refer to __ehdr_start. SHELF's lesson is not that headers never matter, it is that the facts carried by headers can be transported separately and reconstructed where the runtime expects them. Two front ends, one shared runtime, but not perfectly interchangeable. The sidecar formats have no field for PT_GNU_STACK permissions, so headshelf's synthetic stack is always mapped read write. halfshelf, reading the original header, derives that same protection from the real PT_GNU_STACK entry instead, when there is one. A typical entry looks like this (any ordinary ELF, not one of the SHELF samples here, since the included static-pie.ld script only declares the shelf, dynamic and tls segments and never emits a PT_GNU_STACK of its own):

  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000000000000  RW     0x10
So with the shipped test binaries specifically, both front ends land on the same default RW stack anyway, the asymmetry is real in the code but currently unobservable in this repo's own test matrix. A SHELF image linked with an explicit executable stack marker would be the one to actually see halfshelf and headshelf disagree. And because headshelf shares halfshelf's message table wholesale, its version banner and every debug line still print halfshelf:, only the usage string was actually renamed. Cosmetic, but worth knowing before it confuses a debug session. -= Relocations Before The Jump Position independent code still contains pointers whose final values depend on the chosen load bias. If the image has PT_DYNAMIC, the shared relocation code walks Elf64_Dyn records and collects: - DT_RELA, DT_RELASZ and DT_RELAENT - DT_JMPREL, DT_PLTRELSZ and DT_PLTREL The supported x64 relocation set is small by design. For R_X86_64_RELATIVE:

    *(base + r_offset) = base + r_addend
For R_X86_64_IRELATIVE:

    resolver = base + r_addend
    *(base + r_offset) = resolver()
The loader makes two passes. Plain RELATIVE entries are written first. IRELATIVE resolvers run second, after the ordinary image pointers they may use have reached their final values. The same order is applied to both the main RELA table and JMPREL. An unknown relocation is rejected instead of being skipped. That is an important failure policy. Silently ignoring a relocation creates a program that may run until a much less obvious pointer is touched. This is not symbol resolution and it is not a dynamic linker. The slim static PIE schema makes those two relative forms sufficient for the included glibc samples. -= TLS, Because Static Does Not Mean Simple The test program declares:

    __thread int tls_counter = 17;
That small line forces the loader to provide a valid x64 thread pointer. Notably, the plain hello world sample from the Running section above declares no __thread variable at all, yet its debug transcript still prints the preparing PT_TLS runtime state line. A static PIE carries its own PT_TLS segment as an artifact of glibc's internal startup state, whether or not the linked program ever asks for one. The loader does not special case "no user visible TLS" and it reacts to whatever PT_TLS the linker actually emitted. When PT_TLS is present, the shared stack phase normalizes its alignment to at least 16 bytes and creates a minimal initial thread layout: ┌────────────────┬────────────────┬────────────────┐ │ TLS image │ TCB │ tiny DTV │ │ init + zeroes │ thread pointer │ one module │ └────────────────┴────────────────┴────────────────┘ It copies p_filesz initializer bytes from base + tls_vaddr, leaves the rest through p_memsz zeroed, seeds a small Dynamic Thread Vector and calls: arch_prctl(ARCH_SET_FS, tcb_address) On x64 Linux, FS is the thread pointer base used by the usual TLS access sequences. Without this step, the binary may complete relocation and still die the first time libc or the sample touches thread local state. This is an initial thread model for controlled samples. It is not a complete TLS implementation for arbitrary libc versions, dynamically loaded modules or new threads. -= Rebuilding Process Entry A direct jump to e_entry needs a kernel stack. The loader allocates a fresh stack mapping and rebuilds the System V process entry layout: rsp -> argc argv[0] ... NULL envp[0] ... NULL auxv type, value ... AT_NULL, 0 Pointers cannot simply keep referring into the loader's old stack. Argument and environment strings are copied into target storage. So are the 16 random bytes referenced by AT_RANDOM and strings behind AT_PLATFORM and AT_BASE_PLATFORM. Most auxiliary vector records survive. Image records are patched: - AT_PHDR points at the real or reconstructed Program Header Table - AT_PHENT and AT_PHNUM describe that table - AT_ENTRY becomes base + e_entry - AT_BASE becomes zero because there is no ELF interpreter - AT_EXECFN points to the target visible copied pathname The stack pointer is aligned, the target file descriptor is closed and control transfers to:

    entry = base + e_entry
There is no return address. Entry is not a function call. The payload sees the same broad stuff it would see after a kernel ELF launch, even though another userspace executable assembled the scene. -= The memfd Mode The headless loader also accepts:

    cat payload | ./headshelf --memfd payload.binmeta alpha beta
It creates an anonymous file with memfd_create, drains the stripped payload from standard input into the normal staging buffer and mirrors each chunk into that memory descriptor. The descriptor is useful for studying the anonymous file transport pattern, but it is not where execution comes from in this implementation. Mapping is still anonymous and populated from the staging buffer. There is no fexecve, no execveat and no executable temporary file. That distinction is worth stating because it can imply several different designs: - store a normal ELF in a memfd and ask the kernel to execute it - mmap pages directly from a memfd - use a memfd as an anonymous transport while a userspace loader maps bytes headshelf --memfd is the third one. -=Running Running a included sample binary in debug mode looks like this:

$ HALFSHELF_DEBUG=1 make run
halfshelf: SHELF focused userland exec loader v0.0.1 by TMZ (c) 2026
halfshelf: debug tracing enabled
halfshelf: target type ET_DYN
halfshelf: reserving SHELF load window
halfshelf: load bias = 0x0000741c23d02000
halfshelf: synthetic stack base = 0x0000741c23400000
halfshelf: mapping SHELF PT_LOAD at 0x0000741c23d02000
halfshelf: applying direct entry relocations
halfshelf: preparing PT_TLS runtime state
halfshelf: rebuilding argc/argv/envp/auxv
halfshelf: jumping to SHELF entry = 0x0000741c23d08cc0
hello
-= From halfshelf To phork headshelf leaves two artifacts next to each other: metadata and stripped payload. That separation makes the reconstruction boundary wonderfully easy to inspect, but it is not a single self loading file. phork is the next step. It places a loader stub at the front, appends the same SHELFBN1 facts and stripped bytes, and adds a trailing footer so the stub can find both pieces inside itself. That is why halfshelf is useful even if the packer is the flashier final artifact. It isolates the hard runtime before adding a container format and self discovery logic. The next article is phork: Packing SHELF Back Into One ELF. It keeps this runtime and changes the distribution shape from two detached inputs into one executable file. -= Final Thoughts An executable is not its header and it's not merely its code bytes either. There's so much more to it, a lot of other fascinating facts. Pure admiration! halfshelf makes those facts visible by implementing the same launch two ways, both converging on relocation, TLS, stack reconstruction and a direct branch to entry. -= References [1] Introducing SHELF Loading, tmp.0ut volume 1 [2] SHELF Loading by @ulexec [3] The Design and Implementation of Userland Exec [4] System V ABI: Program Loading and Program Headers [5] System V AMD64 ABI [6] GNU ld: PHDRS Command [7] glibc: static PIE relocation source [8] glibc: process and static PIE startup source [9] mmap(2), Linux manual page [10] mprotect(2), Linux manual page [11] arch_prctl(2), Linux manual page [12] memfd_create(2), Linux manual page [13] getauxval(3) and auxiliary-vector entries --[ PREV | HOME | NEXT ]--