web-dev-qa-db-fra.com

Manipulation du microcode du processeur pour changer les opcodes?

J'avais récemment pensé à un moyen extrême d'implémenter la sécurité par l'obscurité et je voulais vous demander si c'est possible.

Une personne n'ayant pas accès à la documentation spéciale du processeur pourrait-elle modifier le microcode du processeur afin d'obscurcir le jeu d'instructions de la machine?

Que faudrait-il changer d'autre pour qu'une machine démarre avec un tel processeur - la manipulation du BIOS serait-elle suffisante?

19
d33tah

Bien que les processeurs x86 modernes permettent le téléchargement du microcode d'exécution, le format est spécifique au modèle, non documenté et contrôlé par des sommes de contrôle et éventuellement des signatures. De plus, la portée du microcode est quelque peu limitée de nos jours, car la plupart des instructions sont câblées. Voir cette réponse pour quelques détails. Les systèmes d'exploitation modernes téléchargent des blocs de microcode au démarrage, mais ces blocs sont fournis par les fournisseurs de CPU eux-mêmes à des fins de correction de bogues.

(Notez que le microcode qui est téléchargé est conservé dans un bloc interne dédié RAM, qui n'est ni Flash ni EEPROM; il est perdu lorsque l'alimentation est coupée.)


Mise à jour: il semble y avoir des idées fausses et/ou une confusion terminologique sur ce qu'est le microcode et ce qu'il peut faire, alors voici quelques explications plus longues.

À l'époque des premiers microprocesseurs, les transistors étaient chers: ils utilisaient beaucoup de surface de silicium, qui est la ressource rare dans les fonderies de puces (plus une puce est grande, plus le taux de défaillance est élevé, car chaque particule de poussière au mauvais endroit fait toute la puce inopérante). Les concepteurs de puces ont donc dû recourir à de nombreuses astuces, dont l'une était le microcode. L'architecture d'une puce de cette époque ressemblerait à ceci:

Z80 architecture

(cette image a été pillée sans vergogne de ce site ). Le CPU est segmenté en plusieurs unités individuelles reliées entre elles par des bus de données. Voyons ce qu'impliquerait une instruction fictive "add B, C" (Ajout du contenu du registre B et du registre C, résultat à stocker de nouveau dans B):

  1. La banque de registres doit placer le contenu du registre B sur le bus de données interne. A la fin du même cycle, l'unité de stockage "TEMP" doit lire la valeur du bus de données et la stocker.
  2. La banque de registres doit placer le contenu du registre C sur le bus de données interne. À la fin du même cycle, l'unité de stockage "A" doit lire la valeur du bus de données et la stocker.
  3. L'unité Unité arithmétique et logique (ALU) devrait lire ses deux entrées (qui sont TEMP et A) et calculer une addition. Le résultat sera disponible sur sa sortie lors du prochain cycle sur le bus.
  4. La banque de registres doit lire l'octet sur le bus de données interne et le stocker dans le registre B.

L'ensemble du processus prendrait quatre cycles d'horloge. Chaque unité de la CPU doit recevoir ses commandes spécifiques dans l'ordre approprié. L'unité de commande, qui envoie les signaux d'activation à chaque unité centrale, doit "connaître" toutes les séquences pour toutes les instructions. C'est là que le microcode intervient. Le microcode est une représentation, sous forme de mots binaires, des étapes élémentaires de ce processus. Chaque unité centrale aurait quelques bits réservés dans chaque microcode. Par exemple, les bits 0 à 3 dans chaque mot seraient destinés à la banque de registres, codant le registre qui doit être exploité, et si l'opération est une lecture ou une écriture; les bits 4 à 6 seraient destinés à l'ALU, lui indiquant quelle opération arithmétique ou logique elle doit effectuer.

Avec le microcode, la logique de commande devient un circuit assez simple: il consiste en un pointeur dans le microcode (qui est un bloc ROM); à chaque cycle, l'unité de contrôle lit le mot de microcode suivant et envoie à chaque unité centrale, sur des fils dédiés, ses bits de microcode. Le décodeur d'instructions est alors une carte d'opcodes (les "instructions de code machine" que le programmeur voit et sont stockées dans la RAM) en décalages dans le bloc de microcode: le décodeur définit le pointeur de microcode sur le premier mot de microcode pour la séquence qui implémente l'opcode.

Une description de ce processus est que le CPU traite vraiment le microcode; et le microcode implémente un émulateur pour les opcodes réels que le programmeur considère comme un "code machine".

