Secret du firmware

Auteurs: Ph.D. Chernov A.V. ( monsieur_cher ) et Ph.D. Troshina K.N.

Comment, en utilisant les hypothèses les plus générales basées sur la connaissance des architectures de processeur modernes, pouvez-vous restaurer la structure du programme à partir d'une image binaire d'une architecture inconnue, puis restaurer des algorithmes et bien plus encore?

Dans cet article, nous parlerons d'une tâche intéressante qui nous a été confiée il y a plusieurs années. Le client a demandé à gérer le micrologiciel binaire de l'appareil qui gérait un certain processus physique. Il avait besoin d'un algorithme de contrôle sous la forme d'un programme C compilé, ainsi que de formules expliquant comment elles fonctionnent et pourquoi. Selon le Client, cela était nécessaire pour assurer la compatibilité avec les "anciens" équipements du nouveau système. La façon dont nous avons finalement traité la physique, dans le cadre de cette série d'articles, nous omettons, mais le processus de récupération de l'algorithme sera examiné en détail.

L'utilisation presque omniprésente des microcontrôleurs programmables dans les appareils de masse (IOT ou SmartHome Internet of Things) nécessite de prêter attention à l'analyse binaire du code intégré, ou, en d'autres termes, à l'analyse binaire du micrologiciel de l'appareil.

Une analyse binaire du micrologiciel de l'appareil peut avoir les objectifs suivants:

  • Analyse du code des vulnérabilités permettant d'accéder de façon non autorisée à l'appareil ou aux données transmises ou traitées par cet appareil.
  • Analyse de code pour les fonctionnalités non documentées, conduisant, par exemple, à une fuite d'informations.
  • Analyse de code pour restaurer les protocoles et interfaces d'interaction avec les appareils afin d'assurer la compatibilité de cet appareil avec les autres.

La tâche d'analyse de code binaire présentée ci-dessus peut être considérée comme un cas particulier de la tâche d'analyse binaire pour garantir la compatibilité des périphériques.

Analyse du format de fichier binaire


Si dans le monde des «vrais» systèmes d'exploitation, les formats de fichiers exécutables sont standardisés, alors dans le monde des programmes embarqués, chaque fournisseur peut utiliser sa solution propriétaire. Par conséquent, l'analyse du fichier de micrologiciel binaire doit commencer par l'analyse du format de fichier binaire.

Au début des travaux, la situation pour nous était la suivante: nous avons reçu un certain fichier avec le firmware sans aucune documentation d'accompagnement. Il n'y avait aucune information sur le format du fichier du firmware, ni sur l'architecture du microcontrôleur.

Le fichier du firmware s'est avéré être un fichier texte. Il contenait des lignes de la forme suivante:

:04013000260F970CF8 :10020000004D000B043F000B34AD010C002FFE4D30 :02023000FD0BC1 :1004000018001A0000001E0008005E000200190052 

Après avoir soigneusement examiné l'ensemble de ces lignes, nous avons réalisé qu'il s'agit d'un format Intel HEX entièrement standard pour les microcontrôleurs. Le fichier se compose d'enregistrements, chacun indiquant son type, son emplacement mémoire, ses données et sa somme de contrôle. En soi, l'utilisation du format Intel Hex implique que le fichier n'est probablement pas chiffré et est une image d'un programme qui réside en mémoire.

Bien que le format Intel Hex prenne en charge l'adressage mémoire jusqu'à 32 bits, il n'y avait que des adresses mémoire 16 bits dans notre fichier. Par conséquent, il est facile de créer un fichier binaire d'une image mémoire à partir d'un fichier texte dans lequel les enregistrements du fichier de test d'origine seront déjà placés aux adresses spécifiées. Il est plus pratique d'inspecter un tel fichier binaire à l'aide des utilitaires d'analyse de fichier binaire, et il est plus facile d'écrire vos propres utilitaires pour celui-ci que pour Intel HEX. Le fichier de mémoire d'image binaire a confirmé que le fichier n'était pas chiffré, car une variété de lignes significatives ont été trouvées dispersées à différents endroits du code.
Cependant, cela n'a pas répondu à la question de savoir quelle architecture ce fichier est.



