Obfusquez-vous ?

le 12/10/2009 par Eric BÉrenguier
Tags: Software Engineering

Les architectures d'exécution "modernes" comme Java et .Net ont apporté des gains indéniables comme la standardisation de l'infrastructure, portabilité, sécurité, performances dans certains cas, l'outillage pour les développeurs ...

Dans ces environnements, on peut obtenir facilement le code source de l'application à partir d'une application packagée (JAR/WAR/EAR ou EXE/DLL). Il suffit d'utiliser un "décompilateur", par exemple jad, JD ou Jode dans le monde Java, et .Net Reflector pour .Net. Il n'existe pas de solution équivalente dans le cas de code natif (par exemple produit par un compilateur C/C++).

Dans certains cas, cela n'est pas souhaitable :

  • Si mon application contient des algorithmes qui constituent un avantage concurrentiel pour ma société et que le code est facilement accessible (par exemple si l'application est distribuée sous forme de client lourd). On peut citer par exemple les algorithmes de pricing dans le domaine de la finance ;
  • Un utilisateur qui a accès à l'application peut la modifier pour contourner un mécanisme de contrôle de licence ou contourner des mécanismes de contrôle d'accès implémentés dans le code client.

Ces exemples s'appuient sur des solutions qui sont rarement choisies en cible pour les nouvelles applications (client lourd, contrôle d'accès sur le client ...), mais beaucoup d'applications "legacy" sont bâties sur ces principes et modifier l'architecture d’une application existante peut se révéler très coûteux voire irréalisable à court-terme.

Les outils d'obfuscation (anglicisme, en français je n’ai trouvé que « obscurcissement » ou « offuscation » qui ne correspondent pas vraiment) proposent de remédier à ce problème. Ils modifient le code compilé (byte code Java,IL ou Intermediate Language en .Net) afin de le rendre incompréhensible. Certains produits annoncent même être capables de bloquer le fonctionnement des décompilateurs. On peut donc se poser la question de l'efficacité de tels outils. Je vais essayer d'apporter des éléments de réponse au travers 2 articles :

  • Ce premier article « Obfusquez vous ? » expliquera leur fonctionnement des outils d'obfuscation au travers d'exemples concrets ;
  • Le deuxième article « Protégez vous ! », plus à destination des chefs de projet et architectes, permettra de s’écarter du code et prendre le recul nécessaire et proposera des bonnes pratiques, des éléments de démarche et des solutions alternatives lorsqu'on souhaite protéger son code.

Voici donc les exemples de code :Manager Advisory - Explicit CodePremier exemple : obfuscation basique Ce premier exemple présente un mécanisme simple et implémenté par tous les outils du marché. Il consiste à renommer tous les symboles (noms de classes, méthodes, packages ...). Les informations de "debug" (numéros de lignes, noms de variables locales ...) sont aussi supprimées pour limiter les informations disponibles sur le code original. Voici le code original de notre exemple :

package com.octo.pokermanager.core;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.util.HashMap;
import java.util.Map;

import com.octo.pokermanager.client.model.Tournament;
import com.octo.pokermanager.exception.InitializeException;

public class TournamentPersisterSerialisableImpl implements TournamentPersister {
	
	private Map< String , Tournament > tournaments = null;
	public static final String DB_FILE_NAME = "persist.db";

	/*
	 * @see
	 * com.octo.pokermanager.server.TournamentPersister#save(com.octo.pokermanager
	 * .client.model.Tournament)
	 */
	public void save(Tournament tournament) {
		if (tournament.getName() == null) {
			throw new IllegalArgumentException(
					"Tournament name should not be null [tournament:"
							+ tournament + "]");
		}

		tournaments.put(tournament.getName(), tournament);
		try {
			persistInFile();
		} catch (IOException e) {
			//TODO : do something
			e.printStackTrace();
		}
	}

