GNU/Linux >> Tutoriels Linux >  >> Linux

Comment désassembler, modifier puis réassembler un exécutable Linux ?

Je ne pense pas qu'il existe un moyen fiable de le faire. Les formats de code machine sont très compliqués, plus compliqués que les fichiers d'assemblage. Il n'est pas vraiment possible de prendre un binaire compilé (par exemple, au format ELF) et de produire un programme d'assemblage source qui compilera le même binaire (ou suffisamment similaire). Pour mieux comprendre les différences, comparez la sortie de la compilation GCC directement à l'assembleur (gcc -S ) versus la sortie de objdump sur l'exécutable (objdump -D ).

Il y a deux complications majeures auxquelles je peux penser. Premièrement, le code machine lui-même n'est pas une correspondance 1-1 avec le code assembleur, à cause de choses comme les décalages de pointeur.

Par exemple, considérez le code C de Hello world :

int main()
{
    printf("Hello, world!\n");
    return 0;
}

Ceci compile en code assembleur x86 :

.LC0:
    .string "hello"
    .text
<snip>
    movl    $.LC0, %eax
    movl    %eax, (%esp)
    call    printf

Où .LCO est une constante nommée et printf est un symbole dans une table de symboles de bibliothèque partagée. Comparez à la sortie de objdump :

80483cd:       b8 b0 84 04 08          mov    $0x80484b0,%eax
80483d2:       89 04 24                mov    %eax,(%esp)
80483d5:       e8 1a ff ff ff          call   80482f4 <[email protected]>

Premièrement, la constante .LC0 n'est plus qu'un décalage aléatoire quelque part en mémoire - il serait difficile de créer un fichier source d'assemblage contenant cette constante au bon endroit, car l'assembleur et l'éditeur de liens sont libres de choisir les emplacements de ces constantes.

Deuxièmement, je ne suis pas tout à fait sûr de cela (et cela dépend de choses comme le code indépendant de la position), mais je pense que la référence à printf n'est pas du tout codée à l'adresse du pointeur dans ce code, mais les en-têtes ELF contiennent un table de correspondance qui remplace dynamiquement son adresse lors de l'exécution. Par conséquent, le code désassemblé ne correspond pas tout à fait au code assembleur source.

En résumé, l'assembly source a des symboles tandis que le code machine compilé a des adresses qui sont difficiles à inverser.

La deuxième complication majeure est qu'un fichier source d'assemblage ne peut pas contenir toutes les informations qui étaient présentes dans les en-têtes de fichier ELF d'origine, comme les bibliothèques à lier dynamiquement et les autres métadonnées placées là par le compilateur d'origine. Il serait difficile de reconstituer cela.

Comme je l'ai dit, il est possible qu'un outil spécial puisse manipuler toutes ces informations, mais il est peu probable que l'on puisse simplement produire du code assembleur qui puisse être réassemblé dans l'exécutable.

Si vous êtes intéressé par la modification d'une petite partie de l'exécutable, je recommande une approche beaucoup plus subtile que la recompilation de l'ensemble de l'application. Utilisez objdump pour obtenir le code d'assemblage de la ou des fonctions qui vous intéressent. Convertissez-le manuellement en "syntaxe d'assemblage source" (et ici, j'aimerais qu'il y ait un outil qui produise réellement le désassemblage dans la même syntaxe que l'entrée) , et modifiez-le comme vous le souhaitez. Lorsque vous avez terminé, recompilez uniquement ces fonctions et utilisez objdump pour déterminer le code machine de votre programme modifié. Ensuite, utilisez un éditeur hexadécimal pour coller manuellement le nouveau code machine au-dessus de la partie correspondante du programme d'origine, en veillant à ce que votre nouveau code ait exactement le même nombre d'octets que l'ancien code (sinon tous les décalages seraient faux ). Si le nouveau code est plus court, vous pouvez le compléter en utilisant les instructions NOP. S'il est plus long, vous risquez d'avoir des problèmes et vous devrez peut-être créer de nouvelles fonctions et les appeler à la place.


Je le fais avec hexdump et un éditeur de texte. Vous devez être vraiment à l'aise avec le code machine et le format de fichier qui le stocke, et flexible avec ce qui compte comme "désassembler, modifier, puis réassembler".

