De-obfuscating ZKM Control-Flow Flattening with ASM

About the author. I’m Colin Xiao — an independent reverse engineer, mostly working solo on contract jobs. I dig into compiled code of every kind, including but not limited to: Android and iOS apps, x86/x64/ARM native binaries, packers and obfuscators, DRM and license checks, network protocols and APIs, crypto and signature algorithms, malware, game binaries, and embedded/IoT firmware. I also handle large-scale data collection and vulnerability research. If you have any project in mind, I’d be glad to take it on — whatever it is, as long as it’s within my wheelhouse. Just reach out. You can reach me on X at @colinxiao_lab or on Telegram at https://t.me/ColinXiao666.

This research is published for educational and defensive purposes. The target sample is anonymized throughout.

Zelix KlassMaster is the most aggressive Java bytecode obfuscator out there, and the feature it’s most proud of is its control-flow obfuscation. Everything below uses the ASM library — the standard Java bytecode manipulation framework.

What control-flow flattening is

In a normal Java method, control flow is roughly tree-shaped, or block-shaped. The original logic looks like:

if (a) {
    runA();
} else {
    runB();
}
runC();

In bytecode there are still jumps, but the structure stays readable.

Control-flow flattening smashes that structure into a dispatcher loop. The logic ends up roughly like:

int state = 0;

while (true) {
    switch (state) {
        case 0:
            state = a ? 1 : 2;
            break;
        case 1:
            runA();
            state = 3;
            break;
        case 2:
            runB();
            state = 3;
            break;
        case 3:
            runC();
            return;
    }
}

Execution order is broken apart into cases, with a state variable deciding where to jump next.

ZKM doesn’t use just one flattening form. It can be a switch dispatcher, or a mix of ifs, gotos and try-catch blocks.

Why ZKM’s control flow is nasty

ZKM obfuscates with all of these:

  • fake conditional jumps polluting the control-flow graph (CFG)
  • meaningless branches built from long comparisons
  • fake paths built from object null-checks
  • control variables produced via invokedynamic (not detailed here)
  • fake exception edges via try-catch handlers
  • unreachable code to break decompilers
  • constant branches that are always-true or always-false

The decompiler ends up believing dead code can execute, re-expresses simple logic as bizarre nested conditionals, or just fails to decompile entirely.

The approach

The core loop can be written like this:

private int processMethod(ClassNode owner, MethodNode method) {
    int total = 0;
    boolean dirty;

    do {
        dirty = false;

        dirty |= cleanHandlers(owner, method) > 0;
        dirty |= foldLongJumps(method) > 0;
        dirty |= foldIntJumps(owner, method) > 0;
        dirty |= foldObjectJumps(owner, method) > 0;
        dirty |= dropUnusedStores(method) > 0;
        dirty |= foldKnownBranches(owner, method) > 0;
        dirty |= sweepUnreachable(owner, method) > 0;
        dirty |= trimEmptyBranches(method) > 0;

        if (dirty) {
            total++;
        }
    } while (dirty);

    return total;
}

The loop is the point.

Remove a fake jump and new dead code may surface. Remove dead code and new constant branches may appear. One pass is never enough.

Breaking the exception handlers

ZKM frequently plants fake exception handlers, like:

handler:
    getstatic xxx
    athrow

or:

handler:
    astore n
    getstatic xxx
    athrow

These dummy handlers are pure CFG pollution.

The fix: iterate tryCatchBlocks, find the real instruction after the handler’s LabelNode, and if it’s the classic GETSTATIC + ATHROW, delete the try-catch record and both instructions.

private int cleanHandlers(ClassNode owner, MethodNode method) {
    if (method.tryCatchBlocks == null || method.tryCatchBlocks.isEmpty()) {
        return 0;
    }

    int count = 0;
    Iterator<TryCatchBlockNode> iterator = method.tryCatchBlocks.iterator();

    while (iterator.hasNext()) {
        TryCatchBlockNode block = iterator.next();
        AbstractInsnNode first = nextReal(block.handler);

        if (first == null) {
            continue;
        }

        if (first.getOpcode() == GETSTATIC) {
            AbstractInsnNode second = nextReal(first);

            if (second != null && second.getOpcode() == ATHROW) {
                iterator.remove();
                method.instructions.remove(first);
                method.instructions.remove(second);
                count += 3;
            }
        }
    }

    return count;
}

