When to use / when not to use
- Use: the target is a Linux ELF executable or .so shared library, and you need to understand the structure, inspect .init_array, GOT/PLT and symbols, and assess security properties
- Do not use: PE (use [[re-format-pe]]), Mach-O (use [[re-format-macho]]); bare-metal firmware binary (non-standard ELF, use the [[re-firmware]] domain)
- Do not use: when you only need function logic (use the decompilation skill directly)
Tool preparation
See [[re-analyze/platform-tips]]—ELF targets are mostly analyzed locally on Linux; for cross-architecture (ARM/MIPS firmware ELF) you can use QEMU user-mode emulation, and static analysis needs no sandbox.
readelf(binutils)
- Linux:
apt install binutils/dnf install binutils/pacman -S binutils - macOS:
brew install binutils(greadelf) - WSL: Linux version
- Verify:
readelf --version
objdump(binutils)
- Same as above (on macOS use
gobjdump) - Verify:
objdump -V
elfutils (the eu-* tool family)
- Linux:
apt install elfutils/dnf install elfutils/pacman -S elfutils - macOS:
brew install elfutils(eu-readelf, etc.) - Verify:
eu-readelf -h /bin/ls
patchelf (modify ELF header/loader)
- Linux:
apt install patchelf/dnf install patchelf/pacman -S patchelf - macOS:
brew install patchelf - Verify:
patchelf --version - Purpose: analysis aid (change RPATH/interpreter) and repair; during analysis, read-only first
Procedure
-
The three readelf tables (ehdr/phdr/shdr):
readelf -h sample # ELF 头: Class/Machine/Entry/Type readelf -l sample # program headers(段→装载偏移,动态加载与 GNU_STACK 在这里) readelf -S sample # section headers(节→地址/大小/标志)Three-table comparison:
-hgives the entry point,-lgives the runtime memory layout,-Sgives the static section view. -
Structure overview (ehdr/phdr/shdr layout): An ELF file = header + program header table + sections + section header table; in 64-bit, ehdr is 64 bytes, phdr is 56 bytes each, and shdr is 64 bytes each. Key ehdr fields and offsets:
0x00 e_ident[16] magic(7F 45 4C 46)+class(0x04:1=32位/2=64位)+data(0x05)+OSABI(0x07) 0x10 e_type 1=REL 2=EXEC 3=DYN(PIE/so) 4=CORE 0x12 e_machine 62=x86-64 3=i386 183=AArch64 243=RISC-V 8=MIPS 0x18 e_entry 入口点 0x20 e_phoff 程序头表偏移 0x28 e_shoff 节头表偏移 0x34 e_ehsize 64(32 位为 52) 0x38 e_phnum | 0x3A e_shentsize | 0x3C e_shnum | 0x3E e_shstrndxThree-table relationship:
readelf -hevery field output can be manually checked in the first 64 bytes of the file (xxd -l 64); the program headers describe "which file segments are mapped to memory with what permissions/alignment", and the section headers describe "named regions in the file such as symbols/strings/code". For the full field table and layout diagram, see [[layout]]. -
Initialization and termination callbacks (.init_array / .fini_array):
readelf -S sample | grep -iE 'init_array|fini_array' readelf -a sample | grep -A5 -i 'init_array' objdump -s -j .init_array sample # .init_array 是函数指针数组(数据节),用 -s 打印内容;-d 只反汇编代码节,实际不输出 # 取到指针后逐个 `objdump -d --start-address=<ptr> --stop-address=<ptr+len> sample` 看回调函数The two execute at different times, so do not conflate them:
.init_arrayThe function pointers in execute in forward array order during the startup initialization phase (before main)—initialization/anti-debugging/decryption are often hidden here and must be checked first;.fini_arrayThe pointers in execute during the exit/dlclose cleanup phase, in reverse array order, and do not immediately follow main (see the pitfalls section for details). -
GOT/PLT and dynamic symbols:
objdump -d -j .plt sample # PLT 桩(外部函数调用入口) readelf -r sample | head -40 # 重定位表(含 GOT 条目) readelf -s sample | grep FUNC # 符号表(动态符号在 .dynsym) objdump -T sample | grep UND # 未定义符号 = 导入 -
Key points for parsing dynamic linking structures(mandatory for .so and dynamically linked executables):
readelf -d sample # dynamic section: DT_* 标签 readelf -s sample | head -20 # .dynsym 动态符号(导入/导出) readelf -r sample | grep -E 'JUMP_SLOT|GLOB_DAT|RELATIVE'Association chain:
DT_STRTAB/DT_SYMTABtags point to dynstr/dynsym; iterate the symbol table with the fixed length given by theDT_SYMENTentry size (standard ELF32Elf32_Symis 16 bytes, ELF64 is 24 bytes; readDT_SYMENTand step by that size, do not hardcode 24);DT_GNU_HASH(new) replacesDT_HASH(old) for symbol lookup; the relocation type determines GOT slot behavior—R_X86_64_JUMP_SLOT(PLT jump),GLOB_DAT(global variable),RELATIVE(base-relative).DT_BIND_NOW(or FLAGS'DF_BIND_NOW) present = all binding is done at startup, no lazy binding (default on modern distributions);but RELRO is an independent condition—full RELRO =PT_GNU_RELROsegment and BIND_NOW both hold (GOT turned read-only); only ifPT_GNU_RELROis Partial RELRO, BIND_NOW alone cannot imply RELRO. For dynamic section parsing details see [[layout]]. -
Approach to symbol recovery in stripped binaries:
readelf -s sample | wc -l # 如果只剩 .dynsym(几十个),说明被 strip strings -n 6 sample | grep -iE 'error|usage|\.so' # 错误消息泄露内部函数名Recovery workflow: string cross-references (
strings -t xtake offset → locate references in Ghidra/radare2) → signature matching against common library functions (Ghidra FLIRT / rizinzsignatures) → trace call relationships backward from the main entry point. -
Security property checks:
readelf -l sample | grep -E 'GNU_STACK|GNU_RELRO' # GNU_STACK 无 E 标志 = 不可执行栈(NX) # GNU_RELRO 存在 + BIND_NOW = 全 RELRO;GOT 只读 readelf -s sample | grep -c __stack_chk_fail # >0 仅为 SSP 线索(静态链接会因 libc 自带 SSP 误报;SSP 按函数施加,不代表全部函数受保护)RELRO/Canary/NX status determines subsequent dynamic analysis (e.g., whether the GOT is writable) and the hijack surface assessment for [[re-imports]].
-
Manual parsing and verification: when readelf output is anomalous or header fields are forged, use
xxd+ Pythonstructto parse ehdr/phdr/shdr directly by offset (for a minimal runnable example and byte samples see [[examples]]); don't discard a parsing failure as a "corrupted file".
Cross-domain combinations
- [[re-binary-core]]: workflow step 3, ELF target format parsing
- [[re-mobile]]: Android native .so libraries (run this skill first before analyzing the app)
- [[re-firmware]]: ELF components in embedded Linux firmware
- [[re-ctf]]: common ELF targets in pwn/reverse challenges
- When a packer/obfuscation is detected, switch to [[re-anti-analysis]]
Common pitfalls and traps
-
init_array hides initialization/anti-debugging: executes earlier than main; looking only at main will miss pre-installed logic
-
After stripping, symbols remain only in dynsym:
readelf -sthe list shrinks drastically; recovery relies on string cross-references + signature matching, don't expect complete symbols -
GOT overwrite is a common attack point: when RELRO is not full, the GOT is writable—both reverse engineering and exploitation analysis must confirm
GNU_RELROandBIND_NOW -
When checking cross-architecture ELF (ARM/MIPS), the local objdump reports "unknown format" → use the corresponding cross toolchain or QEMU emulation (see the Linux branch of [[re-analyze/platform-tips]])
-
The early initialization chain is more than just init_array: symptom—you checked
.init_arraybut still missed earlier-executing logic (e.g.,strcmp@GOTwas hooked but not reproduced at the main breakpoint); cause—during early dynamic linker initialization,.preinit_arrayruns first (earlier than .init_array), and a malicious constructor can overwrite the GOT/install hooks before main; countermeasure—disassemble both.preinit_arrayand.init_array, set a breakpoint at the__libc_start_maininit call site, and verify whether the GOT entries have already been modified before main -
Forged section headers make readelf report errors: symptom—
readelf -S/-lerrors out or output is interrupted (abnormal e_shentsize, absurd program header count, missing dynamic section); cause—obfuscation/adversarial samples forge header fields to make tool parsing fail; countermeasure—xxdmanually verify the key ehdr fields (e_shoff/e_shnum/e_shentsize/e_phnum), correct them to the real values, and re-parse; don't discard it as a "corrupted file" -
fini does not execute immediately after main: symptom—you can't find the "wrap-up" logic at a breakpoint on main's return; cause—
fini/.fini_arrayexecutes during the exit cleanup phase (together with rtld_fini, atexit, and destructors), not right after main; countermeasure—set breakpoints for wrap-up logic on the exit path (exit_group / rtld_fini), don't look for it at the end of main -
R_X86_64_RELATIVE addend must be self-consistent with the vaddr system:
*slot = B + addend(B = load bias). If the artifact's vaddr = ImageBase + RVA (PE conversion scenario), the value stored in the file slot is the target vaddr → addend = stored value; only the "vaddr = pure RVA" system uses存储值 − ImageBase—mixing the two formulas is a final-review-level bug (the deviation is constantly one base, and "being dlopen-able" does not expose it) -
SHF_ALLOC sections must be covered by PT_LOAD: the dynamic area (.dynsym/.dynstr/.hash) is marked SHF_ALLOC but is not inside any segment → the loader does not map it and symbol resolution fails—when hand-writing an ELF generator, give the dynamic area its own PT_LOAD (p_offset and p_vaddr can be decoupled)
-
DSO requires an executable stack but the process has not enabled it → dlopen fails: symptom—
dlopenreportscannot enable executable stack as shared object requires: Invalid argument(glibc directly rejects the dlopen path; this is not kernel behavior); cause—the DSO requires an executable stack but the executable stack was not enabled when the process started, and whether "the absence ofPT_GNU_STACKcounts as a requirement" depends on the target ABI's default stack permissions (x86/x86-64/arm32 etc. are executable by default, aarch64/riscv etc. are non-executable by default); countermeasure—the correct fix is to add a non-XPT_GNU_STACKto the target (PF_R|PF_W, no X, align 16); only when compatibility is truly needed may you temporarily setglibc.rtld.execstack=2(=1has been found ineffective in practice; this value relaxes restrictions and reduces security) -
The segment targeted by a relocation must be writable: if the segment containing the r_offset of GLOB_DAT/RELATIVE is read-only (PF_R), ld.so writing to it causes SIGSEGV—force PF_W on sections containing relocation targets (v1 may give up RELRO and add PT_GNU_RELRO later)
-
Do not use strlen to get the length of shstrtab: the string table starts with
\0, so strlen truncates at the first byte to 1—use an explicit length/sizeof; likewise this will trap .dynstr indexing -
filesz > memsz is a readelf error:
p_memsz = max(vsize, raw_size)ensures filesz≤memsz; the semantics of the BSS zeroing area are handled by the loader -
ELF wrapped by gzexe (disguised as .sh): symptom—dragging the target file into IDA reports "not an ELF format", but the file is indeed an executable program; cause—gzexe gzip-compresses the ELF and wraps it in a shell script (a common compression method on Linux); countermeasure—use hexdump to check the header and confirm (script header + compressed data at the end),
gzexe -ddecompress to restore the real ELF before analysis -
ELF with OLLVM obfuscation + string encryption: intercept at the GOT exit: symptom—static patching is not feasible (
51642-like key strings are only decrypted at runtime and cannot be found in the binary); cause—OLLVM string encryption makes strings appear in memory only at runtime; countermeasure—do not work at the data source, intercept at the data exit: the program's final send must go through the GOT to callsendto/send/write/SSL_write→ hook the GOT slot, and inbufsearch for the needle and, after replacement, call the real function as-is; first use debug mode to confirm that the target string indeed appears in the send buffer before patching;OLLVM may obfuscate the GOT value itself(MOVZ+MOVK×364-bit constant assembled fromgot_addend)—when installing the hook, stay consistent with the obfuscation method (modifyMOVZ+MOVKimmediate rather than directly writing the address) -
code cave injection + sentinel placeholder (no recompilation needed): Phenomenon — the address of the shellcode to be injected depends on the specific target layout, requiring a rewrite for each target; Cause — directly hardcoding addresses is not reusable; Countermeasure — use sentinel values for all addresses within the shellcode (such as
0xCAFEBABEas a prefix) as placeholders, and the patcher scans for the sentinels during injection to replace them with actual addresses (cave address/offset/original init pointer can all be automatically detected); use.init_arrayfor the injection point (automatically called at program startup, earlier than main)