Si vous pouvez vous contenter de faire des "changements ponctuels" (réécrire des octets, mais sans ajouter ni supprimer d'octets), ce sera facile (relativement parlant).

Vous vraiment ne voulez pas déplacer les instructions existantes, car vous auriez alors à ajuster manuellement tout décalage relatif effectué dans le code machine, pour les sauts/branches/chargements/magasins par rapport au compteur de programme, les deux en code dur immédiat valeurs et celles calculées via les registres .

Vous devriez toujours pouvoir vous en tirer sans supprimer d'octets. L'ajout d'octets peut être nécessaire pour des modifications plus complexes et devient beaucoup plus difficile.

Étape 0 (préparation)

Après avoir réellement démonté correctement le fichier avec objdump -D ou tout ce que vous utilisez normalement en premier pour le comprendre et trouver les endroits que vous devez modifier, vous devrez prendre note des éléments suivants pour vous aider à localiser les octets corrects à modifier :

  1. "L'adresse" (décalage par rapport au début du fichier) des octets que vous devez modifier.
  2. La valeur brute de ces octets tels qu'ils sont actuellement (le --show-raw-insn option à objdump est vraiment utile ici).

Vous devrez également vérifier si hexdump -R fonctionne sur votre système. Sinon, pour le reste de ces étapes, utilisez le xxd commande ou similaire au lieu de hexdump dans toutes les étapes ci-dessous (consultez la documentation de l'outil que vous utilisez, je n'explique que hexdump pour l'instant dans cette réponse car c'est celle que je connais).

Étape 1

Vider la représentation hexadécimale brute du fichier binaire avec hexdump -Cv .

Étape 2

Ouvrez le hexdump ed et recherchez les octets à l'adresse que vous souhaitez modifier.

Cours accéléré rapide en hexdump -Cv sortie :

  1. La colonne la plus à gauche contient les adresses des octets (par rapport au début du fichier binaire lui-même, tout comme objdump fournit).
  2. La colonne la plus à droite (entourée de | caractères) est juste une représentation "lisible par l'homme" des octets - le caractère ASCII correspondant à chaque octet y est écrit, avec un . remplace tous les octets qui ne correspondent pas à un caractère imprimable ASCII.
  3. Les éléments importants se trouvent entre les deux :chaque octet sous la forme de deux chiffres hexadécimaux séparés par des espaces, 16 octets par ligne.

Attention :contrairement à objdump -D , qui vous donne l'adresse de chaque instruction et affiche l'hexagone brut de l'instruction en fonction de la façon dont elle est documentée comme étant encodée, hexdump -Cv vide chaque octet exactement dans l'ordre dans lequel il apparaît dans le fichier. Cela peut être un peu déroutant d'abord sur les machines où les octets d'instruction sont dans l'ordre opposé en raison des différences d'endianness, ce qui peut également être désorientant lorsque vous attendez un octet spécifique comme adresse spécifique.

Étape 3

Modifiez les octets qui doivent changer - vous devez évidemment comprendre l'encodage brut des instructions de la machine (pas les mnémoniques d'assemblage) et écrire manuellement dans les octets corrects.

Remarque :Vous ne faites pas besoin de changer la représentation lisible par l'homme dans la colonne la plus à droite. hexdump l'ignorera lorsque vous l'"annulerez".

Étape 4

"Un-dump" le fichier hexdump modifié en utilisant hexdump -R .

