Tous les articles

Remettre des noms sur du code obfusqué, par similarité structurelle

1 juillet 20268 min de lecture

Un obfuscateur de bytecode Java, l'archétype étant ProGuard, renomme tous les symboles d'un programme. Une classe FightManager devient fMz, ses méthodes deviennent a, b, c, ses champs pareil. Le programme tourne à l'identique, mais quand tu décompiles le résultat, tu obtiens un mur de fichiers aux noms de deux ou trois lettres. Je me suis retrouvé avec dans cet état, et à côté un build plus ancien de la même application dont les sources décompilées portaient encore de vrais noms. L'objectif : apparier les deux, pour chaque classe nommée retrouver son jumeau obfusqué.

Ce n'est pas un texte sur l'obfuscation en soi, c'est sur la manière de la remonter à contre-courant, et surtout sur les approches que j'ai pesées avant de m'arrêter sur la plus simple qui tienne la route. Parce que la partie intéressante n'est pas « comment on renomme du code », c'est « qu'est-ce qui, dans un programme, reste stable quand on lui arrache ses noms ».

Ce que les approches évidentes coûtent

Matcher par nom est évidemment mort : c'est précisément l'information qui a disparu. Restent trois familles d'approches sérieuses, et je les ai regardées dans l'ordre.

Diffing de graphes (façon BinDiff)

Tentant pour qui a déjà fait du diffing de binaires : comparer la logique elle-même, graphes de flot de contrôle, séquences d'opérations, forme du bytecode. C'est ce que font des outils comme BinDiff sur du natif. Deux problèmes à mon échelle. D'abord, la sortie d'un décompilateur Java n'est pas fiable au niveau instruction, donc le signal « bas niveau » sur lequel ces méthodes reposent est déjà dégradé avant que je commence. Ensuite, comparer des graphes deux à deux sur dix-neuf mille candidats est cher, et je voulais un outil que je puisse relancer souvent, pas un batch de plusieurs heures. Pour la question réelle, savoir quel fichier correspond à quel fichier, c'est surdimensionné.

Un modèle de langage

Envoyer les deux arbres à un modèle de langage et lui demander d'apparier sémantiquement. Lent et cher à cette volumétrie, et surtout opaque : un outil qui te sort « similarité 0.73 » sans rien montrer ne te laisse ni le vérifier ni le corriger. Sur un travail où une correspondance fausse se propage dans tout ce que tu construis ensuite, l'opacité est disqualifiante.

La troisième, celle que j'ai retenue, part d'une observation simple : un renommeur de symboles renomme les symboles, il ne réécrit pas le reste. Il suffit de lister ce qui survit à la transformation.

Les invariants qui survivent

Quatre signaux passent l'obfuscation intacts, avec des forces très différentes.

Chaînes de caractères
Les messages de log, les textes d'erreur, les clés de configuration restent mot pour mot. Comme ils sont écrits par des humains, ils sont quasi uniques : une classe qui logge trois phrases précises garde ces trois phrases, et deux classes qui partagent exactement ces trois phrases sont presque certainement la même. C'est l'empreinte la plus forte disponible.
Constantes numériques
L'obfuscateur ne renomme pas le nombre 4096 ni le flottant 0.75. Le jeu de constantes d'une classe est stable, mais plus faible : beaucoup de classes partagent les mêmes petits entiers, donc le signal départage mieux qu'il n'identifie.
Imports non obfusqués
Le code référence toujours les classes de frameworks et de bibliothèques tierces, qui gardent leurs vrais noms. Une classe qui importe un logger précis et une structure de données précise ressemble déjà beaucoup moins à ses voisines.
Forme structurelle
Nombre de méthodes, de champs, d'interfaces implémentées. Pris seul c'est trop ambigu pour identifier quoi que ce soit, mais comme départage entre deux candidats déjà proches, c'est précieux.

Un score qu'on peut lire

La mécanique est directe. Je parse chaque fichier avec un parseur d'AST (tree-sitter côté Java), j'en extrais un vecteur de features pour les quatre signaux, et je compare chaque classe nommée du vieux build à tous les candidats obfusqués. Le score est une somme pondérée largement dominée par les chaînes :

01ChaînesConstantesImportsForme
Poids de chaque signal dans le score final
Formule du score
text
score = 0.50 * jaccard(chaines)
      + 0.20 * recouvrement(constantes)
      + 0.15 * recouvrement(imports)
      + 0.15 * proximite(forme)

La règle que je me suis imposée

Chaque candidat proposé affiche le détail par signal, quelles chaînes ont matché, quelles constantes, quels imports, jamais un score nu. Si je dois choisir entre un modèle un peu plus précis mais muet et un modèle un peu moins précis qui montre son raisonnement, je prends le second à chaque fois. C'est un humain qui confirme les correspondances moyennes, et il ne peut confirmer que ce qu'il peut vérifier.

