Spectre et Meltdown : explication technique des 3 failles de sécurité et des mesures correctives (pour un public averti)

Temps de lecture estimé : 12 minute(s)

Depuis que les failles de sécurité Spectre et Meltdown, découvertes sur les processeurs x86-64, ont été révélées publiquement la semaine dernière, le monde de l’IT s’affaire. Il s’agit d’abord de prémunir tous les équipements informatiques de l’exploitation de ces vulnérabilités. Puis de mesurer l’impact des correctifs sur les performances des systèmes, et d’expliquer la menace, de sorte à ce que les utilisateurs effectuent rapidement les mises à jour poussées par les éditeurs de systèmes d’exploitation et d’applications.

 Il y a également de la pédagogie à faire : il faut l’avouer, la compréhension les failles et les différentes façons de les exploiter est ardue. Car ces failles sont liées à la conception même des CPU, au niveau hardware, ce qui est inédit — du moins à cette échelle. Et les contre-mesures sont de plusieurs natures : pansement sur les logiciels et systèmes d’exploitation pour éviter les fuites d’information, et modification du microcode des processeurs (leur microprogramme embarqué).

Vulgariser le fonctionnement des attaques exploitant les failles Meltdown et Spectre n’est pas l’objectif de cet article. Nous souhaitions en revanche apporter notre contribution à la compréhension technique de ces failles, à travers le regard de l’un de nos architectes en sécurité.

Pour suivre les opérations en cours chez OVH pour sécuriser les infrastructures face à Spectre et Meltdown, et retrouver l’ensemble des informations publiées pour vous aider à comprendre la situation, rendez-vous sur notre communiqué.

// Par Thomas Soete //

L’exécution spéculative

Spectre et Meltdown recouvrent trois vecteurs d’attaque distincts, la première des deux failles étant exploitables selon deux méthodologies différentes. Les trois cas sont néanmoins liés à l’exécution spéculative des CPU. Derrière ce terme barbare se cachent des optimisations réalisées depuis une dizaine d’années par les fondeurs pour améliorer la vitesse de traitement des processeurs. Pour faire simple, il s’agit pour un CPU de commencer à exécuter une instruction avant que l’exécution de la précédente ne soit terminée.

Les CPU x86 récents sont en fait des processeurs CISC à cœur RISC. Une unité de décodage prend en charge chaque instruction x86 arrivant dans le CPU, et la découpe en plusieurs micro-opérations, qui sont ensuite exécutées sur les différentes unités de calcul du CPU. Chaque cœur d’un CPU disposant de plusieurs de ces unités de calcul spécialisées, un CPU Haswell, par exemple, peut réaliser quatre opérations arithmétiques, et quatre accès mémoire en parallèle.

Pour comprendre, imaginons un cas simple avec les deux instructions suivantes :

  • incrémenter le registre R1
  • incrémenter le registre R2

Ces deux instructions vont être découpées en micro-opérations, en l’occurrence en deux opérations arithmétiques. Ces deux instructions étant indépendantes l’une de l’autre et le CPU disposant de quatre unités arithmétiques, elles vont être exécutées en parallèle.

Maintenant, considérons les deux instructions suivantes :

  • charger dans le registre R2 la valeur présente à l’adresse mémoire contenue dans le registre R1 + 10
  • incrémenter le registre R3.

La première instruction va être découpée en deux micro-opérations :

  • ① calculer R1+10
  • ② charger en mémoire l’adresse précédemment calculée et la stocker dans R2

La deuxième instruction consiste quant à elle en une seule micro-opération :

  • ③ calculer R3+1

Les deux micro-opérations ① et ② sont dépendantes l’une de l’autre, mais ① et ③ ne le sont pas. Le CPU peut donc lancer l’exécution de ① et ③ sur ses unités arithmétiques. Une fois ① terminée, il va lancer la micro-opération ② et, une fois celle-ci terminée, il va considérer les deux instructions comme terminées.

Dans les faits, la seconde instruction sera terminée avant la première. Néanmoins, pour garder un état cohérent, les changements sont rendus visibles par le CPU dans l’ordre. Donc, même si le CPU a déjà calculé la nouvelle valeur de R2, le registre ne sera réellement modifié qu’une fois la première instruction terminée. Le fait de pouvoir pré-calculer, comme dans ce cas de figure, des valeurs pendant les instructions lentes (un chargement par exemple) permet d’augmenter le rendement du CPU.