Nous avons obtenu un fichier avec l'image mémoire d'un microcontrôleur 16 bits ou 8 bits. Et quel type de microcontrôleur n'est pas clair. Nous avons pris IDA Pro et essayé de démonter le fichier avec toutes les variantes possibles de processeurs pris en charge. Et rien. Aucun des processeurs IDA Pro pris en charge n'est apparu: la liste n'a pas été générée ou elle contenait un non-sens évident. Il s'agissait peut-être d'un programme pour l'un des processeurs IDA Pro pris en charge, mais nous avons fait quelque chose de mal. Par exemple, vous avez juste besoin d'un traitement supplémentaire du fichier image. Dans tous les cas, il était possible ici de suspendre le travail et de demander des informations supplémentaires sur le fichier binaire.

Tous les processeurs sont à peu près les mêmes.


Mais cela est devenu intéressant pour nous, et ce que nous pouvons comprendre du programme binaire, même si le processeur pour lequel il est compilé est inconnu. La réponse est «rien» - sans intérêt, même si nous pouvons comprendre un peu, c'est mieux que rien. Évidemment, les chaînes de texte peuvent fournir des informations sur le programme, mais nous visons plus - comprendre quelque chose de la structure du programme.
Différentes architectures de processeur - un grand nombre. L'évolution de l'informatique a généré même les architectures les plus inhabituelles telles que les ordinateurs ternaires. Cependant, les microprocesseurs et microcontrôleurs qui existent actuellement, au moins ceux de masse, sont remarquablement similaires les uns aux autres.

Ci-dessous, nous énumérons les principes de base communs aux microprocesseurs modernes.

Exécution cohérente des instructions. Le processeur exécute les instructions en séquence dans la mémoire. Il existe des instructions spéciales pour les sauts et appels conditionnels et inconditionnels à partir du sous-programme, qui vous permettent d'interrompre la sélection séquentielle des instructions de la mémoire et de procéder à l'exécution d'une autre instruction. Cependant, les autres instructions supposent une exécution séquentielle et ne contiennent donc pas l'adresse de l'instruction suivante.

Codage binaire. Outre le fait que le processeur traite les données sous forme binaire, les instructions du processeur qui composent le programme exécutable sont codées au format binaire, c'est-à-dire que les champs dans lesquels les paramètres d'instruction sont stockés, par exemple les décalages ou les numéros de registre, occupent un nombre entier de bits. On peut théoriquement imaginer que, malgré l'encodage binaire des données et des programmes, ils seront traités dans le processeur dans un autre système numérique, mais nous ne sommes pas conscients d'une telle exotisme.

En règle générale, les principes suivants ne sont pas des principes architecturaux de base, mais ils sont pratiquement universellement mis en œuvre, en particulier pour le code machine qui n'est pas écrit manuellement en langage assembleur, mais qui est généré par un compilateur de langage de haut niveau.

Programmation procédurale. Le programme est divisé en unités structurelles, qui peuvent être appelées différemment: procédures, fonctions, sous-programmes, etc. Les sous-programmes peuvent appeler d'autres sous-programmes, leur transmettre des paramètres et obtenir le résultat de l'exécution. Il est important que le sous-programme ait un point d'entrée, c'est-à-dire que tous les sous-programmes qui invoquent celui donné vont à la même adresse de point d'entrée.

En règle générale, les routines ont un point de sortie qui renvoie le contrôle au point d'appel, mais cela est moins important que d'exiger un point d'entrée pour chaque routine. Un tel code est généralement obtenu en compilant un programme. L'optimiseur de temps de liaison peut détruire partiellement cette structure pour réduire la taille du programme, et la taille du programme est critique pour les systèmes embarqués. De plus, cette structure peut être détruite par l'obfuscateur de code.