La ROM est compacte : chaque bit ROM prend à peu près la même taille, voire légèrement moins, qu'un transistor. Cela a permis aux concepteurs de CPU de stocker un grand nombre de comportements distincts complexes dans un petit espace de silicium. Ainsi, le très vénérable Motorola 68000 CP , processeur central des Atari ST, Amiga et Sega Megadrive, pourrait tenir dans environ 40000 équivalents transistor, dont un tiers environ est composé de microcode; dans cette toute petite zone, il pourrait héberger quinze registres 32 bits, et mettre en œuvre toute une panoplie de modes d'adressage pour lesquels il était réputé. Les opcodes étaient raisonnablement compacts (économisant ainsi de la RAM); les mots de microcode sont plus gros, mais invisibles de l'extérieur.


Tout cela a changé avec l'avènement des processeurs RISC . RISC vient de la constatation que si le microcode permet des opcodes avec un comportement complexe, il implique également beaucoup de frais généraux dans le décodage des instructions. Comme nous l'avons vu ci-dessus, un simple ajout prendrait plusieurs cycles d'horloge. D'un autre côté, les programmeurs de cette époque (fin des années 1980) évitaient de plus en plus Assembly, préférant l'utilisation de compilateurs. Un compilateur traduit un langage de programmation en une séquence d'opcodes. Il se trouve que les compilateurs utilisent des opcodes relativement simples; les opcodes au comportement complexe sont difficiles à intégrer dans la logique d'un compilateur. Donc, le résultat net était que le microcode impliquait des frais généraux, donc une inefficacité d'exécution, pour des opcodes complexes que les programmeurs n'utilisaient pas!