Vous voilà, rapidement, au fait de ce qu’est l’exécution spéculative, ou le lancement anticipé d’instruction, celui-ci pouvant aller jusqu’à la spéculation sur les futures instructions à réaliser (quitte à en jeter le résultat s’il s’avère que l’instruction n’avait pas besoin d’être exécutée). Nous y reviendrons un peu plus tard.

Meltdown

Revenons à notre exemple : que se passe-t-il si la première instruction génère une erreur ? Normalement, dans ce cas-là, le CPU jette à la poubelle tout ce qu’il a calculé et génère l’erreur. Malheureusement il se trouve que ces pré-calculs laissent des traces visibles à l’extérieur du CPU. Ces traces sont subtiles, mais mesurables comme nous verrons dans le premier exemple.

Pour revenir aux vecteurs d’attaque, qui sont donc au nombre de trois, commençons par la plus simple : Meltdown.

Normalement, la mémoire du kernel n’est pas accessible pour un programme utilisateur. Donc si un programme essaye d’y accéder, cela génère une erreur.
Dans le cas qui nous intéresse, voilà les instructions exécutées :

  • charger dans le registre R1 la valeur à une adresse du kernel
  • charger dans le registre R2 la valeur à une adresse dépendant de la valeur récupérée précédemment

Idéalement, la première instruction est exécutée, génère une erreur, le flow d’exécution s’arrête et la deuxième instruction n’est jamais exécutée. Or, la gestion des erreurs d’accès n’est gérée qu’à la fin du pipeline du CPU, une fois que toutes les micro-opérations ont été exécutées. Ce qui laisse le temps à la deuxième instruction d’être exécutée, si la ressource de calcul nécessaire est disponible. Lorsque l’erreur de la première instruction a été gérée, le CPU est censé annuler ce qu’il a fait. Mais, pour faire simple, le chargement en mémoire a laissé une trace dans le cache du CPU. En effet, afin d’accélérer les accès à la mémoire, le CPU comporte un cache rempli des données récemment accédées. Si la valeur que nous essayons de lire se trouve dans le cache, le temps d’accès est significativement accéléré. Il existe donc un moyen de savoir si une valeur qu’on essaye de lire se trouve dans le cache ou non.

Cela ne nous intéresse pas beaucoup : ici c’est l’opération l’inverse qu’il s’agit de réaliser. Commençons par remplir le cache avec nos propres données, puis faisons exécuter le code ci-dessus. Comme la donnée que le CPU veut lire ne se trouve pas dans le cache, il va remplir une partie du cache avec cette donnée (et donc évincer une partie des données présentes pour faire de la place). L’algorithme qui détermine où sont stockées les données dans le cache est simple ; il dépend simplement de l’adresse de la donnée. La partie du cache qui sera vidée dépendra donc de l’adresse chargée (qui dépend elle-même de la valeur que l’on souhaite récupérer, souvenez-vous). Il nous suffit ensuite de relire nos données en mesurant les temps d’accès pour savoir quelle partie du cache a été vidée, pour ensuite déduire la valeur convoitée.

Quelle parade pour Meltdown ? Pour le kernel Linux, un patch du noyau, appelé PTI (Page Table Isolation). L’effet est que les plages mémoire du kernel ne sont maintenant même plus présentes dans l’espace d’adressage des processus (auparavant, elles y figuraient, mais étaient interdites d’accès). Résultat : impossible qu’une instruction puisse aller lire une valeur. Malheureusement, maintenant, à chaque appel système, le kernel est obligé d’aller changer le mapping mémoire, ce qui a un coût. D’où la baisse de performance. Les autres systèmes d’exploitation comme Mac OS ou Windows ont déployé un patch similaire.

Spectre

Concernant Spectre, l’attaque est légèrement différente, mais s’appuie aussi sur le temps d’accès mémoire du CPU pour récupérer de la donnée.

Variante 1 de spectre (Bounds check bypass)

Imaginons le code suivant :

if (x < y)
{
    z = array1[x]
}

Il s’agit d’une simple vérification que l’on n’essaye pas d’accéder en dehors du tableau array1. On s’attend logiquement à ce que le CPU vérifie d’abord que ‘x’ soit inférieur à ‘y’ avant d’exécuter la suite. Or, pour gagner du temps, le CPU ne va pas toujours exécuter la condition avant de faire l’opération. Il va spéculer sur le fait que la condition soit vraie ou fausse. S’il pense que le résultat va être vrai, il va exécuter les instructions suivantes en attendant d’avoir le résultat de la condition. S’il a prédit juste, tant mieux : il a gagné du temps. Dans le cas contraire, comme précédemment, il annule ce qu’il vient de faire.