Les arbitrages autour de ce score

Le choix des poids n'a rien d'évident, et c'est là qu'est passé le plus de réflexion.

Poids égaux (0.25 chacun)

En pratique ça noie le seul signal quasi unique, les chaînes, sous trois signaux ambigus.

0.50 sur les chaînes

Ce n'est pas cosmétique, ça reflète le fait qu'une phrase de log rare vaut, à elle seule, plus que la structure entière d'une classe.

Sur les chaînes justement, j'ai hésité entre un Jaccard brut sur l'ensemble des littéraux et une pondération par rareté façon TF-IDF, qui ferait peser une phrase unique bien plus qu'un banal "true" ou "error" présent partout. Le TF-IDF est la bonne réponse théorique, mais le Jaccard suffit dans les faits parce que les phrases longues dominent déjà mécaniquement l'intersection. Je l'ai gardé en amélioration possible, pas en prérequis, pour ne pas payer de la complexité avant d'en avoir besoin.

La propagation par graphe, repoussée volontairement

Une fois quelques ancres sûres posées, on pourrait propager le long du graphe de références : si une classe A déjà mappée appelle une classe B, on contraint fortement les candidats de B. Ça résoudrait justement les classes sans chaînes, celles que le scoring seul laisse dans le flou. Je ne l'ai pas mise dans la première version pour deux raisons. Elle exige un socle d'ancres fiables d'abord, donc elle vient après le scoring, pas à sa place. Et elle propage aussi bien les erreurs que les bonnes réponses, donc elle demande une confiance de base que je n'ai qu'une fois les correspondances à fort signal confirmées. C'est une couche au-dessus, pas un remplacement.

Une vérité terrain gratuite

Pour régler les poids sans les deviner, il y a une astuce que je me garde : certains sous-paquets restent partiellement non obfusqués. Ces classes-là forment des paires connues, une vérité terrain gratuite, sur laquelle calibrer les coefficients au lieu de les fixer au doigt mouillé.

Enfin, j'ai regardé les outils de déobfuscation existants avant d'écrire le mien. La plupart supposent qu'on possède déjà un mapping, ou renomment par heuristiques locales identifiant par identifiant. Aucun ne répond à ma question, qui est d'apparier des fichiers entiers en s'appuyant sur un ancien build nommé comme référence. Le but final pourrait être de réécrire les identifiants en place, un mapping transformé en renommage compatible avec un IDE, mais je l'ai laissé hors du périmètre initial : produire d'abord un fichier de correspondances fiable, réécrire ensuite, jamais l'inverse.

Deux temps, et un humain dans la boucle

L'outil tourne en deux phases.

  1. 1

    Indexer

    Une phase d'index qui parse tout en parallèle et persiste les features, une seule fois, ou quand de nouveaux fichiers arrivent.
  2. 2

    Revoir et confirmer

    Une phase de revue où je navigue les classes, regarde les meilleurs candidats et leur ventilation par signal, et confirme.

Les correspondances confirmées sont écrites dans un fichier de mapping qui survit à une réindexation, si bien qu'on avance de façon incrémentale : le fort taux de confiance d'abord, le moyen ensuite, et l'ambigu laissé de côté plutôt que deviné.

Ce que ça ne fait pas

Deux échecs assumés, par construction.

Classes indistinguables

Les petites classes porteuses de données, sans aucune chaîne et de structure identique à des dizaines d'autres, sont indistinguables : l'outil les marque ambiguës plutôt que de forcer une réponse fausse.

Classes scindées ou fusionnées

Quand une classe a été scindée ou fusionnée entre les deux versions, aucun candidat ne matche parfaitement ; les signaux partiels remontent quand même des pistes, mais c'est l'humain qui tranche sur le diff.

Un décompilateur pas toujours fiable

Il y a aussi une réalité désagréable en amont : la sortie d'un décompilateur n'est pas toujours du Java valide. Sur les fichiers que le parseur d'AST refuse, je bascule sur une extraction des chaînes à la regex. On perd la finesse structurelle sur ces cas, on garde le signal le plus fort.

La leçon

À retenir

L'obfuscation par renommage est une transformation à perte, mais la perte porte sur les noms, pas sur tout le reste. La logique, les messages, les constantes, la forme sont autant d'invariants, et dès qu'on les liste, réancrer du sens redevient un problème de similarité classique, du même esprit que le diffing de binaires. La vraie décision n'a pas été d'inventer une mesure exotique, elle a été de refuser les options lourdes, le graphe de flot et le gros modèle, tant que quatre signaux bien choisis et un humain qui confirme suffisaient. Pouvoir expliquer chaque correspondance a fini par valoir bien plus que les quelques points de précision qu'une boîte noire m'aurait peut-être donnés.