Pourquoi le code machine natif ne peut-il pas être facilement décompilé?


16

Avec les langages de machine virtuelle basés sur le bytecode comme Java, VB.NET, C #, ActionScript 3.0, etc., vous entendez parfois à quel point il est facile de télécharger un décompilateur sur Internet, d'exécuter le bytecode à travers lui un bon moment, et souvent, trouver quelque chose pas trop loin du code source d'origine en quelques secondes. Soi-disant ce type de langage est particulièrement vulnérable à cela.

J'ai récemment commencé à me demander pourquoi vous n'en entendez pas plus à ce sujet concernant le code binaire natif, alors que vous savez au moins dans quelle langue il a été écrit à l'origine (et donc, dans quelle langue essayer de décompiler). Pendant longtemps, j'ai pensé que c'était simplement parce que le langage machine natif était tellement plus fou et plus complexe que le bytecode typique.

Mais à quoi ressemble le bytecode? Cela ressemble à ceci:

1000: 2A 40 F0 14
1001: 2A 50 F1 27
1002: 4F 00 F0 F1
1003: C9 00 00 F2

Et à quoi ressemble le code machine natif (en hexadécimal)? Cela ressemble bien sûr à ceci:

1000: 2A 40 F0 14
1001: 2A 50 F1 27
1002: 4F 00 F0 F1
1003: C9 00 00 F2

Et les instructions viennent d'un état d'esprit quelque peu similaire:

1000: mov EAX, 20
1001: mov EBX, loc1
1002: mul EAX, EBX
1003: push ECX

Donc, étant donné le langage pour essayer de décompiler un binaire natif en, disons C ++, qu'est-ce qui est si difficile? Les deux seules idées qui me viennent immédiatement à l'esprit sont 1) c'est beaucoup plus complexe que le bytecode, ou 2) le fait que les systèmes d'exploitation ont tendance à paginer les programmes et à disperser leurs morceaux pose trop de problèmes. Si l'une de ces possibilités est correcte, veuillez expliquer. Mais de toute façon, pourquoi n'en entendez-vous jamais parler?

REMARQUE

Je suis sur le point d'accepter l'une des réponses, mais je veux d'abord mentionner quelque chose. Presque tout le monde fait référence au fait que différentes parties du code source original peuvent correspondre au même code machine; les noms des variables locales sont perdus, vous ne savez pas quel type de boucle a été utilisé à l'origine, etc.

Cependant, des exemples comme les deux qui viennent d'être mentionnés sont plutôt triviaux à mes yeux. Cependant, certaines des réponses tendent à dire que la différence entre le code machine et la source d'origine est considérablement plus que quelque chose d'aussi trivial.

Mais par exemple, lorsqu'il s'agit de choses comme les noms de variables locales et les types de boucles, le bytecode perd également ces informations (au moins pour ActionScript 3.0). J'ai déjà récupéré ces trucs dans un décompilateur auparavant, et je ne me souciais pas vraiment si une variable était appelée strMyLocalString:Stringou loc1. Je pouvais toujours regarder dans cette petite portée locale et voir comment il était utilisé sans trop de problèmes. Et une forboucle est à peu près la même chose exacte qu'unwhileboucle, si vous y pensez. De plus, même lorsque j'exécutais la source via irrFuscator (qui, contrairement à secureSWF, ne fait pas beaucoup plus que simplement randomiser les noms de variables et de fonctions des membres), il semblait toujours que vous pouviez simplement commencer à isoler certaines variables et fonctions dans des classes plus petites, figure comment ils sont utilisés, attribuez-leur vos propres noms et travaillez à partir de là.

Pour que cela soit un gros problème, le code machine devrait perdre beaucoup plus d'informations que cela, et certaines des réponses vont dans ce sens.


35
Il est difficile de faire une vache avec des hamburgers.
Kaz Dragon

4
Le problème principal est qu'un binaire natif conserve très peu de métadonnées sur le programme. Il ne conserve aucune information sur les classes (ce qui rend C ++ particulièrement difficile à décompiler) et pas toujours rien sur les fonctions - ce n'est pas nécessaire car un CPU exécute intrinsèquement du code d'une manière assez linéaire, une instruction à la fois. De plus, il est impossible de faire la différence entre le code et les données ( lien ). Pour plus d' informations, vous pouvez envisager la recherche ou re-demander à RE.SE .
ntoskrnl

