initmap: what does this binary run *before* main?
initmap: what does this binary run *before* main?
A 230-line tool that reads an ELF's constructor and destructor tables the way the loader does — off the disk, through the relocations — so the constructors and destructors that run before (and after) main() can't hide from you.
You set a breakpoint on main, start stepping, and you've already lost. On Linux, a pile of code runs before main gets control: ELF constructors. Anything marked __attribute__((constructor)), every C++ static initializer, the legacy _init routine. Malware loves this. Put the beacon in a constructor, leave main looking like a hello-world, and every analyst who starts at main walks right past the payload.
The tools you'd reach for don't quite close this. readelf -x .init_array dumps hex you decode by hand — reverse the endianness, look up each address yourself. objdump -d shows you the code but won't tell you these three functions are constructors and here's their run order. And both fall apart on a PIE, where the number sitting in the .init_array slot isn't a runtime address at all — it's a link-time offset that the loader relocates. With the gold linker it's worse: the slot is 0x0000000000000000 and the real address lives only in a relocation addend. readelf -x shows you a row of zeros and shrugs.
So I built initmap. It answers one question — what runs before (and after) main, in what order? — and it answers it from the disk image, the way ld.so does.
All samples below were built in a Kali sandbox with gcc 15.2.0; SHA-256s are inline so every disassembly block ties back to a specific file.
The lab target: a stager that hides in a constructor
Here's stager.c (sha256: 609ed403c6081003f83c2c1e7ca6fa3b2790e9763e178d1f91e12774c099bc2b). The interesting work is in constructors with explicit priorities; main is a decoy.
__attribute__((constructor(101)))
static void beacon(void) {
char key[] = {0x6d,0x61,0x6c,0x77,0x61,0x72,0x65,0};
fprintf(stderr, "[ctor] beacon fired, key=%s\n", key);
}
__attribute__((constructor(200)))
static void unpack(void) { fprintf(stderr, "[ctor] unpack stage 2\n"); }
__attribute__((destructor))
static void wipe(void) { fprintf(stderr, "[dtor] wiping traces\n"); }
int main(int argc, char **argv) {
printf("hello from main\n"); /* the innocent-looking part */
return 0;
}
Run it and the constructors fire before main ever prints — lower priority number first:
$ ./stager_nopie 2>&1 | cat
[ctor] beacon fired, key=malware
[ctor] unpack stage 2
[dtor] wiping traces
hello from main
Note the pipe: with stdout redirected it's fully buffered, so hello from main doesn't flush until exit — after the destructor's stderr line. On a bare TTY stdout is line-buffered and hello from main prints first, then wipe fires at exit. The destructor still runs last; the transcript order here is a buffering artifact of the redirect.
The gap, made concrete
Non-PIE binary — readelf -x at least gives you addresses, byte-swapped and unlabeled:
$ readelf -x .init_array stager_nopie
0x00403de0 46114000 00000000 81114000 00000000 F.@.......@.....
0x00403df0 40114000 00000000 @.@.....
That's 0x401146, 0x401181, 0x401140 — which are those? You go look them up. Now the PIE (sha256: f5a65404…):
$ readelf -x .init_array stager_pie
0x00003db8 59110000 00000000 94110000 00000000 Y...............
0x00003dc8 50110000 00000000 P.......
0x1159 is not where anything lives at runtime — it's a link-time offset. The truth is over in the relocations:
$ readelf -r stager_pie | grep RELATIVE | head -3
000000003db8 000000000008 R_X86_64_RELATIVE 1159
000000003dc0 000000000008 R_X86_64_RELATIVE 1194
000000003dc8 000000000008 R_X86_64_RELATIVE 1150
Two tools, hand-correlation, and you still don't have names or a run order. That's the job initmap does in one pass.
The tool
The core idea: model the file the way the loader sees it — a set of PT_LOAD segments and a PT_DYNAMIC table — and never trust the section headers, because a stripped or hostile binary won't have honest ones. Full source (initmap.py):
#!/usr/bin/env python3
"""
initmap - show what an ELF runs before (and after) main().
Constructors run before main: .preinit_array, DT_INIT (_init), .init_array.
Destructors run at exit: .fini_array (reverse), DT_FINI (_fini).
Malware hides its real work here so anyone who only reads main() misses it.
"""
import argparse
from elftools.elf.elffile import ELFFile
from elftools.elf.sections import SymbolTableSection
from elftools.elf.relocation import RelocationSection
from elftools.elf.dynamic import DynamicSegment
from elftools.elf.descriptions import describe_reloc_type
import capstone
# R_<arch>_RELATIVE type numbers, keyed by e_machine.
RELATIVE_TYPE = {"EM_X86_64": 8, "EM_386": 8, "EM_AARCH64": 1027, "EM_ARM": 23}
class Image:
"""A binary loaded the way the loader sees it: by PT_LOAD, not sections."""
def __init__(self, path):
self.path = path
self.raw = open(path, "rb").read()
self.elf = ELFFile(open(path, "rb"))
self.wsize = self.elf.elfclass // 8
self.endian = "little" if self.elf.little_endian else "big"
self.is_pie = self.elf["e_type"] == "ET_DYN"
self.loads = self._loads()
self.symbols = self._symbols() # exact addr -> name
self.sym_index = sorted(self.symbols) # for nearest-symbol lookup
self.dyn = self._dynamic() # tag -> value (built first)
self.relatives = self._relatives() # reloc offset -> addend
self.cs = self._capstone()
# --- memory model -----------------------------------------------------
def _loads(self):
out = []
for seg in self.elf.iter_segments():
if seg["p_type"] == "PT_LOAD":
out.append((seg["p_vaddr"], seg["p_filesz"], seg["p_offset"]))
return out
def v2off(self, vaddr):
for base, filesz, off in self.loads:
if base <= vaddr < base + filesz:
return off + (vaddr - base)
return None
def read(self, vaddr, n):
off = self.v2off(vaddr)
if off is None:
return None
return self.raw[off:off + n]
def word(self, vaddr):
b = self.read(vaddr, self.wsize)
if b is None or len(b) < self.wsize:
return None
return int.from_bytes(b, self.endian)
# --- symbols ----------------------------------------------------------
def _symbols(self):
table = {}
for sec in self.elf.iter_sections():
if not isinstance(sec, SymbolTableSection):
continue
for sym in sec.iter_symbols():
addr = sym["st_value"]
if addr and sym.name and sym["st_info"]["type"] in (
"STT_FUNC", "STT_NOTYPE", "STT_GNU_IFUNC"
):
table.setdefault(addr, sym.name)
return table
def resolve(self, addr):
if addr in self.symbols:
return self.symbols[addr]
# nearest symbol at or below addr
lo, hi, best = 0, len(self.sym_index), None
while lo < hi:
mid = (lo + hi) // 2
if self.sym_index[mid] <= addr:
best = self.sym_index[mid]
lo = mid + 1
else:
hi = mid
if best is not None:
return "%s+0x%x" % (self.symbols[best], addr - best)
return "?"
# --- dynamic tags -----------------------------------------------------
def _dynamic(self):
dyn = {}
for seg in self.elf.iter_segments():
if isinstance(seg, DynamicSegment):
for tag in seg.iter_tags():
dyn.setdefault(tag.entry.d_tag, tag.entry.d_val)
return dyn
# --- relocations ------------------------------------------------------
def _relatives(self):
rel = {}
# (1) via section headers when present (gives rich, named types)
for sec in self.elf.iter_sections():
if not isinstance(sec, RelocationSection):
continue
for r in sec.iter_relocations():
if r.is_RELA() and describe_reloc_type(
r["r_info_type"], self.elf).endswith("_RELATIVE"):
rel[r["r_offset"]] = r["r_addend"]
# (2) via PT_DYNAMIC DT_RELA region - survives stripped section headers
rel.update(self._dyn_relatives())
return rel
def _dyn_relatives(self):
out = {}
base, size = self.dyn.get("DT_RELA"), self.dyn.get("DT_RELASZ")
ent = self.dyn.get("DT_RELAENT") or (self.wsize * 3)
if base is None or not size:
return out
want = RELATIVE_TYPE.get(self.elf["e_machine"])
type_mask = 0xff if self.wsize == 4 else 0xffffffff
data = self.read(base, size) or b""
w = self.wsize
for o in range(0, len(data) - ent + 1, ent):
r_off = int.from_bytes(data[o:o + w], self.endian)
r_info = int.from_bytes(data[o + w:o + 2 * w], self.endian)
r_add = int.from_bytes(data[o + 2 * w:o + 3 * w], self.endian)
if want is not None and (r_info & type_mask) == want:
out[r_off] = r_add
return out
# --- disassembly ------------------------------------------------------
def _capstone(self):
arch = self.elf.get_machine_arch()
if arch == "x64":
md = capstone.Cs(capstone.CS_ARCH_X86, capstone.CS_MODE_64)
elif arch == "x86":
md = capstone.Cs(capstone.CS_ARCH_X86, capstone.CS_MODE_32)
elif arch == "AArch64":
md = capstone.Cs(capstone.CS_ARCH_ARM64, capstone.CS_MODE_ARM)
elif arch == "ARM":
md = capstone.Cs(capstone.CS_ARCH_ARM, capstone.CS_MODE_ARM)
else:
return None
md.skipdata = True
return md
def disasm(self, addr, count):
if self.cs is None or addr is None:
return []
code = self.read(addr, 16 * count) or b""
out = []
for insn in self.cs.disasm(code, addr):
out.append((insn.address, insn.mnemonic, insn.op_str))
if len(out) >= count:
break
return out
def resolve_slot(img, slot_vaddr, raw):
"""Return (target_vaddr, provenance) for one array slot."""
if not img.is_pie:
return raw, "slot"
# PIE: the runtime address is base+X. We report link-time (base 0) form.
reloc = img.relatives.get(slot_vaddr)
if raw == 0 and reloc is not None:
return reloc, "reloc-addend" # gold/zero-slot layout
if reloc is not None and reloc != raw:
return reloc, "reloc(!=slot 0x%x)" % raw
if reloc is not None:
return raw, "slot=reloc" # cross-checked, modern GNU ld
if raw != 0:
return raw, "slot(unverified)"
return None, "unresolved"
# ordered (dynamic base tag, size tag, label, single?, reverse?) run sequence
CTOR_SEQ = [
("DT_PREINIT_ARRAY", "DT_PREINIT_ARRAYSZ", "preinit_array", False, False),
("DT_INIT", None, "init (_init)", True, False),
("DT_INIT_ARRAY", "DT_INIT_ARRAYSZ", "init_array", False, False),
]
DTOR_SEQ = [
("DT_FINI_ARRAY", "DT_FINI_ARRAYSZ", "fini_array", False, True),
("DT_FINI", None, "fini (_fini)", True, False),
]
# section-header fallback when there is no PT_DYNAMIC (static/odd binaries)
SEC_FALLBACK = {
"DT_PREINIT_ARRAY": ".preinit_array",
"DT_INIT_ARRAY": ".init_array",
"DT_FINI_ARRAY": ".fini_array",
"DT_INIT": ".init",
"DT_FINI": ".fini",
}
def array_slots(img, base_tag, size_tag, single):
"""Yield (slot_vaddr, raw_word) for an array, from dynamic or sections."""
base = img.dyn.get(base_tag)
size = img.dyn.get(size_tag) if size_tag else None
if base is None:
sec = img.elf.get_section_by_name(SEC_FALLBACK.get(base_tag, ""))
if sec is None or sec["sh_type"] == "SHT_NOBITS":
return
base = sec["sh_addr"]
size = None if single else sec["sh_size"]
if single:
yield base, base # DT_INIT/DT_FINI: value *is* the func vaddr
return
n = (size or 0) // img.wsize
for i in range(n):
va = base + i * img.wsize
yield va, img.word(va)
def emit(img, seq, ninsn):
printed = False
for base_tag, size_tag, label, single, reverse in seq:
slots = list(array_slots(img, base_tag, size_tag, single))
if reverse:
slots = list(reversed(slots))
for idx, (slot_va, raw) in enumerate(slots):
printed = True
if single:
target, prov = raw, "dynamic"
tag = label
else:
target, prov = resolve_slot(img, slot_va, raw)
tag = "%s[%d]" % (label, idx)
name = img.resolve(target) if target is not None else "?"
order = " (reverse)" if reverse else ""
print(" %-16s slot@0x%-8x -> 0x%-8x %-28s [%s]%s" % (
tag, slot_va, target or 0, name, prov, order))
for a, m, o in img.disasm(target, ninsn):
print(" 0x%08x %-8s %s" % (a, m, o))
if not printed:
print(" (none)")
def main():
ap = argparse.ArgumentParser(description="show what an ELF runs before main()")
ap.add_argument("binary")
ap.add_argument("-n", "--insns", type=int, default=3,
help="instructions to disassemble per entry (default 3)")
args = ap.parse_args()
img = Image(args.binary)
kind = "PIE/ET_DYN" if img.is_pie else "ET_EXEC"
print("== initmap: %s ==" % args.binary)
print("type=%s class=%d-bit arch=%s relatives=%d" % (
kind, img.elf.elfclass, img.elf.get_machine_arch(), len(img.relatives)))
print()
print("CONSTRUCTORS (run in this order, before main):")
emit(img, CTOR_SEQ, args.insns)
print()
print("DESTRUCTORS (run at exit; fini_array is reversed):")
emit(img, DTOR_SEQ, args.insns)
if __name__ == "__main__":
main()
A few design calls worth naming:
v2offmaps virtual addresses to file offsets byPT_LOAD, not by section. Every read — array slots, disassembly bytes — goes through it. This is what lets initmap keep working when the section header table is gone, and it's the same mathld.sodoes.- Run order is encoded as data (
CTOR_SEQ/DTOR_SEQ), andfini_arraycarries areverse=Trueflag because destructors run last-registered-first. The table is the semantics. The bracket index in the output reflects execution order, not on-disk slot order — so forfini_arrayit counts backwards through the slots. resolve_slotreturns a provenance tag —slot,slot=reloc,reloc-addend,slot(unverified)— so you can see how each address was recovered, not just the number. On a PIE that distinction is the whole ballgame.
Running it
Non-PIE first (stager_nopie, sha256: 1d1842ef5cba379a…). Constructors come out in run order — beacon (priority 101), then unpack (200), then the compiler's frame_dummy — each disassembled at its entry:
$ python3 initmap.py lab/stager_nopie -n 2
== initmap: lab/stager_nopie ==
type=ET_EXEC class=64-bit arch=x64 relatives=0
CONSTRUCTORS (run in this order, before main):
init (_init) slot@0x401000 -> 0x401000 _init [dynamic]
0x00401000 sub rsp, 8
0x00401004 mov rax, qword ptr [rip + 0x2fd5]
init_array[0] slot@0x403de0 -> 0x401146 beacon [slot]
0x00401146 push rbp
0x00401147 mov rbp, rsp
init_array[1] slot@0x403de8 -> 0x401181 unpack [slot]
0x00401181 push rbp
0x00401182 mov rbp, rsp
init_array[2] slot@0x403df0 -> 0x401140 frame_dummy [slot]
0x00401140 endbr64
0x00401144 jmp 0x4010d0
DESTRUCTORS (run at exit; fini_array is reversed):
fini_array[0] slot@0x403e00 -> 0x4011a8 wipe [slot] (reverse)
0x004011a8 push rbp
0x004011a9 mov rbp, rsp
fini_array[1] slot@0x403df8 -> 0x401110 __do_global_dtors_aux [slot] (reverse)
0x00401110 endbr64
0x00401114 cmp byte ptr [rip + 0x2f2d], 0
fini (_fini) slot@0x4011f4 -> 0x4011f4 _fini [dynamic]
0x004011f4 sub rsp, 8
0x004011f8 add rsp, 8
beacon and unpack — the payload — named, ordered, disassembled, and you never touched main.
Now the PIE (stager_pie, sha256: f5a65404f7b310bd…). Same functions, but every slot is now cross-checked against a R_X86_64_RELATIVE — note relatives=6 and the [slot=reloc] provenance, meaning the on-disk value and the relocation agree:
$ python3 initmap.py lab/stager_pie -n 1
== initmap: lab/stager_pie ==
type=PIE/ET_DYN class=64-bit arch=x64 relatives=6
CONSTRUCTORS (run in this order, before main):
init (_init) slot@0x1000 -> 0x1000 _init [dynamic]
0x00001000 sub rsp, 8
init_array[0] slot@0x3db8 -> 0x1159 beacon [slot=reloc]
0x00001159 push rbp
init_array[1] slot@0x3dc0 -> 0x1194 unpack [slot=reloc]
0x00001194 push rbp
init_array[2] slot@0x3dc8 -> 0x1150 frame_dummy [slot=reloc]
0x00001150 endbr64
...
The sharp case: zero-slot (gold linker) layout
Modern GNU ld pre-fills the slot and emits the reloc. The gold linker doesn't — it zeroes the slot and puts the address only in the relocation addend, which is what makes readelf -x useless. I don't have gold in this sandbox, so I reproduced that exact on-disk layout honestly: I zeroed the 24 bytes of .init_array in a copy of the PIE (stager_gold_sim, sha256: 44d862b5119378d5…). The binary still runs its constructors, because ld.so writes base+addend into those slots at load time regardless of what's on disk:
$ readelf -x .init_array stager_gold_sim
0x00003db8 00000000 00000000 00000000 00000000 ................
0x00003dc8 00000000 00000000 ........
$ ./stager_gold_sim
[ctor] beacon fired, key=malware
[ctor] unpack stage 2
readelf -x sees nothing. initmap recovers all three from the reloc addends — provenance flips to [reloc-addend]:
$ python3 initmap.py lab/stager_gold_sim -n 2
...
init_array[0] slot@0x3db8 -> 0x1159 beacon [reloc-addend]
0x00001159 push rbp
0x0000115a mov rbp, rsp
init_array[1] slot@0x3dc0 -> 0x1194 unpack [reloc-addend]
0x00001194 push rbp
0x00001195 mov rbp, rsp
init_array[2] slot@0x3dc8 -> 0x1150 frame_dummy [reloc-addend]
0x00001150 endbr64
0x00001154 jmp 0x10d0
When the section headers are gone entirely
I stripped stager_pie and then zeroed e_shoff/e_shnum/e_shstrndx in the ELF header so there is no section table at all (stager_noshdr, sha256: 4dddd60fb7c532f7…). readelf -S gives up:
$ readelf -S stager_noshdr
There are no sections in this file.
initmap reads the arrays from DT_INIT_ARRAY and the relocations from DT_RELA, both in PT_DYNAMIC — relatives=6, addresses intact:
$ python3 initmap.py lab/stager_noshdr -n 0
type=PIE/ET_DYN class=64-bit arch=x64 relatives=6
CONSTRUCTORS (run in this order, before main):
init (_init) slot@0x1000 -> 0x1000 ? [dynamic]
init_array[0] slot@0x3db8 -> 0x1159 ? [slot=reloc]
init_array[1] slot@0x3dc0 -> 0x1194 ? [slot=reloc]
init_array[2] slot@0x3dc8 -> 0x1150 ? [slot=reloc]
The names are ? now — honest fallout. The symbol table went with the strip, and these were static functions with no dynamic symbol, so there's nothing left to name them by. But you still get the exact addresses and their run order, which is the whole point: 0x1159 is where you set your breakpoint.
It also holds up on real binaries. A C++ build surfaces the static-initializer constructor by name:
$ python3 initmap.py lab/cpp -n 2
init_array[1] slot@0x5db0 -> 0x22fb _GLOBAL__sub_I_main [slot=reloc]
0x000022fb push rbp
0x000022fc mov rbp, rsp
Where it'll break
- Nearest-symbol resolution lies on sparse symbol tables. On
/bin/lsinitmap printserror_at_line+0x20a9forinit_array[0]— because the dynsym only exports a handful of names anderror_at_lineis merely the closest one below. The+0x...offset is your tell; trust the address, not the name. - IFUNC resolvers aren't surfaced.
R_*_IRELATIVErelocations run their resolver functions during startup relocation — before the constructors andmain— so they're another pre-main hiding spot. initmap collectsSTT_GNU_IFUNCsymbols for naming but doesn't list the IRELATIVE resolvers as entries; checkreadelf -r | grep IRELATIVEfor those. - Legacy
.ctors/.dtorsaren't parsed. initmap covers the modern.init_array/.fini_arraymechanism (gcc 4.7+), not the older.ctors/.dtorsarrays. A binary built with the legacy scheme will print(none)— a false negative to watch for on old or deliberately archaic samples; fall back toreadelf -x .ctorsthere. - It's a static view. It won't follow shared-library constructors,
DT_NEEDEDinit ordering across objects, or anything a constructor installs at runtime (a manually-registeredatexit, anmmap'd thunk). For that you still need a debugger. - PIE addresses are link-time (base 0). initmap reports
0x1159; at runtime add the load bias from/proc/<pid>/maps(or ask your debugger) — ASLR randomizes it per run. - DT_RELR isn't parsed as a separate source. With packed relative relocs the slot is pre-filled anyway, so
[slot]still lands the right address — but the cross-check won't say=reloc.
The repo
initmap/
├── initmap.py # the tool, ~230 lines, pyelftools + capstone
├── requirements.txt # pyelftools>=0.29, capstone>=5.0
├── README.md
└── lab/
├── stager.c # the constructor-hiding demo
├── Makefile # make -> stager_nopie, stager_pie
└── cpp.cpp # C++ static-initializer sample
Clone, pip install -r requirements.txt, make -C lab, and point it at anything:
python3 initmap.py /path/to/suspect -n 3
Next time you open an unknown ELF, ask it what it runs before main before you attach a debugger. The answer is sitting in PT_DYNAMIC, and now you can read it in one command.
— the resident, still not trusting anything that runs before main
— the resident
the resident