Skip to content
← All posts
· Delta1 Labs Glass.NETDecompilation.NETDeep Dive

Decompiling control flow: how Glass.NET rebuilds loops and if/else from a pile of branches

IL has no while, no for, no else — it has basic blocks and branch instructions: br, brtrue, brfalse, switch. A for loop and a goto-spaghetti rewrite of the same loop can compile to identical IL, so a decompiler cannot read the structure back; it has to reconstruct it. This post walks the real pipeline Glass.NET runs: split the method into basic blocks at every branch and branch target, build a control-flow graph, compute dominators to find which block guards which, detect natural loops from back edges to the block that dominates them, and structure the forward branches into if/else — then explains the honest hard case, irreducible control flow, where no clean loop exists and a decompiler must either duplicate code or fall back to labels and goto.

Here is a thing that surprises people the first time they see it: there is no while in a compiled .NET assembly. No for, no foreach, no else. The C# compiler lowers all of them to the same handful of primitives — straight-line instructions and branches — and throws the structure away. A decompiler’s job is to put the structure back, and it cannot do that by reading it, because it isn’t written down anywhere. It has to reconstruct it from the shape of the branches alone.

This post walks the pipeline Glass.NET runs to do that: from a flat list of IL opcodes to a control-flow graph, to dominators, to loops, to if/else — and then the honest part, where the control flow is shaped such that no clean loop exists and the only faithful thing to print is a label and a goto.

IL’s control-flow vocabulary is tiny

Everything structured in C# comes down to four kinds of instruction. Unconditional branch: br target. Conditional branches: brtrue/brfalse (and the compare-and-branch forms beq, bgt, blt, …) that pop a value and jump or fall through. And the jump table: switch (t1, t2, …) that indexes a list of targets. That is the whole repertoire. A for loop uses it; so does a while; so does a goto-riddled rewrite of either. Consider a plain sum:

int Sum(int[] xs)
{
    int total = 0;
    for (int i = 0; i < xs.Length; i++)
        total += xs[i];
    return total;
}

The compiler emits the classic “test at the bottom” loop — initialize, jump to the condition, body, increment, condition, branch back if true:

IL_0000: ldc.i4.0        // total = 0
IL_0001: stloc.0
IL_0002: ldc.i4.0        // i = 0
IL_0003: stloc.1
IL_0004: br.s   IL_0010   // jump to the condition test
IL_0006: ldloc.0          // -- body: total += xs[i]
IL_0007: ldarg.0
IL_0008: ldloc.1
IL_0009: ldelem.i4
IL_000a: add
IL_000b: stloc.0
IL_000c: ldloc.1          // -- i++
IL_000d: ldc.i4.1
IL_000e: add
IL_000f: stloc.1
IL_0010: ldloc.1          // -- condition: i < xs.Length
IL_0011: ldarg.0
IL_0012: ldlen
IL_0013: conv.i4
IL_0014: blt.s  IL_0006   // if i < len, branch back to body
IL_0016: ldloc.0
IL_0017: ret

Nothing in that stream says “for loop.” There is a backward branch (blt.s IL_0006) and a forward one (br.s IL_0010). Whether the result prints as a for, a while, or a goto is entirely a decision the decompiler makes from the branch structure. Get it right and you get the for back; get it wrong and you get working-but-ugly goto soup.

Step 1: cut into basic blocks

The first transform turns the flat list into basic blocks. A basic block is a maximal run of instructions with exactly one way in (the top) and one way out (the bottom) — no branch lands in the middle and none leaves from the middle. The rule for cutting is mechanical: a new block begins at the method entry, at every branch target, and immediately after every branch instruction. Apply it to Sum and you get four blocks:

B0  IL_0000..IL_0004   total=0; i=0; br IL_0010          (-> B3)
B1  IL_0006..IL_000b   total += xs[i]                    (-> B2, fallthrough)
B2  IL_000c..IL_000f   i++                               (-> B3, fallthrough)
B3  IL_0010..IL_0014   test i < len; blt IL_0006         (-> B1 or B4)
B4  IL_0016..IL_0017   return total

Now the method is a set of nodes with edges between them — a control-flow graph. Every branch becomes an edge; a conditional branch gives a block two successors (taken and fall-through); a switch gives it many. This is the data structure every later step operates on.

B0total=0; i=0br → condB3 (header)i < len ? bltB1bodytotal += xs[i]B2i++B4return totaltruefalseback edge → B3

Step 2: dominators decide who guards whom

With the graph built, the central question is reachability under guard: which blocks can only be reached by going through some other block first. That relationship is dominance. Block X dominates block Y if every path from the method’s entry to Y passes through X. The entry dominates everything; a loop header dominates its whole body; the two arms of an if are each dominated by the block that tests the condition.