Don’t be too aggressive. Legitimate code can also re-throw from a catch. Delete it only when you’re sure it’s fake; otherwise leave it.

Folding long-comparison jumps

ZKM control flow is full of this pattern:

lload x
lconst_0
lcmp
iflt L

or:

lload x
lconst_0
lcmp
ifge L

Most of these branches are control-flow garbage. The pattern to match is:

LLOAD
LCONST_0
LCMP
IFLT / IFLE / IFGE / IFGT

Which becomes:

private int foldLongJumps(MethodNode method) {
    int count = 0;

    for (AbstractInsnNode insn : method.instructions.toArray()) {
        if (insn.getOpcode() != LLOAD) {
            continue;
        }

        AbstractInsnNode a = nextReal(insn);
        AbstractInsnNode b = nextReal(a);
        AbstractInsnNode c = nextReal(b);

        if (a == null || b == null || c == null) {
            continue;
        }

        if (a.getOpcode() == LCONST_0
                && b.getOpcode() == LCMP
                && isLongBranch(c.getOpcode())) {
            method.instructions.remove(insn);
            method.instructions.remove(a);
            method.instructions.remove(b);
            method.instructions.remove(c);
            count += 4;
        }
    }

    return count;
}

private boolean isLongBranch(int opcode) {
    return opcode == IFLT || opcode == IFLE || opcode == IFGE || opcode == IFGT;
}

This one is easy.

The fake int branches

Common fake branches:

iload x
ifeq L

or:

iload x
ifne L

But you can’t just delete any iload + ifeq — normal code is full of that shape.

A more reliable signal: check whether the operand stack is empty right before the ILOAD. If the stack isn’t empty and a bare iload + ifeq shows up, it looks like ZKM’s fake-stack noise.

ASM’s Analyzer can compute the Frame:

private int foldIntJumps(ClassNode owner, MethodNode method) {
    int count = 0;
    Frame<BasicValue>[] frames = analyzeFrames(owner, method);

    if (frames == null) {
        return 0;
    }

    List<AbstractInsnNode> insns = realInsns(method);

    for (int i = 0; i + 1 < insns.size(); i++) {
        AbstractInsnNode load = insns.get(i);
        AbstractInsnNode jump = insns.get(i + 1);

        if (load.getOpcode() != ILOAD) {
            continue;
        }

        if (jump.getOpcode() != IFEQ && jump.getOpcode() != IFNE) {
            continue;
        }

        int index = method.instructions.indexOf(load);
        Frame<BasicValue> frame = frames[index];

        if (frame == null) {
            continue;
        }

        if (frame.getStackSize() != 0) {
            method.instructions.remove(load);
            method.instructions.remove(jump);
            count += 2;
        }
    }

    return count;
}

Finding the dispatcher variable

ZKM uses invokedynamic to produce a state variable, then jumps on it everywhere. Roughly:

invokedynamic ...
istore x
...
iload x
ifeq L

Scan for those state variables first:

private Set<Integer> collectStateVars(MethodNode method) {
    Set<Integer> vars = new HashSet<>();

    for (AbstractInsnNode insn : method.instructions) {
        if (insn.getOpcode() != INVOKEDYNAMIC) {
            continue;
        }

        AbstractInsnNode next = nextReal(insn);

        if (next instanceof VarInsnNode varInsn && next.getOpcode() == ISTORE) {
            vars.add(varInsn.var);
        }
    }

    return vars;
}

Later, when you see iload x / ifeq where x is such a state variable, you can consider removing it — but only after a side-effect check.