L'imbrication des appels de sous-programme peut être organisée à l'aide de la pile, qui peut toujours être utilisée pour transmettre des arguments au sous-programme et stocker des variables locales, mais au niveau actuel de développement de l'architecture, ces informations sont prématurées.

Comment appliquer ces principes à l'analyse initiale du code binaire?

Nous faisons l'hypothèse de base qu'il existe une instruction RET (retour d'un sous-programme) dans le système de commande du processeur. Cette instruction a une sorte de représentation binaire fixe, que nous allons rechercher. Si RET n'est pas le seul, comme dans x86, où RET a un argument - la taille de la zone de paramètres de sous-programme, ou si RET est un effet secondaire d'une opération plus compliquée, comme dans ARMv7, où la valeur PC peut être extraite de la pile simultanément avec les valeurs des autres registres (ldmfd sp! , {fp, pc}), alors, très probablement, notre recherche heuristique ne donnera pas de résultats.

Nous devons également émettre immédiatement des hypothèses raisonnables sur le principe des instructions de codage du processeur à l'étude. Les processeurs existants utilisent plusieurs principes pour coder les instructions:

  • Un flux d'octets à partir duquel des instructions sont générées et différentes instructions sont codées avec un nombre d'octets différent. Dans cette catégorie, le représentant le plus célèbre est la famille x86, des premiers processeurs 8080 aux processeurs 64 bits les plus modernes. Une instruction de processeur x86_64 peut être codée dans une séquence de 1 à 16 octets. La même famille de processeurs avec des longueurs d'instruction variables comprend 8051, qui est utilisé dans les microcontrôleurs.
  • Un flux de valeurs 16 bits. De plus, chaque instruction a une taille fixe - 16 bits.
  • Un flux de valeurs 16 bits, tandis que les instructions sont de taille variable. L'un des représentants de cette famille est l'architecture PDP-11, dans laquelle la commande elle-même occupe les 16 premiers bits, et elle peut être suivie de valeurs directes ou d'adresses de mémoire pour l'adressage direct. Cela inclut le codage THUMB dans l'architecture ARM.
  • Un flux de valeurs 32 bits, chaque instruction a une taille fixe de 32 bits. Ce sont la majorité des processeurs RISC 32 et 64 bits: ARMv7, ARMv8, MIPS.

Choisir entre un flux d'octets de longueur variable et un flux de mots de 16 bits aidera à visualiser l'image mémoire «à l'œil nu». Peu importe la façon dont les instructions du processeur sont encodées, dans un programme d'une longueur suffisante, elles seront inévitablement répétées. Par exemple, sur l'instruction x86

 add %ebx,%eax 

qui ajoute les valeurs des registres eax et ebx et met le résultat dans eax, est codé sur deux octets:

 01 d8. 

Sur l'instruction ARMv7

 add r0, r0, r1 

qui ajoute les valeurs des registres r0 et r1 et place le résultat dans r0, est codé par la valeur 32 bits e0800001.

Dans un programme suffisamment volumineux, ces instructions seront répétées plusieurs fois. Si une séquence d'octets qui nous intéresse (par exemple, 01 d8) se produit à une adresse arbitraire non alignée, nous pouvons supposer que les instructions du processeur sont codées par un flux d'octets de taille variable. Si la valeur, par exemple, e0800001 est trouvée uniquement à des adresses qui sont des multiples de 4, nous pouvons faire une hypothèse sur une taille fixe d'instructions de processeur. Bien sûr, il y a une erreur ici: nous avons pris des octets de données pour une instruction, ou c'est arrivé par hasard que certaines instructions se sont toujours avérées alignées. Cependant, l'impact d'un tel «bruit» sur un programme de taille suffisante sera faible.

Lorsque nous avons examiné le micrologiciel analysé sous cet angle, il est devenu clair que les instructions pour le processeur en question sont probablement codées avec des valeurs 16 bits.

Donc, en supposant que le codage de l'instruction RET est une valeur fixe de 16 bits, essayons de la trouver. On retrouve dans l'image du programme les valeurs 16 bits les plus courantes. Dans notre cas, les événements suivants se sont produits:

  (hex)   0b01 854 5.1% 0800 473 2.8% 8c0d 432 2.6% 2b00 401 2.4% 4e1c 365 2.2% 0801 277 1.6% 

Nous rechercherons l'instruction RET parmi ces valeurs 16 bits les plus souvent rencontrées dans le code. Immédiatement, nous chercherons l'instruction CALL - couplée à l'instruction RET. L'instruction CALL a au moins un paramètre - l'adresse de saut, donc des valeurs fixes sont indispensables.

Nous supposons que dans de nombreux cas, immédiatement après la fin d'un sous-programme, c'est-à-dire après l'instruction RET, un autre sous-programme commence et ce sous-programme est appelé par l'instruction CALL à partir d'un autre point du programme. Un grand nombre de sauts à l'adresse immédiatement après RET sera l'une des caractéristiques de l'instruction CALL. Bien sûr, cette règle n'est pas universelle, car sur certaines plates-formes, en particulier ARMv7, immédiatement après la fin du sous-programme, un pool constant est généralement localisé. Dans ce cas, nous pouvons considérer une plage d'adresses raisonnable immédiatement après RET comme points de transition de l'instruction RET.

Dans le cas de l'instruction CALL, il peut y avoir beaucoup d'options pour l'encoder dans le sous-programme. Premièrement, le processeur peut utiliser un ordre d'octets différent dans le mot: little-endian, comme sur la plupart des processeurs modernes, quand un entier multi-octets est écrit en mémoire, en commençant par l'octet bas, et big-endian, quand un entier multi-octets est écrit en mémoire, en commençant d'octet élevé. Presque tous les processeurs modernes fonctionnent en mode petit-boutiste, mais vous ne devez pas ignorer d'autres ordres d'octets possibles en un mot.
Deuxièmement, l'instruction CALL peut utiliser soit l'adressage absolu du point de saut, soit l'adressage relatif à l'adresse actuelle. Dans le cas d'un adressage absolu, l'instruction codée contient l'adresse à laquelle vous souhaitez vous rendre dans certains bits de l'instruction codée. Pour garantir que le sous-programme est appelé de n'importe quel point de l'espace d'adressage 16 bits vers n'importe quel autre point à l'adresse absolue du mot 16 bits, l'instruction codée n'est pas suffisante, car en plus de l'adresse de transition, les bits du code d'opération doivent être stockés ailleurs. Par conséquent, il est logique de considérer deux mots de 16 bits d'affilée et d'essayer différentes options pour diviser l'adresse de transition entre ces mots.

Une alternative au codage absolu d'une adresse de routine est le codage relatif. Dans l'instruction codée, nous enregistrons la différence entre l'adresse du sous-programme et le point courant. Le codage relatif est généralement préférable à l'absolu, car, tout d'abord, un programme avec des transitions relatives est indépendant de la position, c'est-à-dire qu'il peut être localisé en mémoire à partir de n'importe quelle adresse sans aucune modification du code binaire. Deuxièmement, pour coder l'offset, moins de bits peuvent être réservés que la dimension de l'espace d'adressage, du fait que dans de nombreux cas la routine appelée n'est pas si éloignée de celle qui appelle. Cependant, si le décalage de l'appel est en dehors de la plage de valeurs représentables, vous devrez insérer des instructions spéciales - «sauts».

Le codage relatif d'une adresse de sous-programme peut être effectué avec quelques variantes: premièrement, l'adresse du point actuel du programme peut être prise soit comme l'adresse de l'instruction actuelle, soit comme l'adresse de l'instruction suivante, comme dans les processeurs x86, ou l'adresse d'une autre instruction près du point actuel. Par exemple, dans les processeurs ARM, le point de référence est l'adresse de l'instruction courante +8 (c'est-à-dire non pas l'adresse de l'instruction suivant l'appel, mais l'adresse de l'instruction suivant la suivante). De plus, puisque dans notre cas le programme est écrit comme un flux de mots de 16 bits, il est logique de s'attendre à ce que le décalage soit exprimé en mots. Autrement dit, pour obtenir l'adresse de la routine appelée, l'offset devra être multiplié par 2.

En tenant compte de tout ce qui précède, nous obtenons l'espace d'énumération suivant pour rechercher une paire CALL / RET en code binaire.

Tout d'abord, nous prenons des mots de 16 bits de la liste des valeurs les plus courantes dans le code comme candidats pour l'instruction RET. Ensuite, nous recherchons dans l'instruction CALL:

  • Ordre des octets des mots big-endian et little-endian
  • Codage absolu et relatif de l'adresse de routine dans l'instruction.

Pour le codage absolu, nous considérons deux valeurs de 16 bits dans une rangée, à savoir, nous trions diverses options pour placer un champ de bits qui stocke une adresse absolue dans un mot de 32 bits, et pour le codage relatif, nous considérons à la fois des valeurs de 16 bits et deux mots de 16 bits dans une rangée . Ensuite, nous trions les différentes options pour placer un champ de bits qui stocke les décalages. On vérifie si le décalage est exprimé en octets ou en mots de 16 bits, c'est-à-dire s'il faut multiplier le décalage par 2, on vérifie différentes options pour le point de référence: l'adresse de l'instruction courante, l'adresse de l'instruction suivante.

Pour chacune des options de l'espace de recherche décrit ci-dessus, nous calculons des statistiques:

  • Combien d'adresses supposées du début des sous-programmes ne sont pas évidemment correctes, c'est-à-dire qu'elles sont situées là où il n'y a rien, ou où les données (lignes explicites ou tableaux explicites de valeurs) sont évidemment situées. Même pour la valeur correspondant au codage correct de l'instruction CALL, il est tout à fait possible qu'un petit nombre d'adresses incorrectes du début du sous-programme soit possible si la valeur correspondant à l'instruction CALL apparaît accidentellement dans les données.
  • Combien d'adresses de début de routine putatives se trouvent immédiatement après l'instruction RET putative.
  • Combien d'adresses de début hypothétiques de routines sont utilisées plus d'une fois.

Si nos hypothèses sur une paire d'instructions CALL / RET sont correctes, elles devraient se trouver dans l'espace d'énumération décrit. Mais il peut aussi y avoir des faux positifs. Eh bien, nous commençons la recherche.

Et nous ne trouvons qu'une seule option possible!

 Trying 8c0d as RET After-ret-addr-set-size: 430 Matching call opcodes for 1, ff00ff00, 1: 000b003d: total: 1275, hits: 843 (66%), misses: 432 (33%), coverage: 76% 

Ainsi, le mot 16 bits 8c0d convient comme candidat pour l'instruction RET. Au total, le firmware contient 430 positions d'adresses de programme immédiatement après cette instruction. Nous avons considéré des valeurs de 32 bits (deux mots de 16 bits consécutifs), avec une valeur de masque d'adresse de ff 00 ff 00, une instruction avec le code 00 0b 00 3d a été trouvée. Il existe 1275 instructions de ce type, dont 843 (soit 66%) transfèrent le contrôle au point suivant immédiatement le candidat au RET. Ainsi, deux instructions ont été identifiées:

  • RET: 8c0d (Little-Endian 16 bits)
  • APPEL HHLL: 0bHH 3dLL (2 Little-Endian 16 bits)

L'instruction CALL utilise l'adressage absolu et lors de l'écriture de l'adresse de saut, l'octet haut est écrit en premier, puis l'octet bas est écrit. Il est possible qu'en réalité, ce soient deux instructions de processeur, dont chacune charge la moitié de l'adresse de transition, mais du point de vue de l'analyse de programme, ce n'est pas important. Connaissant les instructions CALL et RET, nous pouvons délimiter plus précisément les zones de code et les données de programme, qui seront importantes pour une analyse plus approfondie.

À suivre ...

Plus loin, nous dirons comment les transitions conditionnelles et inconditionnelles et certaines opérations arithmétiques et logiques ont été restaurées.

Source: https://habr.com/ru/post/fr442262/


All Articles