RISC est, tout simplement, la suppression du microcode dans le CPU. Les opcodes que le programmeur (ou le compilateur) voit sont le microcode, ou assez proches. Cela signifie que les opcodes RISC sont plus grands (généralement 32 bits par opcode, comme dans les processeurs ARM, Sparc, Mips, Alpha et PowerPC d'origine) avec un encodage plus régulier. Un processeur RISC peut alors traiter une instruction par cycle. Bien sûr, les instructions font moins de choses que leurs homologues CISC ("CISC" est ce que font les processeurs non RISC, comme le 68000).

Par conséquent, si vous voulez programmer en microcode, utilisez un processeur RISC. Dans un vrai processeur RISC, il n'y a pas de microcode stricto sensu; il existe des opcodes qui sont traduits avec une correspondance 1 à 1 en bits d'activation pour toutes les unités centrales. Cela donne au compilateur plus d'options pour optimiser le code, tout en économisant de l'espace dans le CPU. Le premier BRAS n'utilisait que 30000 transistors, moins que le 68000, tout en fournissant sensiblement plus de puissance de calcul pour la même fréquence d'horloge. Le prix à payer était un code plus grand, mais RAM était de moins en moins cher à cette époque (c'est à ce moment-là que la taille de l'ordinateur RAM a commencé à être comptée en mégaoctets au lieu de simples kilo-octets) .


Puis les choses ont encore changé en devenant plus confus. Le RISC n'a pas tué les processeurs du CISC. Il s'est avéré que rétrocompatibilité est une force extrêmement forte dans l'industrie informatique. C'est pourquoi les processeurs x86 modernes (comme Intel i7 ou même plus récent) sont toujours capables d'exécuter du code conçu pour le 8086 de la fin des années 1970. Donc, les processeurs x86 ont pour implémenter des opcodes avec des comportements complexes. Le résultat est que les processeurs modernes ont un décodeur d'instructions qui sépare les opcodes en deux catégories:

  • Les opcodes simples et habituels que les compilateurs utilisent sont exécutés comme RISC, "câblés" en comportements fixes. Les ajouts, les multiplications, les accès à la mémoire, les opcodes de flux de contrôle ... sont tous gérés de cette façon.
  • Les opcodes inhabituels et complexes conservés pour la compatibilité sont interprétés avec le microcode, qui est limité à un sous-ensemble des unités dans le CPU afin de ne pas interférer et induire une latence dans le traitement des opcodes simples. Un exemple d'une instruction microcodée dans un x86 moderne est fsin, qui calcule la fonction sinus sur un opérande à virgule flottante.

Comme les transistors ont beaucoup rétréci (un quad-core i7 à partir de 2008 utilise 731 millions de transistors), il est devenu tout à fait tolérable de remplacer le bloc ROM pour microcode avec un bloc [~ # ~] ram [~ # ~]. Ce bloc RAM est toujours interne au CPU, inaccessible à partir du code utilisateur, mais il peut être mis à jour. Après tout, le microcode est une sorte de logiciel, donc il a des bugs. Les fournisseurs de CPU publient des mises à jour pour le microcode de leur CPU. Une telle mise à jour peut être téléchargée par le système d'exploitation à l'aide de certains opcodes spécifiques (cela nécessite des privilèges au niveau du noyau). Puisque nous parlons de RAM, ce n'est pas permanent et doit être effectué à nouveau après chaque démarrage.

Le contenu de ces mises à jour du microcode n'est pas du tout documenté; ils sont très spécifiques au modèle exact du CPU et il n'y a pas de standard. De plus, il existe checksums qui sont censés être MAC ou peut-être même signatures numériques : les fournisseurs veulent garder un contrôle strict de ce qui entre dans la zone du microcode. Il est concevable qu'un microcode conçu de manière malveillante puisse endommager le CPU en déclenchant des "courts-circuits" dans le CPU.


Résumé: le microcode n'est pas aussi génial que ce qu'il est souvent craqué. À l'heure actuelle, le piratage de microcodes est une zone fermée; Les vendeurs de CPU le réservent pour eux-mêmes. Mais même si vous pourriez écrire votre propre microcode, vous seriez probablement déçu: dans le CPU moderne, le microcode n'affecte que les unités périphériques du CPU.

Quant à la question initiale, un "comportement opcode obscur" implémenté dans le microcode ne serait pas pratiquement différent d'un émulateur de machine virtuelle personnalisé, comme ce à quoi @Christian est lié. Ce serait la "sécurité par l'obscurité" à son meilleur, c'est-à-dire pas très bien. De telles choses sont vulnérables à la rétro-ingénierie.

Si le microcode légendaire pourrait implémenter un moteur de décryptage complet avec une zone de stockage inviolable pour les clés, alors vous pourriez avoir une solution anti-reverse-engineering vraiment robuste . Mais le microcode ne peut pas faire ça. Cela nécessite un peu plus de matériel. Le Cell CP peut le faire; il a été utilisé dans la Sony PS3 (Sony l'a cependant bâclé dans d'autres domaines - le CPU n'est pas seul dans le système et ne peut pas assurer une sécurité totale par lui-même).

21
Thomas Pornin

Vous entrez vraiment dans le domaine de " Here be dragons " lorsque vous examinez la manipulation matérielle comme celle-ci. Je ne connais aucune recherche ou attaque sauvage qui ait fait une expérimentation pratique avec cela, donc ma réponse sera purement académique.

Tout d'abord, il est probablement préférable d'expliquer un peu le fonctionnement du microcode. Si vous êtes déjà au courant de ces choses, n'hésitez pas à aller de l'avant, mais je préfère inclure les détails pour ceux qui ne le savent pas. Un microprocesseur se compose d'une vaste gamme de transistors sur une puce en silicium qui s'interconnectent de manière à fournir un ensemble de fonctions de base utiles. Ces transistors modifient leurs états en fonction des changements internes de tension ou des transitions entre les niveaux de tension. Ces transitions sont déclenchées par un signal d'horloge, qui est en fait une onde carrée qui bascule entre la haute et la basse tension à une fréquence élevée - c'est là que nous obtenons des mesures de "vitesse" pour les CPU, par ex. 2 GHz. Chaque fois qu'un cycle d'horloge bascule entre basse et haute tension, un seul changement interne est effectué. C'est ce qu'on appelle un tic d'horloge. Dans les appareils les plus simples, un seul coup d'horloge peut constituer une opération entièrement programmée, mais ces appareils sont extrêmement limités en termes de ce qu'ils sont capables de faire.

Comme les processeurs sont devenus plus complexes, la quantité de travail qui doit être effectuée au niveau du matériel pour fournir même les opérations les plus élémentaires (par exemple, l'ajout de deux entiers 32 bits) a augmenté. Une seule instruction d'assemblage native (par exemple add eax, ebx) pourrait impliquer beaucoup de travail interne, et le microcode est ce qui définit ce travail. Chaque tick d'horloge exécute une seule instruction de microcode, et une seule instruction native peut impliquer des centaines d'instructions de microcode.

Regardons une version extrêmement simpliste d'une lecture en mémoire, pour l'instruction mov eax, [01234000], c'est-à-dire déplacer un entier 32 bits de la mémoire à l'adresse 01234000 dans un registre interne. Tout d'abord, le processeur doit lire l'instruction dans son cache d'instructions interne, ce qui est une tâche compliquée en soi. Ignorons cela pour l'instant, mais cela implique de nombreuses opérations à l'intérieur de l'unité de contrôle (CU) qui analysent l'instruction et amorcent diverses autres unités internes. Une fois que l'unité de commande a analysé l'instruction, elle doit alors exécuter un groupe de micro-instructions pour effectuer l'opération. Tout d'abord, il doit vérifier que le pipeline de mémoire système est prêt pour une nouvelle instruction (rappelez-vous que les puces mémoire prennent également des commandes) afin qu'il puisse faire une lecture. Ensuite, il doit envoyer une commande de lecture au pipeline et attendre qu'il soit réparé. Le DDR est asynchrone, il doit donc attendre une interruption pour dire que l'opération est terminée. Une fois l'interruption déclenchée, la CPU poursuit l'instruction. L'opération suivante consiste à déplacer la nouvelle valeur de la mémoire dans un registre interne. Ce n'est pas aussi simple qu'il y paraît - les registres que vous reconnaîtriez normalement (eax, ebx, ecx, edx, ebp, etc.) ne sont pas fixés à un ensemble physique particulier de transistors dans la puce. En fait, un CPU a beaucoup plus de registres internes physiques qu'il n'en expose, et il utilise une technique appelée renommage de registre pour optimiser la traduction des données entrantes, sortantes et traitées. Les données réelles du bus mémoire doivent donc être déplacées dans un registre physique, puis ce registre doit être mappé sur un nom de registre exposé. Dans ce cas, nous le mapperions à eax.

Tout ce qui précède est une simplification - l'opération réelle peut impliquer beaucoup plus de travail, ou peut être gérée par un périphérique interne dédié. En tant que tel, vous pourriez regarder une grande séquence de microinstructions qui font très peu par elles-mêmes, mais s'ajoutent à une seule instruction. Dans certains cas, des micro-instructions spéciales sont utilisées pour déclencher des opérations matérielles internes asynchrones qui gèrent une opération particulière, conçues pour améliorer les performances.

Comme vous pouvez le voir, le microcode est extrêmement compliqué. Non seulement cela varierait énormément entre les types de CPU, mais aussi entre les versions et les révisions. Cela rend la chose difficile à cibler - vous ne pouvez pas vraiment dire quel microcode est programmé dans l'appareil. Non seulement cela, mais la façon dont le microcode est programmé dans la puce est également spécifique à chaque processeur. En plus de cela, il n'est pas documenté et a une somme de contrôle, et nécessite potentiellement quelques vérifications de signature aussi. Vous auriez besoin d'un matériel sérieux pour inverser l'ingénierie des mécanismes et des vérifications.

Supposons un instant que vous pourriez écraser le microcode d'une manière utile. Comment feriez-vous quelque chose d'utile? Gardez à l'esprit que chaque code décale simplement certaines valeurs dans les composants internes du matériel, plutôt qu'une véritable opération. Obscurcir les opcodes en jonglant avec le microcode nécessiterait un système d'exploitation personnalisé complet et un chargeur de démarrage, mais le BIOS continuerait (probablement) de fonctionner. Malheureusement, les systèmes plus modernes utilisent UEFI plutôt que l'ancienne spécification du BIOS, ce qui implique une certaine exécution de code sur le CPU en mode réel. Cela signifie que vous auriez besoin d'un BIOS et d'un système d'exploitation entièrement nouveaux, tous écrits à partir de zéro. À peine une méthode d'occultation utile. En plus de cela, vous ne pourrez peut-être même pas remapper les instructions, car les valeurs d'octets apparemment arbitraires ne sont pas si arbitraires - les bits individuels sont mappés à des codes qui sélectionnent différentes zones des internes du CPU. Les modifier peut briser la capacité du processeur à même analyser les données d'instruction.

Un exercice plus intéressant serait d'implémenter une nouvelle instruction qui vous fait passer de ring3 à ring0 et une autre qui revient en arrière, le tout sans effectuer de vérification. Cela vous permettrait de faire des choses amusantes avec une élévation de privilèges sans jamais avoir besoin de portes dérobées spécifiques au système d'exploitation.

21
Polynomial

Oui, c'est possible même si certains ne le pensent pas. J'ai proposé quelques idées sur le blog de Schneier dans ce sens. Il existe plusieurs façons de procéder:

  1. Votre propre microcode qui commence par un processeur qui ne changera pas . Cela peut être accompli en utilisant un noyau ouvert, par exemple, et en gelant la conception interne. Ensuite, vous (et d'autres utilisateurs) faites un microcode personnalisé dessus. C'est beaucoup de travail comme d'autres l'ont noté. Cependant, vous pouvez faire du langage de haut niveau pour les compilateurs de microcode/micro-instruction (les rechercher sur Google en utilisant ces mots clés). Le combo est l'approche lourde. Une version plus simple du concept est le PALcode d'Alpha qui vous permet de créer de nouvelles instructions composées d'instructions existantes et exécutées atomiquement. Je ne sais pas si cette fonctionnalité existe dans les processeurs encore en production.

  2. L'autre approche, la mienne, consistait à créer un microcode et à simplement changer les identifiants des instructions du code machine. Le compilateur et le signataire du microcode sont sur une machine protégée, non en réseau ou assis derrière une garde hautement assurée. Le code Shell entrant a un effet aléatoire qui, dans des recherches universitaires similaires, ne conduit presque jamais à l'exécution de code. (Google Instruction Set Randomization car il existe même des prototypes de CPU sur ce genre de chose.) Le schéma produirait également une chaîne d'outils avec compilateur, débogueur, etc. Le processeur Xtensa I.P. de Tensilica génèrent déjà des CPU et des chaînes d'outils pour des applications spécifiques. C'est ... beaucoup plus simple que ça. ;)

  3. La meilleure approche consiste à modifier l'architecture pour n'autoriser que des opérations sensibles sur les données. Ces architectures sont appelées "étiquetées", "capacités", etc. Les architectures balisées ajoutent des balises aux morceaux de mémoire représentant un type de données (par exemple entier, tableau, code). Le type de processeur vérifie l'intégrité des opérations individuelles avant de les autoriser. La conception sécurisée de Crash-safe.org le fait. Les systèmes de capacité consistent à compartimenter les systèmes avec des pointeurs sécurisés vers des éléments de code et de données. Le projet CHERI de Cambrige le fait. Les deux styles ont été utilisés dans le passé pour développer des systèmes pratiques avec d'excellentes propriétés de sécurité et/ou des antécédents. Livre définitif sur eux ci-dessous. Mes conceptions actuelles les exploitent comme une base solide sur laquelle construire un système d'exploitation sécurisé dans la veine des systèmes GEMSOS, KeyKOS ou JX.

http://homes.cs.washington.edu/~levy/capabook/

Juste donner cette réponse parce que, malgré les réponses négatives, des choses dans l'esprit de ce que vous décrivez ont été faites et quelques-unes ont été testées contre les attaques courantes. Ils ne font tout simplement pas totalement un nouveau microcode, surtout à la main. Ils utilisent des raccourcis comme je l'ai mentionné ou conçoivent un processeur pour simuler cet effet. Il pourrait être battu s'il devenait courant, mais arrêterait la plupart des injections de code dans l'intervalle. J'ai recommandé d'utiliser Linux sur PPC (avec les informations d'identification supprimées) pour les applications professionnelles il y a longtemps pour cette raison à une petite foule d'utilisateurs. Ils sont toujours sans malware et piratent après 5+ ans avec un approvisionnement régulier de matériel bon marché. Donc, je m'attends à ce qu'une approche aléatoire ISA ou microcodage fonctionne encore mieux, une capacité/taguée meilleure que cela (même contre les pros), et une combinaison des deux) encore mieux.

4
Nick P

Je ne pense pas que changer le microcode de x86 soit possible mais exécuter un émulateur sur le dessus avec un microcode différent est possible et utilisé. Cet émulateur peut être construit pour démarrer au démarrage de la même manière que le démarrage du processeur (oui, les processeurs doivent également bootstrap aussi).

L'obscurcissement des opcodes est utilisé dans les protecteurs PE qui généreront un ensemble unique d'opcodes et la machine virtuelle qui pourra interpréter ces opcodes. Cette méthode rend l'analyse statique difficile et est utilisée pour l'anti-piratage et l'écriture de logiciels malveillants. Un exemple de cette technologie est Themida .

2
Cristian Dobre