Conception d`un analyseur de binaire ARM

Transcription

Conception d`un analyseur de binaire ARM
EPM–RT–2013-03
CONCEPTION D’UN ANALYSEUR DE BINAIRE ARM
Adrien Vergé
Département de Génie informatique et génie logiciel
École Polytechnique de Montréal
Avril 2013
EPM-RT-2013-03
CONCEPTION D’UN
ANALYSEUR DE BINAIRE ARM
Vergé, Adrien
Département de génie informatique et génie logiciel
École Polytechnique de Montréal
Avril 2013
2013
Adrien Vergé
Tous droits réservés
Dépôt légal :
Bibliothèque nationale du Québec, 2010
Bibliothèque nationale du Canada, 2010
EPM-RT-2013-03
Conception d’un analyseur de binaire ARM
par : Adrien Vergé
Département de génie informatique et génie logiciel
École Polytechnique de Montréal
Toute reproduction de ce document à des fins d'étude personnelle ou de recherche est autorisée à
la condition que la citation ci-dessus y soit mentionnée.
Tout autre usage doit faire l'objet d'une autorisation écrite des auteurs. Les demandes peuvent
être adressées directement aux auteurs (consulter le bottin sur le site http://www.polymtl.ca/) ou
par l'entremise de la Bibliothèque :
École Polytechnique de Montréal
Bibliothèque – Service de fourniture de documents
Case postale 6079, Succursale «Centre-Ville»
Montréal (Québec)
Canada H3C 3A7
Téléphone :
Télécopie :
Courrier électronique :
(514) 340-4846
(514) 340-4026
[email protected]
Ce rapport technique peut-être repéré par auteur et par titre dans le catalogue de la Bibliothèque :
http://www.polymtl.ca/biblio/catalogue.htm
É COLE P OLYTECHNIQUE
DE M ONTR ÉAL
INF6302 - R É -I NG ÉNIERIE DU L OGICIEL
Conception d’un
analyseur de binaire ARM
Rapport final
Auteur :
Adrien V ERG É
15 avril 2013
Superviseurs :
Ettore M ERLO
Thierry L AVOIE
2
Table des matières
1
.
.
.
.
.
5
5
5
5
6
7
2
Cadre théorique
2.1 Aspect légal . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
2.2 État de l’art . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
8
8
8
3
Réalisation du projet
3.1 Préparations préliminaires . . . . . . . . . . . . . .
3.1.1 Structure générale . . . . . . . . . . . . . .
3.1.2 Outils utilisés . . . . . . . . . . . . . . . . .
3.1.3 Création de binaires de test . . . . . . . . .
3.2 Implémentation . . . . . . . . . . . . . . . . . . . .
3.2.1 Machine virtuelle . . . . . . . . . . . . . . .
3.2.2 Désassembleur . . . . . . . . . . . . . . . .
3.2.3 Décompilateur . . . . . . . . . . . . . . . .
3.3 Génération de graphes . . . . . . . . . . . . . . . .
3.3.1 Graphe d’appel . . . . . . . . . . . . . . . .
3.3.2 Graphe de flot de contrôle intra-procédural
3.4 Difficultés rencontrées . . . . . . . . . . . . . . . .
3.4.1 Branchements dynamiques . . . . . . . . .
3.4.2 Mauvaise interprétation . . . . . . . . . . .
3.4.3 Optimisations du compilateur . . . . . . .
3.4.4 Fonctions imbriquées . . . . . . . . . . . .
3.4.5 La librairie C . . . . . . . . . . . . . . . . .
3.5 Performances . . . . . . . . . . . . . . . . . . . . .
4
5
Présentation du projet
1.1 Résumé . . . . . . . . .
1.2 Enjeux . . . . . . . . .
1.3 Objectifs . . . . . . . .
1.4 Hypothèses . . . . . .
1.5 Lien avec ma recherche
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
Résultats
4.1 Utilisation du livrable . . . .
4.1.1 Compilation . . . . . .
4.1.2 Analyse . . . . . . . .
4.1.3 Affichage des graphes
4.2 Exemples . . . . . . . . . . . .
4.2.1 helloworld . . . . . . .
4.2.2 GNU Coreutils . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
Discussion
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
10
10
10
12
13
13
13
14
15
20
20
21
23
23
23
24
24
25
26
.
.
.
.
.
.
.
28
28
28
28
29
29
30
31
33
3
4
1 Présentation du projet
1.1 Résumé
Nous proposons de réaliser un programme permettant l’analyse de binaires exécutables compilés pour l’architecture ARM. Plus précisément, cet analyseur permet
d’extraire les informations nécessaires à la reconstruction de l’architecture du programme. Cette reconstruction architecturale comprend la détection des instructions
de branchement, la récupération des fonctions utilisées par le développeur, ainsi
que les liens entre ces différentes fonctions. Sous certaines conditions (énoncées
dans la section 1.4), la reconstruction est fidèle à la structure du programme étudié,
et notre analyseur est capable de tracer le graphe d’appel ainsi que le graphe de flot
de contrôle (CFG).
Bien que le but de ce projet ne soit pas de décompiler des exécutables binaires, il
pose les bases pour une étude de ce genre. En effet, il permet de délimiter les fonctions et les branchements inter-procéduraux, ce qui serait une première étape dans
la récupération complète du code source d’un programme. Pour le lecteur intéressé,
des utilitaires de décompilation existent déjà à ce jour et sont mentionnés dans la
section 2.2.
1.2 Enjeux
L’analyse de binaires compilés que nous proposons dans ce projet est un exemple de rétro-ingénierie informatique, appliquée à l’architecture ARM. Elle permet de comprendre le fonctionnement d’un programme ≪ en boı̂te noire ≫, pour
éventuellement en créer une version adaptée à ses besoins, lorsque cela est autorisé
d’un point de vue juridique (voir la section 2.1).
La multiplication des machines utilisant l’architecture ARM, en partie due au
succès des appareils mobiles, rend légitime l’existence d’outils permettant cette étude. Le portage de pilotes de périphériques d’appareils embarqués est un exemple
d’application d’un tel outil, dans le cas où le fabricant n’avait pas prévu l’utilisation de son appareil avec d’autres systèmes (comme GNU/Linux), mais est d’accord avec le portage par un développeur tiers. Avec la prolifération des tablettes
numériques et téléphones intelligents fonctionnant sous Windows, et n’ayant pas
de pilotes à code ouvert, la question de la rétro-ingénierie autorisée mérite d’être
abordée.
1.3 Objectifs
Dans le cadre de ce projet d’une session, nous nous fixons les objectifs suivants :
– récupérer les fonctions utilisées dans le programme source : adresses de début
et de fin, nom quand il est disponible ;
– détecter les branchements au sein des fonctions : appels inter-procéduraux ou
sauts, conditionnels ou pas, adresses statiques ou dynamiques ;
5
– détecter les appels système.
En utilisant ces informations, notre programme doit être en mesure de :
– tracer le graphe d’appel ;
– tracer le graphe du flot de contrôle (CFG) de chaque fonction.
1.4 Hypothèses
Conteneur Les programmes compilés que nous étudierons seront au format ELF
(Executable and Linkable Format [16]). Nous avons fait ce choix car ce format de conteneur a une spécification ouverte et est très bien documenté. De plus, c’est le format des exécutables sous GNU/Linux, ce qui est un avantage car de nombreux
programmes de ce type sont disponibles.
Architecture Le contenu d’un exécutable dépend de l’architecture pour laquelle il
est compilé. En effet, le code machine n’est pas le même selon sur quel processeur
le programme est exécuté. Pour cette raison, nous devons faire un choix. C’est l’architecture ARM 32-bit version 5 qui a été retenue, pour les raisons suivantes.
– Contrairement au x86, les instructions sont à longueur fixe. Des instructions
à longueur variable sont beaucoup plus dures à extraire car elles ne sont pas
alignées en mémoire. Le choix de la version 5 est dû au fait qu’elle précède
l’introduction des instructions Thumb dans le jeu ARM. Les instructions Thumb
étant codées sur 16 bits, le mélange de Thumb et d’instructions régulières complique l’analyse du code binaire de la même façon que des instructions à
longueur variable.
– C’est une architecture RISC (Reduced Instruction Set Computer), assez facile à
étudier car elle comprend un nombre limité d’instructions. Dans le jeu d’instructions ARMv5, nous en avons compté 147 [6].
Librairies dynamiques La majorité des programmes utilisent des librairies partagées, pour éviter de contenir des portions de code déjà définies dans d’autres
fichiers. Au lancement de l’application, ces librairies sont chargées en mémoire pour
pouvoir être utilisées. Ce type de chargement dynamique sera exclu car il complexifierait le code du projet, sans ajouter aucun concept intéressant. En effet, n’importe
quel programme à chargement dynamique peut avoir son équivalent statique : ce ne
sont que des sections brutes à inclure dans l’exécutable et à ajouter dans la table des
symboles. L’hypothèse retenue ici sera donc d’utiliser des programmes compilés
statiquement.
Langage source On utilisera des programmes compilés écrits en C, car ce langage
est un des plus utilisés : par exemple, dans la plupart des systèmes d’exploitation.
La traduction en code machine de ce langage bas niveau n’est pas trop éloignée du
code originel. Ceci permettra un déverminage plus aisé, notamment lorsqu’il faudra
comparer le code source et la reconstruction générée par notre analyseur.
6
Compilateur et optimisation Le compilateur utilisé pour générer l’exécutable influe grandement sur l’organisation du code binaire. Pour cette raison, nous nous
limiterons à des programmes compilés avec le compilateur C gcc [12], qui est le
compilateur libre le plus utilisé.
De même, les optimisations du compilateur rendent les binaires plus complexes
à analyser (voir section 3.4). Dans un premier temps, nous nous sommes limités à
des exécutables compilés avec l’option -O0 de gcc [12], ce qui désactive les optimisations. Depuis, les réarrangements du binaire dûs à l’optimisation ont été pris en
compte par notre analyseur. Celui-ci retrouve les fonctions des exécutables compilés
avec -O2 (qui est l’option par défaut). Nous n’avons pas essayé avec -O3.
1.5 Lien avec ma recherche
Dans le cadre de ma maı̂trise recherche à l’École Polytechnique de Montréal,
sous la direction de Michel Dagenais, je travaille sur des processeurs ARM. Mon but
est de configurer des modules matériels intégrés à certains nouveaux processeurs,
afin de générer des traces de programmes pour la suite de traçage LTTng [3]. Ce projet de ré-ingénierie du logiciel est donc l’occasion pour moi de me familiariser avec
les processeurs ARM, tout en expérimentant dans le domaine de la ré-ingénierie et
rétro-ingénierie.
7
2 Cadre théorique
2.1 Aspect légal
La légalité des méthodes mises en œuvres lors de ce projet est absolument requise. La rétro-ingénierie, et plus particulièrement la décompilation de programmes
est sujette à controverse. Pour être sûr de travailler dans un cadre légal, nous avons
fait les recherches nécessaires en matière de droit canadien.
Il y a plusieurs usages légaux de la rétro-ingénierie : en cas de code source perdu,
pour la recherche, ou plus souvent pour l’interopérabilité. Par exemple, il est autorisé de décompiler un pilote propriétaire pour rendre son matériel compatible
avec son système d’exploitation.
Voilà un extrait de la loi sur le droit d’auteur de la législation canadienne, article
30.6 [7].
Ne constitue pas une violation du droit d’auteur le fait, pour le propriétaire
d’un exemplaire — autorisé par le titulaire du droit d’auteur — d’un programme d’ordinateur, ou pour le titulaire d’une licence permettant l’utilisation
d’un exemplaire d’un tel programme :
≪
a) de reproduire l’exemplaire par adaptation, modification ou conversion, ou
par traduction en un autre langage informatique, s’il établit que la copie
est destinée à assurer la compatibilité du programme avec un ordinateur
donné, qu’elle ne sert qu’à son propre usage et qu’elle a été détruite dès
qu’il a cessé d’être propriétaire de l’exemplaire ou titulaire de la licence,
selon le cas ;
b) de reproduire à des fins de sauvegarde l’exemplaire ou la copie visée à
l’alinéa a) s’il établit que la reproduction a été détruite dès qu’il a cessé
d’être propriétaire de l’exemplaire ou titulaire de la licence, selon le cas. ≫
Dans le cadre de ce projet, nous travaillerons exclusivement sur des logiciels
dont la licence autorise l’étude du code binaire : des programmes de test de notre
composition, et les utilitaires du projet GNU Coreutils [10] (voir la section 3.1.3).
2.2 État de l’art
Analyse de binaires Des outils existent pour étudier les programmes compilés.
Leur utilisation simplifie énormément la tâche car ils essaient de retrouver les fonctions et structures, et calculent automatiquement les adresses de saut des branchements. Sous GNU/Linux, on peut citer gdb [11] pour l’analyse dynamique et objdump [9] pour l’analyse statique. Pour les programmes Windows, les outils IDA [14],
WinDbg [15] et OllyDbg [17] sont les références pour l’analyse de binaires.
Décompilation La décompilation est le fait de recréer le code source d’un logiciel à partir de l’exécutable binaire. Pour du code partiellement compilé, tel que le
8
bytecode Java ou Python, il existe des méthodes de décompilation qui reposent des
bases théoriques. Lorsque le code est entièrement compilé, comme c’est le cas pour
le C, le problème n’a pas de solution théorique. En effet, la compilation entraı̂ne une
perte d’information irréversible comme par exemple les noms des variables et les
structures de données. En pratique, on arrive à retrouver certaines informations à
partir d’un binaire, et dans certains cas à obtenir un code source qui, recompilé, a le
même comportement que le binaire initial.
Parmi les logiciels tentant de décompiler des programmes, on peut citer Boomerang [1], projet de décompilateur à code ouvert, ou dcc [8] qui fait l’objet d’une
thèse.
9
3 Réalisation du projet
3.1 Préparations préliminaires
3.1.1 Structure générale
Nous avons choisi d’écrire l’analyseur de binaire ARM en C, essentiellement
pour des raisons de performance.
Il doit être capable d’ouvrir un fichier binaire, l’étudier, et reconstruire l’image du programme et des fonctions dans un format approprié. Pour cela, nous
décomposerons l’analyseur en parties ayant des tâches indépendantes. Elles sont
représentées dans la figure 1.
Machine virtuelle Cette partie ouvre le programme à analyser, reconnaı̂t s’il s’agit
d’un exécutable au bon format (ELF, ARM 32-bit), et charge ensuite les sections
exécutables en mémoire. Elle exporte une fonction permettant de lire une instruction
à une adresse donnée. Point important, elle est aussi capable d’extraire à quelle
adresse le programme commence. Enfin, cette partie lit la table des symboles. Lorsqu’elle est présente, cette table contient entre autres les noms associés à certaines
adresses. Le cas échéant, ces symboles seront utilisés pour nommer les fonctions
détectées.
Historique des adresses Cette partie définit une classe qui garde en mémoire
l’ensemble des adresses déjà visitées, afin de ne pas relire plusieurs fois les mêmes
instructions. Elle est hautement optimisée pour éviter les longues boucles de comparaison. Elle dispose de mécanismes de création et de fusion d’intervalles, qui rendent les tests (appartenance ou non à l’historique) très efficaces.
Désassembleur Cette partie contient un ensemble de fonctions spécifiques à l’architecture ARMv5 32-bit. Elle est utilisée par la partie décompilation pour caractériser les instructions de branchement, et pour calculer les adresses de saut.
Décompilateur Cette partie lit les instructions à partir de la machine virtuelle, et
décode celles qui sont intéressantes. Les instructions les plus intéressantes sont les
branchements (sauts, appels, retours), mais les autres types sont aussi analysés car
ils peuvent apporter de l’information. Par exemple, les instructions NOP peuvent
renseigner sur la fin des fonctions. De même, une lecture ou écriture à une adresse
particulière indique généralement que le code à cette adresse n’est pas fait pour être
exécuté.
Une fois toutes les instructions d’intérêt enregistrées, on associe chaque branchement à un saut, un retour ou un appel de fonction. Cette décision est faite par un algorithme bien spécifique et développé de manière empirique ; il est décrit en détail
dans la section 3.2.3. C’est aussi à ce moment que l’on détermine les adresses de
début et de fin des fonctions.
10
groups.c
common.h
arrays.c
vm.c
11
F IGURE 1 – Structure générale de l’analyseur
Exécutable
binaire
decompiler.c
rebuilt_program.c
Graphes
arm_instructions.c
A
Z
Enfin, cette partie parcourt le code binaire de chaque fonction reconstruite à la
recherche des appels système.
Programme reconstruit Cette partie définit des structures de données pour représenter l’image du programme, ses branchements et ses fonctions. Elle inclut des
méthodes pour modifier facilement ces structures, et les analyser pour générer le
graphe d’appel et le CFG. Le format d’affichage peut être du simple texte, ou bien
un fichier structuré adapté à l’affichage d’un graphe avec GraphViz [2].
Fonctions générales D’autres fichiers définissent des macros pour gérer efficacement les tableaux de taille dynamique, et des fonctions pour effectuer des algorithmes de tri (tri fusion).
3.1.2 Outils utilisés
Compilateur croisé Pour tester notre analyseur, il est nécessaire de pouvoir créer
des binaires ARM arbitraires. Depuis un ordinateur standard avec une architecture
x86, il faut utiliser un compilateur croisé, capable de générer du code machine pour
l’architecture ARM. Nous avons opté pour le compilateur gcc de la suite GNU EABI
[12]. Sous Debian, la commande correspondante est arm-linux-gnueabi-gcc.
Désassembleur Un désassembleur comme objdump [9] permet de traduire les instructions binaires d’un programme en assembleur lisible. Un tel outil nous permet d’une part de vérifier que notre analyseur décode les instructions correctement,
mais aussi de vérifier que la structure du programme que nous extrayons est correcte.
Librairie libelf La Free Software Foundation fournit une librairie qui simplifie l’étude
de binaires au format ELF : la libelf [13]. Cette librairie permet de récupérer les
adresses des sections exécutables d’un binaire ELF, ainsi que les symboles du programme, tout en s’affranchissant des difficultés liées à la plateforme : 32 ou 64 bits,
petit ou grand boutisme, etc.
Générateur de graphes GraphViz [2] est un logiciel de génération de graphes à
partir de fichiers en langage DOT. Voici un exemple de graphe généré par GraphViz :
digraph callgraph {
A -> B -> Z;
A -> C;
}
B
Z
A
C
Cet outil sera utile pour générer les graphes de flot de contrôle et graphes d’appel, à partir d’une sortie au format DOT de notre analyseur.
12
3.1.3 Création de binaires de test
Binaires simplistes Les premiers binaires analysés sont des programmes extrêmement simples : il s’agit de helloworld et helloworld-stdlib. Le premier
ne contient que six fonctions élémentaires, le second est comme le premier, mais
compilé avec la librairie C standard. Leur compilation s’effectue au moyen des commandes suivantes :
arm-linux-gnueabi-gcc -Wall -O0 -static -march=armv5 \
-nostdlib -o helloworld helloworld.c
arm-linux-gnueabi-gcc -Wall -O0 -static -march=armv5 \
-o helloworld-stdlib helloworld-stdlib.c
Binaires réalistes Afin de tester de véritables programmes, nous avons compilé
la suite GNU Coreutils [10], qui contient les classiques ls, echo, cat, wc, cp... Pour
générer les binaires au bon format, il est nécessaire de configurer le projet avec les
bonnes options de compilation :
./configure --host=arm-linux-gnueabi \
--target=arm-linux-gnueabi CFLAGS=-O0 LDFLAGS=-static
make -j 8
Autres binaires Les binaires ainsi créés sont inclus dans l’archive du livrable, pour
que le lecteur puisse les utiliser directement. Néanmoins, il est possible d’essayer
n’importe quel autre programme, il suffit pour cela de le compiler avec les bonnes
options : utiliser un compilateur croisé et indiquer à l’éditeur de lien de générer des
librairies statiques.
3.2 Implémentation
3.2.1 Machine virtuelle
Le code de cette partie est présent dans le fichier vm.c.
Format ELF Le format Executable and Linkable Format [16], présenté dans la figure 2,
est le standard sur les systèmes Unix. Un binaire ELF commence par une en-tête,
puis le reste du fichier peut être vu comme une suite de segments, ou une suite de
sections selon le point de vue. Nous adopterons la vision des sections, qui est plus
adaptée à notre problème.
Adresse d’entrée L’adresse de la première instruction du programme, appelée
point d’entrée, doit être récupérée à partir de l’en-tête ELF. Généralement, c’est
0x8150 pour un programme compilé avec la librairie C.
13
Chargement en mémoire Le but de
cette partie de la machine virtuelle est
ELF header
de charger en mémoire les sections
qui contiennent du code machine.
Program header table
Pour cela, notre programme parcourt
l’ensemble des sections à la recherche
de celles de type SHT PROGBITS
.text
et qui possèdent les drapeaux SHF ALLOC,
correspondant respectivement à du
.rodata
code exécutable et à une partie devant être chargée en mémoire pour
...
l’exécution.
.data
Lorsque notre programme trouve
une section de ce type, il récupère
sa position, sa taille, alloue une
Section header table
région en mémoire et y copie les
données. Il faut prendre soin de
F IGURE 2 – Structure d’un fichier ELF.
conserver l’adresse à laquelle les
Source : Wikipedia.
données doivent être chargées lors
d’une véritable exécution, car celle-ci
est différente de celle où on les charge dans le processus de l’analyseur. Lorsqu’une
instruction analysée fera référence à une adresse donnée, on devra alors la translater
de manière adéquate.
{
{
Table des symboles Pour une lecture plus agréable des graphes générés par l’analyseur, nous nommons les fonctions par leur véritable nom, lorsque celui-ci est
disponible. Ceci est possible en lisant la table des symboles du binaires, qui correspond à la section de type SHT SYMTAB. Cette section, normalement utile à l’édition
de liens dynamique, contient des noms associés à certaines adresses de l’espace
mémoire du processus : fonctions, sections, segments...
3.2.2 Désassembleur
Le code de cette partie est présent dans le fichier arm instructions.c. Elle est
responsable de reconnaı̂tre les instructions de code machine (grâce aux informations
du manuel de référence de l’architecture ARM [6]), et notamment les instructions
de branchement. Celles-ci sont toutes celles qui modifient le registre PC (Program
Counter).
Il en existe différent types, les plus simples étant les instructions de branchement
explicites. Il s’agit de b, bl, bx, blx et bxj. En plus de celles-là, il faut prendre en
compte toutes les opérations usuelles qui agissent sur le registre PC, par exemple
avec une simple addition. Le nombre de ces instructions étant très élevé, nous avons
préféré les classer par catégories. Nous représentons ces catégories plus bas.
14
Opérations à décalage :
31
30
29
28
27
26
25
24
23
22
21
20
19
18
condition 0 0 0 opcode
17
16
15
Rn
14
13
12
11
10
9
8
7
6
5
4
3
2
1
0
Rm
Rd
Opérations avec immédiat :
31
30
29
28
27
26
25
24
23
22
21
20
19
18
condition 0 0 1 opcode
17
16
15
Rn
14
13
12
11
10
9
8
7
6
Rd
5
4
3
2
1
0
immédiat
Load/store avec offset immédiat :
31
30
29
28
27
26
25
24
23
22
21
20
19
18
condition 0 1 0 opcode
17
16
15
Rn
14
13
12
11
10
9
8
Rd
7
6
5
4
3
2
1
0
3
2
1
0
immédiat
Load/store avec offset registre :
31
30
29
28
27
26
25
24
23
22
21
20
19
condition 0 1 1 opcode
18
17
16
15
Rn
14
13
12
11
10
9
8
7
6
5
4
Rm
Rd
Load/store multiple :
31
30
29
28
27
26
25
24
23
22
21
20
19
condition 1 0 0 opcode
18
17
16
15
Rn
14
13
12
11
R1
10
9
8
7
6
5
4
3
R3
R2
2
1
0
R4
Branchement et branchement avec lien (b, bl, blx) :
31
30
29
28
27
26
25
24
23
22
21
20
19
18
17
16
15
condition 1 0 1
14
13
12
11
10
9
8
7
6
5
4
3
2
1
0
offset
Pour tester si une instruction est un branchement, il faut donc traiter les cas
précédents, en vérifiant si le registre PC est affecté par l’opération.
Cette partie calcule aussi l’adresse du branchement lorsque cela est possible, c’est
à dire dans le cas où l’instruction est un branchement vers une adresse donnée par
un offset : b, bl ou blx.
3.2.3 Décompilateur
Le code de cette partie est présent dans le fichier decompiler.c. La procédure
de détection des fonctions se divise en trois grandes étapes :
1. Parcours de toutes les instructions du binaire, avec enregistrement de toutes
les déclarations (statements) d’intérêt.
15
2. Découverte des fonctions, de leurs adresses de début et de fin, en utilisant les
déclarations.
3. Détermination des appels, sauts et retour au sein des fonctions trouvées.
4. Détection des appels système au sein des fonctions.
Détection des déclarations Les déclarations, ou statements, représentent les points
particuliers du code du binaire. Il peut s’agir :
– d’instructions de branchement (BRANCH),
– d’instructions vides (NOP),
– d’appels système,
– de valeurs stockées en dur (WORD).
Une déclaration peut être conditionnelle ou inconditionnelle. Parmi les branchements, on définit trois sous-types : les appels de fonctions (CALL), les sauts définitifs
(JUMP) et les retours de fonctions (RETURN).
La détection consiste à lire toutes les instructions depuis un point de départ, et à
s’arrêter lorsque l’on trouve une instruction de retour (RETURN) ou de saut (JUMP)
inconditionnel.
Pendant cette lecture, si on a trouvé un branchement dont l’adresse est calculable
(branchement statique), on ajoute l’adresse de destination à la liste des adresses à
explorer. Ainsi, en sautant d’appel en appel, on parcourt les fonctions contenues
dans l’exécutable. Cette procédure est décrite dans le pseudo-algorithme suivant.
1:
2:
3:
4:
5:
6:
7:
8:
9:
10:
11:
12:
13:
14:
15:
16:
17:
18:
19:
20:
21:
declarations ← []
a explorer ← []
a explorer.ajouter(point d entree)
for all adresse de depart ∈ a explorer do
for (adresse ← adresse de depart ; ; adresse ← adresse + 4) do
if deja visitee(adresse) then
sortir
end if
declarer deja visitee(adresse)
instruction ← lire(adresse)
if est un branchement(instruction) then
declarations.ajouter(BRANCH, adresse)
if adresse de destination calculable(instruction) then
destination ← calculer adresse(instruction)
a explorer.ajouter(destination)
end if
if est un retour(instruction) then
sortir
else if est un saut inconditionnel(instruction) then
sortir
end if
16
22:
23:
24:
25:
26:
27:
28:
29:
else if est un nop(instruction) then
declarations.ajouter(NOP, adresse)
else if est un load ou store(instruction) then
destination ← calculer adresse(instruction)
declarations.ajouter(WORD, destination)
end if
end for
end for
La figure 3 montre la détection des déclarations sur un exemple de programme.
Détection des fonctions Il faut savoir qu’il n’y a pas d’instruction d’appel de fonction en ARM, ni de retour. Les classiques call et ret du jeu d’instructions x86
n’existent pas dans celui d’ARM. À leur place, il existe un registre LR (Link Register), conçu pour stocker l’adresse de retour lors d’un appel de fonction.
Pour les appels de fonctions, toute la difficulté est de détecter les branchements
qui sont effectivement des appels, en les différenciant des autres branchements qui
proviennent de if /else. Voici comment nous procédons.
Nous considérons comme des appels :
– les instructions branch and link, qui sauvegardent la valeur du registre PC (Program Counter) dans le registre LR (afin de sauvegarder l’adresse à laquelle retourner à la fin de la procédure) ;
– les sauts inconditionnels vers une adresse hors de la fonction (ce qui signifie
qu’on ne reviendra pas : la fonction est terminée).
Le problème est de définir ≪ hors de la fonction ≫ : si le saut inconditionnel est 8
octets plus loin, ce n’est pas un saut vers une autre fonction, mais probablement
le saut qui permet d’éviter un bloc else. Pour pallier à cela, nous définissons une
variable f in minimum, qui contient l’adresse minimale à laquelle la fonction peut
s’arrêter. Cette valeur est mise à jour à chaque saut conditionnel (par exemple, bloc
if ), pour dire que la fin de la fonction est au minimum après le bloc if.
Pour plus de clarté, ce comportement est illustré dans les figures 4 et 5.
Pour ce qui est des retours, nous les caractérisons dans les deux cas suivants :
– les branchements dont la destination est l’adresse dans LR : bx lr, de code
0xe12fff1e ;
– les dépilements d’une valeur vers le registre PC (Program Counter) : pop [fp,
pc], de code 0xe8bd8800.
Il est nécessaire de préciser que cette solution ne couvre pas tous les cas, car
certains retours peuvent être exotiques et il n’existe pas de méthode universelle
pour les détecter. Le problème est discuté plus en détail dans la section 3.4.
17
void *fonction1(int a)
{
if (a == 42)
return 0xdeadbeef;
else
return 0xcafec0ca;
}
void _start()
{
fonction1(5);
}
<fonction1>:
80b8:
e52db004
80bc:
e28db000
80c0:
e24dd00c
80c4:
e50b0008
80c8:
e51b3008
80cc:
e353002a
80d0:
1a000001
80d4:
e59f3014
80d8:
ea000000
80dc:
e59f3010
80e0:
e1a00003
80e4:
e28bd000
80e8:
e8bd0800
80ec:
e12fff1e
80f0:
deadbeef
80f4:
cafec0ca
push
{fp}
add fp, sp, #0
sub sp, sp, #12
str r0, [fp, #-8]
ldr r3, [fp, #-8]
cmp r3, #42 ; 0x2a
bne 80dc
ldr r3, [pc, #20] ; 80f0
b
80e0
ldr r3, [pc, #16] ; 80f4
mov r0, r3
add sp, fp, #0
ldmfd
sp!, {fp}
bx lr
.word
0xdeadbeef
.word
0xcafec0ca
<_start>:
80f8:
e92d4800
80fc:
e28db004
8100:
e3a00005
8104:
ebffffeb
8108:
e8bd8800
810c:
e1a00000
push
{fp, lr}
add fp, sp, #4
mov r0, #5
bl 80b8 <fonction1>
pop {fp, pc}
nop
JUMP (cond)
JUMP
RETURN
WORD
WORD
CALL
RETURN
NOP
F IGURE 3 – Exemple de déclarations dans un programme simple. De gauche à
droite : source C ; assembleur généré ; déclarations détectées par l’analyseur.
if (a == 42)
b = 0;
else
b = 1;
80cc:
80d0:
80d4:
80d8:
80dc:
80e0:
e353002a
1a000001
e3a03000
ea000000
e3a03001
e1a00003
cmp
bne
mov
b
mov
mov
r3, #42
80dc
r3, #0
80e0
r3, #1
r0, r3
JUMP (cond)
JUMP
f in minimum
F IGURE 4 – Exemple de détection de fonction avec traitement de saut inconditionnel. L’instruction en 0x80d8 n’est pas considérée comme un appel à une nouvelle
fonction car f in minimum a été placée après lors du décodage du saut conditionnel
en 0x80d0.
<_IO_flush_all>:
add4: e3a00001
add8: eafffea3
f in minimum
mov r0, #1
b
a86c <_IO_flush_all_lockp>
JUMP
F IGURE 5 – Exemple provenant d’une fonction de la librairie C standard :
IO flush all. L’instruction en 0xadd8 est considérée comme un appel à une
nouvelle fonction car f in minimum pointe vers une adresse antérieure. La fonction
IO flush all se termine donc à cet endroit.
18
La procédure globale est synthétisée dans le pseudo-algorithme suivant.
1:
2:
3:
4:
5:
6:
7:
8:
9:
10:
11:
12:
13:
14:
15:
16:
17:
18:
19:
20:
21:
22:
23:
24:
25:
26:
27:
28:
29:
30:
31:
32:
33:
34:
35:
36:
37:
38:
tri fusion(declarations)
f onctions ← []
f onctions.ajouter fonction(start ← point d entree)
for all f ∈ f onctions do
f in minimum ← 0
for all d ∈ declarations apres adresse(f.start) do
if d.type = NOP or d.type = W ORD then
if f in minimum ≤ d.adresse + 4 then
f.end ← d.adresse
sortir
end if
else if d.type = RET URN then
if f in minimum ≤ d.adresse + 4 then
f.end ← d.adresse + 4
sortir
end if
else if d.type = JUMP and branchement inconditionnel(d) then
if f in minimum ≤ d.adresse + 4 then
f.end ← d.adresse + 4
if adresse de destination calculable(d) then
destination ← calculer adresse(d)
if destination < f.start or destination ≥ d.adresse + 4 then
f onctions.ajouter fonction(start ← destination)
end if
end if
sortir
end if
else if d.type = JUMP and adresse de destination calculable(d) then
destination ← calculer adresse(d)
if destination + 4 < f in minimum then
f in minimum ← destination + 4
end if
else if d.type = CALL and adresse de destination calculable(d) then
destination ← calculer adresse(d)
f onctions.ajouter fonction(start ← destination)
end if
end for
end for
19
Détection des appels système Finalement, l’analyseur effectue une dernière passe
pour détecter les appels système. Ceux-ci sont caractérisés par des instructions du
type de celle représentée plus bas. Sous GNU/Linux, le numéro de l’appel doit
être stockée dans le registre 7, on peut donc trouver ce numéro dans l’instruction
précédant l’appel système. On aurait par exemple pour un open (appel numéro 5),
l’instruction mov r7, #5, suivie de l’appel système représenté ici.
31
30
29
28
27
26
25
24
23
22
21
20
19
18
17
16
15
14
13
12
11
10
9
8
7
6
5
4
3
2
1
0
condition 1 1 1 1
3.3 Génération de graphes
3.3.1 Graphe d’appel
À partir des informations collectées, le graphe d’appel est assez simple à construire. Rappelons qu’après analyse du binaire, nous avons en mémoire :
– la liste des fonctions ;
– la liste des déclarations (sauts, appels, retours, instructions WORD et NOP,
appels système) de chaque fonction.
Pour produire le graphe d’appel dans un format interprétable par GraphViz, il faut
afficher :
– un ensemble de sommets qui correspondent aux fonctions ;
– un ensemble d’arêtes qui représentent les appels d’une fonction vers une autre.
C’est exactement ce que nous faisons : pour chaque fonction nous affichons son nom
et ses instructions de branchement qui sont des appels vers d’autres fonctions.
Voici un exemple de programme en C, la sortie produite par l’analyseur, et le
graphe généré par GraphViz.
void print(char *str)
{
// do something
}
void init()
{
print("init...");
}
void end()
{
print("end...");
}
void main()
{
init();
end();
}
void _start()
{
main();
}
digraph G {
F0 [label="_start"];
F0 -> F1;
F1 [label="main"];
F1 -> F2;
F1 -> F3;
F2 [label="init"];
F2 -> F4;
F3 [label="end"];
F3 -> F4;
F4 [label="print"];
}
20
_start
main
init
end
print
3.3.2 Graphe de flot de contrôle intra-procédural
Le graphe de flot de contrôle (CFG) d’un programme montre l’ensemble des
chemins qui peuvent être suivis durant l’exécution. Il met notamment en évidence
les branchements conditionnels liés aux tests et aux boucles. Nous ne souhaitons
pas produire de CFG global représentant tout l’exécutable, car cela ferait un graphe
énorme et difficilement lisible. Au lieu de cela, nous préférerons générer un CFG
intra-procédural, montrant le flot de contrôle au sein d’une fonction particulière
choisie par l’utilisateur.
Rappelons qu’avant de générer le graphe de flot de contrôle d’une fonction, nous
avons en mémoire la liste de tous ses branchements, ainsi que leurs caractéristiques.
Nous utilisons ces informations pour produire le graphe, en respectant les principes
suivants :
Points d’entrée et de sortie Nous créons deux nœuds spéciaux dans le graphe,
correspondant aux adresses de début et de fin de la fonction analysée. Le nœud de
début est connecté à la première instruction ; tandis que toutes les instructions de
retour plus la dernière instruction sont connectées au nœud de fin.
Appels Un appel de fonction (CALL) donnera naissance à trois nœuds dans le graphe : celui de l’adresse
de l’instruction appelante, celui de la fonction et celui
de l’adresse de retour (adresse appelante + 4 octets).
0x8150
0x8150
Le flux de contrôle passe successivement par ces trois
nœuds, dans l’ordre. Dans le cas d’un appel avec condition (instruction conditionnelle), on ajoute une arête
fonction
fonction
allant directement du premier au troisième nœud.
Celle-ci représente le cas où la condition n’est pas
vérifiée, et l’instruction d’appel non exécutée.
0x8154
0x8154
Le mécanisme que nous venons de décrire est justifié par le fait qu’un appel de fonction est sensé être
suivi d’un retour, qui ramène le flot d’exécution juste
après l’instruction appelante (adresse + 4 octets). Cela n’est pas toujours le cas, car
certaines fonctions ne retournent pas. Ce problème est dû à l’optimisation de la
compilation, qui est discutée dans la section 3.4.3.
Pour les appels système (SYSCALL), le principe est le même : on crée trois nœuds
et deux ou trois arêtes.
Sauts On considère deux types de sauts (JUMP) :
– vers une adresse connue à l’intérieur de la fonction analysée ;
– vers une adresse connue à l’extérieur de la fonction, ou inconnue (calculée
dynamiquement).
21
0x8250
0x8250
0x8250
0x8250
0x8254
0x999c
0x8254
0x999c
?
?
Pour chacun de ces deux types, le saut peut être conditionnel ou non. Lorsqu’il
l’est, on ajoute une arête vers le nœud représentant l’instruction à l’adresse + 4
octets. Ceci correspond à la non-exécution du saut, due à une condition non vérifiée.
Pour les sauts vers une adresse connue à l’intérieur de la fonction, on ajoute
simplement une arête vers le nœud de l’adresse correspondante. Pour les autres, on
crée un nœud spécial, inconnu, vers lequel est transféré le flot d’exécution.
Retours Pour les retours, on trace simplement une arête allant du nœud de l’instruction jusqu’au nœud représentant la fin de la fonction.
L’algorithme conçu pour construire le CFG intra-procédural se divise en six étapes.
Elles sont toutes nécessaires à la création du graphe. Des essais ont été faits pour
grouper des opérations et accélérer le calcul, mais cela a généré des problèmes
pour certains cas particuliers. Pour cette raison, nous gardons ces six étapes, qui
décomposent le travail de manière claire :
1. Détection de toutes les adresses utiles à la construction du graphe. Ceci est fait
en utilisant les déclarations enregistrées pendant la phase de décompilation
(voir section 3.2.3). On enregistre notamment : les adresses des branchements,
les adresses les suivant directement (+ 4 octets) s’ils sont conditionnels, les
adresses de destination des branchements.
2. Tri croissant des adresses, et suppression des doublons.
3. Établissement des correspondances entre les nœuds et les déclarations.
4. Traçage des arêtes partant de chaque nœud, en utilisant les déclarations associées.
5. Simplification : suppression des nœuds inutiles. La détection de l’étape 1 a
généré plus de nœuds que nécessaire, nous retirons donc ceux qui n’ont qu’un
seul parent et un seul enfant, et qui sont des nœuds réguliers (pas des appels
de fonction ou des appels système).
6. Génération du descripteur de graphe.
La sortie de cet algorithme, au format texte, est un descripteur comprenant les
nœuds et les arêtes. Elle peut être utilisée pour dessiner un graphe avec GraphViz.
Voici un exemple de programme en C, la sortie produite par l’analyseur, et le
graphe généré par GraphViz.
22
ENTRY
0x824c
#include <stdio.h>
void main(int argc)
{
if (argc == 0)
return;
else if (argc > 0)
printf("Ah.");
brk();
}
digraph G {
N_0_824c
N_0_824c
N_0_826c
N_0_826c
N_0_826c
N_0_8278
N_0_8278
N_0_8278
N_1_8284
N_1_8284
N_0_8288
N_0_8288
N_1_8288
N_1_8288
N_0_8294
N_0_8294
N_0_82a0
}
[label="ENTRY\n0x824c"];
-> N_0_826c;
[label="0x826c"];
-> N_0_8278;
-> N_0_8294;
[label="0x8278"];
-> N_1_8284;
-> N_0_8288;
[label="_IO_printf"];
-> N_0_8288;
[label="0x8288"];
-> N_1_8288;
[label="brk"];
-> N_0_8294;
[label="0x8294"];
-> N_0_82a0;
[label="EXIT\n0x82a0"];
0x826c
0x8278
_IO_printf
0x8288
brk
0x8294
EXIT
0x82a0
3.4 Difficultés rencontrées
3.4.1 Branchements dynamiques
Un branchement est dynamique quand l’adresse vers laquelle aller est calculée,
et non indiquée statiquement dans le programme. Une adresse calculée dépend potentiellement d’une entrée utilisateur, d’une variable d’environnement ou de l’état
du système d’exploitation, on ne peut donc pas la prévoir par notre analyse statique,
en général. Les risques avec les sauts dynamiques sont, entre autres, de passer à côté
d’un appel de fonction sans savoir comment y aller, ou bien de ne pas détecter un
retour et de penser que la fonction continue plus bas.
Cette difficulté n’a pas de solution générale, mais pourrait être réduite en utilisant des techniques d’analyse dynamique. Ceci pourrait être fait dans de futurs
travaux.
3.4.2 Mauvaise interprétation
Certaines instructions ou suites d’instructions peuvent être mal interprétées.
Voici quelques exemples.
1. Retour non détecté. Imaginons un retour de fonction, qui peut par exemple
s’exprimer bx lr (branchement vers l’adresse de retour, stockée dans le registre LR). Si cette instruction est remplacée par les deux suivantes : mov r3,
lr et bx r3, le retour ne sera pas détecté.
2. Faux retour. À l’inverse, si on a mov lr, #42 suivi de bx lr, l’analyseur
croira à un retour alors qu’il s’agit d’un saut vers l’adresse 42.
3. Branchement vers une adresse a priori dynamique. Un appel vers la fonction à l’adresse 0x42 s’écrirait normalement bl 42. Encore une fois, si l’on
23
a mov r1, #0x42 suivi de bl r1, l’analyseur croira à un branchement vers
l’adresse contenue dans r1. Cette adresse n’étant a priori pas déterminable, il
enregistrera un saut dynamique et n’ira pas explorer la fonction à l’adresse
0x42.
On pourrait penser qu’une solution simple serait de lire les instructions précédant
un branchement a priori dynamique, pour voir si l’adresse de destination est donnée
juste avant. Malheureusement, on ne peut pas se fier aux instructions précédant
le branchement, car il pourrait très bien y avoir un saut depuis une adresse lointaine vers l’instruction de branchement, sans passer par les instructions situées juste
avant celle-ci.
En revanche, nous pensons que la génération du graphe des dominateurs pourrait pallier à ce problème. Ceci pourrait être fait dans de futurs travaux.
3.4.3 Optimisations du compilateur
Lorsqu’un programme est optimisé, le compilateur réordonne les instructions
pour réduire la taille du binaire, et rendre son exécution plus rapide. Dans certains
cas d’optimisations poussées, le réarrangement du binaire complique notre tâche
car il peut empêcher de détecter des appels ou des retours de fonctions. Lorsqu’il
optimise, le compilateur peut, entre autres :
– supprimer des fonctions simples pour les inclure directement dans le code de
la fonction appelante ;
– imbriquer des fonctions entre elles ;
– rendre des appels de fonction dynamiques.
Dans un premier temps, nous avons travaillé sur des binaires compilés avec
l’option -O0 de gcc, qui désactive l’optimisation. Depuis, nous avons étudié les
réarrangements commis par l’optimisation, afin de la prendre en compte dans l’analyse du binaire. Nous sommes maintenant capables d’analyser des binaires avec
l’optimisation par défaut (-O2). Cependant, nous n’avons pas étudié les effet d’une
optimisation plus poussée. Par conséquent, l’analyseur peut rencontrer des problèmes s’il essaie de reconstruire des programmes compilés avec -O3.
3.4.4 Fonctions imbriquées
Dans certaines fonctions optimisées, notamment celles de la librairie C standard,
l’analyseur ne détecte pas d’instruction de retour. Le contrôle passe donc à la fonction suivante, et la procédure se termine à la fin de cette dernière. Dans ce cas, on
obtient deux fonctions imbriquées, ayant la même adresse de fin mais des débuts
différents.
<fonction_complete>:
a000: e3a00001
mov r0, #1
...
bloc 1
...
24
a03c: e3a00005
<sous_fonction>:
a040: e1a03000
...
bloc 2
...
a05c: e12fff1e
mov r0, #5
; pas de retour
mov r3, r0
bx lr
; retour commun aux 2 fonctions
La solution retenue pour gérer ces situations est d’effectuer une passe pour trouver les fonctions imbriquées. À chaque couple de fonctions trouvé, on change l’adresse de fin de la première pour qu’elle corresponde au début de la seconde, et ajoute
un appel à la deuxième à la fin de son code.
Ceci est fait par la fonction rp fix overlapping functions de decompiler.c.
3.4.5 La librairie C
Les logiciels sont souvent compilés en incluant la librairie C standard, qui fournit
de nombreuses fonctionnalités usuelles (flux stdin, fonctions fopen, strncpy) et
encapsule les appels systèmes. Ceci a plusieurs conséquences néfastes sur le fonctionnement de notre analyseur ARM :
– la libc ajoute plus de 800 fonctions au programme ;
– ces fonctions sont optimisées, imbriquées et à sauts dynamiques ;
– le point d’entrée du programme ( start) n’appelle pas la fonction main, mais
libc start main, et l’appel à la fonction main est dynamique donc a priori
pas détectable par l’analyseur.
Pour cette raison, nous avons commencé notre étude avec des binaires compilés
avec l’option -nostdlib. Depuis la première version, nous avons fait les modifications nécessaires pour que la librairie C standard soit prise en charge.
Premièrement, nous avons remarqué que l’adresse de la fonction main est toujours stockée dans un WORD à l’adresse virtuelle 0x8184. C’est la fonction start
qui se charge de lire ce WORD et de le passer en argument à libc start main.
Cette dernière appelle main en faisant un branchement dynamique vers cette adresse.
Ce branchement est toujours situé à l’adresse de libc start main + 0x1a8.
Notre solution comporte donc les étapes suivantes :
– test de présence de la libc ;
– le cas échéant, remplacement du branchement dynamique en ( libc start main
+ 0x1a8) par un appel statique vers l’adresse contenue dans le WORD en
0x8184.
D’autre part, le nombre de fonctions ajoutées par la librairie C est très important.
Même si elles ne sont pas toutes détectées par l’analyseur (cela dépend de si elles
sont appelées ou pas), il en reste plusieurs centaines. Il est nécessaire de pouvoir
masquer ces fonctions. Pour cela, nous les marquons avec un drapeau et proposons
une option pour les omettre lors de l’affichage des graphes.
25
3.5 Performances
Même si ce programme est un projet de démonstration, nous pensons qu’il est
important d’analyser ses performances pour comprendre les points critiques et voir
quels choix algorithmiques affectent le temps d’exécution.
Optimisation des boucles Nous avons utilisé les outils perf [4] et valgrind [5] pour
obtenir des statistiques sur l’exécution de notre programme.
perf stat ./arm-analyser
valgrind --tool=callgrind ./arm-analyser
La première commande permet d’avoir des informations générales : temps d’exécution total, temps passé en attente du système d’exploitation, nombre de fautes de
page... La seconde procède à une étude plus approfondie pour donner le temps total
passé dans chaque fonction.
Ces outils nous ont permis de détecter et de corriger une mauvaise implémentation qui faisait passer 80% du temps dans la fonction memcmp. Le problème était le
suivant. Tous les tableaux utilisés par l’analyseur sont à longueur dynamique, pour
cela ils sont gérés par des macros définies dans common.h. Dans la première implémentation, la longueur d’un tableau n’était stockée nulle part, et la fin de celui-ci
était marqué par un élément nul. La macro d’itération sur ces tableaux testait donc,
pour chaque élément, s’il était nul (avec memcmp) et dans l’affirmative, s’arrêtait.
Vue la longueur ce certains tableaux (la liste des déclarations par exemple), le parcours était fortement ralenti par la comparaison systématique de ses éléments à
l’élément nul. Depuis, nous avons modifié la façon dont les tableaux sont gérés :
ceux-ci sont toujours à longueur dynamique, mais leur longueur est enregistrée à
l’adresse précédant le début du tableau, ce qui nous affranchit du test avec memcmp.
Le temps d’exécution a été divisé par quatre.
Parallélisation Il est possible d’améliorer la vitesse d’exécution de notre programme en tirant profit de la parallélisation des fils d’exécution (multi-threading). Pour
cela, il faut déterminer quelles portions des algorithmes n’ont pas de dépendances
vis-à-vis des autres. Les portions indépendantes peuvent alors s’exécuter en parallèle sans affecter le résultat.
Par exemple, on pourrait diviser la partie qui parcourt le programme et lit les
instructions en quatre threads, chacun étant chargé d’une partie du binaire. De la
même façon, la reconnaissance des fonctions en utilisant les déclarations peut être
divisée en plusieurs sous-tâches travaillant chacune à des adresses différentes. Ceci
pourra être fait dans de futurs travaux.
26
date
11 février
18 mars
15 avril
étape
Avoir un programme capable de charger des binaires, charger
leurs sections exécutables et trouver le point d’entrée.
Créer un binaire de test assez simple, compilé pour ARM.
Commenter le code et documenter le projet.
Gérer les programmes compilés sans libc ni optimisation (-O0).
X
X
X
X
Afficher la liste des fonctions et leurs adresses.
Créer des binaires ARM réalistes et plus compliqués : la suite
Coreutils (ls, cat, echo, wc...)
Améliorer la méthode de détection des fonctions en faisant une
liste de toutes les instructions de branchement.
Résoudre le problème des fonctions imbriquées.
Trouver les noms des fonctions dans la table des symboles.
Gérer les programmes compilés avec la libc et -O0.
X
X
Afficher le graphe d’appel.
Trouver une solution pour le problème des sauts dynamiques.
Gérer certaines parties de la librairie C standard.
Gérer les programmes compilés avec la libc et -O2.
Analyser les performances pour optimiser le temps d’exécution.
Avoir une analyse de vrais programmes (true, wc, echo) qui est
fidèle à la réalité.
X
Afficher le graphe de flot de contrôle intra-procédural.
TABLE 1 – Échéancier
27
X
X
X
X
X
X
X
X
X
4 Résultats
4.1 Utilisation du livrable
4.1.1 Compilation
Dépendance Pour compiler le projet, il convient de s’assurer d’avoir la dépendance requise : la libelf. Cette librairie s’installe avec la paquet libelf-dev sous
Debian, ou elfutils-libelf-devel sous Fedora.
Compilation La compilation s’effectue ensuite avec la commande make, ce qui
exécute la commande :
gcc -std=gnu11 -Wall -o arm-analyser -lelf *.c *.h
et génère notre analyseur ARM : arm-analyser.
4.1.2 Analyse
L’exécution de l’analyseur sans argument affiche l’aide :
Usage: ./arm-analyser action [options] program
actions:
help
display this help
fn
dump functions
cg
generate callgraph
cfg
generate CFG (option -f needed)
options:
-s
show standard C library
-f FN
limit action to function FN (name or address)
options for action fn:
-c
compact dump (names, addresses and childs)
-cc
very compact dump (only addresses)
Affichage des fonctions Pour lancer l’analyse du binaire et afficher toutes les fonctions détectées, il faut utiliser l’action fn. L’option -c permet un affichage condensé,
plus facile à lire, et l’option -s affiche aussi les fonction de la librairie C (qui sont
masquées par défaut, voir la section 3.4.5). On obtient une liste des fonctions, avec
leurs adresses de début et de fin, ainsi que les fonctions qu’elles appellent. Exemple :
$ ./arm-analyser fn -c programme
main
0x00008388 0x000083d0
fonction1
0x000082e0 0x0000833c
fonction2
0x0000833c 0x00008388
fonction3
0x0000824c 0x00008274
28
fonction1,fonction2
fonction3
Affichage des déclarations Lors de l’affichage des fonctions, si l’on omet l’option
-c, toutes les déclarations sont affichées. En combinant l’affichage avec l’option -f,
qui n’affiche que la fonction désirée, on obtient toutes les déclarations détectées au
sein de la fonction. Exemple :
$ ./arm-analyser
main
08388 {
083ac
BRANCH
083bc
BRANCH
083cc
BRANCH
083d0 }
fn -f main programme
( CALL )
( CALL )
(RETURN)
static addr -> 082e0 (fonction1)
static addr -> 0833c (fonction2)
dynam. addr
Génération du graphe d’appel Pour générer un descripteur de graphe d’appel
utilisable par GraphViz, on utilise l’action cg. Exemple :
$ ./arm-analyser cg programme
Génération du graphe de flot de contrôle Pour générer un descripteur de CFG
pour une fonction en particulier, on utilise l’action cfg avec l’option -fn. Exemple :
$ ./arm-analyser cfg -f fonction1 programme
4.1.3 Affichage des graphes
Une fois que l’analyseur a produit un descripteur de graphe, il faut le tracer avec
GraphViz. Ce logiciel peut s’utiliser avec différentes commandes, selon la géographie
voulue pour le graphe. Les plus communs sont dot (affichage hiérarchique, de haut
en bas) et neato (affichage centré).
Exemple de traçage du graphe d’appel de l’exécutable programme dans le fichier
graphe.eps :
$ ./arm-analyser cg programme | dot -T eps -o graphe.eps
4.2 Exemples
Nous présentons ici des exemples d’analyses de binaires, montrant les résultats
de notre projet.
Tous les résultats donnés dans cette section peuvent être reproduits en utilisant
les programmes de démonstration fournis dans l’archive. Ces programmes sont
situés dans le dossier test.
29
4.2.1 helloworld
Ce programme très simple vise à faire une première démonstration de notre
analyseur ARM.
Voici son code source :
int one_arg(int val)
{
return val + 1;
}
int two_args(int val1, int val2)
{
return val1 + val2;
}
int three_args(int val1, int val2, int val3)
{
return val1 * val2 - val3;
}
int test(int val)
{
if (val > 5)
return one_arg(val);
else if (val < 0)
return two_args(42, val);
return 9;
}
void operations(int val)
{
one_arg(val++);
two_args(42, val);
three_args(87, 88, 89);
}
int main(int argc, char **argv)
{
int a = test(argv[0][0]);
operations(a);
return a;
}
Il est compilé avec gcc, en incluant la libc :
$ arm-linux-gnueabi-gcc -Wall -O0 -static -march=armv5 \
-o helloworld helloworld.c
Résultat de l’analyse avec l’action fn et en cachant la libc :
$ ./arm-analyser fn test/helloworld
main
08388 {
083ac
BRANCH ( CALL )
083bc
BRANCH ( CALL )
083cc
BRANCH (RETURN)
083d0 }
test
082e0 {
082f8
BRANCH ( JUMP ) cond.
08300
BRANCH ( CALL )
08308
BRANCH ( JUMP )
08314
BRANCH ( JUMP ) cond.
08320
BRANCH ( CALL )
08328
BRANCH ( JUMP )
08338
BRANCH (RETURN)
0833c }
operations
0833c {
08360
BRANCH ( CALL )
0836c
BRANCH ( CALL )
0837c
BRANCH ( CALL )
08384
BRANCH (RETURN)
08388 }
one_arg
0824c {
08270
BRANCH (RETURN)
08274 }
two_args
08274 {
static addr
static addr
dynam. addr
-> 082e0 (test)
-> 0833c (operations)
static
static
static
static
static
static
dynam.
addr
addr
addr
addr
addr
addr
addr
->
->
->
->
->
->
static
static
static
dynam.
addr
addr
addr
addr
-> 0824c (one_arg)
-> 08274 (two_args)
-> 082a4 (three_args)
dynam. addr
30
0830c
0824c (one_arg)
08330
0832c
08274 (two_args)
08330
082a0
BRANCH (RETURN)
082a4 }
three_args
082a4 {
082dc
BRANCH (RETURN)
082e0 }
dynam. addr
dynam. addr
Résultat de l’analyse, avec traçage du graphe d’appel et du CFG de la fonction
test :
$ ./arm-analyser cg test/helloworld | dot -Teps -o callgraph.eps
$ ./arm-analyser cfg -f test test/helloworld | dot -Teps -o cfg-test.eps
ENTRY
0x82e0
0x82f8
main
0x8314
test
operations
one_arg
two_args
one_arg
three_args
two_args
0x8330
EXIT
0x833c
4.2.2 GNU Coreutils
Afin de tester l’analyseur ARM sur de véritables programmes, nous fournissons
dans l’archive certains binaires de la suite GNU Coreutils, déjà compilés pour l’architecture ARM. Il s’agit de basename, cat, chmod, cp, echo, false, head, ls,
md5sum, mv, pwd, rm, seq, sleep, true, wc et yes.
La figure 6 montre le résultat de l’analyse sur le programme sleep. Ces graphes
ont été obtenus avec les commandes :
$ ./arm-analyser cg test/coreutils/sleep | dot -Teps \
-Gratio=fill -Gsize=3.5,10 -Grankdir=LR -o cg.eps
$ ./arm-analyser cfg -f main test/coreutils/sleep | dot \
-Teps -o cfg.eps
31
f259
ENTRY
0x8418
f199
F147
set_program_name
__gconv_compare_alias_cache
F252
0x8438
F80
setlocale
f136
new_composite_name
__gconv_compare_alias
0x8444
F195
F196
bindtextdomain
0x844c
F7
bindtextdomain
set_binding_values
__textdomain
F109
0x8454
F189
F183
atexit
F122
F184
0x8494
F185
strip
syscall #6
close
_nl_load_locale_from_archive
syscall #5
open
parse_long_options
0x84ac
F82
__textdomain
rpl_getopt_long
__strndup
0x84b4
mmap64
syscall #192
mmap2
F116
0x84b8
F112
F128
usage
F188
F129
F130
_nl_intern_locale_data
_nl_load_locale
syscall #5
open
0x84c0
_nl_find_locale
F111
0x84c4
F197
0x84d4
dcgettext
syscall #6
close
syscall #3
read
0x854c
0x86a0
syscall #-1
(unknown)
xstrtod
F86
0x8564
F69
error
F75
0x86a8
argz_create_sep
__aeabi_dcmpge
usage
setlocale
0x8590
F85
setname
f90
0x8500
0x85dc
f139
rpl_getopt_long
f206
0x85e4
rpl_getopt_internal
F78
0x85e8
_getopt_internal_r
xmalloc
last_component
exchange
0x85f0
F76
0x8634
0x85f8
set_program_name
0x863c
syscall #240
futex
__argz_add_sep
F238
0x8504
0x8594
F198
fputc
0x850c
dcgettext
syscall #240
futex
__strcasecmp
usage
0x85ac
___printf_chk
F24
___fprintf_chk
syscall #240
futex
F96
__muldf3
quote
fwrite
version_etc_va
version_etc_arn
parse_long_options
F11
0x85c4
F49
F142
syscall #240
futex
error
xrealloc
F61
xalloc_die
0x8528
F2
quotearg_n_options
F138
quote_n_mem
__aeabi_dadd
F89
f313
quotearg_buffer_restyled
__ctype_b_loc
__ctype_get_mb_cur_max
0x8548
quote_n
f345
F362
f379
mbsinit
0x8604
F13
__iswprint
quote
__libc_alloca_cutoff
f382
f384
__mbsrtowcs_l
F263
F220
F276
f386
0x8608
f365
main
F25
__mbsrtowcs
error_tail
xnanosleep
fputws_unlocked
__wcslen
print_errno_message
0x8618
F181
F156
__errno_location
F105
error
F103
F57
0x8660
xstrtod
dcgettext
F18
__aeabi_dcmpge
__aeabi_dcmpeq
0x8670
__aeabi_i2d
f224
xnanosleep
syscall #162
nanosleep
rpl_nanosleep
__subdf3
__aeabi_cdrcmple
dtotimespec
__aeabi_cdcmpeq
__nedf2
__nanosleep
error
F55
__aeabi_d2iz
F56
__aeabi_dcmpgt
__muldf3
atexit
__aeabi_dcmplt
0x861c
exit
f98
F8
0x8624
__aeabi_dadd
F IGURE 6 – Programme sleep : graphe d’appel et CFG de la fonction main
32
0x86ac
5 Discussion
Au cours de ce projet, nous avons étudié la structure d’exécutables binaires pour
l’architecture ARM, et conçu un outil permettant d’extraire et d’afficher cette structure de manière automatique. Notre outil permet, entre autres, de générer le graphe
d’appel du programme, et le graphe de flot de contrôle de ses fonctions. Cette
analyse peut être à la base de l’étude complète d’un fichier binaire, telle qu’une
décompilation.
Cependant, la portée de nos résultats est restreinte par les hypothèses que nous
avons posées (voir la section 1.4). Il faut notamment souligner que les seuls binaires
étudiés étaient au format ELF (systèmes GNU/Linux), compilés par gcc. Cela exclut
par exemple, le portage de pilotes Windows (autorisé par le fabricant), mentionné
dans la section 1.2.
Néanmoins, notre étude a posé des bases théoriques générales pouvant être
réutilisées. Elle laisse la porte ouverte a une généralisation du processus, de manière
à être compatible avec d’autres systèmes.
33
Références
[1] Boomerang. http://boomerang.sourceforge.net/. A general, open
source, retargetable decompiler of machine code programs.
[2] Graphviz. http://www.graphviz.org/. Générateur de graphes.
[3] LTTng, the Linux Trace Toolkit next-generation. http://lttng.org/. Suite
de traçage logiciel à haute performance.
[4] Perf. https://perf.wiki.kernel.org/. Profilage sur Linux et compteurs
de performance.
[5] Valgrind. http://valgrind.org/. Suite d’outils d’analyse dynamique et
d’instrumentation.
[6] ARM limited. ARM Architecture Reference Manual, 2005.
[7] Législation Canadienne. Loi sur le droit d’auteur. http://lois-laws.
justice.gc.ca/fra/lois/C-42/TexteComplet.html, 1985-2012.
[8] Cristina Cifuentes. Reverse Compilation Techniques. PhD thesis, Queensland
University of Technology, 1994.
[9] Free Software Foundation. GNU Binutils objdump. http://www.gnu.org/
software/binutils/.
[10] Free Software Foundation.
software/coreutils/.
GNU Coreutils.
http://www.gnu.org/
[11] Free Software Foundation. GNU Debugger (gdb). http://www.gnu.org/
software/gdb/.
[12] Free Software Foundation. GNU Embedded Application Binary Interface
(EABI) gcc. http://gcc.gnu.org/.
[13] Free Software Foundation. Librairie Libelf. http://directory.fsf.org/
libs/misc/libelf.html.
[14] Hex-Rays. IDA, the Interactive Disassembler. https://www.hex-rays.
com/products/ida/index.shtml.
[15] Microsoft. Windows Debugger (WinDbg). http://msdn.microsoft.com/
en-us/windows/hardware/gg463009.aspx.
[16] TIS Committee. Tool Interface Standard (TIS) Portable Formats Specification, version
1.2, 1995.
[17] Oleh Yuschuk. OllyDbg. http://www.ollydbg.de/.
34