Obfuscation a decompiler cannot fold
2026.09
A .NET assembly decompiles to readable C#. Names, control flow, and constants all survive the round trip through IL, so anyone with ILSpy gets something close to your source back. Obfuscators exist to break that, and most of them ship a pile of transforms and a marketing number that no attacker ever produced.
BLACKLINE inverts the order the same way the network work did. The adversary is built first. No protection pass ships until a named attacker has run against it and the result has a number attached. The instruments came before the transforms: a Z3 resistance harness, a model of a decompiler's constant folder that runs in CI, blind reviewers who score recovery without the answer key, and a census that counts what the pipeline actually touched rather than what it was allowed to touch.
State the ceiling up front. No purely software construction defeats an emulator. One reviewer wrote a disassembler and an emulator for the custom bytecode and reproduced every line of the target's output. Offline software surrenders its secrets to execution eventually, so the honest goal is raising the cost of static and manual recovery, not claiming a wall.
The harness refuses folklore
The classic constant-hiding trick is mixed boolean-arithmetic: replace c with an expression that evaluates to c but reads as noise, using identities like a + b == (a^b) + 2*(a&b). The first mint run put six hand-written linear-MBA template families through Z3 at a 30-second budget. It accepted zero of thirty-six.
| Template family | bw32 resist | bw64 resist | Verdict | |
|---|---|---|---|---|
TrivialXor c ^ (x^x) | 4-7 ms | 4-5 ms | folklore | |
AddSub c + x - x | 4-5 ms | 4-5 ms | folklore | |
LinearAB (c^y) + 2(c&y) - y | 5-9 ms | 6-13 ms | Z3 has linear bitvector theory | |
| PolyZero `c + (x\ | y) + (x&y) - x - y` | 19-20 ms | 37-39 ms | same |
| TwoLayerLinear | 5-6 ms | 6-8 ms | depth does not help | |
| SeededLinear | 16-17 ms | 28-29 ms | a baked constant does not fool the theory |
Linear MBA from the Zhou-Martinez family is dead as a construction, because Z3's bitvector preprocessor has explicit theory for exactly those identities. MBA-Blast (USENIX 2021) proved it in general: linear MBA reduces to a four-term normal form in 9 to 53 ms. That paper reads as an obituary, and the 0-of-36 result rediscovered it empirically before a single template shipped.
The construction that survives is LOKI (USENIX Security 2022), and adopting it required correcting a training-data error first: MBA-Blast and QSynth are attacker papers, deobfuscators, not the defender sources they were filed as. LOKI's design composes four things, each aimed at a different attack class:
- Key encoding. A per-site selector
e_i(k)that returns 1 for exactly one valid key. The
factorization variant sets e_i(k) := (n mod k) == 0 where n = p*q is a semiprime. Z3 has no partial solutions for factorization, so it is forced into exhaustive search. LOKI measured 0 of 1000 cracked at a one-hour timeout each.
- Merged cores.
output := sum_i e_i(k) * core_i. Every core executes, only the selected one
reaches the output, so taint analysis and backward slicing cannot strip the decoys as dead code.
- MBA equivalence classes. Enumerate small expressions, group by behavior over a thousand
inputs, verify each class with Z3, then rewrite recursively by swapping members within a class.
- Superoperators. Chain semantics through SSA data dependencies so the attacker cannot
decompose the whole into independent synthesis tasks.
BLACKLINE Stage 3 is the constant-hiding specialization of that machinery. The per-call-site key k derives from the method's metadata token and the IL offset, so it also becomes the selector that later stages hang integrity checks on.
The layers
Renaming uses Unicode confusables (Cyrillic and Greek letters that render like Latin, bytewise distinct in UTF-8) plus NFKC-collision identifiers, where two symbols with distinct names normalize to the same string. A runtime probe confirmed CoreCLR loads and dispatches both, so the trick is exploitable rather than folklore. Renderer-versus-runtime asymmetry carries it: a decompiler shows two visually identical tokens, the CLR resolves by metadata token.
Above renaming sit constant unfolding (the merged cores above), control-flow work (opaque predicates gated on quadratic residues, and CFG splitting through a residue-gated dispatcher), metadata anti-tooling (sharded #US streams, shadowed vtables, obfuscated resource names), and a register-based virtual machine that extracts a method's IL into a per-build-randomized bytecode and replaces the body with a call to the interpreter. Integrity is modeled as a data dependency rather than a control dependency: a tampered canary feeds the wrong k, the wrong core fires, and the result is silently wrong downstream. There is no boolean check to find and NOP, because there is no detected state.
That is a lot of surface. Measure-first discipline kept it honest, mostly by producing bad news.
The virtual machine shipped the answer key
The VM arc chased coverage: get every method of the recursive-descent parser corpus to virtualize. It got there. All four of the parser's core methods extract into bytecode, on every seed, with the polymorphism property intact.
Then two blind reviewers scored recovery against the frozen bar (pass required recovery of 2 or lower out of 5, and a 2-point drop from the pre-virtualization baseline near 4). The result was 5 out of 5 on every virtualized method, from both reviewers, independently. Recovery went up relative to the un-virtualized baseline. One reviewer went past scoring, wrote a disassembler and an emulator, ran the recovered bytecode, and reproduced all eighteen output lines of the target exactly, including 2147483647 + 1 = -2147483648 and both exception paths. The only thing neither recovered was names.
Virtualization moved the logic somewhere a reviewer could execute it exactly instead of inferring it. The cause was narrower than the failure mode suggested, and both reviewers named it as their top tell: BLACKLINE applied zero protection passes to its own interpreter. Run decompiled to about 6900 lines of plain C# in which every handler was one legible line. P_1[a] = P_1[b] + P_1[c] is the opcode table written out. The rename pass scrambled the interpreter's names and nothing else, so the premise that virtualizing control flow defeats control-flow recovery had never actually been tested. The answer key shipped in the same file as the puzzle.
The seed-permuted ISA that was supposed to carry resistance contributed about eight bits against a static oracle, and both reviewers broke it in minutes. Dispatch is switch(b ^ K) with K derived at runtime, statically opaque and irrelevant, because operand widths are fixed and public. "The walk must consume the whole stream with no undefined opcode" is a self-validating oracle over 256 candidates, and the real K was the unique survivor on all four methods.
Why it folded
Hardening the interpreter came next, and the number is the point of this whole post. The substrate took MBA and opaque-predicate passes; its IL grew 5.4 times, from 2724 to 14739 instructions. The decompiled output grew from 32,145 to 32,310 lines, which is 0.5 percent. The handler tell, P_1[a] = P_1[b] + P_1[c], appeared 7 times in both builds, byte-identical, loop counter and all. Everything the hardening added, in full, was 24 copies of bool N = Du % Du == Du and 24 copies of int N = N + (N ? D : D) * N.
ILSpy folded all of it. The opaque predicates fold because they are literal % literal == literal. The MBA folds because it is an arithmetic identity over constants. The property underneath every broad-but-shallow measurement across the whole project was the same: any transform whose inputs are all constants is reducible, and the decompiler already does it.
The root cause was one line of intent. The merged cores are functions of operands x and y, and the emitter bound both to literal zero and baked the key beside the encoder that consumes it. Every leaf of every emitted site was a constant. ILSpy was not defeating the MBA, it was folding a tree with no runtime input in it.
The fix is nearly free, and the reason it is free is worth stating. The merged-core verifier proves output(x, y, k) == target for all x and y. The operand channel is universally quantified, so it takes any runtime value at zero correctness risk, while k alone must be exact. Binding the operands to a runtime-seeded static field turns the constant tree into an expression the folder cannot reduce. Against ilspycmd 8.2 the sites came through verbatim: (int)(field + 1282311183), the encoder as 2729789u % (uint)numN == 0, the full MBA trees over runtime values, folded by nothing. First decompiler-opaque output the codebase produced, and it was a one-line change of what the leaves are.
The instrument that would have caught the original defect now ships in production. FoldabilityAnalyzer runs a two-valued abstract interpretation over an emitted site, matching what a decompiler's folder does. The emitter refuses to splice a runtime-bound site that still reads as foldable. It carries a naked control asserting that the pre-fix emitter shape reads as foldable, so if that control ever passes as opaque, the instrument has gone blind and every result behind it is void.
Falsifying a feature before funding it
Control-flow flattening was the obvious next lever, since MBA multiplies the cost of understanding one value but leaves the path count at one. The plan was to hide each block's successor behind a merged core. Prototyped standalone and falsified in a single turn, at a cost of one turn.
The dispatcher computes a selector from live data and branches on it. An attacker does not read the selector expression, they run it, because it is IL and evaluation is free. That makes the attack encoding-independent: merged cores, rewrite depth, and per-build polymorphism change the selector's syntax and never its semantics, and sampling only touches semantics.
| edge | recovery cost |
|---|---|
| unconditional | 1 sample |
| conditional, candidate set | median 3, p95 6, max 11 (200 seeds) |
| control: selector depends on a secret the sampler lacks | does not converge |
Unconditional edges are unhidable by theorem, not by weakness of a construction: a single successor means the value is input-invariant, so evaluating at arbitrary inputs returns it. Conditional edges leak their candidate set to sampling, and "follow both paths" needs nothing more than the candidate set. Only a secret the analyst does not hold resisted the attack, and offline software surrenders those, so that row of the table stands as a control rather than a proposal. The sampling harness ships as a reusable floor, so any future "we hid the control flow" claim gets scored in seconds.
Coverage was the real constraint
With opacity finally real, the binding constraint moved to reach. A single merged-core composition costs roughly 8.9 KB of CIL against a 48 KB per-method budget, itself 16 KB of headroom under the 64 KB method-body limit the runtime enforces. So a method got about five protected constants however many it had. One corpus method with 314 constants got five.
Constant outlining removes the ceiling instead of raising it: emit one [NoInlining] internal static helper per constant, each with its own 64 KB, and the caller's constant count stops mattering. Eligible coverage went from 10.6 percent to 100 percent, and the protected build ran faster, which was the opposite of the prediction. Splicing 8.9 KB into a hot method wrecks it for the JIT; putting the work behind a call leaves the caller's real code intact. Three-run average on one corpus: spliced 2920 ms, outlined 1677 ms, naked 68 ms.
Then the census corrected the headline. "100 percent" was 100 percent of the eligible set, and eligibility was the pass's own choice. ConstantDenominatorCensus counts every constant in the naked assembly with no filter and attributes each excluded one to the pass that rejected it, so it cannot drift from what the pipeline does.
| corpus | constants | reachable before | reachable after |
|---|---|---|---|
| ConstantHeavy | 46 | 100% | 100% |
| ExpressionParser | 73 | 87.7% | 98.6% |
| ReflectionHeavy | 19 | 84.2% | 94.7% |
| ObfSample | 181 | 84.0% | 86.2% |
| AsyncConsole | 28 | 67.9% | 96.4% |
| LinqQuery | 22 | 36.4% | 95.5% |
| total | 369 | 82.7% | 92.1% |
The honest number was 82.7 percent, not 100. The biggest single gap was structural: Roslyn compiles top-level statements into a [CompilerGenerated] entry-point class, which eligibility refused outright, so on a default-template app (the C# default since .NET 6) the pipeline protected no constants in Main at all. Admitting that class structurally closed most of the gap. What remains is floats, which the integer MBA grammar cannot touch, and a handful of newarr sites carved out for JIT blob-init recognition. Across integer constants the figure is 97.7 percent.
What the instruments kept catching
A pattern recurred often enough to become the project's main engineering lesson, and it is worth one paragraph rather than a moral. A measurement keyed to something a later transform destroys degrades to a passing number instead of an error. A site-count instrument keyed on a stale metadata token had been reporting a fabricated zero. A signature detector's exclusion set was keyed on the interpreter's name, which the rename pass scrambles, so on the shipped stack it excluded nothing and every full-stack number since had counted stubs it was documented to skip. A JIT-crash carve-out list lived private to the one pass that needed it until a second pass needed it and reproduced the crash. The fixes all have the same shape: key instruments on structure and signature, the same handles a reverser uses, never on names. Two more standing rules fell out. Every gate ships a naked control asserting the un-protected shape reads as unprotected, or the gate proves nothing. And eligibility is not protection: reach gets verified by counting bytecode fields in the output binary, because a method can be eligible, silently decline translation, and leave its original body in place. A build once reported 19 of 19 correctness checks passing while nothing had virtualized at all.
What is claimed
The positioning is deliberately partitioned rather than totaled. Against IL-shape pattern tooling and generic VM extractors, the protected population holds. Per-build ISA randomization raises the cost against generic extractors to that of bytecode-aware tooling that re-derives the permutation, which is the same construction cost as defeating LOKI-class targets. Controlled-execution recovery is breached by intent, because no obfuscator at any tier defends against an emulator inside a deterministic build.
The value of the exercise sits above any single transform. Every claim in the record is bounded by a number some named adversary produced, several of those numbers are failures, and the failures are the parts worth reading. A protector that measures itself against a real decompiler spends most of its time discovering that its output folds. A marketing percentage is built to hide that discovery.
MIT licensed.