┌───────────────────────┐
▄▄▄▄▄ ▄▄▄▄▄ ▄▄▄▄▄ │
│ █ █ █ █ █ █ │
│ █ █ █ █ █▀▀▀▀ │
│ █ █ █ █ ▄ │
│ ▄▄▄▄▄ │
│ █ █ │
│ █ █ │
│ █▄▄▄█ │
│ ▄ ▄ │
│ █ █ │
│ █ █ │
│ █▄▄▄█ │
│ ▄▄▄▄▄ │
│ █ │
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 ]--