All articles

Putting names back on obfuscated code, through structural similarity

July 1, 20268 min read

A Java bytecode obfuscator, the archetype being ProGuard, renames every symbol in a program. A FightManager class becomes fMz, its methods become a, b, c, and its fields the same. The program runs identically, but when you decompile the result, you get a wall of files with two or three letter names. I ended up with in that state, alongside an older build of the same application whose decompiled sources still carried real names. The goal: match the two, for every named class find its obfuscated twin.

This isn't a piece about obfuscation itself, it's about how to walk it back upstream, and above all about the approaches I weighed before settling on the simplest one that actually holds up. Because the interesting part isn't "how do you rename code," it's "what, in a program, stays stable once you strip away its names."

What the obvious approaches cost

Matching by name is obviously dead: that's precisely the information that's gone missing. Three serious families of approaches remain, and I looked at them in order.

Graph diffing (BinDiff style)

Tempting for anyone who's already done binary diffing: compare the logic itself, control flow graphs, instruction sequences, bytecode shape. That's what tools like BinDiff do on native code. Two problems at my scale. First, a Java decompiler's output isn't reliable at the instruction level, so the "low level" signal these methods rely on is already degraded before I even start. Second, pairwise comparing graphs across nineteen thousand candidates is expensive, and I wanted a tool I could rerun often, not a multi-hour batch job. For the actual question, knowing which file matches which file, it's overkill.

A language model

Send both trees to a language model and ask it to match them semantically. Slow and expensive at this volume, and above all opaque: a tool that spits out "similarity 0.73" without showing anything leaves you unable to either verify it or fix it. On a piece of work where one wrong match propagates into everything you build afterward, opacity is disqualifying.

The third, the one I settled on, starts from a simple observation: a symbol renamer renames symbols, it doesn't rewrite everything else. All you have to do is list what survives the transformation.

The invariants that survive

Four signals pass through obfuscation intact, with very different strengths.

String literals
Log messages, error text, configuration keys stay word for word. Since they're written by humans, they're nearly unique: a class that logs three specific sentences keeps those three sentences, and two classes that share exactly those three sentences are almost certainly the same one. It's the strongest fingerprint available.
Numeric constants
The obfuscator doesn't rename the number 4096 or the float 0.75. A class's set of constants is stable, but weaker: many classes share the same small integers, so the signal discriminates better than it identifies.
Unobfuscated imports
The code still references framework and third-party library classes, which keep their real names. A class that imports a specific logger and a specific data structure already looks a lot less like its neighbors.
Structural shape
Number of methods, fields, implemented interfaces. On its own it's too ambiguous to identify anything, but as a tiebreaker between two already-close candidates, it's valuable.

A score you can actually read

The mechanics are straightforward. I parse each file with an AST parser (tree-sitter on the Java side), extract a feature vector for the four signals, and compare every named class from the old build against all the obfuscated candidates. The score is a weighted sum heavily dominated by the strings:

01StringsConstantsImportsShape
Weight of each signal in the final score
Score formula
text
score = 0.50 * jaccard(chaines)
      + 0.20 * recouvrement(constantes)
      + 0.15 * recouvrement(imports)
      + 0.15 * proximite(forme)

The rule I imposed on myself

Every proposed candidate shows the breakdown by signal, which strings matched, which constants, which imports, never a bare score. If I have to choose between a slightly more accurate but mute model and a slightly less accurate one that shows its reasoning, I take the second one every time. It's a human who confirms the medium-confidence matches, and they can only confirm what they can verify.

The trade-offs around that score

The choice of weights is anything but obvious, and that's where most of the thinking went.

Equal weights (0.25 each)

In practice this drowns the one near-unique signal, the strings, under three ambiguous ones.

0.50 on strings

This isn't cosmetic, it reflects the fact that one rare log sentence is worth, on its own, more than a class's entire structure.

On the strings specifically, I hesitated between a raw Jaccard over the whole set of literals and a rarity-weighted approach along the lines of TF-IDF, which would make a unique sentence weigh far more than a run-of-the-mill "true" or "error" found everywhere. TF-IDF is the theoretically correct answer, but Jaccard is good enough in practice because long sentences already mechanically dominate the intersection. I kept it as a possible improvement, not a prerequisite, so as not to pay for complexity before actually needing it.

Graph propagation, deliberately postponed

Once a few reliable anchors are in place, you could propagate along the reference graph: if an already-mapped class A calls a class B, that strongly constrains B's candidates. This would solve precisely the string-less classes, the ones scoring alone leaves unresolved. I didn't put it in the first version for two reasons. It requires a base of reliable anchors first, so it comes after scoring, not in its place. And it propagates errors just as readily as correct answers, so it demands a baseline confidence that I only have once the high-signal matches are confirmed. It's a layer on top, not a replacement.

A free source of ground truth

To tune the weights without guessing, there's a trick I keep up my sleeve: some sub-packages remain partially unobfuscated. Those classes form known pairs, a free source of ground truth, against which to calibrate the coefficients instead of setting them by feel.

Finally, I looked at existing deobfuscation tools before writing my own. Most assume you already have a mapping, or rename things via local, identifier-by-identifier heuristics. None answer my question, which is matching entire files against an old named build used as a reference. The end goal could be to rewrite identifiers in place, turning a mapping into an IDE-compatible rename, but I left that out of the initial scope: produce a reliable correspondence file first, rewrite later, never the other way around.

Two phases, with a human in the loop

The tool runs in two phases.

  1. 1

    Index

    An indexing phase that parses everything in parallel and persists the features, once, or whenever new files show up.
  2. 2

    Review and confirm

    A review phase where I browse the classes, look at the best candidates and their per-signal breakdown, and confirm.

Confirmed matches are written to a mapping file that survives reindexing, so progress happens incrementally: high confidence first, medium next, and the ambiguous cases left aside rather than guessed.

What it doesn't do

Two deliberate failure modes, by design.

Indistinguishable classes

Small data-carrying classes, with no strings at all and a structure identical to dozens of others, are indistinguishable: the tool flags them as ambiguous rather than forcing a wrong answer.

Split or merged classes

When a class has been split or merged between the two versions, no candidate matches perfectly; the partial signals still surface leads, but it's the human who decides based on the diff.

A decompiler that isn't always reliable

There's also an unpleasant reality further upstream: a decompiler's output isn't always valid Java. For files the AST parser rejects, I fall back to regex-based string extraction. You lose structural granularity on those cases, but you keep the strongest signal.

The lesson

À retenir

Rename-based obfuscation is a lossy transformation, but the loss falls on the names, not on everything else. The logic, the messages, the constants, the shape are all invariants, and as soon as you list them, re-anchoring meaning becomes a classic similarity problem again, in the same spirit as binary diffing. The real decision wasn't inventing some exotic metric, it was refusing the heavyweight options, the flow graph and the big model, for as long as four well-chosen signals and a human confirming were enough. Being able to explain every match ended up being worth far more than the few points of precision a black box might have given me.