Étape 5 (vérification de l'intégrité)

objdump votre nouveau unhexdump ed et vérifiez que le démontage que vous avez modifié semble correct. diff contre le objdump de l'original.

Sérieusement, ne sautez pas cette étape. Je fais le plus souvent une erreur lors de la modification manuelle du code machine et c'est ainsi que j'en attrape la plupart.

Exemple

Voici un exemple réel de travail lorsque j'ai récemment modifié un binaire ARMv8 (little endian). (Je sais, la question est étiquetée x86 , mais je n'ai pas d'exemple x86 sous la main, et les principes fondamentaux sont les mêmes, seules les instructions sont différentes.)

Dans ma situation, j'avais besoin de désactiver une vérification de prise en main spécifique "vous ne devriez pas faire cela":dans mon exemple binaire, en objdump --show-raw-insn -d afficher la ligne qui m'intéressait ressemblait à ceci (une instruction avant et après donnée pour le contexte):

     f40:   aa1503e3    mov x3, x21
     f44:   97fffeeb    bl  af0 <[email protected]>
     f48:   f94013f7    ldr x23, [sp, #32]

Comme vous pouvez le voir, notre programme sort "utilement" en sautant dans un error fonction (qui termine le programme). Inacceptable. Nous allons donc transformer cette instruction en un no-op. Nous recherchons donc les octets 0x97fffeeb à l'adresse/file-offset 0xf44 .

Voici le hexdump -Cv ligne contenant ce décalage.

00000f40  e3 03 15 aa eb fe ff 97  f7 13 40 f9 e8 02 40 39  |[email protected]@9|

Remarquez comment les octets pertinents sont réellement inversés (le codage Little Endian dans l'architecture s'applique aux instructions machine comme à toute autre chose) et comment cela se rapporte de manière légèrement non intuitive à quel octet se trouve à quel décalage d'octet :

00000f40  -- -- -- -- eb fe ff 97  -- -- -- -- -- -- -- --  |[email protected]@9|
                      ^
                      This is offset f44, holding the least significant byte
                      So the *instruction as a whole* is at the expected offset,
                      just the bytes are flipped around. Of course, whether the
                      order matches or not will vary with the architecture.

Quoi qu'il en soit, je sais en regardant d'autres démontages que 0xd503201f se désassemble en nop donc cela semble être un bon candidat pour mon instruction no-op. J'ai modifié la ligne dans le hexdump ed fichier en conséquence :

00000f40  e3 03 15 aa 1f 20 03 d5  f7 13 40 f9 e8 02 40 39  |[email protected]@9|

Reconverti en binaire avec hexdump -R , a désassemblé le nouveau binaire avec objdump --show-raw-insn -d et vérifié que la modification était correcte :

     f40:   aa1503e3    mov x3, x21
     f44:   d503201f    nop
     f48:   f94013f7    ldr x23, [sp, #32]

Ensuite, j'ai exécuté le binaire et j'ai obtenu le comportement que je voulais - la vérification correspondante n'a plus provoqué l'abandon du programme.

Modification du code machine réussie.

!!! Attention !!!

Ou ai-je réussi ? Avez-vous repéré ce que j'ai raté dans cet exemple ?

Je suis sûr que vous l'avez fait - puisque vous demandez comment modifier manuellement le code machine d'un programme, vous savez probablement ce que vous faites. Mais pour le bénéfice de tous les lecteurs qui pourraient lire pour apprendre, je vais développer :

Je n'ai changé que le dernier instruction dans la branche error-case ! Le saut dans la fonction qui quitte le programme. Mais comme vous pouvez le voir, enregistrez x3 était en cours de modification par le mov juste au dessus! En fait, un total de quatre (4) les registres ont été modifiés dans le cadre du préambule de l'appel error , et un registre était. Voici le code machine complet pour cette branche, à partir du saut conditionnel sur le if bloc et se terminant là où le saut va si le conditionnel if n'est pas prise :

     f2c:   350000e8    cbnz    w8, f48
     f30:   b0000002    adrp    x2, 1000
     f34:   91128442    add x2, x2, #0x4a1
     f38:   320003e0    orr w0, wzr, #0x1
     f3c:   2a1f03e1    mov w1, wzr
     f40:   aa1503e3    mov x3, x21
     f44:   97fffeeb    bl  af0 <[email protected]>
     f48:   f94013f7    ldr x23, [sp, #32]

Tout le code après la branche a été généré par le compilateur en supposant que l'état du programme était tel qu'il était avant le saut conditionnel ! Mais en faisant juste le dernier saut vers le error code de fonction un no-op, j'ai créé un chemin de code où nous atteignons ce code avec un état de programme incohérent/incorrect !

Dans mon cas, cela en fait semblait pas causer de problèmes. J'ai donc eu de la chance. Très chanceux :seulement après avoir déjà exécuté mon binaire modifié (qui, soit dit en passant, était un binaire critique pour la sécurité  :il avait la capacité de setuid , setgid , et changez le contexte SELinux !) ai-je réalisé que j'avais oublié de tracer les chemins de code pour savoir si ces changements de registre affectaient les chemins de code qui sont venus plus tard !

Cela aurait pu être catastrophique - n'importe lequel de ces registres aurait pu être utilisé dans un code ultérieur en supposant qu'il contenait une valeur précédente qui a maintenant été écrasée ! Et je suis le genre de personne que les gens connaissent pour sa réflexion méticuleuse sur le code et comme pédant et tatillon pour être toujours consciencieux en matière de sécurité informatique.

Et si j'appelais une fonction où les arguments se déversaient des registres sur la pile (comme c'est très courant, par exemple, sur x86) ? Et s'il y avait en fait plusieurs instructions conditionnelles dans le jeu d'instructions précédant le saut conditionnel (comme c'est souvent le cas, par exemple, sur les anciennes versions d'ARM) ? J'aurais été dans un état encore plus imprudemment incohérent après avoir fait ce changement apparemment le plus simple !

Donc, voici mon rappel d'avertissement : Manipuler manuellement les binaires, c'est littéralement supprimer tous sécurité entre vous et ce que la machine et le système d'exploitation permettront. Littéralement tous les progrès que nous avons réalisés dans nos outils pour détecter automatiquement les erreurs de nos programmes, disparus .

Alors, comment résoudre ce problème plus correctement ? Continuez à lire.

Supprimer le code

Pour efficacement /logiquement "supprimer" plus d'une instruction, vous pouvez remplacer la première instruction que vous souhaitez "supprimer" par un saut inconditionnel à la première instruction à la fin des instructions "supprimées". Pour ce binaire ARMv8, cela ressemblait à ceci :

     f2c:   14000007    b   f48
     f30:   b0000002    adrp    x2, 1000
     f34:   91128442    add x2, x2, #0x4a1
     f38:   320003e0    orr w0, wzr, #0x1
     f3c:   2a1f03e1    mov w1, wzr
     f40:   aa1503e3    mov x3, x21
     f44:   97fffeeb    bl  af0 <[email protected]>
     f48:   f94013f7    ldr x23, [sp, #32]

En gros, vous "tuez" le code (le transformez en "code mort"). Sidenote :Vous pouvez faire quelque chose de similaire avec des chaînes littérales intégrées dans le binaire :tant que vous souhaitez la remplacer par une chaîne plus petite, vous pouvez presque toujours vous en sortir en écrasant la chaîne (y compris l'octet nul de fin s'il s'agit d'un "C- chaîne") et, si nécessaire, en écrasant la taille codée en dur de la chaîne dans le code machine qui l'utilise.

Vous pouvez également remplacer toutes les instructions indésirables par des no-ops. En d'autres termes, nous pouvons transformer le code indésirable en ce qu'on appelle un "traîneau sans opération" :

     f2c:   d503201f    nop
     f30:   d503201f    nop
     f34:   d503201f    nop
     f38:   d503201f    nop
     f3c:   d503201f    nop
     f40:   d503201f    nop
     f44:   d503201f    nop
     f48:   f94013f7    ldr x23, [sp, #32]

Je m'attendrais à ce que cela ne fasse que gaspiller des cycles CPU par rapport à sauter par-dessus, mais c'est plus simple et donc plus sûr contre les erreurs , parce que vous n'avez pas à comprendre manuellement comment encoder l'instruction de saut, y compris à déterminer le décalage/l'adresse à utiliser - vous n'avez pas à penser autant pour un traîneau sans opération.

Pour être clair, l'erreur est facile :j'ai raté deux (2) fois lors de l'encodage manuel de cette instruction de branche inconditionnelle. Et ce n'est pas toujours de notre faute :la première fois, c'était parce que la documentation que j'avais était obsolète/incorrecte et disait qu'un bit était ignoré dans l'encodage, alors qu'il ne l'était pas, alors je l'ai mis à zéro lors de mon premier essai.

Ajout de code

Vous pourriez utiliser théoriquement cette technique pour ajouter les instructions de la machine aussi, mais c'est plus complexe, et je n'ai jamais eu à le faire, donc je n'ai pas d'exemple concret pour le moment.

Du point de vue du code machine, c'est assez simple :choisissez une instruction à l'endroit où vous voulez ajouter du code et convertissez-la en une instruction de saut vers le nouveau code que vous devez ajouter (n'oubliez pas d'ajouter la ou les instructions que vous remplacé dans le nouveau code, à moins que vous n'en ayez pas besoin pour votre logique ajoutée, et pour revenir à l'instruction à laquelle vous souhaitez revenir à la fin de l'ajout). En gros, vous "épissez" le nouveau code.

Mais vous devez trouver un endroit pour mettre ce nouveau code, et c'est la partie la plus difficile.

Si vous êtes vraiment heureusement, vous pouvez simplement ajouter le nouveau code machine à la fin du fichier, et cela "fonctionnera simplement":le nouveau code sera chargé avec le reste dans les mêmes instructions machine attendues, dans votre espace d'adressage espace qui tombe dans une page mémoire correctement marquée comme exécutable.

D'après mon expérience hexdump -R ignore non seulement la colonne la plus à droite, mais aussi la colonne la plus à gauche - vous pouvez donc littéralement mettre zéro adresse pour toutes les lignes ajoutées manuellement et ça marchera.

Si vous êtes moins chanceux, après avoir ajouté le code, vous devrez en fait ajuster certaines valeurs d'en-tête dans le même fichier :si le chargeur de votre système d'exploitation s'attend à ce que le binaire contienne des métadonnées décrivant la taille de la section exécutable (pour des raisons historiques souvent appelée la section "texte"), vous devrez trouver et ajuster cela. Auparavant, les binaires n'étaient que du code machine brut - de nos jours, le code machine est enveloppé dans un tas de métadonnées (par exemple ELF sur Linux et quelques autres).

Si vous êtes encore un peu chanceux, vous pourriez avoir un endroit "mort" dans le fichier qui est correctement chargé dans le cadre du binaire aux mêmes décalages relatifs que le reste du code qui est déjà dans le fichier (et que point mort peut s'adapter à votre code et est correctement aligné si votre processeur nécessite un alignement des mots pour les instructions du processeur). Ensuite, vous pouvez l'écraser.

Si vous êtes vraiment malchanceux, vous ne pouvez pas simplement ajouter du code et il n'y a pas d'espace mort que vous pouvez remplir avec votre code machine. À ce stade, vous devez essentiellement être intimement familiarisé avec le format exécutable et espérer que vous pourrez trouver quelque chose dans ces contraintes qu'il est humainement possible de réaliser manuellement dans un délai raisonnable et avec une chance raisonnable de ne pas le gâcher. .


@mgiuca a correctement répondu à cette réponse d'un point de vue technique. En fait, désassembler un programme exécutable en une source d'assemblage facile à recompiler n'est pas une tâche facile.

Pour ajouter quelques éléments à la discussion, il existe quelques techniques/outils qui pourraient être intéressants à explorer, bien qu'ils soient techniquement complexes.

  1. Instrumentation statique/dynamique . Cette technique consiste à analyser le format de l'exécutable, à insérer/supprimer/remplacer des instructions d'assemblage spécifiques dans un but donné, à corriger toutes les références aux variables/fonctions dans l'exécutable et à émettre un nouvel exécutable modifié. Certains outils que je connais sont :PIN, Hijacker, PEBIL, DynamoRIO. N'oubliez pas que la configuration de ces outils dans un but différent de celui pour lequel ils ont été conçus peut s'avérer délicate et nécessite une compréhension des formats exécutables et des jeux d'instructions.
  2. Décompilation complète de l'exécutable . Cette technique tente de reconstruire une source d'assemblage complète à partir d'un exécutable. Vous voudrez peut-être jeter un coup d'œil au désassembleur en ligne, qui essaie de faire le travail. Vous perdez de toute façon des informations sur les différents modules source et éventuellement les noms de fonctions/variables.
  3. Décompilation reciblable . Cette technique essaie d'extraire plus d'informations de l'exécutable, en regardant les empreintes digitales du compilateur (c'est-à-dire des modèles de code générés par des compilateurs connus) et d'autres éléments déterministes. L'objectif principal est de reconstruire le code source de niveau supérieur, comme la source C, à partir d'un exécutable. Cela permet parfois de récupérer des informations sur les noms de fonctions/variables. Considérez que compiler des sources avec -g offre souvent de meilleurs résultats. Vous voudrez peut-être essayer le décompilateur retargetable.

La plupart de ces informations proviennent des domaines de recherche sur l'évaluation de la vulnérabilité et l'analyse de l'exécution. Ce sont des techniques complexes et souvent les outils ne peuvent pas être utilisés immédiatement prêts à l'emploi. Néanmoins, ils fournissent une aide inestimable lors de la rétro-ingénierie de certains logiciels.


Linux
  1. Comment gérer et répertorier les services sous Linux

  2. Comment installer et tester Ansible sur Linux

  3. Comment installer et utiliser Flatpak sous Linux

  4. Comment compiler et installer un logiciel à partir du code source sous Linux

  5. Comment coder un module du noyau Linux ?

Comment installer et utiliser Linux Screen ?

Comment renommer des fichiers et des répertoires sous Linux

Comment compresser des fichiers et des répertoires sous Linux

Comment rendre un fichier exécutable sous Linux

Comment installer et utiliser PuTTY sous Linux

Comment installer et utiliser phpMyAdmin sous Linux