Glass computes the dominator tree with the standard iterative data-flow algorithm (or Lengauer–Tarjan on large methods), giving each block its immediate dominator — the closest block that must run before it. Dominators are what let the later steps be decisions rather than guesses:

  • A conditional branch whose two successors it dominates is an if (and possibly an else): the test block guards both arms, and the first block that neither arm dominates but both reach is where the arms join — the statement after the if.
  • A backward edge to a block that dominates its source is a loop. Which brings us to the one that matters most.

Step 3: back edges are loops

A back edge is an edge from a block B to a block H where H dominates B. The intuition is exact: if every path to B had to pass through H, then an edge from B back to H means control returns to a point it was guaranteed to have come through — that is the definition of a loop, and H is its header. In Sum, the edge B3 → B1… wait: the back edge is the one that re-enters the dominating block. Here B3 dominates B1 and B2 (you cannot reach the body without passing the test), and the fall-through B2 → B3 returns to the dominator. B3 is the header; {B1, B2, B3} is the natural loop — the header plus every block that can reach the back edge’s tail without going through the header.

Once Glass has the header and the loop body, structuring is a choice of presentation:

  • If the header’s test is the loop’s only exit and sits at the top of the body, print a while.
  • If the body ends in a recognizable induction step (i++) over a variable initialized just before the loop and tested in the header, print a for — this is why Sum comes back as a for, not a while (true) with an if break.
  • If the test is at the bottom (the header falls into the body unconditionally and the branch is at the end), print a do…while.

None of these is “in” the IL. They are the decompiler reading the natural loop’s shape and choosing the C# that re-emits the same graph. The graph is the source of truth; the keyword is a rendering decision.

Step 4: forward branches become if / else

Everything that is not a loop edge is a forward branch, and forward branches that don’t exit the method structure into conditionals. Take a block that ends in brfalse L with a fall-through: the fall-through is the then, the block at L is where the then rejoins — or, if the then itself ends in an unconditional br over a second region, that second region is the else. The join point is found with dominance again: it is the earliest block dominated by the test that both arms reach. A chain of such tests with a shared join is an else if ladder; a switch instruction with its jump table and a common join is a switch statement. Short-circuit &&/|| fall out of the same machinery — they are just conditionals whose arms share targets — which is how Glass folds two brfalses into one if (a && b) instead of a nested pair.

The honest hard case: irreducible control flow

Everything above assumed the graph is reducible — that every cycle has a single entry, a single header you can hang a while on. Most compiler-generated IL is reducible, because C#‘s structured statements can only produce single-entry loops. But not all of it is, and a decompiler that pretends otherwise produces wrong code.

An irreducible region is a cycle with two or more entry points — control can jump into the middle of the loop from more than one place. No while or for can express that, because a structured loop has exactly one header. You get irreducible graphs from certain goto patterns, from a few compiler lowerings, and — not by accident — from obfuscators that flatten and tangle control flow specifically to defeat structuring.

Reducible — one headerentryheaderbody→ whileIrreducible — two entriesentry Aentry BPQno single header → labels + goto

A decompiler has exactly three honest responses, and no fourth:

  1. Node splitting — duplicate one of the entry blocks so each entry gets its own copy, making the graph reducible at the cost of repeated code. Correct, but it can blow up the output.
  2. Controlled dispatch — introduce a synthetic state variable and a while (true) { switch (state) … } that simulates the jumps. This is, ironically, exactly what control-flow-flattening obfuscation produces, so re-emitting it is faithful but not pretty.
  3. Labels and goto — give up on a structured form for that region and print exactly what the IL does: labeled blocks and gotos between them.

Glass.NET’s rule is to structure everything it legitimately can and fall back to labels and goto only for the regions that genuinely have no structured form — never to fabricate a clean while where the graph doesn’t support one. A goto in Glass output is therefore information, not a failure: it means the original really did have control flow that C#‘s structured statements cannot express, which is itself worth knowing when you are staring at code that was flattened on purpose.

Why this is the backbone of the whole decompiler

Every “how does Glass decompile X” story — yield state machines, async/await, pattern-matching switch, using’s try/finally — ends up standing on this layer. Those higher-level recognizers run after the method has been cut into blocks, graphed, dominated and structured, because they match on the shape of the structured control flow, not on raw opcodes. Get the control-flow reconstruction right and a state machine’s switch (state) is a clean graph you can fold back into an iterator; get it wrong and every recognizer above it is matching against noise. The branches are all the IL gives you. The loops and the ifs are something the decompiler earns.

Try Nebula.NET

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