Les premiers pas
Apprendre l'assembleur ARM est une étape cruciale pour quiconque s'intéresse au développement bas niveau ou à la recherche en sécurité informatique, notamment dans le domaine de l'exploitation. Ce langage de programmation permet d'interagir directement avec le matériel via des instructions très proches du processeur, offrant ainsi une maîtrise fine des opérations exécutées. Avant de plonger dans des concepts avancés comme la création de shellcode ou la manipulation de chaînes ROP (Return-Oriented Programming), il est essentiel de comprendre les fondements de l'architecture ARM. Cette série de tutoriels commence donc par poser les bases nécessaires, avec une introduction générale à l'assembleur ARM, suivie d'une exploration progressive des différents composants du langage.
L'environnement de développement est une composante clé pour la pratique de l'assembleur ARM. Même si vous ne possédez pas de matériel physique tel qu'un Raspberry Pi, il est possible de mettre en place un laboratoire virtuel grâce à QEMU, un émulateur de processeur. En y installant une image compatible ARM, comme celle de Raspbian, vous pouvez simuler un système ARM complet sur votre PC. Cela permet d'expérimenter avec du code assembleur dans un environnement sûr, contrôlé et proche du réel. Il est également recommandé de se familiariser avec les outils de débogage comme GDB, permettant d'examiner le contenu des registres, suivre l'exécution instruction par instruction, et comprendre le comportement du code en profondeur.
Au fil des sept parties du tutoriel, plusieurs aspects fondamentaux du langage assembleur ARM seront abordés. On commencera par découvrir les registres et les types de données manipulés, avant de se pencher sur le jeu d'instructions - le cour du langage - permettant de réaliser des opérations arithmétiques, logiques ou de contrôle de flux. Les tutoriels expliqueront aussi comment charger et stocker des données en mémoire, comment effectuer des transferts multiples avec efficacité, et comment gérer la pile et les fonctions. L'exécution conditionnelle et les branchements, très spécifiques à ARM grâce à son architecture optimisée, seront aussi analysés. Cette progression graduelle vise à vous doter de bases solides pour aborder ensuite des scénarios d'exploitation plus complexes dans un contexte ARM.
Pourquoi ARM ?
Ce tutoriel s'adresse généralement à ceux souhaitant apprendre les bases de l'assemblage ARM, et plus particulièrement à ceux s'intéressant à l'écriture d'exploits sur la plateforme ARM. Vous avez peut-être déjà remarqué que les processeurs ARM sont omniprésents. En regardant autour de vous, on compte bien plus d'appareils équipés d'un processeur ARM que de processeurs Intel. Cela inclut les téléphones, les routeurs, sans oublier les objets connectés dont les ventes semblent exploser ces derniers temps. Cela dit, le processeur ARM est devenu l'un des coeurs de processeur les plus répandus au monde. Ce qui nous amène à constater que, comme les PC, les objets connectés sont sujets à des abus de validation d'entrée, tels que les dépassements de tampon. Compte tenu de l'utilisation répandue des appareils ARM et du potentiel d'utilisation abusive, les attaques contre ces appareils sont devenues beaucoup plus fréquentes.
Pourtant, nous avons plus d'experts spécialisés dans la recherche en sécurité x86 que dans ARM, bien que l'assemblage ARM soit peut-être le langage d'assemblage le plus simple et le plus répandu. Alors, pourquoi ne pas se concentrer davantage sur ARM ? Peut-être parce qu'il existe davantage de ressources d'apprentissage sur l'exploitation des vulnérabilités Intel que sur ARM.
Microprocesseur ARM vs microprocesseur Intel
Il existe de nombreuses différences entre Intel et ARM, mais la principale réside dans l'ensemble d'instructions. Intel est un processeur CISC (Complex Instruction Set Computing) doté d'un jeu d'instructions plus vaste et plus riche en fonctionnalités, permettant à de nombreuses instructions complexes d'accéder à la mémoire. Il offre donc davantage d'opérations et de modes d'adressage, mais moins de registres qu'ARM. Les processeurs CISC sont principalement utilisés dans les PC, les stations de travail et les serveurs classiques.
ARM est un processeur RISC (Reduced Instruction Set Computing) et possède donc un ensemble d'instructions simplifié (100 instructions ou moins) et des registres plus polyvalents que CISC. Contrairement à Intel, ARM utilise des instructions qui opèrent uniquement sur les registres et utilise un modèle de chargement/entreposage pour l'accès mémoire, ce qui signifie que seules les instructions de chargement/entreposage peuvent accéder à la mémoire. Cela signifie que l'incrémentation d'une valeur 32 bits à une adresse mémoire particulière sur ARM nécessiterait trois types d'instructions (chargement, incrémentation et d'entreposage) : charger d'abord la valeur à une adresse donnée dans un registre, l'incrémenter dans le registre, puis la stocker en mémoire depuis le registre.
L'ensemble d'instructions réduit présente des avantages et des inconvénients. L'un des avantages est que les instructions peuvent être exécutées plus rapidement, ce qui permet potentiellement une plus grande vitesse (les systèmes RISC raccourcissent le temps d'exécution en réduisant le nombre de cycles d'horloge par instruction). L'inconvénient est que le nombre réduit d'instructions implique une plus grande efficacité d'écriture logicielle avec les instructions limitées disponibles. Il est également important de noter qu'ARM dispose de deux modes : le mode ARM et le mode Thumb. Les instructions Thumb peuvent être de 2 ou 4 octets.
D'autres différences entre ARM et x86 sont :
- Avec ARM, la plupart des instructions peuvent être utilisées pour l'exécution conditionnelle.
- Les processeurs Intel x86 et x86-64 utilisent le format little-endian.
- L'architecture ARM était little-endian avant la version 3. Depuis, les processeurs ARM sont devenus bi-endian et disposent d'un paramètre permettant de commuter l'endianness.
Il existe des différences non seulement entre Intel et ARM, mais aussi entre les différentes versions d'ARM. Une fois les fondamentaux compris, vous pourrez facilement comprendre les nuances de la version ARM que vous avez choisie. Les exemples de ce tutoriel ont été créés sur un ARMv6 32 bits (Raspberry Pi 1 ; les explications se rapportent donc à cette version.
La dénomination des différentes versions d'ARM peut également prêter à confusion :
Famille ARM | Architecture ARM |
---|---|
ARM7 | ARM v4 |
ARM9 | ARM v5 |
ARM11 | ARM v6 |
Cortex-A | ARM v7-A |
Cortex-R | ARM v7-R |
Cortex-M | ARM v7-M |
Écriture en assembleur
Avant de vous lancer dans le développement d'exploits ARM, vous devez d'abord comprendre les bases de la programmation en assembleur, ce qui nécessite quelques connaissances de base avant de pouvoir l'apprécier. Mais pourquoi avons-nous besoin d'assembleur ARM ? N'est-il pas suffisant d'écrire nos exploits dans un langage de programmation/script «normal» ? Ce n'est pas le cas si nous voulons faire de la rétro-ingénierie et comprendre le flux de programmation des binaires ARM, créer son propre shellcode ARM, créer des chaînes ROP ARM et déboguer des applications ARM.
Il n'est pas nécessaire de connaître tous les détails du langage assembleur pour pouvoir faire de la rétro-ingénierie et développer des exploits, mais certains éléments sont nécessaires pour en comprendre le contexte global.
Qu'est-ce que le langage assembleur ? Le langage assembleur n'est qu'une fine couche syntaxique superposée au code machine, composée d'instructions codées en représentations binaires (code machine), ce que notre ordinateur comprend. Alors pourquoi ne pas écrire du code machine ? Ce serait vraiment pénible. C'est pourquoi nous allons écrire de l'assembleur, de l'assembleur ARM, beaucoup plus facile à comprendre pour les humains. Notre ordinateur ne peut pas exécuter de code assembleur lui-même, car il a besoin de code machine. L'un des outils que vous pouvez utilisé pour assembler le code assembleur en code machine est un assembleur GNU du projet GNU Binutils, nommé as, fonctionnant avec les fichiers sources portant l'extension *.s.
Une fois votre fichier assembleur écrit avec l'extension *.s, vous devez l'assembler avec as et le lier avec ld :
as programme.s -o programme.o ld programme.o -o programme |
L'assemblage sous le capot
Commençons par le bas et remontons jusqu'au langage assembleur. Au niveau le plus bas, nous trouvons les signaux électriques de notre circuit. Ces signaux sont formés en commutant la tension électrique sur l'un des deux niveaux suivants : 0 volt (arrêt) ou 5 volts (marche). Comme il est difficile de déterminer la tension du circuit à l'oil nu, nous choisissons d'écrire des motifs de tensions marche/arrêt à l'aide de représentations visuelles, les chiffres 0 et 1, non seulement pour représenter l'absence ou la présence d'un signal, mais aussi parce que 0 et 1 sont des chiffres du système binaire. Nous regroupons ensuite la séquence de 0 et 1 pour former une instruction en code machine, qui est la plus petite unité de travail d'un processeur informatique. Voici un exemple d'instruction en langage machine :
1110 0001 1010 0000 0010 0000 0000 0001 |
Jusqu'ici, tout va bien, mais nous ne nous souvenons plus de la signification de chacun de ces motifs (de 0 et 1). C'est pourquoi nous utilisons des mnémoniques, des abréviations, pour nous aider à mémoriser ces motifs binaires, où chaque instruction en code machine reçoit un nom. Ces mnémoniques sont souvent composés de trois lettres, mais ce n'est pas obligatoire. Nous pouvons écrire un programme en utilisant ces mnémoniques comme instructions. Ce programme est appelé programme en langage assembleur, et l'ensemble des mnémoniques utilisés pour représenter le code machine d'un ordinateur est appelé le langage assembleur de cet ordinateur. Par conséquent, le langage assembleur est le niveau le plus bas utilisé par les humains pour programmer un ordinateur. Les opérandes d'une instruction viennent après le(s) mnémonique(s). Voici un exemple :
- MOV R2, R1
Maintenant que nous savons qu'un programme assembleur est composé d'informations textuelles appelées mnémoniques, nous devons les convertir en code machine. Comme mentionné précédemment, dans le cas de l'assembleur ARM, le projet GNU Binutils fournit un outil appelé as. L'utilisation d'un assembleur comme as pour convertir du langage assembleur (ARM) en code machine (ARM) s'appelle l'assemblage.
En résumé, nous avons appris que les ordinateurs comprennent (réagissent à) la présence ou l'absence de tensions (signaux) et qu'il est possible de représenter plusieurs signaux par une séquence de 0 et de 1 (bits). Nous pouvons utiliser le code machine (séquences de signaux) pour faire réagir l'ordinateur d'une manière bien définie. Comme nous ne nous souvenons pas de la signification de toutes ces séquences, nous leur donnons des abréviations - des mnémoniques - et les utilisons pour représenter des instructions. Cet ensemble de mnémoniques est le langage assembleur de l'ordinateur et nous utilisons un programme appelé Assembleur pour convertir le code de la représentation mnémonique en code machine lisible par ordinateur, de la même manière qu'un compilateur le fait pour les langages de haut niveau.