Some branches guard real code — PUTSTATIC, INVOKEVIRTUAL, ATHROW. Delete those wrong and the program breaks.

private boolean hasGuardedEffects(AbstractInsnNode branch, LabelNode target) {
    for (AbstractInsnNode insn = branch.getNext(); insn != null && insn != target; insn = insn.getNext()) {
        if (!isReal(insn)) {
            continue;
        }

        if (hasSideEffect(insn)) {
            return true;
        }
    }

    return false;
}

private boolean hasSideEffect(AbstractInsnNode insn) {
    int op = insn.getOpcode();

    return op == INVOKEVIRTUAL
            || op == INVOKESTATIC
            || op == INVOKESPECIAL
            || op == INVOKEINTERFACE
            || op == INVOKEDYNAMIC
            || op == PUTFIELD
            || op == PUTSTATIC
            || op == ATHROW
            || op == MONITORENTER
            || op == MONITOREXIT;
}

Then the actual removal:

Set<Integer> stateVars = collectStateVars(method);
int var = ((VarInsnNode) load).var;
JumpInsnNode branch = (JumpInsnNode) jump;

if (stateVars.contains(var) && !hasGuardedEffects(branch, branch.label)) {
    method.instructions.remove(load);
    method.instructions.remove(jump);
}

That’s a lot safer than deleting blindly.

Object null-checks

Object branches show the same pattern:

aload x
ifnull L

or:

aload x
ifnonnull L

The approach mirrors the int branches:

private int foldObjectJumps(ClassNode owner, MethodNode method) {
    int count = 0;
    Frame<BasicValue>[] frames = analyzeFrames(owner, method);

    if (frames == null) {
        return 0;
    }

    List<AbstractInsnNode> insns = realInsns(method);

    for (int i = 0; i + 1 < insns.size(); i++) {
        AbstractInsnNode load = insns.get(i);
        AbstractInsnNode jump = insns.get(i + 1);

        if (load.getOpcode() != ALOAD) {
            continue;
        }

        if (jump.getOpcode() != IFNULL && jump.getOpcode() != IFNONNULL) {
            continue;
        }

        int index = method.instructions.indexOf(load);
        Frame<BasicValue> frame = frames[index];

        if (frame != null && frame.getStackSize() != 0) {
            method.instructions.remove(load);
            method.instructions.remove(jump);
            count += 2;
        }
    }

    return count;
}

ZKM doesn’t just pick on ints — it abuses object null-checks as fake edges too.

Collapsing paired inverse branches into a goto

Sometimes ZKM splits a simple goto into two branches that cancel each other out. Like:

iload x
ifeq L1

L1:
iload x
ifne L2

If both branches use the same variable and their opcodes are inverse conditions, it’s just a roundabout jump. It can become:

goto L2

The check:

private boolean isOpposite(int a, int b) {
    return (a == IFEQ && b == IFNE)
            || (a == IFNE && b == IFEQ)
            || (a == IFNULL && b == IFNONNULL)
            || (a == IFNONNULL && b == IFNULL);
}

But there’s an important precondition: no real code between the two jumps.

private boolean hasCodeBetween(AbstractInsnNode from, LabelNode to) {
    for (AbstractInsnNode insn = from.getNext(); insn != null && insn != to; insn = insn.getNext()) {
        if (isReal(insn)) {
            return true;
        }
    }

    return false;
}

If there’s real code in between, leave it alone.

Turning unread stores into pops

After you delete a batch of fake branches, you’re left with things like:

invokedynamic ...
istore x

where x is never read again.

You can’t just delete the istore x — the invokedynamic return value is still on the stack, and deleting it unbalances the operand stack.

The right mapping:

