Skip to content

Function merging — fmerge

Module-only. Collapses functions that share a group= label into one selector-dispatched super-function, so distinct routines become indistinguishable cases of a single body. Runs first, before the function pipeline — so the merged super-function is then obfuscated by everything downstream.

Grouping

Functions with the same group= label merge into the same super-function:

__attribute__((annotate("obf: fmerge(group=alpha)"))) int  parse_hdr(const u8*, int);
__attribute__((annotate("obf: fmerge(group=alpha)"))) long crc_step(long, const u8*);
__attribute__((annotate("obf: fmerge(group=alpha)"))) void reset_ctx(ctx_t*);
// group=beta would form a separate super-function
  • fmerge(group=NAME) — explicit bucket; all same-NAME functions become one super-function.
  • bare fmerge — dropped into the default _auto pool and chunked by chunk= (default 4).
  • A function belongs to exactly one group. A group needs ≥ 2 eligible members; smaller groups are skipped (a single-function trampoline adds nothing).

Options

Only group= is per-function; the rest are group-wide (resolved from the group's members).

Key Scope Default Meaning
group per-func Merge-bucket label (string).
chunk group 4 _auto-pool chunk size (bare fmerge only, 2–16).
opaqueSel group 1 Obfuscate the selector.
dispatch group switch Dispatch shape — switch or indirectbr.
minInsts group 4 Skip functions smaller than this.
maxInsts group 2000 Skip functions larger than this (blow-up bound).
stripDbg group 1 Drop debug info on merged bodies.
thunkAddrTaken group 0 Merge address-taken / external functions via a thunk.
launderSel group 0 Load call-site selectors from a mutable global (defeats automated devirtualization).

How dispatch is hidden

The call-site selector is not the literal case index. A group-wide key K obfuscates it: each call site passes an obfuscated selector, and dispatch computes idx = selector XOR K. Downstream mba / constenc then bury K further.

Selector laundering (launderSel=1)

With static selectors, a symbolic engine can trace each call straight to its dispatched behaviour — automated devirtualization. Laundering gives each group a mutable global holding its selectors; every call site (and thunk) reads its own via a volatile load, so the constant is no longer visible to static analysis.

Thunks (thunkAddrTaken=1)

Address-taken or externally-visible functions can't simply be erased. With this knob their body is replaced by a thin forwarder (thunk) into the super-function; internal direct calls are rewritten straight to the super-function, and only indirect / external uses go through the thunk.

Eligibility & safety

Members are dropped from a group (with a warning) if they violate the merge constraints (e.g. byval/sret, naked, inline asm, musttail, blockaddress-in-body). If dropping members takes a group below 2, the whole group is skipped. Each group is committed transactionally — if any member can't be merged, the group is rolled back and the originals are left untouched.

Erasing annotated functions

fmerge erases merged originals. Any pass or tool that reads llvm.global.annotations must account for this — the annotation use is consumed and the function may no longer exist after merge. See DEV internals.