Skip to content
← All posts
· Delta1 Labs Deep diveNebula.NETControl flow

Control-flow flattening in .NET: turning methods into a dispatcher

How Nebula.NET rewrites a method's branches into a flat state machine so decompilers lose the plot — with real C#, the IL shape, and the costs to weigh.

Open any .NET assembly in a decompiler and you get something close to the original C# back. That is the whole problem obfuscation exists to solve: IL carries the method’s structure — its if/else, its loops, its early returns — almost intact, and a decompiler reads that structure to reconstruct readable code. Control-flow flattening attacks exactly that: it keeps what the method does while destroying the shape a decompiler relies on to explain how.

Here is what it is, how Nebula.NET implements it, and the trade-offs worth understanding before you turn it on.

The shape a decompiler leans on

Consider a trivial method:

int Classify(int n)
{
    if (n < 0) return -1;
    if (n == 0) return 0;
    return 1;
}

Compiled, this is a handful of basic blocks joined by conditional branches. A decompiler walks that graph, recognises the branch patterns, and prints back something nearly identical to the source. The control-flow graph is the readability.

Flattening: one loop, one switch, one state variable

Flattening dissolves the graph. Every basic block becomes a case in a switch, wrapped in a loop, with a single integer state variable deciding which block runs next. Instead of block A falling through to B, block A sets state = <B> and loops back to the dispatcher. The method’s logic is intact; its structure is gone.

Conceptually the method above becomes:

int Classify(int n)
{
    int state = 0, result = 0;
    while (true)
    {
        switch (state)
        {
            case 0:  state = (n < 0) ? 1 : 2; break;
            case 1:  result = -1; state = 5; break;
            case 2:  state = (n == 0) ? 3 : 4; break;
            case 3:  result = 0; state = 5; break;
            case 4:  result = 1; state = 5; break;
            case 5:  return result;
        }
    }
}

A decompiler faithfully reconstructs this — a dispatcher loop over opaque case numbers — because that is genuinely what the IL now says. The branch structure that told a reader “this is a three-way classify” no longer exists in the metadata. The reader is left single-stepping a state machine.

Before — a branch graphn < 0 ?n == 0 ?return→After — one dispatcherwhile(true) switch(state)c0c1c2c3c4state decides the next case; blocks no longer fall through

How Nebula.NET does it — and where it stops

Nebula.NET’s control-flow pass rewrites eligible methods into exactly this dispatcher form, then hardens the dispatcher itself so the state transitions are not trivially constant-folded back:

  • Opaque predicates — the switch selector and conditional transitions are mixed with expressions that always evaluate to a known value at runtime but are expensive to prove statically, so a decompiler cannot simply collapse the states.
  • MBA (mixed boolean-arithmetic) substitution — arithmetic that feeds the state variable is rewritten into equivalent but tangled forms, so even partial evaluation does not recover the clean state = N.

Two engineering details matter in practice. First, flattening only applies where it is safe: a method whose join point sits at a non-empty evaluation-stack depth is left structurally intact but still gets the predicate and MBA hardening (we call this “predicate-only”). The engine never trades correctness for density — a method that cannot be flattened soundly is protected a different way, not broken. Second, the transform is opt-in per method: you scope it with include/exclude rules so your hot paths stay untouched.

The costs, stated plainly

Flattening is not free, and anyone who tells you otherwise is selling you a surprise in production:

  • Size. The dispatcher and the hardened transitions add IL. Expect the flattened methods to grow noticeably; the assembly as a whole grows modestly because you flatten selected methods, not everything.
  • Speed. A switch-dispatched loop has more branches than straight-line code. For most business logic this is lost in the noise; for a tight numeric inner loop it is not. Do not flatten your hot path — protect the logic that embodies your IP, licensing checks, and algorithms, and leave the performance-critical arithmetic alone.
  • Debuggability. Flattened methods are deliberately hard to step through. That is the point for shipped builds, and the reason you keep an unobfuscated build and the Nebula symbol map for your own crash triage.

Where it fits in the free/Pro line

The Free edition flattens up to two methods — enough to see the transform on your own code and decide. The Pro edition removes the cap and adds the surrounding suite (string and resource encryption, anti-tamper, metadata hardening, and MSBuild/CI integration) so flattening runs as one pass in a pipeline rather than a one-off.

The honest framing: flattening raises the cost of understanding your code, it does not make understanding impossible — nothing short of moving logic off the client does that. Used with judgement — scoped to the code that matters, kept off the hot path — it turns a five-minute decompile into a problem most attackers will not pay to solve.

Try it on one method: install the CLI with dotnet tool install --global Delta1Labs.Nebula.Tool, add a method to your control-flow include list, and decompile the before and after. See the Nebula.NET docs to get started.

Try Nebula.NET

Harden your .NET code in minutes — start with the free edition.