Au siècle dernier, Sartre écrivait : « Plus claire la lumière, plus sombre l'obscurité… Il est impossible d'apprécier correctement la lumière sans connaître les ténèbres. » C'est cette pensée qui va nous guider tout au long de cet article : découvrir et comprendre quelques techniques d'obfuscation, mais surtout appréhender les mécanismes qui les guident, pour en implémenter de plus robustes, ou pour mieux les contrer.
1. Bienvenue dans l'obscurité
Obfusquer un code source, c'est le transformer de telle sorte que son « comportement observable » ne change pas, tout en rendant « plus difficile » la compréhension de ce comportement à partir d'une analyse du code exécuté. Idéalement, une analyse en boîte blanche du code obfusqué n'apporte aucune information supplémentaire par rapport à une analyse en boîte noire. C'est le Graal de l'obfuscation !
Généralement, à la manière d'une serrure qui rend l'ouverture d'une porte plus difficile, mais n'empêche pas le voleur motivé, une obfuscation rend la rétro-ingénérie d'un programme plus complexe, mais un analyste motivé disposant de suffisamment de temps arrivera à ses fins. Il existe même un argument théorique [0] prouvant qu'il est impossible de construire un obfuscateur générique parfait.
Qui utilise des obfuscateurs ? Plein de monde ! Des industriels désireux de protéger leur propriété intellectuelle (e.g. protocole d'IMessage pour Apple) ou leurs clefs de chiffrement (1) (e.g. client Dropbox), ou des concepteurs de malwares, soucieux de cacher la signature de leur code au regard inquisiteur des antivirus, ou encore des militaires espérant protéger un quelconque algorithme, même si le vaisseau embarquant ce dernier tombe aux mains de l'ennemi.
L'ennemi, cet être honni ! Il a les pleins pouvoirs sur votre application. Certes il n'a pas accès à votre code source, mais il est libre d'exécuter, décompiler, déboguer, tracer, instrumenter votre application (insérer ici le troll sur Oracle). Il a lui aussi à sa disposition des outils pour l'assister : des solveurs, des décompilateurs, des bases de données, et son « flair ». C'est tout ça qu'il va falloir tromper.
2. Premier exemple
Certaines obfuscations sont… nulles. Bon, pas vraiment nulles, mais inutiles si utilisées seules : elles protègent mal votre programme parce qu'il est possible d'écrire une transformation de code qui annule « facilement » ses effets. Le « facilement » est important ici : si le coût (disons, en temps de calcul ou mémoire) est rédhibitoire, c'est tout bon.
Prenons un exemple simple : la séparation de constante. C'est une transformation qui consiste à remplacer une opération impliquant une constante en deux opérations impliquant deux constantes. Par exemple :
a = b ^ 0x1331;
devient :
a' = b ^ 0x0330
a = a' ^ 0x1001 ( = a' ^ (0x0330 ^ 0x1331) )
où 0x0330 pourrait être n'importe quel entier tiré aléatoirement.
Cette obfuscation de donnée dissimule effectivement la constante 0x0330 à un grep naïf, et même à une analyse des traces puisque cette constante n'est jamais présente en mémoire, ni dans un registre (en désactivant toute optimisation ultérieure du compilateur, évidemment).
Malheureusement, une simple propagation de constantes fera réapparaître la constante originale. Pire, si on appelle les simplifications standards d'un optimiseur de code, il déconstruira l'obfuscation… Néanmoins, cette obfuscation n'a pas un impact significatif sur les performances (elle travaille sur des registres), aucun impact sur l'utilisation mémoire, et à moins d'avoir un code dégénéré en entrée (e.g. un code qui aurait une très grande densité de littéraux), l'impact sur la taille du binaire n'est pas critique.
Le concepteur d'obfuscation se doit de faire ce genre d'analyse pour chaque nouvelle obfuscation : de quoi protège-t-elle ? De quoi ne protège-t-elle pas ? Quel est le coût de la protection (performance, mémoire, taille du binaire) ? Peut-elle dans de rares cas améliorer ces critères ?
3. Second exemple
Observons maintenant cette égalité :
x ^ y = x - y - 2 * (x | ~ y) – 2 ;
Elle doit sa particularité au fait qu'elle combine des opérations d'arithmétique entière (- et ∗) avec des opérations d'arithmétique booléenne (| et~). C'est ce qui la rend difficile à simplifier, du moins du point de vue de solveurs comme Z3 ou d'outils de calcul symbolique comme Sympy. C'est cette fois une obfuscation des traitements, et on peut l'intégrer à un moteur de transformation d'expressions qui effectuera la substitution.
Reprenons les critères précédents : c'est une obfuscation qui semble résister aux analyses statiques, une analyse dynamique n'apportera pas grand-chose non plus pour une attaque en boîte blanche. Elle a un certain coût en temps d'exécution et en taille de binaire, mais qui reste raisonnable. On se gardera pourtant de l'utiliser à outrance : cela attirera l'œil exercé de l'analyste, et une fois qu'il aura identifié le motif et délimité ses entrées-sorties, il pourra mener une petite attaque en boîte noire qui lui permettra de retrouver rapidement le xor.
4. Connaître son ennemi
En combinant les deux transformations précédentes (éventuellement de façon itérative), on rend la propagation de constantes plus complexe. On obtient l'union de leurs bonnes propriétés et on a effectivement mieux protégé la constante de départ, mais c'est au prix du produit des coûts.
Ainsi va la vie au pays de l'obfuscation : tout se paye et il faut une certaine expertise pour trouver la bonne combinaison d'obfuscations qui apportera une bonne protection à moindres frais…
Ce qu'il est ainsi important de juger, c'est le coût d'une obfuscation en terme de temps d'exécution, de mémoire utilisée et de taille binaire finale par rapport au coût en terme de temps perdu par l'attaquant. Évaluer ce temps est une tâche compliquée :quelles métriques choisir pour définir la complexité d'un code ?En effet, les métriques utiliséeshabituellement en ingénierie logicielle, comme la complexité cyclomatique, le nombre d'instructions, le couplage, donnent plus une estimation sur la difficulté de maintenir une base de code que sur la difficulté à l'attaquer. Bien souvent, la mise en situation réelle est également nécessaire, c'est d'ailleurs parfois le thème de challenges !
Un critère pouvant cependant être pris en compte est celui des limites des outils de rétro-ingénierie publics couramment utilisés. Parmi ces outils, il y a l'incontournable IDA [1] pour l'analyse statique ou encore la librairie de désassemblage Capstone, et tout une suite d'outils d'analyse dynamique tels que le projet Medusa [2], les frameworks Miasm [3] et Metasm [4], Radare2 [5] ou plus récemment Triton [6], présenté dans ce dossier.
Chacun de ces outils apporte des fonctionnalités très intéressantes pour un analyste (telles que l'évaluation symbolique ou la simplification d'expressions) lui permettant de gagner un temps non négligeable, mais possède aussi des limites les rendant parfois inutilisables.
Typiquement, certains de ces logiciels reposent sur une représentation symbolique de chaque instruction assembleur. Par exemple, l'instruction x86 mov rax,rbx sera représentée symboliquement par { rax = rbx }. Les jeux d'instructions évoluant constamment (ajout de nouvelles instructions vectorielles par exemple), il n'est pas souvent facile pour ces outils de suivre ces nouveautés. L'utilisation de telles instructions peut ainsi rendre ces outils obsolètes pour peu que l'attaquant ne puisse pas facilement les adapter.
D'autres limites dans les modèles utilisés peuvent apparaître. Par exemple, rappelons qu'un registre processeur n'a fondamentalement aucun type, il stocke un certain nombre de bits. Or, suivant l'instruction utilisée, ce registre va être considéré potentiellement comme un entier signé, non signé ou comme un nombre flottant. La valeur symbolique de ce registre va donc dépendre de différentes représentations au cours du temps. Il ne sera donc pas forcément trivial de simplifier des expressions résultantes de telles manipulations, et peut perdre certains solveurs comme Z3 [7], utilisé par exemple dans Triton (qui n'est pas aujourd'hui capable de gérer ce genre de cas).
Cependant, il ne faut pas oublier que cet indicateur est biaisé par le fait que les attaquants ont des outils privés. La rumeur court par exemple que certains pourraient décompiler de l'assembleur x86 32/64 bits vers l'IR LLVM de manière assez efficace… Le jeu du chat et de la souris perdure.
5. Petite cartographie des obfuscations
Collberg a déjà référencé [8] un grand nombre d'obfuscations classiques. Sans aucun doute, il existe bien plus d'obfuscations qui n'ont pas été publiées (2) et qui ne le seront certainement pas tant qu'un analyste ne les aura pas rencontrées et fait tomber : parfois la protection accordée par une obfuscation tombe, au moins partiellement, quand la technique est connue. Si on reprend l'égalité de la section précédente, savoir qu'elle existe permet de la contrer par une simple recherche de motifs : pas besoin d'un solveur !
Il existe deux grandes catégories d'obfuscation :
- les obfuscations des données : chiffrement, transformation des accès aux données par une fonction réversible, reshaping des données, séparation de variables, etc.
- les obfuscations des traitements : aplatissement du graphe de flot de contrôle, insertion de code mort, combinaison d'expansion/extraction de procédure (3).
Une obfuscation peut s'appliquer de manière manuelle (4) ou de manière automatique en utilisant un obfuscateur de code (e.g. O-LLVM [9]). L'approche automatique a beaucoup d'avantages, notamment en termes d'ingénierie, mais elle doit faire des hypothèses plus fortes sur le code d'entrée.
Ces obfuscateurs automatiques peuvent travailler à plusieurs niveaux, qui sont d'ailleurs complémentaires :
- Code source, par exemple en transformant du code C en code C obfusqué. On parle alors de compilateur source-à-source.
- Représentation interne, par exemple en transformant du bytecode LLVM comme le fait O-LLVM.
- Binaire, comme le font par exemple les packers.
Chaque approche possède ses avantages et inconvénients.
L'approche source à source présente l'avantage d'être facilement intégrable à une chaîne de compilation existante, chaîne utilisant par exemple un compilateur développé pour une architecture propriétaire. Cependant, elle est spécifique à un langage donné (C != C++ != Objective C) et on ne contrôle pas ce que le compilateur classique va faire de nos transformations.
Travailler sur une représentation interne (comme le bytecode LLVM) permet d'avoir un meilleur contrôle sur le flot de compilation, donc sur le code produit. En terme d'ingénierie, cela rend également les obfuscations quasiment indépendantes du langage source. On ne peut cependant pas complètement jouer sur les instructions émises, voir leurs encodages en phase de génération du code assembleur, à moins de spécialiser pour chaque (famille d') architecture. L'intégration de l'obfuscateur au sein de la chaîne de compilation peut entraîner un changement de compilateur, qui n'est pas forcément toujours possible (certification, extensions propriétaires), ou du moins sans douleur…
Les packers binaires ont l'avantage de travailler (par définition !) sur le binaire final et d'être ainsi facilement intégrables à une chaîne de compilation. De plus, ils supportent virtuellement n'importe quel langage compilé. Un inconvénient est la dépendance forte entre un packer et l'architecture et OS ciblés. De plus, il est généralement plus difficile de remonter à un niveau d'abstraction correct pour utiliser des analyses puissantes (e.g. points-to). Il est aussi plus difficile d'appliquer des transformations telles que l'obfuscation du flot de contrôle au niveau binaire.
Suivant le langage utilisé, on peut aussi obfusquer conjointement l'application et son runtime, qui devient alors spécifique à l'application. C'est la technique utilisée par le client Dropbox, comme décrit dans [10].
6. Simple obfuscation du graphe de flot de contrôle en se basant sur LLVM
Nous allons maintenant présenter une série d'obfuscations de bytecode LLVM, en nous concentrant sur la protection du graphe de flot de contrôle (CFG). Pour suivre les exemples utilisés, il vaut mieux avoir une petite compréhension du bytecode LLVM, et surtout de la représentation utilisée pour le CFG. Dans LLVM, toute fonction est composée d'une liste de blocs de base (BB) : ce sont les nœuds du graphe, et le premier élément de la liste est l'unique point d'entrée de la fonction. Il n'est pas possible de brancher à l'intérieur d'un BB, seulement au début. Les arêtes du graphe sont décrites par des instructions de branchement, principalement :
br label <dest> ;
Pour un saut inconditionnel vers le BB <dest> :
br i1 <cond>, label <iftrue>, label <iffalse> ;
Pour un saut conditionné par le booléen <cond> vers <iftrue> ou <iffalse> :
switch <intty> <value>, label <defaultdest> [ <intty> <val>, label <dest> … ]
Pour un saut parmi plusieurs destinations suivant la valeur prise par l'entier <value>.
Sauf exception, il n'est pas possible de sortir autrement d'un BB que par sa dernière instruction, appelée terminator. Dans la suite, on ignorera la gestion des exceptions.
Comment perturber le CFG d'une fonction ? Examinons quelques classiques.
6.1. Duplication de blocs de base
L'idée de cette transformation est de dupliquer un bloc de base et d'ajouter un branchement gardé par une variable quelconque prise dans le contexte, par exemple la parité de l'adresse d'un alloca.
.-----.
-| BB0 |-
.----. .-----. / .-----. \ .------.
| BB | => | Pre |- -| Post |
.----. .-----. \ .-----. / .------.
-| BB1 |-
.-----.
Où Pre se charge de calculer la condition de branchement (le prédicat), BB0 et BB1 sont des clones de BB où les registres ont été renommés - LLVM utilise une forme SSA pour les assignations de registres virtuels. Post se charge de fusionner les registres renommés dans les registres initiaux à l'aide d'instructions phi [11]. L'intérêt de cette transformation est que les obfuscations suivantes vont transformer BB0 et BB1 de manière différente, et l'analyste aura donc plus de travail à faire puisqu'il aura deux blocs à analyser. À condition que notre prédicat soit suffisamment furtif, i.e. qu'il n'apparaisse pas de façon évidente comme un prédicat construit à partir de rien.
Comparer les valeurs de deux registres de même type visibles depuis BB est une façon bien plus furtive de construire un prédicat se fondant dans la normalité.
6.2 Mutation de blocs de base
Cette obfuscation ressemble à la précédente, à cela près que le prédicat est constant et connu à la compilation (5). On s'autorise alors à modifier le corps de la branche qui n'est jamais prise. L'objectif est de rendre une analyse statique plus complexe, mais l'intérêt face à une analyse dynamique est faible : la branche n'est jamais prise, et même si un outil n'arrive pas à prouver ce fait, l'analyste ne perdra probablement pas de temps à essayer d'analyser un code qui ne paraît pas exécuté.
6.3 Aplatissement du graphe de flot de contrôle
Cette transformation a déjà fait l'objet d'un excellent article dans MISC n°68, mais elle est importante ici, nous la présenterons donc brièvement. Elle protège le CFG en encodant la table de transition du CFG dans un énorme switch, comme illustré par la petite figure suivante :
A P
| |
.- -. D
| | |
.->-B C => .-.-.-.
| | | | | | |
.-<-.- -. A B C E
|
E
La structure du graphe a disparu (visuellement), elle est maintenant encodée dans le code du bloc de dispatch D qui obfusque la table de transition.
Une fois de plus, on notera qu'une bonne combinaison peut être intéressante :
MutateBB + DuplicateBB + CFG Flattening
Cette combinaison génère de nombreux blocs de base, dont certains sont inutiles, mais la relation entre les BB (qui était évidente après la duplication/mutation) est maintenant masquée par l'aplatissement de graphe. Il faut donc casser celui-ci pour s'attaquer aux deux autres transformations. Une obfuscation similaire serait de remplacer le CFG flattening par une obfuscation qui transforme le graphe de flot de contrôle en une combinaison d'exceptions et de gestionnaire d'exceptions…
6.4 Transformation d'un code impératif en code pseudo-fonctionnel
Pour finir cette balade nocturne, nous allons examiner une obfuscation originale, utilisée dans le premier niveau du challenge HITB proposé par QuarksLab en 2014 [12].
Il s'agit d'une transformation source-à-source de code Python, qui s'inspire de la sémantique dénotationnelle pour transformer un code écrit dans un style impératif en un code écrit dans un style fonctionnel. Dis autrement, on cherche à transformer une fonction en lambda fonction comme dans l'exemple suivant, où :
def popcount(n):
r = 0
while n:
if n & 1:
r += 1
n >>= 1
return r
devient :
popcount = (lambda n: (lambda _: (_.__setitem__('$', (_['r'] if ('r' in _) else
r)), _)[(-1)])((lambda _: (lambda f, _: f(f, _))((lambda __, _: ((lambda _:
__(__, _))((lambda _: (_.__setitem__('n', ((_['n'] if ('n' in _) else n) >> 1)),
_)[(-1)])((lambda _: ((lambda _: (_.__setitem__('r', ((_['r'] if ('r' in _) else
r) + 1)), _)[(-1)])(_) if ((_['n'] if ('n' in _) else n) & 1) else _))(_))) if
(_['n'] if ('n' in _) else n) else _)), _))((lambda _: (_.__setitem__('!', 0),
_.__setitem__('r', _['!']), _)[(-1)])({'n': n, '$': None})))['$'])
Voici un petit aperçu des règles de transformation utilisées :
1. Tous les accès à des variables locales sont faits à travers un dictionnaire, qui contient initialement les arguments ('n': n) et la valeur de retour stockée dans l'identifiant '$' ('$': None). Ce dictionnaire est passé en paramètre de chaque lambda fonction et est également renvoyé, de telle sorte que chaque instruction devient une lambda fonction qui « met à jour » l'état mémoire.
2. Toutes les instructions (par opposition aux expressions) sont transformées en lambda fonction, en suivant les règles suivantes :
F(s1 ; s2) => lambda _ : F(s2)(F(s1)(_))
F(var = exp) => lambda _ : (_.__setitem__(var, E(exp)), _)[-1]
F(if cond: s1\nelse: s2) => lambda _ : F(s1) if E(cond) else F(s2)
F(return exp) => lambda _: (_.__setitem('$', E(exp)), _)[-1]
La formule pour les boucles while est un poil plus complexe et repose sur la notion d'Y combinator [13] :
F('while cond: s1\nelse: s2', state) = lambda _:
Y(lambda self, _:
self(self, F(s1, _))
if E(cond, _)
else S(s2, _),
)
3. Toutes les expressions sont transformées en remplaçant les accès en lecture à des variables par des lectures de l'état mémoire courant grâce à la fonction E :
E(name) => _['name'] if 'name' in _ else name
Le lecteur averti (et aguerri) aura remarqué que l'utilisation d'un dictionnaire Python pour représenter l'état mémoire courant nous éloigne d'un langage fonctionnel puisque le dictionnaire n'est pas copié. Mais ça va plus vite ainsi :-)
6.5 Protection des données en mémoire
La protection des données d'un programme peut être cruciale. En effet, ces données peuvent être de plusieurs types : des données statiques utilisées par le programme (clés de chiffrement, S-BOX AES) ou des données sensibles produites par différents algorithmes (comme le déchiffrement et décodage d'une vidéo dans le cas d'une DRM).
Dans les deux cas, le but est d'empêcher un attaquant de récupérer facilement ces données, par analyse statique et/ou dynamique.
Pour illustrer ces différents scénarios, nous allons prendre le cas hypothétique d'une solution de protection de vidéos à la demande qui chiffre les vidéos envoyées à ses utilisateurs (par Internet), et les déchiffre sur le poste client avant de les afficher.
L'architecture (très simple, à ne pas reproduire chez soi) serait la suivante :
- À l'envoi de la vidéo demandée par le serveur, celle-ci est chiffrée en AES, utilisant ainsi une clé de 16 octets générée aléatoirement (notée Kv) ;
- Cette clé Kv est elle-même chiffrée avant d'être envoyée à l'utilisateur. Notons la clé utilisée Ks ;
- Le serveur renvoie ainsi à l'utilisateur la vidéo chiffrée et la clé Ks.
Côté utilisateur, afin de pouvoir lire la vidéo reçue, il faut d'abord déchiffrer la clé Ks. Pour notre exemple, ce « chiffrement » sera constitué d'un simple « ou exclusif » avec un tableau constant de 16 octets et partagé entre le serveur et le client (appelé par la suite Kx). À noter que ceci n'est pas du tout suffisant pour une protection digne de ce nom, mais nous rappelons que cette architecture ne doit pas être implémentée à la maison ;-)
6.5.1 Cas des données statiques
La donnée statique à protéger dans le cas de cette DRM est le tableau de constantes Kx.
Partons du pseudo-code C suivant, qui permet de retrouver la clé Kv à partir de Ks et Kx :
static uint8_t secret_Kx[16] = {0xAA, 0xBB, …, 0xFF};
void get_video_key(uint8_t Kv[16], const uint8_t Ks[16])
for (int i = 0; i < 16; i++)
Kv[i] = Ks[i] ^ Kx[i];
La première approche qui vient à l'esprit serait de chiffrer le tableau secret_Kx, et de le déchiffrer au démarrage et/ou avant utilisation. Le pseudo-code C serait le suivant :
static uint8_t secret_Kx[16] = encrypted({0xAA, 0xBB, …, 0xFF});
void reencrypt_secret_Kx();
void decrypt_secret_Kx();
void get_video_key(uint8_t Kv[16], const uint8_t Ks[16])
for (int i = 0; i < 16; i++)
Kv[i] = Ks[i] ^ Kx[i];
Le tableau secret_Kx serait ainsi une version chiffrée du tableau initial. Les fonctions reencrypt_secret_Kx et decrypt_secret_Kx permettraient de déchiffrer et rechiffrer à la volée le tableau pour pouvoir l'utiliser.
Le désavantage principal de cette approche est qu'un attaquant peut assez facilement se placer juste après l'appel à la fonction decrypt_secret_Kx et récupérer sa sortie en mémoire.
Une autre approche serait d'appliquer une transformation octet par octet sur le tableau secret_Kx, et de n'appliquer cette transformation qu'à la lecture. L'intérêt est que le tableau décodé complet n’apparaît jamais en mémoire. Le désavantage est que cette transformation peut être détectable très facilement dans un code aussi simple que le précédent.
Une technique serait alors de combiner les deux idées précédentes, et bien sûr d'utiliser un algorithme de dérivation un peu plus « évolué » qu'un simple « ou exclusif », ou appliquer certaines couches d'obfuscations supplémentaires.
De plus, d'autres problèmes se posent pour automatiser ces tâches. En effet, dans le pseudo-code écrit, le tableau secret_Kx est déclaré « static », et est ainsi local à la compilation unit (comprendre le fichier « .c ») dans lequel il est déclaré. La propriété la plus importante qui en découle est qu'il ne peut être utilisé par une autre compilation unit ou, dans le cas des bibliothèques partagées, par une autre bibliothèque.
Sans cette propriété, il serait impossible de transformer le tableau secret_Kx sans s'assurer que tous les accès soient « gardés » par les diverses fonctions de transformations. Cependant, dans le cas d'un binaire (et non d'une bibliothèque partagée), une analyse exécutée au moment de l'édition de lien pourrait malgré tout permettre de telles transformations (cf. -flto sous clang).
6.5.2 Cas des données dynamiques
Dans notre exemple, une donnée « dynamique » (créée par le programme) à protéger est typiquement le résultat du déchiffrement de la clé Ks (soit Kv). En effet, même en combinant les techniques évoquées ci-dessus, un attaquant pourrait tout simplement récupérer la clé Kv présente en mémoire après l'appel à la fonction get_video_key.
Une idée est alors de transformer toutes les écritures dans le tableau Kv, et d'appliquer la transformation inverse lors d'une lecture.
Imaginons le pseudo-code C suivant :
static uint8_t secret_Kx[16] = encrypted({0xAA, 0xBB, …, 0xFF});
void get_video_key(uint8_t Kv[16], const uint8_t Ks[16])
for (int i = 0; i < 16; i++)
Kv[i] = transformation_a_inserer_ici(Ks[i] ^ Kx[i]);
struct AES_CTX {
uint8_t exp_keys[16][8];
};
void expand_aes_key(AES_CTX* ctx, const uint8_t key[16])
{
for (int i = 0; i < 16; i++) {
ctx→>exp_keys[0][i] = transformation_inverse_a_inserer_ici(key[i]);
ctx->exp_keys[1] = expand_one_key(ctx→exp_keys[0], …);
[...]
}
void set_aes_ctx_for_video(AES_CTX* ctx, const uint8_t Ks[16])
uint8_t Kv[16]; // clé à retrouver et protéger !
get_video_key(Kv, Ks) ;
expand_aes_key(ctx, Kv) ;
Dans la fonction get_video_key, chaque écriture dans Kv est précédée d'une transformation inversible. L'inversion de cette transformation doit ensuite être utilisée lors de la création du contexte AES (expansion de clé).
Le lecteur assidu se sera fait la réflexion que la « première » clé étendue dans le contexte AES n'est autre que la clé d'origine. On pourrait appliquer une autre transformation inversible lors du stockage des clés étendues, et appliquer les transformations inverses lors du décodage. On le comprend, ce processus peut être propagé tant que jugé nécessaire.
En terme d'implémentation, un des plus gros challenges est que, pour un tableau local à une fonction donnée (typiquement le tableau Ks de la fonction set_aes_ctx_for_video), il faut suivre son utilisation à travers les différentes fonctions qui l'utilisent (on parle alors d'analyse inter-procédurale), et spécialiser ces fonctions avec une version locale possédant les transformations locales adéquates. À noter que ce genre de transformations est déjà fait par des optimisations de LLVM, par exemple lors de la propagation de constantes inter-procédurales.
Dans le cas des variables globales, les mêmes problèmes qui apparaissent dans le cas des données statiques (voir ci-dessus) sont présents. En effet, il faut pouvoir être certain d'avoir la liste complète de toutes les fonctions utilisant les tableaux protégés !
À titre d'exemple, dans le cas ci-dessus, une protection des valeurs du tableau Kv dans la fonction set_aes_ctx_for_video impliquerait le pseudo-code C suivant :
static uint8_t __attribute__((always_inline)) lin_transform_1(const uint8_t v)
{
return v*7+1;
}
static uint8_t __attribute__((always_inline)) inv_lin_transform_1(const uint8_t v)
{
return (v-1)*183;
}
static void get_video_key_custom_1(uint8_t Kv[16], const uint8_t Ks[16])
for (int i = 0; i < 16; i++)
Kv[i] = lin_transform_1(Ks[i] ^ Kx[i]);
static void expand_aes_key_custom_1(AES_CTX* ctx, const uint8_t key[16])
for (int i = 0; i < 16; i++) {
ctx→>exp_keys[0][i] = inv_lin_transform_1(key[i]);
ctx->exp_keys[1] = expand_key(ctx→exp_keys[0], …);
[...]
}
void set_aes_ctx_for_video(AES_CTX* ctx, const uint8_t Ks[16])
uint8_t Kv[16]; // clé à retrouver et protéger !
get_video_key_custom_1(Kv, Ks);
expand_aes_key_custom_1(ctx, Kv);
La transformation a pour but de traquer les fonctions qui utilisent le tableau Kv au sein de la fonction set_aes_ctx_for_video, d'en créer de nouvelles versions (suffixées ici avec _custom_1), de « décorer » les accès au dit tableau, et de faire ainsi de suite récursivement au sein de ces nouvelles fonctions. Si une fonction qui utilise ledit tableau ne faisant pas partie de la compilation unit en cours est atteinte, alors la transformation ne peut avoir lieu. Typiquement, dans notre exemple, si nous protégeons les clés étendues, alors nous devons inclure le code complet du chiffrement d'un bloc AES dans la même compilation unit (ou les sources du programme compilé, en supposant que nous sommes capables de créer des passes au moment de l'édition de liens ; cf section 6.5.1).
6.5.3 Un tableau peut en cacher un autre
Un problème que nous avons passé sous silence jusque-là est le problème de l'aliasing. L'aliasing est le fait que, dans des langages tels que le C et C++, un segment de mémoire peut être accédé par plusieurs symboles dans le programme. Prenons l'exemple ci-dessous :
int f(size_t n)
char* buf = malloc(n);
char* mid = buf + n/2;
mid[0] = 'a';
buf[n/2+1] = 'b';
Ici, mid n'est qu'un alias vers le milieu du buffer buf : on a buf[n/2+1] == mid[1]. Imaginons que nous voulions protéger mid, il faudrait aussi protéger la dernière écriture. De plus, il n'est pas forcément évident de savoir que la taille de buf est n. En effet, il pourrait être retourné par une fonction dont nous n'avons pas le code source, présente dans une bibliothèque externe par exemple.
Un autre exemple intéressant est de considérer les dépassements de pile comme des problèmes d'aliasing. Sur les architectures Intel, le pointeur de retour de la fonction se trouve à la fin de la pile, tel qu'on peut y accéder à travers un tableau alloué sur la pile. On pourrait ainsi considérer le problème de détection des dépassements de pile comme un problème « classique » de détection d'aliasing !
7. La lumière crée l'ombre comme la lumière détruit l'ombre
La conception d'une obfuscation est très semblable à la conception d'une optimisation de code (e.g. déroulement de boucle ou réduction de force). Mais le problème est moins bien défini : la métrique à optimiser n'est pas claire, car il est difficile de noter objectivement le degré d'obfuscation d'une fonction, ou de comparer le niveau d'obfuscation atteint par l'application de deux obfuscations différentes.
Pour cette raison, il est souhaitable de donner à l'utilisateur d'un obfuscateur la possibilité de guider précisément le processus d'obfuscation plutôt qu'un vague --obfuscation-level=<n>… Difficile problème puisqu'il faut alors trouver un utilisateur averti !
À chaque obfuscation sa technique de reverse, ce qui apparente la conception d'obfuscations à un jeu du chat et de la souris. Avec une petite punition pour le concepteur d'obfuscations : quand l'obfuscation ne préserve pas le comportement du code d'origine, il doit… déboguer du code obfusqué :-)
Remerciements
Un grand merci à Ninon Eyrolles, Fernand Lone Sang et André Moulu pour leurs relectures et commentaires !
Notes
(1) C'est même un domaine spécifique : la cryptographie en boîte blanche.
(2) Vraiment aucun doute : les auteurs de cet article en ont développé plusieurs dans le cadre de l'obfuscateur closed-source epona réalisé en interne à QuarksLab.
(3) Vous ne connaissiez pas ces jolis termes pour parler d'inlining et d'outlining ?
(4) Certains plaisantins aiment à dire que c'est une technique naturellement employée par une partie des étudiants. Mais c'est ignorer que, ce qu'un esprit maladroit peut faire par inadvertance, un esprit retors peut le sublimer…
(5) Autant utiliser une vraie constante et laisser une obfuscation ultérieure la rendre opaque. Soyons économes !
Références
[0] On the (im)possibility of obfuscating programs,byB. Barak, O. Goldreich, R. Impagliazzo, S. Rudich, A. Sahai, S. P. Vadhan, and K. Yang.
[1] IDA, the Interactive Disassembler, https://www.hex-rays.com/products/ida/
[2] Medusa, an open source interactive disassembler, https://github.com/wisk/medusa
[3] Miasm, a reverse engineering framework in Python, https://github.com/cea-sec/miasm
[4] Metasm, an assembly manipulation suite, http://metasm.cr0.org/
[5] Radare2, a portable reversing framework, http://www.radare.org/r/
[6] Triton, a concolic execution framework, https://github.com/JonathanSalwan/Triton
[7] Z3, a theorem prover, https://github.com/Z3Prover/z3
[8] A Taxonomy of Obfuscating Transformations, by C.Collberg, C. Thomborson, and D. Low
[9] O-LLVM, an LLVM-based code obfuscator, http://o-llvm.org
[10] Looking Inside the (Drop) Box, by D. Kholia and P. Węgrzyn
[11] LLVM Language Reference, http://llvm.org/docs/LangRef.html
[12] Quarkslab HITB 2014 Challenge, http://blog.quarkslab.com/you-like-python-security-challenge-and-traveling-win-a-free-ticket-to-hitb-kul.html
[13] Y Combinator, https://en.wikipedia.org/wiki/Fixed-point_combinator#Fixed_point_combinators_in_lambda_calculus