Rewriting Control Flow

Every transformation so far changed names, values, or boundaries while leaving the program's shape alone. This lesson goes after the shape itself. The branching and looping structure of a program, which paths exist and how they connect, can be rewritten into a completely different structure that behaves identically. It is the most disorienting transformation in the classical toolkit, and understanding it properly means meeting the control-flow graph first.

The control-flow graph

Compilers describe a program's shape as a graph. Each node is a basic block, a run of statements that always execute together, top to bottom, with no branching inside. Each edge is a possible jump between blocks: the two arms of an if, the back edge of a loop. This picture, the control-flow graph or CFG, is what every serious code tool actually works with. It is the program's street map.

Read the diagram like a board game. Execution is a token that starts at the top, moves along arrows, and at every diamond picks one arrow based on the current values in memory. A while loop is nothing but an arrow that points back upward. Every program you have stepped through in this academy was secretly a token walking a graph like this.

The key property: many different graphs compute the same behaviour. Any rewrite of the graph that preserves which statements execute, in what order, under which conditions, is a legal transformation. Structured loops, chains of gotos, and state machines are interchangeable costumes for the same underlying execution.

One behaviour, two shapes

Here is a loop that sums the numbers 1 to n, in its natural shape and then rebuilt as a state machine. The rebuilt version keeps a state variable, and a single loop dispatches on it: each numbered state is one of the original basic blocks, and instead of the code's layout deciding what runs next, an assignment to state does.

n = 3
total = 0
i = 1
while i <= n {
  total = total + i
  i = i + 1
}
print "sum:", total
n = 3
state = 1
while state != 0 {
  if state == 1 {
    total = 0
    i = 1
    state = 2
  } else {
    if state == 2 {
      if i <= n {
        state = 3
      } else {
        state = 4
      }
    } else {
      if state == 3 {
        total = total + i
        i = i + 1
        state = 2
      } else {
        print "sum:", total
        state = 0
      }
    }
  }
}

This rewrite is called control-flow flattening: every block becomes a numbered case, and the readable nesting collapses into one flat dispatcher loop. The original shape, a loop you could see at a glance, is gone from the text. It survives only as a trail of state numbers.

Step through the flattened machine

Now run the flattened version yourself, one statement at a time. Keep your eye on the state variable: it is the program counter in disguise. Watch it walk 1, then bounce between 2 and 3 while the sum builds, then take 4 on the way out. The original loop is still here, executing perfectly, hidden inside the bouncing.

Both versions print the same sum, and a step-by-step trace of each executes the same arithmetic in the same order. On the CFG level they are far closer than the text suggests: the same blocks exist in both, only the edges are administered differently, by layout in one and by a variable in the other.

What shape rewriting buys, honestly

Flattening attacks the static reader. Skimming works because nesting mirrors logic: indented code belongs to the branch above it, a loop's body sits visibly inside it. Flattened code breaks that mirror. To recover the original loop you must trace state assignments by hand, effectively simulating the program on paper, which turns minutes of skimming into real work. That is a genuine, measurable cost, and it is why flattening earned a permanent place in the obfuscation toolkit you will study in Level 6.

Its limits are just as concrete. The dispatcher is itself a recognisable pattern, and deobfuscation tools attack it structurally: identify the state variable, evaluate which state each block assigns next, rebuild the edges, and the original CFG reassembles itself. When state transitions are plain constants, as in our example, that recovery is fully mechanical. Hardened flattening therefore computes next-state values in convoluted ways, which is where opaque predicates return to the story. And dynamic analysis never struggled in the first place: a runtime trace walks straight through the dispatcher, recording the true execution order as it happens.

There is also a performance clause in the honesty contract. The dispatcher adds a comparison chain on every single block transition, and it defeats many of the optimizations runtimes apply to natural loops. Shape rewriting is one of the more expensive classical transforms at runtime, which is why measured tools apply it selectively rather than to every function.

Control flow is a graph, and the graph is rewritable: the same blocks, edges, and behaviour can wear a structured shape or a flattened dispatcher. Flattening converts readable nesting into state-variable bookkeeping, a real tax on static reading, while tracing tools and structural analysis can walk or rebuild it, and the runtime pays a speed toll. Shape is malleable. Behaviour is the invariant.

Frequently asked questions

Is the state machine version really the same program?

By the definition this level runs on, yes: for every input it executes the same operations in the same order and produces the same output. The text is very different, the trace is the same modulo the bookkeeping assignments to state, and behaviour is what transformations are required to preserve.

Do compilers themselves ever flatten control flow?

Compilers constantly rewrite control flow, but usually toward efficiency: unrolling loops, merging branches, reordering blocks for the hardware. Flattening specifically trades speed for opacity, so outside a few niche uses like implementing generators and state machines, it is characteristic of obfuscation rather than optimization.

Keep learning

  • Control Flow Flattening: Turning Structure into a State Machine
  • Splitting and Merging Functions
  • Opaque Predicates: Branches Only the Author Can Trust
  • Can AI Deobfuscate Protected Code?
  • Lua Obfuscator
  • JavaScript Obfuscator
  • PHP Obfuscator

All lessons