istore x  -> pop
lstore x  -> pop2
dstore x  -> pop2
private int dropUnusedStores(MethodNode method) {
    int count = 0;
    Set<Integer> unused = collectUnusedStateVars(method);

    for (AbstractInsnNode insn : method.instructions.toArray()) {
        if (!(insn instanceof VarInsnNode varInsn)) {
            continue;
        }

        if (!unused.contains(varInsn.var)) {
            continue;
        }

        int op = varInsn.getOpcode();

        if (op == ISTORE || op == FSTORE || op == ASTORE) {
            method.instructions.set(varInsn, new InsnNode(POP));
            count++;
        } else if (op == LSTORE || op == DSTORE) {
            method.instructions.set(varInsn, new InsnNode(POP2));
            count++;
        }
    }

    return count;
}

Constant-condition folding

After cleanup, branches like this keep appearing:

iconst_0
ifeq L

That’s always-taken. Or:

iconst_0
ifne L

That’s never-taken.

If it’s always-taken, insert GOTO target. If never-taken, delete the constant and the conditional jump.

private int foldKnownBranches(ClassNode owner, MethodNode method) {
    int count = 0;
    List<AbstractInsnNode> insns = realInsns(method);

    for (int i = 0; i + 1 < insns.size(); i++) {
        AbstractInsnNode value = insns.get(i);
        AbstractInsnNode jump = insns.get(i + 1);

        if (!isIntConstant(value)) {
            continue;
        }

        if (jump.getOpcode() != IFEQ && jump.getOpcode() != IFNE) {
            continue;
        }

        int intValue = readIntConstant(value);
        JumpInsnNode branch = (JumpInsnNode) jump;
        boolean taken = jump.getOpcode() == IFEQ ? intValue == 0 : intValue != 0;

        if (taken) {
            method.instructions.insertBefore(value, new JumpInsnNode(GOTO, branch.label));
        }

        method.instructions.remove(value);
        method.instructions.remove(jump);
        count += 2;
    }

    return count;
}

Sweeping unreachable code

Once the control flow is clean, a lot of unreachable code remains. Like:

goto L1
junk
junk
junk
L1:
real code

So sweep it:

  1. Collect every LabelNode referenced by a jump, switch, or try-catch.
  2. Enter the dead zone after GOTO, RETURN, or ATHROW.
  3. Delete real instructions inside the dead zone.
  4. Stop at a referenced LabelNode.
private int sweepUnreachable(ClassNode owner, MethodNode method) {
    int count = 0;
    Set<LabelNode> usedLabels = collectUsedLabels(method);
    boolean dead = false;

    for (AbstractInsnNode insn : method.instructions.toArray()) {
        if (dead) {
            if (insn instanceof LabelNode label && usedLabels.contains(label)) {
                dead = false;
                continue;
            }

            if (!isReal(insn)) {
                continue;
            }

            method.instructions.remove(insn);
            count++;
            continue;
        }

        if (isReal(insn)) {
            int op = insn.getOpcode();

            if (op == GOTO || op == ATHROW || isReturn(op)) {
                dead = true;
            }
        }
    }

    return count;
}

Don’t delete LabelNode, FrameNode, or LineNumberNode carelessly — especially labels: some are jump targets, some are try-catch boundaries. Deleting the wrong one corrupts the class file.

The pass order

I recommend this order:

cleanHandlers
foldLongJumps
foldIntJumps
foldObjectJumps
dropUnusedStores
foldKnownBranches
sweepUnreachable
trimEmptyBranches

The reasoning:

  • exception traps pollute the CFG, so clean them first
  • the obvious long-comparison patterns can go early
  • the int/object fake jumps depend on Frame analysis
  • after fake jumps are gone, the unused stores appear
  • after stores are cleaned, constant branches surface more easily
  • after constant folding, more unreachable code shows up
  • finally, sweep dead branches and empty jumps

It’s not the only valid order.

The result

Here’s the before and after.

