skip to content
naft.pink

the only honest test of an obfuscator is writing the deobfuscator

Years ago I wrote an obfuscator for Java programs and a deobfuscator that undoes it, both built on ASM, both working on compiled bytecode rather than source. This was 2021/2022 and I'm writing about it from memory rather than from the code, so treat the specifics below as roughly-remembered rather than a transcript — the lesson is the part I'm sure of.

Building the second one is the only reason I learned anything from the first.

an obfuscator always looks like it works#

This is the trap, and it's structural rather than a mistake anyone makes out of carelessness.

You run your tool. You open the output in a decompiler. It's unreadable — names are gone, the control flow is soup, the strings aren't there. It looks like exactly what you set out to build, and every instinct says you're done.

But "I can't read this" and "this is expensive to reverse" are different claims, and only the second one matters. The first is guaranteed by construction: you scrambled it, so of course it looks scrambled to you. You're the least qualified person to judge, because you're reading output you already know the shape of and grading it on how alien it feels.

The gap between those two claims only becomes visible when somebody sits down and actually tries to undo it. The fastest way to arrange that is to be them.

bytecode, not source#

Worth saying what layer this operates at, because it changes what's available.

The tool doesn't rewrite Java and recompile. It reads the .class files javac already produced and rewrites those directly. ASM is a visitor over the class format — you hand it a reader, it walks the structure, and you intercept the parts you care about:

class Renamer extends ClassVisitor {
    @Override
    public MethodVisitor visitMethod(
            int access, String name, String desc, String sig, String[] ex) {
        return super.visitMethod(access, rename(name), desc, sig, ex);
    }
}

Two things follow from working down here. The first is that the tool never needs source, which is the entire premise of the deobfuscator — if you had the source you wouldn't be running it. The second is that the JVM will happily execute class files javac would never emit. There are shapes down there that no Java file can describe, and a source-level tool simply cannot reach them.

what the reverser kept killing#

The pattern that repeated: I'd add a transformation, admire how bad the output looked, then write the undo for it and find out it was a lot less work than the obfuscation had been.

That's the useful signal. A transformation that a mechanical pass reverses completely isn't protection — it's noise. It costs you real things: bigger artefacts, slower loading, sometimes a genuine behaviour change you don't notice for weeks. And it buys nothing against anyone with the patience to write the reversal.

Renaming is the clearest case. Stripping every identifier down to a, b, aa feels devastating and mostly isn't, because names were never load-bearing — the structure survives untouched, and a reverser doesn't need your names, only consistent ones they can assign themselves. It raises the cost of reading without raising the cost of understanding, and the second is the one an attacker is paying.

What survived my own reversing attempts were the transformations where undoing them meant reasoning about what the program actually does, not just pattern matching on what it looks like. Anything a visitor could spot and rewrite mechanically, a visitor did.

the part that generalises#

I stopped working on the JVM a long time ago and this is still the project I think about most, because the lesson wasn't about bytecode at all:

You learn almost nothing about a defence by admiring it. You learn everything by attacking it.

Anyone can build the half that looks strong. The half that tells you the truth is the one that tries to break it, and it's the half people skip, because it's less fun and it mostly produces bad news about work you already did.

That instinct is the thing that carried forward. It's why I care about the race in an auction system where two bids land in the same instant, and about the booking slot two people click at once — not because those failures showed up in testing, but because the interesting case is the adversarial one and you only find it by going looking on purpose.

Nobody reports the double-booking bug. They just don't come back.


The concrete version of all this — what happened when the deobfuscator was pointed at ProGuard and Allatori, and why encrypted strings hand you the key along with the lock — is a separate note.

all notes