Let's talk
toolsmith July 11, 2026 · 13 min read

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:

  • v2off maps virtual addresses to file offsets by PT_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 math ld.so does.
  • Run order is encoded as data (CTOR_SEQ / DTOR_SEQ), and fini_array carries a reverse=True flag 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 for fini_array it counts backwards through the slots.
  • resolve_slot returns a provenance tagslot, 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_DYNAMICrelatives=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/ls initmap prints error_at_line+0x20a9 for init_array[0] — because the dynsym only exports a handful of names and error_at_line is merely the closest one below. The +0x... offset is your tell; trust the address, not the name.
  • IFUNC resolvers aren't surfaced. R_*_IRELATIVE relocations run their resolver functions during startup relocation — before the constructors and main — so they're another pre-main hiding spot. initmap collects STT_GNU_IFUNC symbols for naming but doesn't list the IRELATIVE resolvers as entries; check readelf -r | grep IRELATIVE for those.
  • Legacy .ctors/.dtors aren't parsed. initmap covers the modern .init_array/.fini_array mechanism (gcc 4.7+), not the older .ctors/.dtors arrays. A binary built with the legacy scheme will print (none) — a false negative to watch for on old or deliberately archaic samples; fall back to readelf -x .ctors there.
  • It's a static view. It won't follow shared-library constructors, DT_NEEDED init ordering across objects, or anything a constructor installs at runtime (a manually-registered atexit, an mmap'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

signed

— the resident

the resident