Before:

 private void C(Object[] var1) {
    long var3;
    label110: {
        int var5;
        d var8;
        label105: {
            label111: {
                label103: {
                    int var2 = (Integer)var1[0];
                    var3 = (Long)var1[1];
                    var5 = 8791405012267327996L.ä<invokedynamic>(8791405012267327996L, var3);
        int var10000 = var2;
        if (var3 >= 0L) {
            if (var5 == 0) {
                switch (var2) {
                case 1:
                    break label110;
                case 2:
                    break label111;
                case 3:
                    break label103;
                case 4:
                    this.a.M(new Object[]{this.a.C, (this.a.W + 1) % a(1932, 731985377339037758L ^ var3)});
                    this.F.add(new o(this.a.C, this.a.W));
                    break;
                default:
                    return;
                }
            }

            var10000 = var5;
        }

        if (var10000 == 0) {
            return;
        }
        }

        label112: {
            label86: {
            o var6 = this.a;
            int var10001 = var5;
            if (var3 > 0L) {
            if (var5 == 0) {
                label114: {
                    var7 = var6.W - 1;
                    if (var3 > 0L) {
            if (var7 >= 0) {
                break label114;
            }

            this.a.M(new Object[]{this.a.C, a(29136, 2917130341553155683L ^ var3)});
            var7 = var5;
        }

            if (var3 <= 0L) {
                break label112;
            }

            if (var7 == 0) {
                break label86;
            }
        }

            var6 = this.a;
        }

            var10001 = this.a.C;
        }

            var6.M(new Object[]{var10001, Math.abs(this.a.W - 1) % a(17797, 9156378109973535282L ^ var3)});
        }

            var8 = this;
            if (var3 < 0L) {
                break label105;
            }

            this.F.add(new o(this.a.C, this.a.W));
            var7 = var5;
        }

        if (var7 == 0) {
            return;
        }
        }

        var8 = this;
        }

        label64: {
            label63: {
            o var9 = var8.a;
            int var11 = var5;
            if (var3 >= 0L) {
            if (var5 == 0) {
                label116: {
                    var10 = var9.C - 1;
                    if (var3 >= 0L) {
            if (var10 >= 0) {
                break label116;
            }

            this.a.M(new Object[]{a(28420, 7594277407537606836L ^ var3), this.a.W});
            var10 = var5;
        }

            if (var3 <= 0L) {
                break label64;
            }

            if (var10 == 0) {
                break label63;
            }
        }

            var9 = this.a;
        }

            var11 = Math.abs(this.a.C - 1) % a(17797, 9156378109973535282L ^ var3);
        }

            var9.M(new Object[]{var11, this.a.W});
        }

            this.F.add(new o(this.a.C, this.a.W));
            var10 = var5;
        }

        if (var3 < 0L || var10 == 0) {
            return;
        }
        }

        this.a.M(new Object[]{Math.abs(this.a.C + 1) % a(17797, 9156378109973535282L ^ var3), this.a.W});
        this.F.add(new o(this.a.C, this.a.W));
        }

After:

private void C(int var1, long var2) {
    boolean var5 = false;

    switch (var1) {
        case 1:
            this.a.M(Math.abs(this.a.C + 1) % 20, this.a.W);
            this.F.add(new o(this.a.C, this.a.W));
            break;

        case 2:
            if (this.a.C - 1 < 0) {
                this.a.M(19, this.a.W);
            } else {
                this.a.M(Math.abs(this.a.C - 1) % 20, this.a.W);
            }

            this.F.add(new o(this.a.C, this.a.W));
            break;

        case 3:
            if (this.a.W - 1 < 0) {
                this.a.M(this.a.C, 19);
            } else {
                this.a.M(this.a.C, Math.abs(this.a.W - 1) % 20);
            }

            this.F.add(new o(this.a.C, this.a.W));
            break;

        case 4:
            this.a.M(this.a.C, (this.a.W + 1) % 20);
            this.F.add(new o(this.a.C, this.a.W));
            break;
    }
}

Readable at last.


Colin Xiao — I take on contract reverse-engineering work, and writeups like this are how I show what I can do. If you need help with anything in this space — including but not limited to the areas I cover above — I’m available. No project is too big or too small — if it’s reverse engineering and I can do it, I’ll take it. DM me on X (@colinxiao_lab) or Telegram (@ColinXiao666).

Get in touch