Ici le travail d’un potentiel attaquant est d’entraîner le CPU à considérer que la condition va être juste, pour ensuite lui donner une valeur totalement arbitraire. Considérons que la lecture de array1[x] corresponde à aller lire l’adresse de adresse de array1 + x. Si on parvient à donner comme valeur adresse attaquée - adresse de array1, le CPU va aller lire adresse de array1 + (adresse attaquée - adresse de array1), soit adresse attaquée. Ici, nous n’allons pas bien loin, car nous sommes simplement parvenus à aller faire lire quelque chose au CPU à l’adresse que nous connaissions déjà.

Maintenant, considérons le code suivant :

if (x < y)
{
    z = array2[array1[x]]
}

Ici, le CPU est censé aller lire une adresse qui dépend du résultat de array1[x]. Or, nous avons vu juste au-dessus qu’il était possible de faire en sorte que le résultat de array1[x] soit une valeur que l’on souhaite récupérer. Le CPU va donc charger une adresse qui dépend de cette valeur. Si nous reprenons les détails évoqués pour Meltdown, le CPU va devoir vider une ligne de cache pour charger cette donnée, et on va donc pouvoir déduire la valeur présente à l’adresse attaquée.

Dans ce cas, nous sommes en présence d’un problème de logique au niveau du CPU, qui relève de la conception hardware des processeurs. Pas de patch possible au niveau du kernel. Une solution est de modifier les programmes générés (on parle donc de patcher les compilateurs), pour rajouter ce qu’on appelle une instruction desérialisation après le if() demandant explicitement au CPU de ne plus faire de spéculation. L’opération conseillée par Intel/AMD est d’utiliser LFENCE. Mais, comme le but de la spéculation était de gagner du temps, on force le CPU à attendre et, naturellement, on affecte les performances.

Concernant la variante 2 de Spectre (Branch Target Injection), celle-ci se révèle plus vicieuse.

Les CPU x86 permettent d’appeler une fonction dont l’adresse est stockée en mémoire. Comme aller chercher cette valeur en mémoire est une opération coûteuse, le CPU va essayer de deviner l’adresse où il doit aller, et il va commencer à y exécuter les instructions. Lorsqu’il a terminé d’obtenir l’adresse, il vérifie s’il a vu juste ou s’il s’est trompé sur l’emplacement. Dans le premier cas, il a gagné du temps. Dans l’autre, il annule ce qu’il a commencé à exécuter, et c’est comme si l’on avait attendu la lecture complète. C’est du gagnant-gagnant.

Mais que se passe-t-il si nous parvenons à entraîner le CPU pour qu’il réalise une mauvaise prédiction ? Et qu’on arrive à ce que cette mauvaise prédiction soit sous notre contrôle ? Et qu’on arrive donc à appeler une fonction qui va effectuer une lecture mémoire dépendant d’une valeur que l’on souhaite récupérer ? Cela vous rappelle quelque chose ?

C’est précisément ce que des chercheurs ont réussi à faire. Comme pour les deux autres failles, la lecture mémoire va laisser une trace qu’il est ensuite possible de mesurer pour en extraire la valeur.

Ici non plus, pas de patch kernel possible pour sécuriser les applications. Comme pour la variante 1 de Spectre, une solution est de changer le code généré par les compilateurs pour empêcher le CPU de réaliser cette prédiction sur l’emplacement des fonctions.

Il existe deux pistes pour corriger cette faille : la première consiste donc à modifier les compilateurs (Retpoline) ; la seconde repose sur le patch du microcode des CPU, comportant une nouvelle fonctionnalité sur laquelle travaille Intel, permettant au kernel de contrôler les prédictions du CPU. Mais, comme le kernel est lui aussi faillible à ce genre d’attaque, il faut également le corriger.

Comme vous pouvez le constater, exploiter les failles Meltdown et Spectre est loin d’être trivial. Il existe bel et bien des POC en laboratoire de l’exploitation de ces vulnérabilités, mais nous n’avons à ce jour aucune information faisant état d’une attaque par ce biais dans un environnement réel. Les méthodologies sont probablement encore à inventer pour exploiter ces failles avec un résultat probant. Néanmoins, ne doutez pas de l’imagination de ceux qui travaillent déjà sur le sujet. Et profitons de l’avance probable que nous avons sur eux pour, cette fois, tenter de leur couper l’herbe sous le pied.

Architecte en sécurité chez OVH.