En début d’année 2018, deux rapports concernant des failles de sécurité sont sortis et ont provoqué une panique dans le monde de la sécurité informatique.
Deux failles touchant la quasi-totalité des ordinateurs depuis 20 ans sont rendues publiques.
La première, Meltdown, ne touche que certains modèles de processeurs Intel.
La seconde, Spectre, est beaucoup plus inquiétante car elle touche la grande majorité des processeurs déployés dans le monde.
Ces deux vulnérabilités permettent potentiellement de lire la totalité de la mémoire d’un système, divulguant à l’insu de la victime les données sensibles qui y sont stockées.
Le fait que ces vulnérabilités soient d’origine matérielle rend ces failles d’autant plus dangereuses et uniques puisqu’elles ne sont pas corrigibles via un simple patch logiciel.
L’objet de cet article est d’expliquer simplement le fonctionnement de la faille Spectre.
Historique
Spectre et Meltdown ont permis de mettre au grand jour une faille impactant tous les processeurs sortis depuis 1995. Durant ces deux décennies, différents travaux ont pu ouvrir la voie, en voici une liste non exhaustive :
1995 : Un article publié par le IEEE Symposium on Security and Privacy prévient des risques de la découverte des canaux auxiliaires permettant la lecture du cache du processeur.
2005 : Daniel Bernstein de l’Université de l’Illinois a démontré qu’il était possible d’extraire une clé de chiffrement du protocole OpenSSL AES en exécutant une attaque de type cache-timing attack
2013 : Yuriv Yarom et Katrina Falkner de l’Université d’Adélaïde ont montré comment mesurer le temps d’accès à une information donnant la possibilité à un logiciel espion de déterminer si cette information est issue du cache processeur ou non.
Janvier 2017 : Anders Fogh fait une présentation à l’Université de Bochum sur la recherche automatique de canal auxiliaire, en particulier sur les processeurs avec un pipeline utilisé par plus d’un cœur. Spectre a été découverte indépendamment par différents groupes de recherche, notamment par des chercheurs du Project Zero de Google, une équipe de Google spécialisée dans la recherche de failles Zero Day.
1er juin 2017 : Les fabricants de matériel concernés ont été informés des vulnérabilités.
3 janvier 2018 : Deux rapports ont été rendus publics. L’un traitant de Spectre et le second concernant Meltdown.
Cette faille fut nommée Spectre, parce que son origine sera difficile à corriger et qu’elle nous hantera longtemps.
Rappels techniques
Qu’est-ce qu’un processeur
Afin de comprendre comment fonctionne Spectre, il est nécessaire de revenir sur certaines notions de base, particulièrement sur le fonctionnement d’un processeur et les différentes parties qui le composent.
Voici un schéma simplifié de la structure d’un processeur :
- Un Thread contient différents registres (le type de mémoire la plus rapide du processeur) et a la capacité d’exécuter un flux de code machine.
- Parmi ces registres, un est dédié à la gestion des instructions (appelé registre d’instruction ou IR) et contient les instructions en cours d’exécution et ceux à venir.
- Chaque Thread est contenu dans un cœur.
- Chaque cœur contient de multiples niveaux de cache, L1 est le cache le plus petit et le plus rapide, L2 est plus lent mais dispose d’une plus grosse capacité de stockage.
- Les caches sont partagés entre tous les Thread d’un même cœur.
- Un processeur peut posséder plusieurs cœurs qui détiennent chacun leurs caches.
- Le cache de type L3 est quant à lui partagé entre tous les cœurs d’un processeur. Dans certains modèles, les caches L2 peuvent être partagés entre plusieurs cœurs.
La mémoire cache
Les processeurs ont des algorithmes qui permettent à ces derniers de savoir s’ils risquent d’avoir besoin d’une information pour plus tard. Dans ce cas, l’information est stockée en cache (en premier lieu L1, puis L2 puis L3… jusqu’à la RAM).
Quand un processeur souhaite récupérer une donnée, il va la chercher dans le cache L1, s’il la trouve il peut continuer, on appelle cela un cache hit.
Dans le cas contraire, il va devoir aller chercher l’information dans une autre mémoire (L2 puis L3 puis Ln… jusqu’à la RAM) et cela s’appelle un cache miss.
Un cache miss peut être problématique, car le temps que l’information soit retrouvée, l’exécution de l’instruction est interrompue. Il a donc fallu trouver un moyen de réduire au maximum cette interruption dans l’exécution d’un programme.
Pipeline et prédiction de branchement
Un processeur est formé d’un pipeline contenant le code en cours d’exécution ainsi que les prochaines instructions qui seront réalisées, le tout séquentiellement.
Un pipeline est l’ensemble des actions qui vont être réalisées afin d’exécuter une instruction. Supposons que l’on dispose d’une instruction à l’adresse 0x1 demandant d’initialiser l’adresse mémoire 0xF avec la valeur 5.
Un pipeline à 7 étapes réalisera les actions suivantes dans l’ordre :
- PC : mise à jour du Program Counter ; // on signifie au processeur que nous sommes à l’adresse mémoire 0x1
- Fetch : chargement de l’instruction depuis la mémoire ; // 0x1 mov $0x5 0xF
- Decode : décodage de l’instruction ;
- Register Read : lecture des opérandes dans les registres ; // Nous n’avons aucune valeur à lire
- Exec : calcul impliquant l’unité de calcul du processeur ; // Nous n’avons pas de calcul à réaliser
- Mem : accès mémoire en lecture ou écriture ; // écriture de la valeur 5 dans la mémoire 0xF
- Writeback : écriture du résultat d’une lecture ou d’un calcul dans les registres.
Chacune de ces actions se réalise en un cycle machine du processeur, il faut dans ce cas 7 cycles afin d’achever une instruction, il est possible d’avoir un pipeline possédant plusieurs étages, chaque étage correspondant à une instruction réalisée en parallèle.
Lorsqu’une condition est présente dans le code, il est possible que la future série d’instructions à exécuter se trouve à une autre adresse mémoire.
Un branchement est une opération consistant à se déplacer au sein d’un code exécuté par un processeur, en sautant à une adresse identifiée au lieu de poursuivre l’exécution du code séquentiellement.
Cette exécution de branchement interrompt le fonctionnement séquentiel d’un programme et provoque un nettoyage du pipeline, ce qui peut provoquer une perte de temps.
Une prédiction de branchement (ou prédiction de branche) est donc un processus qui cherche à déterminer en avance quel sera le bon chemin à prendre, les instructions sont préchargées dans le pipeline afin de limiter au maximum l’interruption du programme.
S’il s’avère que la prédiction est incorrecte, le pipeline est vidé et rechargé avec les bonnes instructions, si la prédiction est correcte le pipeline continue son traitement séquentiel.
Nous allons illustrer ce qu’est un branchement et une prédiction de branchement avec un cas simple :
Imaginons le code suivant :
int a = 0; int b = 5; int c; if (a < b){ c = 10; }else{ c = 20; }
Une fois décompilé voilà ce que l’on obtient :
0x100000f70 <+0>: pushq %rbp
0x100000f71 <+1>: movq %rsp, %rbp
0x100000f74 <+4>: movl $0x0, -0x4(%rbp)
0x100000f7b <+11>: movl $0x0, -0x8(%rbp) // initialisation de la variable a
0x100000f82 <+18>: movl $0x5, -0xc(%rbp) // initialisation de la variable b = 5
0x100000f89 <+25>: movl -0x8(%rbp), %eax
// on compare l’octet à l’adresse 0xc (variable b) avec celle en 0x8 (variable a)
0x100000f8c <+28>: cmpl -0xc(%rbp), %eax.
// on passe à l’adresse mémoire 0x100000fa1 si b est supérieur ou égal à a
0x100000f8f <+31>: jge 0x100000fa1; <+49>
0x100000f95 <+37>: movl $0xa, -0x10(%rbp). // si a < b on met la variable c à 10
0x100000f9c <+44>: jmp 0x100000fa8; <+56> // On passe à l’instruction à l’adresse 0x100000fa8
0x100000fa1 <+49>: movl $0x14, -0x10(%rbp).// si b <= a On met la variable c à 20
0x100000fa8 <+56>: xorl %eax, %eax
0x100000faa <+58>: popq %rbp
0x100000fab <+59>: retq
On observe ici la création de deux branches à cause de la condition :Langage: Assembleur
La première où a < b :
0x100000f74 <+4>: movl $0x0, -0x4(%rbp)
0x100000f7b <+11>: movl $0x0, -0x8(%rbp) // initialisation de la variable a
0x100000f82 <+18>: movl $0x5, -0xc(%rbp) // initialisation de la variable b = 5
0x100000f89 <+25>: movl -0x8(%rbp), %eax
0x100000fa1 <+49>: movl $0x14, -0x10(%rbp). // si a < b on met la variable c à 20
0x100000fa8 <+56>: xorl %eax, %eax
et la seconde où b >= a :Langage: Assembleur
0x100000f74 <+4>: movl $0x0, -0x4(%rbp)
0x100000f7b <+11>: movl $0x0, -0x8(%rbp) // initialisation de la variable a
0x100000f82 <+18>: movl $0x5, -0xc(%rbp) // initialisation de la variable b = 5
0x100000f89 <+25>: movl -0x8(%rbp), %eax
0x100000f95 <+37>: movl $0xa, -0x10(%rbp). // on met la variable c à 10
0x100000fa8 <+56>: xorl %eax, %eax
Si l’adresse mémoire -0xc ou -0x8 n’est pas présent dans le cache L1, le processeur choisira, en fonction de ce qu’il aura déjà exécuté, si une branche à plus de chance d’être exécutée et pourra commencer à lire les instructions, c’est ce que l’on appelle une prédiction de branchement.Langage: Assembleur
L’exécution spéculative
Comme défini plus haut, le CPU utilise plusieurs niveaux de cache et chaque donnée absente de ce cache augmente le temps d’exécution d’un programme.
Lorsqu’un processeur se retrouve face à une instruction qui l’oblige à chercher une information dans la mémoire vive, au lieu de patienter, il exécutera les instructions suivantes de son registre, et réinitialisera son état s’il réalise que les instructions exécutées n’étaient pas censées être réalisées.
On appelle cela l’exécution spéculative, voici un exemple permettant de le démontrer.
if (x < array1_size) { y = array2[array1[x] * 256] ; }
Dans l’exemple précédent, supposons que :
array1_size n’est pas présent dans le cache.
array1 est présent dans le cache.
Le processeur peut spéculer sur le fait que array1_size est supérieur à X et continuer son exécution.
Si sa prédiction est bonne, il peut continuer son exécution et nous aurons gagné du temps.
Sinon le résultat des instructions est supprimé et nous n’avons pas perdu plus de temps que si la spéculation n’avait pas été faite.
La différence entre la prédiction de branchement et l’exécution spéculative repose sur le fait que la prédiction de branchement ne représente que le choix pris par le processeur lors d’un embranchement.
L’exécution spéculative quant à elle va un peu plus loin et va commencer à réaliser les instructions, conservant les résultats dans son cache.
Présentation de Spectre : les variantes
Les rapports publiés le 3 janvier 2018 font état de trois variantes sur les vulnérabilités, deux sont liées à Spectre, la troisième est celle de Meltdown.
CVE-2017-5753 : bounds check bypass
Cette variante, que nous allons détailler plus bas, repose sur le système d’optimisation des programmes notamment la prédiction de branchement et de la capacité au processeur à faire des mises en cache implicites.
Cela signifie que, lorsqu’un programme fait une exécution spéculative et charge une donnée, il stocke cette information dans son cache.
Lorsque le processeur réalise que sa prédiction de branchement est mauvaise, il réinitialise les informations de son registre pour reprendre le bon flot d’exécution.
Cependant, le cache n’est pas réinitialisé et les données qui ne sont pas censées être présente sont conservées.
Pour aller plus loin, vous pouvez consulter le Rapport Mitre sur CVE-2017-5753
CVE-2017-5715 : branch target injection
Cette variante peut être utilisée dans le cas où le code victime dispose d’une branche indirecte.
Une branche indirecte est une branche dont l’adresse mémoire d’arrivée ne peut être définie qu’au moment du runtime, dans le cas du polymorphisme en C# nous avons cet exemple :
class Base { public: virtual void Foo() = 0; }; class Derived : public Base { public: void Foo() override { … } }; Base* obj = new Derived; obj->Foo();
Lorsque l’on appelle la méthode Foo(), le processeur passe par une branche dont la destination n’est pas fixée et oblige le processeur à trouver l’adresse mémoire de l’implémentation de cette méthode.
À ce moment, au lieu de bloquer l’exécution du programme jusqu’à ce que l’adresse mémoire soit trouvée, le processeur peut tenter de deviner l’emplacement de la fonction.
Il a été montré qu’il est possible que deux programmes dans des contextes différents puissent influencer mutuellement leur prédiction de branchement et interférer dans leur fonctionnement.
C’est-à-dire qu’un code malveillant peut perturber la prédiction de la branche indirecte du programme ci-dessus et le forcer à croire que sa destination est la fonction victime de la première variante, par exemple :
if (x < array1_size) { y = array2[array1[x] * 256] ; }
Une fois que le processeur aura chargé ces instructions, il débutera son exécution spéculative et stockera les informations normalement inaccessibles dans son cache.
Lorsque le processeur aura découvert la véritable adresse de la méthode Foo(); il réinitialisera l’état de son registre, mais son cache ne sera pas nettoyé.
Pour aller plus loin, vous pouvez consulter le Rapport Mitre sur CVE-2017-5715
CVE-2017-5754 : rogue data cache load (Meltdown)
Cette variante tente de lire la mémoire du kernel à partir de l’espace utilisateur sans passer par les vérifications des droits d’accès aux espaces mémoire demandés.
On utilise donc les modèles de code des variantes précédentes mais dans l’espace utilisateur.
Afin d’optimiser les performances d’un programme, lorsqu’une instruction cherche à récupérer une information à une adresse mémoire spécifique, les contrôles d’accès se font sur un chemin parallèle de façon asynchrone.
Cependant, l’information contenue dans l’adresse mémoire chargée peut être utilisée directement par les instructions suivantes dans le registre.
Lorsque la vérification de l’autorisation échoue, elle déclenche une exception qui produit une réinitialisation du registre.
La faille repose sur le fait que le temps que cette vérification se fasse, l’information récoltée peut néanmoins être lue.
Pour aller plus loin, vous pouvez consulter le Rapport Mitre sur CVE-2017-5754
Analyse de la variante Bounds Check Bypass
Nous allons analyser le snippet suivant :
unsigned int array1_size = 16; uint8_t array1[16] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}; uint8_t array2[256 * 512]; char * secret = « password »; uint8_t temp = 0; void victim_function(size_t x) { if (x < array1_size) { temp = array2[array1[x] * 512]; } } /// CODE DE L’ATTAQUANT int CACHE_HIT_TRESHOLD = 80 // on considère que si la vitesse d’accès à une information est inférieure à 80, c’est qu’elle provient du cache uint8_t read_memory_byte(malicious_address){ int a = 5 //un index valide pour l’entrainement /* * ÉTAPE 1 : on entraîne la prédiction de branchement à considérer que la condition * x &lt; array1_size est vraie */ for(int i = 0 ; i < 50 ; i++){ empty_cache() ; // on vide volontairement le cache. victim_function(a) ; } // ÉTAPE 2 : on appelle la fonction victime avec l’adresse mémoire arbitraire victim_function(malicious_address); /* * ÉTAPE 3 : * À ce moment le résultat de array2[array1[x] * 512]; est mis en cache. * Il nous suffit donc de boucler sur toutes les valeurs possibles de x entre 0 et 256, * La valeur qui aura été récupérée le plus rapidement sera la valeur de l’octet que nous cherchions * à obtenir. */ for(int i = 0 ; i < 256 ;i++){ uint8_t time1 = get_processor_clock_time(); // on récupère le nombre de cycles d’horloge du processeur (void *) array2[i * 512]; // on accède à la valeur uint8_t time2 = get_processor_clock_time(); // on récupère le nombre de cycles d’horloge du processeur // on calcule le temps qu’il a fallu au processeur pour obtenir la valeur de array2[i * 512] // si le nombre de cycles est inférieur au seuil, on considère que la valeur provient du cache if(time2 – time1 < CACHE_HIT_THRESHOLD){ /* ÉTAPE4 : cette valeur est issue du cache, elle équivaut donc à l’octet contenu à l’adresse « malicious_address » */ return i; } } } int main(){ uint8_t malicious_memory_address = get_address_of_password(); // on récupère l’adresse du mot de passe for(int i = 0; < password.length; i++){ //On boucle sur chaque adresse mémoire qui constitue le mot de passe uint8_t secret_byte = read_memory_byte(malicious_memory_address + i); // on lit chaque octet contenu dans chacune des adresses mémoires } }
Cet extrait a été volontairement simplifié et vulgarisé afin d’améliorer la compréhension, il n’est donc pas fonctionnel en l’état.
Nous allons détailler chaque étape du processus :
Dans un premier temps nous entraînons le processeur à dire que x < array1_size est vrai en forçant la suppression de array1_size et de array2 du cache à chaque itération de l’entraînement.
Le processeur sera obligé d’aller chercher les valeurs de array1_size et de array2 dans la RAM et, en attendant que le résultat de x < array1_size soit trouvé, le processeur entraîné fera une prédiction de branche, considérera que la condition est dans tous les cas vraie et réalisera une exécution spéculative en exécutant la prochaine instruction.
De ce fait, si nous faisons tourner la fonction victim_function 50 fois avec une valeur X valide, il supposera lors de la 51e itération que X est aussi valide et continuera le script en attendant de récupérer les vraies valeurs depuis la RAM.
Sauf que, lors de la 51e itération nous aurons pris le soin de sélectionner X en s’arrangeant pour que le résultat de array1[x]; retourne une valeur normalement inaccessible car out-of-bound.
En reprenant le code ci-dessus nous allons tenter de récupérer le contenu de la variable secret.
La variable secret possède la chaîne de caractères password. Supposons que la variable soit stockée dans la mémoire à l’adresse 0x0010 .
En supposant que le texte est écrit en ASCII, chaque caractère correspond à 1 octet dans la mémoire, donc notre secret est stocké de l’adresse 0x0010 à l’adresse 0x0017.
Notre array1 est stocké à l’adresse 0x0020. Donc, afin de connaître la valeur de X à fournir lors de la 51e itération, il nous faut réaliser le calcul suivant : 0x0010-0x0020 = -0x0010, soit -16 une fois converti en décimal.
Lorsque notre code atteindra lors de son exécution spéculative l’instruction : array1[-16], la valeur récupérée par le processeur sera l’octet représentant le p du mot password.
En temps normal, le processeur détecterait que la valeur X demandée est en dehors des limites de array1 et lèverait une exception, cependant durant l’exécution spéculative le processus a été entraîné à avoir une valeur X valide et continuera donc à aller récupérer l’information demandée sans se soucier des permissions.
La représentation décimale de la lettre p est 112.
Le processeur résout de fait l’instruction suivante dans le script, c’est-à-dire : array2[X* 512] avec X=112 et enregistre le résultat de l’opération dans son cache.
Lorsque le processeur se rend compte de son erreur, il réinitialise son registre mais laisse le cache L1 en l’état. Lorsque nous réaliserons ensuite l’instruction array2[X* 512] avec X=112, le processeur pourra récupérer le résultat bien plus rapidement qu’avec toute autre valeur de X.
Il nous suffit alors de réaliser à nouveau l’instruction array2[X * 512] avec toutes les valeurs de X possibles entre 0 et 256 (la taille maximale d’un octet).
À chaque itération nous calculons le temps que le processeur aura mis à fournir le résultat de l’opération.
Nous remarquerons ainsi que l’instruction array2[ X * 512 ] avec X=112 à été exécutée plus rapidement que pour toutes les autres valeurs de X.
Nous pouvons donc en déduire que l’octet que nous avons récupéré lors de l’exécution spéculative contient la valeur 112 (0x70) et donc que le premier octet composant la variable secret est p.
Donc que 0x0010 = 0x70
Il nous suffit simplement ensuite de répéter le processus précédent sur toutes les adresses mémoires (0x0011, 0x0012 … 0x0018) afin de reconstituer le mot de passe complet. Nous obtiendrons ainsi le résultat suivant :
0x0010 = 0x70
0x0011 = 0x61
0x0012 = 0x73
0x0013 = 0x73
0x0014 = 0x77
0x0015 = 0x6f
0x0016 = 0x72
0x0017 = 0x64.
De cette manière, nous avons pu déduire chaque octet constituant la variable secret grâce à un canal auxiliaire et sans jamais manipuler directement cette même variable.
Différence entre Spectre et Meltdown
Meltdown n’utilise pas la prédiction de branchement pour parvenir à réaliser une exécution spéculative.
Il repose sur l’observation du moment où une instruction provoque la réinitialisation du registre.
De plus, il utilise une faille d’élévation de privilège spécifique aux processeurs Intel qui permet d’outrepasser la vérification de l’adresse mémoire.
L’exécution d’une telle instruction déclenche une annulation de la branche, mais avant que cette annulation ne soit prise en compte, le code exécuté autorise donc la lecture du contenu de la mémoire à travers un canal auxiliaire.
Cela permet à Meltdown d’accéder à la mémoire du noyau depuis une application utilisateur.
Meltdown ne fonctionne qu’avec les processeurs Intel disposant de cette faille de sécurité.
Conclusion
Qu’est ce que Spectre est en mesure de faire ?
Spectre est capable de lire l’ensemble de la mémoire d’un ordinateur et ainsi accéder à des données sensibles présentes sur le système, comme des mots de passe ou encore être en mesure de lire les saisies clavier.
Il n’est cependant pas possible de réaliser des opérations d’écriture, seule la lecture est possible.
Comment réaliser une attaque par Spectre ?
Afin de pouvoir réaliser une telle attaque, il faut être en mesure d’exécuter sur la machine cible un code contenant le schéma de code de la variante bounds check bypass.
Ce code doit être présent dans un programme existant ou interprété par un compilateur JIT (Just-In-Time), par exemple celui utilisé par JavaScript, afin de générer le code vulnérable.
La section 4.3 du rapport public sur Spectre explique en effet qu’une implémentation JavaScript est possible.
Un POC a été réalisé sur Google Chrome permettant ainsi de lire des zones de la mémoire du processus normalement inaccessible.
Son principe étant lié à la structure même du processeur, un même code n’aura pas forcément le même comportement selon l’architecture sur lequel il tourne.
Un attaquant devra donc cibler un type de machine spécifique en réalisant son attaque.
De plus, l’emplacement des informations en mémoire étant aléatoire, un attaquant ne pourra pas cibler une zone spécifique à analyser. Cela ne lui servirait à rien.
Il sera obligé de lire aléatoirement des portions entières de la mémoire en espérant obtenir quelque chose d’intéressant.
En supposant qu’une personne réalise une attaque sur une machine dont il n’a pas d’accès physique, il devra enregistrer les données collectées quelque part sur le système de la cible afin de pouvoir transférer ces informations via internet.
Cela peut rapidement représenter de grandes quantités d’information à envoyer depuis le réseau de la victime sans que celui-ci ne soit alerté.
Spectre est de fait une faille difficile à exploiter et les cas d’utilisation réels n’ont pas encore été détectés.
Comment limiter les risques ?
Ces failles sont des problèmes matériels, à part remplacer complètement les processeurs, il est impossible de supprimer définitivement ces vulnérabilités.
Néanmoins, les principaux acteurs du domaine ont déjà réalisé des patchs de sécurité, ces patchs limitent les risques en empêchant l’accès à la faille, mais ne règlent pas le fait que cette vulnérabilité soit toujours présente sur nos processeurs.
Il faut donc mettre à jour au plus vite les systèmes d’exploitation ainsi que les logiciels susceptibles d’être victime de cette faille.
Documentation
Voici quelques liens qui m’ont aidé à construire cet article et qui peuvent vous permettre d’approfondir vos recherches sur Spectre et Meltdown :
Lire une mémoire avec un canal auxiliaire par le Google Project Zero