	private void persistInFile() throws IOException {
		FileOutputStream underlyingStream = new FileOutputStream(DB_FILE_NAME);
		ObjectOutputStream serializer = new ObjectOutputStream(underlyingStream);
		serializer.writeObject(tournaments);
		serializer.flush();
		underlyingStream.flush();
		serializer.close();
		underlyingStream.close();
	}
// ...

Et voici le code récupéré après passage d'un obfuscateur et décompilation :

package a.a.a.b;

import a;
import a.a.a.a.a.i;
import a.a.a.d.a;
import java.io.*;
import java.util.HashMap;
import java.util.Map;

public class c
    implements a.a.a.b.a
{
    private Map a;

    public void a(i j)
    {
        if(j.M() == null)
            throw new IllegalArgumentException((new StringBuilder()).append(a.a("nTLHPZP_\\O\021TWVP\032YSFOB_\rTMO\001XC\033KOvW9ajThH|Z|_xOo")).append(j).append(a.a("\b")).toString());
        a.put(j.M(), j);
        try
        {
            B();
        }
        catch(IOException ioexception)
        {
            ioexception.printStackTrace();
        }
    }

    private void B()
        throws IOException
    {
        FileOutputStream fileoutputstream = new FileOutputStream("persist.db");
        ObjectOutputStream objectoutputstream = new ObjectOutputStream(fileoutputstream);
        objectoutputstream.writeObject(a);
        objectoutputstream.flush();
        fileoutputstream.flush();
        objectoutputstream.close();
        fileoutputstream.close();
    }
// ...

On remarque sur cet exemple qu'un même nom de symbole a été utilisé plusieurs fois pour réduire encore la lisibilité du code.

On peut aussi remarquer que les chaînes de caractères ont été chiffrés (cf. ligne 18) par la fonction de « string obfuscation » proposée par certains produits. Son objectif est de masquer des informations présentes dans le code comme des mots de passe ou autre information sensible. Le lecteur attentif aura remarqué que le code permettant de déchiffrer ces informations se trouve quelque part en clair dans l’application packagée et qu’il est facile de le retrouver.

Deuxième exemple : obfuscation "avancée" Le dernier mécanisme étudié ici est le "control-flow obfuscation" (obfuscation des structures de contrôle). En Java et .Net, les structures de contrôles du langage (if, for, while, exceptions ...) se traduisent en byte code par des branchements simples (l'équivalent d'un "goto" pour ceux qui s'en souviennent). Il est possible d'écrire du byte code correct qui ne correspond à aucune structure de contrôle du langage de haut niveau,

Un obfuscateur va donc modifier légèrement une classe de manière à ne pas modifier son comportement, mais à faire échouer un décompilateur, ce qui se traduit en général par un résultat faux du décompilateur (code qui ne compile pas, ou qui n’est pas équivalent au code original), ou même faire planter le décompilateur.

L'impact du « control flow obfuscation » est très variable selon les produits.

Voici le code original :

package com.octo.pokermanager.client.model;
import java.io.Serializable;
import java.util.List;

public class RandomPlayerSelector implements Serializable, PlaceSelector {
	public void reorderPlayers(List< Players > players) {
		Player tmp = null;
		int nbPerm = players.size();
		while (nbPerm>0) {
			int i = (int) Math.round(Math.random()*(players.size()-1));
			int j = nbPerm---1;
			tmp = players.get(i);
			players.set(i, players.get(j));
			players.set(j, tmp);
		}
	}
}

Et le code obfusqué décompilé :

package a.a.a.a.a;
import java.io.Serializable;
import java.util.List;

public class k
    implements Serializable, d
{
    public void a(List list)
    {
        Object obj = null;
        int i = list.size();
_L3:
        JVM INSTR ifle 83;
           goto _L1 _L2
_L1:
        int j = (int)Math.round(Math.random() * (double)(list.size() - 1));
        int i1 = i-- - 1;
        l l1 = (l)list.get(j);
        list.set(j, list.get(i1));
        list.set(i1, l1);
        i;
          goto _L3
_L2:
    }
}

Le décompilateur n'a donc pas su produire du code recompilable.

Conclusion Cette description n'est pas exhaustive, il existe d'autres mécanismes comme par exemple :

  • l'injection de code mort (code non appelé, ou n'ayant pas d'impact sur le fonctionnement du code) dans le corps des méthodes pour réduire la lisibilité ;
  • le remplacement de code par du code équivalent fonctionnellement (par exemple avec de l'"inlining" de méthodes, ajouts de tests toujours vrais, etc ...);
  • le chiffrement des fichiers jar ou assembly (il ne s'agit pas d'obfuscation à proprement parler).

On peut donc se demander si ces outils sont réellement efficaces ? S'il existe des moyens de contournement ? D'autres méthodes plus efficaces sont-t-elles à privilégier ?

Mon prochain article essaiera de répondre à ces questions et abordera en particulier les points suivants :

  • Quelles sont les limitations des obfuscateurs ?
  • Quelles sont les bonnes pratiques pour réussir la mise en œuvre d’un outil d'obfuscation ?
  • Les outils d'obfuscation se présentent comme des « moulinettes » qui protègent automatiquement le code et les coûts de licence de ces produits sont en général raisonnables, donc à première vue le coût de mise en oeuvre ne semble pas important. Mais y-a-t-il des coûts cachés ? Quel est l'impact sur le processus projet ?
  • Existe-t-il des solutions alternatives ?

En attendant, n'hésitez pas à réagir sur ces exemples et sur l'obfuscation en général.