This document describes resurgo's DWARF CFI-based strategy for function entry detection, using records from the .eh_frame section.
CFI (Call Frame Information) is a DWARF standard for describing how to
unwind the call stack at any point in a program, enabling backtrace(),
exception handling, and debuggers. .eh_frame is the ELF section that stores
CFI data as a sequence of CIE and FDE records.
On Linux, the compiler emits -fasynchronous-unwind-tables by default on
x86-64 and ARM64. This causes one FDE to be generated for every function,
unconditionally - including leaf functions, cold paths, and internal helpers
that carry no other detectable signal (no prologue, never called directly).
Each FDE contains the function's precise entry address (initial_location).
Critically, .eh_frame is typically present in production stripped binaries
where .symtab and .debug_* are long gone.
This makes CFI the highest-confidence detection source in resurgo: the addresses it provides were written by the compiler, not inferred by heuristics.
The .eh_frame section is a flat sequence of records, back to back in memory.
Every record starts with two fixed fields:
+----------+----------+- - - - - - - - - - -+
| length | CIE_id | payload |
| 4 bytes | 4 bytes | variable |
+----------+----------+- - - - - - - - - - -+
|
+-- 0 -> CIE (shared header)
+-- non-zero -> FDE (offset back to its CIE)
length(4 bytes): how many bytes follow, not countinglengthitself.0is a terminator - stop parsing.0xffffffffsignals the 64-bit extended form (the real length is in the next 8 bytes); this is rare and skipped for now.CIE_id(4 bytes):0means this record is a CIE. Any other value means this record is an FDE, and the value is the byte offset from the current position back to the associated CIE.
offset 0
|
v
[length 4B][CIE_id 4B][ payload ][ next record ...
| |_______________ length ________________|
|___________________________________ length + 4 ____|
Advance to the next record by jumping length + 4 bytes from the start of the
current record.
A CIE is the shared header referenced by one or more FDEs. It avoids repeating common metadata in every FDE. Its body contains:
-
Version (1 byte)
-
Augmentation string (null-terminated ASCII) - a schema that declares which extra fields will appear in the augmentation data block (field 6 below), and in what order. Each character is a flag:
'z'- an augmentation data block follows; its length is ULEB128-encoded before the fields. If'z'is absent the block does not exist.'R'- the block contains a pointer encoding byte forinitial_location'P'- the block contains a personality routine pointer (C++ exception handler)'L'- the block contains an LSDA (Language Specific Data Area) encoding byte
-
Code alignment factor (ULEB128) - scale factor for advance-location instructions (e.g. 1 on x86-64, 4 on ARM64). Consumed to advance past it; not used for entry detection.
-
Data alignment factor (SLEB128) - scale factor for register-save offset instructions (e.g. -8 on x86-64). Consumed to advance past it; not used for entry detection.
-
Return address register (1 byte)
-
Augmentation data block (only if
'z'is in the augmentation string): the binary payload for the fields declared in the augmentation string. The characters after'z', taken in order, tell you how to decode it:'R'→ read 1 byte (the FDE pointer encoding, save this);'P'→ read a pointer (skip);'L'→ read 1 byte (skip).Augmentation string: "z R L \0" | | | +-- 'L' field → 1 byte in the block (skip) +---- 'R' field → 1 byte in the block (save: FDE pointer encoding) Augmentation data block: [block length ULEB128][ R: 1 byte ][ L: 1 byte ]
+----------+----------+---------+----------------+------------+------------+----------+----------+----------+
| length | CIE_id=0 | version | aug string \0 | code_align | data_align | ret_reg | aug len | aug data |
| 4 bytes | 4 bytes | 1 byte | variable | ULEB128 | SLEB128 | 1 byte | ULEB128 | variable |
+----------+----------+---------+----------------+------------+------------+----------+----------+----------+
|___________________|
only if 'z' in aug string
An FDE covers one contiguous code range (one function, or an inlined region). Its body starts with:
initial_location- the entry address of the covered range, encoded per the FDE pointer encoding byte saved from its CIE (stored in the CIE augmentation block).address_range- the size of the covered range (same encoding, unsigned).- Augmentation data and CFI opcodes (not needed for entry detection; skipped).
+----------+-----------------+------------------+---------------+------------------------+
| length | CIE_pointer | initial_location | address_range | aug data + CFI opcodes |
| 4 bytes | 4 bytes | variable | variable | skipped |
+----------+-----------------+------------------+---------------+------------------------+
^
encoding from 'R' field in CIE augmentation block
Several CIE fields use variable-length integer encodings to avoid wasting space
on fixed-width integers. Each byte contributes 7 bits of data; the MSB is
a continuation flag (1 = more bytes follow, 0 = last byte). The "128" in
the name reflects that each byte carries one base-128 digit (2^7 = 128).
ULEB128 is unsigned; SLEB128 is signed (the last byte is sign-extended from
bit 6).
Small values (< 128) cost 1 byte. A 64-bit value costs at most 10 bytes (ceil(64/7)). In practice DWARF alignment factors and register numbers are almost always < 128, so the savings are real.
The FDE pointer encoding byte is split into two nibbles (half a byte each):
- Upper nibble - base: where the value is relative to.
0x0_= absolute;0x1_= PC-relative (relative to the address of the field in the loaded binary); other bases exist but are uncommon. - Lower nibble - format: how the value is stored.
0x_0= raw pointer (pointer-sized);0x_b= signed 32-bit (sdata4); other formats exist.
The two common encodings on Linux:
| Encoding byte | Name | Decoding |
|---|---|---|
0x00 |
DW_EH_PE_absptr |
raw uint64 (or uint32 on 32-bit) |
0x1b |
DW_EH_PE_pcrel|sdata4 |
int32 + address of the field in the loaded binary |
For 0x1b, the reference address is
section.Addr + offset_of_field_within_section.
Anything else: skip the FDE silently (log at debug level, do not fail).
// parseEhFrameEntries parses the .eh_frame section of f and returns the
// absolute virtual address of every FDE's initial_location field.
// These addresses are function entry points written by the compiler.
//
// Returns nil (no error) if .eh_frame is absent - the caller treats this
// as a signal to fall back to the disassembly-only pipeline.
// Returns an error only for genuinely malformed data.
func parseEhFrameEntries(f *elf.File) ([]uint64, error)Walking algorithm:
- Find the
.eh_framesection. If absent, returnnil, nil. - Read all section bytes; record the section's load address (
sec.Addr). - Walk records in a loop, tracking
offsetwithin the section bytes:- a. Read
length(4 bytes, host byte order fromf.ByteOrder). Stop if0. Skip if0xffffffff(64-bit form). - b. Read
CIE_id(4 bytes). - c. If
CIE_id == 0: parse the CIE, extract and store the FDE encoding byte, keyed by the CIE's offset in the section. - d. If
CIE_id != 0: look up the FDE encoding byte from the referenced CIE. Decodeinitial_location; append the resolved VA to results. - e. Advance
offsetbylength + 4.
- a. Read
- Return the collected VA slice.
CFI data is handled by two independent pipeline components:
EhFrameDetector (detector phase) calls parseEhFrameEntries(f) and
emits one FunctionCandidate per FDE address with DetectionCFI and
ConfidenceHigh. If .eh_frame is absent it returns an empty slice; the
pipeline falls back to disassembly-only results.
EhFrameFilter (filter phase) retains only candidates whose address
appears in the FDE set, upgrading their confidence to ConfidenceHigh. It is
a pure filter - it only removes candidates, never adds them.
The two components are independent: callers can use EhFrameDetector alone
via WithDetectors, or EhFrameFilter alone via WithFilters.
Candidates sourced from CFI data receive DetectionCFI. Because FDE
entries are compiler-generated and not inferred, they are treated as the
highest-confidence source. Disassembly candidates confirmed by an FDE may be
promoted or merged with the richer disassembly metadata.
- Not universal: Go binaries use
.gopclntabinstead of.eh_frame. ARM bare-metal uses.ARM.exidx/.ARM.extab. Hand-written assembly only has FDE entries if the programmer adds.cfi_*directives. - Aggressive stripping:
strip -R .eh_frameremoves the section entirely. The fallback path handles this transparently. - ELF-specific: this strategy is only available via
DetectFunctionsFromELF(or directly throughEhFrameDetector/EhFrameFilter). The raw-bytes APIsDetectProloguesandDetectCallSitesare not affected. - Inlined regions: a single function can produce multiple FDEs if the compiler splits it into hot/cold regions. The current parser emits one candidate per FDE; deduplication by VA handles this correctly.