Compiling Your Code Into Bytecode

The last lesson built the machine that runs bytecode. But bytecode does not fall from the sky. Something has to translate your ordinary source into that flat list of instructions, and that something is a compiler, the same kind of tool that turns any language into a lower form. Virtualization simply points a compiler at an invented target instead of a real CPU. This lesson walks that translation end to end.

Two halves of one pipeline

Every virtualizer has a front end and a back end. The front end reads your source and understands its structure. The back end emits instructions for the target machine. In between sits a syntax tree, the shared representation both halves agree on. If you took the level on how programs are represented, you have met all of these already. Virtualization is those exact stages with the final target swapped out.

Step one: source becomes a tree

Text is a bad shape to compile from, because meaning in code is nested, not linear. So the first move is to parse the source into a tree that captures which parts belong inside which. In the expression two plus three times four, the multiplication has to sit deeper in the tree than the addition, because it must happen first. The tree, not the left-to-right text, is what encodes that order.

Expand the tree above. The assignment sits at the top. Its value is an addition, and one side of that addition is a multiplication holding three and four. Edit the source and watch the shape change. This tree is the exact thing the back end is about to walk.

Step two: walking the tree to emit instructions

The back end visits the tree from the bottom up. To emit code for an addition, it first emits the code that leaves the left value on the stack, then the code that leaves the right value on the stack, then a single add instruction to combine them. Applied to the whole tree, this rule produces a flat instruction stream where operands are always pushed before the operation that consumes them. That is why a stack machine works so cleanly: a tree walk naturally produces exactly the order a stack wants.

source:   answer = 2 + 3 * 4

tree:     Assign answer
            +
             2
             *
              3
              4

emitted:  PUSH 2
          PUSH 3
          PUSH 4
          MUL          ; 3 * 4 first, because it sits deeper
          ADD          ; then 2 + 12
          STORE answer

Think of the tree walk as reading a set of nested boxes and writing a to-do list. You cannot combine two things until both are on the table, so the list always says fetch this, fetch that, then combine, working outward from the innermost box. The emitted instructions are just that to-do list, flattened into a straight line.

Why the emitted order surprises people

The written source reads two plus three times four, left to right. The emitted bytecode computes three times four first. Nothing was reordered dishonestly; the tree captured the real order of operations, and the flat stream simply spells it out. This is the first reason virtualized output looks nothing like your source: even before any protection tricks, a faithful compile already rearranges the surface into stack order and strips away every plus sign, name, and parenthesis.

A statement compiles the same way. An if becomes code that evaluates the condition, then a conditional jump that skips the body when the condition is false, then the body, then a landing spot. A while adds a jump at the bottom back to the condition. The tree walk has a small rule for each kind of node, and stitched together those rules turn an entire program into one instruction stream with jumps standing in for every branch and loop.

This is also where per-build variation is injected cleanly. Because the back end is the only place opcode numbers are chosen, a virtualizer can assign a fresh numbering to push, add, jump, and the rest on every build, and emit against that numbering. The front end and tree are unchanged; only the final encoding differs. An attacker who decoded one build's numbers has learned a table the next build discards. The later lesson on per-build machines returns to exactly this.

Virtualization is a compiler with an invented target. Parse source into a tree that captures real precedence, walk the tree bottom up emitting operands before operations, and stitch jumps in for branches and loops. The output is a flat instruction stream that no longer resembles the source, before a single protection trick is added.

Frequently asked questions

Is the compiler inside an obfuscator different from a normal compiler?

Structurally, no. It parses, builds a tree, and emits instructions, exactly like any compiler. The only differences are the target, which is an invented machine rather than a real CPU, and the freedom to renumber that machine per build. Everything you know about compilers transfers directly.

Does virtualized bytecode still contain my variable names?

No. By the time the tree is walked into a flat stream, names have become stack slots or numbered locations. A store instruction points at a slot, not at the word you wrote. That is one reason a decompiler pointed at the output cannot simply print your original identifiers back.

Do statements like if and while turn into bytecode too?

Yes. Each kind of node has an emit rule. Conditions become evaluate-then-conditional-jump, loops add a jump back to the top. The whole program becomes one instruction stream where jumps stand in for every branch and loop, which is why the visible control flow belongs to the machine rather than to your code.

Keep learning

  • What Is Bytecode Virtualization?
  • How a Stack Machine Runs Bytecode
  • From Text to Tokens: Lexing
  • VM Obfuscation vs Identifier Renaming
  • How Lua Obfuscation Actually Works
  • Lua Obfuscator
  • JavaScript Obfuscator
  • Python Obfuscator

All lessons