Réponses:


39

À chaque étape de la compilation, vous perdez des informations irrécupérables. Plus vous perdez d'informations de la source d'origine, plus il est difficile de décompiler.

Vous pouvez créer un décompilateur utile pour le code d'octet car beaucoup plus d'informations sont conservées à partir de la source d'origine que lors de la production du code machine cible final.

La première étape d'un compilateur est de transformer la source en une représentation intermédiaire souvent représentée sous forme d'arbre. Traditionnellement, cet arbre ne contient pas d'informations non sémantiques telles que des commentaires, des espaces blancs, etc. Une fois ces informations supprimées, vous ne pouvez pas récupérer la source d'origine de cet arbre.

L'étape suivante consiste à rendre l'arbre dans une certaine forme de langage intermédiaire qui facilite les optimisations. Il y a pas mal de choix ici et chaque infrastructure de compilateur a la sienne. En règle générale, cependant, des informations telles que les noms de variables locales, les grandes structures de flux de contrôle (comme si vous avez utilisé une boucle for ou while) sont perdues. Certaines optimisations importantes se produisent généralement ici, propagation constante, mouvement de code invariant, alignement de fonctions, etc.

Une étape après cela consiste à générer les instructions réelles de la machine qui pourraient impliquer ce que l'on appelle une optimisation "à judas" qui produisent une version optimisée des modèles d'instructions courants.

À chaque étape, vous perdez de plus en plus d'informations jusqu'à ce que, à la fin, vous en perdiez tellement qu'il devient impossible de récupérer quoi que ce soit ressemblant au code d'origine.

Le code octet, en revanche, enregistre généralement les optimisations intéressantes et transformatrices jusqu'à la phase JIT (le compilateur juste à temps) lorsque le code machine cible est produit. Le byte-code contient beaucoup de métadonnées telles que les types de variables locales, la structure de classe, pour permettre au même byte-code d'être compilé en plusieurs codes machine cible. Toutes ces informations ne sont pas nécessaires dans un programme C ++ et sont ignorées dans le processus de compilation.

Il existe des décompilateurs pour divers codes machine cibles, mais ils ne produisent souvent pas de résultats utiles (quelque chose que vous pouvez modifier puis recompiler) car une trop grande partie de la source d'origine est perdue. Si vous disposez d'informations de débogage pour l'exécutable, vous pouvez faire un travail encore meilleur; mais, si vous avez des informations de débogage, vous avez probablement aussi la source d'origine.


5
Le fait que les informations soient conservées afin que JIT puisse mieux fonctionner est essentiel.
btilly

Les DLL C ++ sont-elles alors facilement décompilables?
Panzercrisis

1
Pas dans quelque chose que je considérerais utile.
chuckj

1
Les métadonnées ne sont pas «pour permettre la compilation du même code d'octet sur plusieurs cibles», elles sont là pour la réflexion. La représentation intermédiaire reciblable n'a pas besoin d'avoir l'une de ces métadonnées.
SK-logic

2
Ce n'est pas vrai. Une grande partie des données sont là pour la réflexion mais la réflexion n'est pas la seule utilisation. Par exemple, l'interface et les définitions de classe sont utilisées pour créer définir un décalage de champ, construire des tables virtuelles, etc. sur la machine cible, ce qui permet de les construire de la manière la plus efficace pour la machine cible. Ces tables sont construites par le compilateur et / ou l'éditeur de liens lors de la production de code natif. Une fois cela fait, les données utilisées pour les construire sont supprimées.
chuckj

11

La perte d'informations, comme le soulignent les autres réponses, est un point, mais ce n'est pas le casse-tête. Après tout, vous ne vous attendez pas à ce que le programme d'origine revienne, vous voulez juste une représentation dans un langage de haut niveau. Si le code est en ligne, vous pouvez simplement le laisser, ou factoriser automatiquement les calculs courants. Vous pouvez en principe annuler de nombreuses optimisations. Mais il y a certaines opérations qui sont en principe irréversibles (sans une quantité infinie de calcul au moins).

Par exemple, les branches peuvent devenir des sauts calculés. Code comme celui-ci:

select (x) {
case 1:
    // foo
    break;
case 2:
    // bar
    break;
}

pourrait être compilé en (désolé que ce ne soit pas un vrai assembleur):

0x1000:   jump to 0x1000 + 4*x
0x1004:   // foo
0x1008:   // bar
0x1012:   // qux

Maintenant, si vous savez que x peut être 1 ou 2, vous pouvez regarder les sauts et inverser cela facilement. Mais qu'en est-il de l'adresse 0x1012? Devriez-vous en créer un case 3également? Vous devrez suivre l'ensemble du programme dans le pire des cas pour déterminer les valeurs autorisées. Pire encore, vous devrez peut-être considérer toutes les entrées utilisateur possibles! Au cœur du problème, vous ne pouvez pas distinguer les données et les instructions.

Cela étant dit, je ne serais pas entièrement pessimiste. Comme vous l'avez peut-être remarqué dans `` l'assembleur '' ci-dessus, si x vient de l'extérieur et n'est pas garanti à 1 ou 2, vous avez essentiellement un mauvais bug qui vous permet de sauter n'importe où. Mais si votre programme est exempt de ce type de bogue, il est beaucoup plus facile de le raisonner. (Ce n'est pas par hasard que les langages intermédiaires "sûrs" comme CLR IL ou le bytecode Java sont beaucoup plus faciles à décompiler, même en mettant de côté les métadonnées.) Ainsi, dans la pratique, il devrait être possible de décompiler certains bons comportementsprogrammes. Je pense à des routines de style individuelles et fonctionnelles, qui n'ont pas d'effets secondaires et des entrées bien définies. Je pense qu'il y a quelques décompilateurs qui peuvent donner un pseudocode pour des fonctions simples, mais je n'ai pas beaucoup d'expérience avec de tels outils.


9

La raison pour laquelle le code machine ne peut pas être facilement converti en code source d'origine est que beaucoup d'informations sont perdues lors de la compilation. Les méthodes et les classes non exportées peuvent être intégrées, les noms de variables locales sont perdus, les noms de fichiers et les structures sont entièrement perdus, les compilateurs peuvent effectuer des optimisations non évidentes. Une autre raison est que plusieurs fichiers source différents pourraient produire exactement le même assemblage.

Par exemple:

int DoSomething()
{
    return Add(5, 2);
}

int Add(int x, int y)
{
    return x + y;
}

int main()
{
    return DoSomething();
}

Peut être compilé pour:

main:
mov eax, 7;
ret;

Mon assemblage est assez rouillé, mais si le compilateur peut vérifier qu'une optimisation peut être effectuée avec précision, il le fera. Cela est dû au fait que le binaire compilé n'a pas besoin de connaître les noms DoSomethinget Add, ainsi que le fait que la Addméthode a deux paramètres nommés, le compilateur sait également que la DoSomethingméthode retourne essentiellement une constante, et il pourrait aligner à la fois l'appel de méthode et le méthode elle-même.

Le but du compilateur est de créer un assembly, pas un moyen de regrouper des fichiers source.


Envisagez de changer la dernière instruction en juste retet dites simplement que vous supposiez la convention d'appel C.
chuckj

3

Les principes généraux ici sont les correspondances plusieurs à un et le manque de représentants canoniques.

Pour un exemple simple de phénomène plusieurs-à-un, vous pouvez penser à ce qui se passe lorsque vous prenez une fonction avec des variables locales et la compilez en code machine. Toutes les informations sur les variables sont perdues car elles deviennent simplement des adresses mémoire. Quelque chose de similaire se produit pour les boucles. Vous pouvez prendre une boucle forou whileet si elles sont structurées correctement, vous pouvez obtenir un code machine identique avec des jumpinstructions.

Cela soulève également le manque de représentants canoniques du code source d'origine pour les instructions du code machine. Lorsque vous essayez de décompiler des boucles, comment mappez-vous les jumpinstructions sur les constructions en boucle? Faites-vous des forboucles ou des whileboucles.

Le problème est encore exacerbé par le fait que les compilateurs modernes effectuent diverses formes de pliage et de doublure. Donc, au moment où vous arrivez au code machine, il est pratiquement impossible de dire de quelles constructions de haut niveau le code machine de bas niveau provient.

En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.