Exploration des formats binaires en utilisant le bytecode du fichier .class comme exemple.

image


Si vous n'avez pas peur de l'image ci-dessus, si vous savez comment big-endian diffère de little-endian, si vous avez toujours été intéressé par la façon dont les fichiers binaires sont "arrangés", alors cet article est pour VOUS!


Présentation


Sur Habré, il y avait déjà plusieurs articles sur la rétro-ingénierie des formats binaires et sur l'étude de la structure bytecode d'un fichier .class:
Pool de constantes
Fondamentaux Java Bytecode ,
Bytecode Java "Bonjour tout le monde" ,
Hello World de bytecode pour JVM etc.
Le chercheur a pour tâche soit de traiter un protocole binaire inconnu, soit de creuser une structure binaire pour laquelle il existe une spécification.


Mon intérêt pour les formats binaires s'est manifesté même lorsque j'étais étudiant et j'ai écrit un article sur le développement du pilote du système de fichiers Linux. Quelques années plus tard, j'ai donné des conférences sur les bases de Linux pour les experts en médecine légale - dans le temps, Linux était une nouveauté et un jeune spécialiste après l'université pouvait dire beaucoup de nouvelles choses aux experts adultes. En me disant comment supprimer un vidage d'un disque à l'aide de dd, et après avoir connecté l'image à un autre ordinateur pour étude, j'ai réalisé que l'image du disque contient beaucoup d'informations intéressantes. Ces informations pourraient être extraites même sans monter l'image (hein, montage -o loop ...) si vous connaissiez les spécifications du format du système de fichiers et disposiez des outils appropriés. Malheureusement, je n'avais pas de tels outils.


Après quelques années, j'avais besoin de décompiler la bibliothèque Java. Il n'y avait pas d'interface graphique JD à cette époque, ainsi qu'un décompilateur idéologique, mais il y avait JAD. Pour ma bibliothèque, le JAD a produit un mélange d'opcodes Java avec des messages d'erreur. De plus, JAD ne prenait pas en charge les annotations et dans Java 6, qui apparaissait alors, elles étaient pleinement utilisées. Armé de la spécification de la machine virtuelle Java, j'ai commencé ...


Idée


J'avais besoin d'un mécanisme universel pour décrire les structures binaires et d'un chargeur universel. Le chargeur, en utilisant la description, lira les données binaires en mémoire. Vous devez généralement gérer des nombres, des chaînes, des tableaux de données et des structures composées. Tout est simple avec des nombres - ils ont une longueur fixe - 1, 2, 4 ou 8 octets et peuvent être immédiatement mappés aux types de données disponibles dans la langue. Par exemple: octet, court, int, long pour Java. Pour les types numériques de plus d'un octet, un marqueur d'ordre d'octets (la représentation dite BigEndian / LittleEndiang) doit être fourni.


Les chaînes sont plus compliquées - elles peuvent être dans différents encodages (ASCII, UNICODE), avoir une longueur fixe ou variable. Une chaîne de longueur fixe peut être considérée comme un tableau d'octets. Pour les chaînes de longueur variable, vous pouvez utiliser deux options d'enregistrement - indiquer la longueur de la chaîne au début de la chaîne (Pascal ou chaînes préfixées par la longueur) ou mettre un caractère spécial à la fin de la chaîne pour indiquer la fin de la chaîne. En tant que tel signe, un octet avec une valeur de zéro est utilisé (les soi-disant srings à terminaison nulle). Les deux options ont des avantages et des inconvénients, dont une discussion dépasse le cadre de cet article. Si la taille est spécifiée au début, alors lors du développement du format, vous devez déterminer la longueur maximale de la chaîne: le nombre d'octets que nous devons allouer au marqueur de longueur en dépend: 2 8 - 1 pour un octet, 2 16 - 1 pour deux octets, etc.


Nous distinguerons les structures de données composites en classes distinctes, en poursuivant la décomposition en nombres et en chaînes.


Structure du fichier .class


Nous devons en quelque sorte décrire la structure du fichier Java .class. En conséquence, je voudrais avoir un ensemble de classes Java, où chaque classe ne contient que des champs qui correspondent à la structure de données à l'étude et, éventuellement, des méthodes auxiliaires pour afficher l'objet sous une forme lisible par l'homme lorsque la méthode toString () est appelée. De manière catégorique, je ne voudrais pas avoir la logique à l'intérieur qui est responsable de la lecture ou de l'écriture d'un fichier.


Nous prenons la spécification de la machine virtuelle Java,
Spécification JVM, Java SE 12 Edition .
Nous nous intéresserons à la section 4 "Le format de fichier de la classe".


Afin de déterminer quels champs dans quel ordre charger, nous introduisons l'annotation @FieldOrder (index = ...). Nous devons indiquer explicitement l'ordre des champs pour le chargeur, car la spécification ne nous donne aucune garantie sur l'ordre dans lequel ils seront enregistrés dans un fichier binaire.


Le fichier Java .class commence par 4 octets de nombre magique, deux octets de la version mineure de Java et deux octets de la version principale. Nous emballons le nombre magique dans la variable int, et le numéro de version mineur et majeur en bref:


@FieldOrder(index = 1) private int magic; @FieldOrder(index = 2) private short minorVersion; @FieldOrder(index = 3) private short majorVersion; 

Plus loin dans le fichier .class se trouve la taille du pool constant (variable à deux octets) et du pool constant lui-même. Nous introduisons l'annotation @ContainerSize pour déclarer la taille des tableaux et des structures de liste. La taille peut être fixe (nous la définirons via l'attribut value) ou avoir une longueur variable, déterminée par la variable précédemment lue. Dans ce cas, nous utiliserons l'attribut "fieldName", qui indique à partir de quelle variable nous lirons la taille du conteneur. Selon la spécification (section 4.1,
"La structure ClassFile"), la taille réelle du pool constant diffère de 1 de la valeur
qui est écrit dans constant_pool_count:


 u2 constant_pool_count; cp_info constant_pool[constant_pool_count-1]; 

Pour tenir compte de ces corrections, nous introduisons un attribut correcteur supplémentaire dans les annotations @ContainerSize.
Maintenant, nous pouvons ajouter une description du pool constant:


  @FieldOrder(index = 4) private short constantPoolCount; @FieldOrder(index = 5) @ContainerSize(fieldName = "constantPoolCount", corrector = -1) private List<ConstantPoolItem> constantPoolList = new ArrayList<>(); 

Dans le cas de calculs plus complexes, vous pouvez simplement ajouter une méthode get qui retournera la valeur souhaitée:
  @FieldOrder(index= 1) private int containerSize; @FieldOrder(index = 2) @ContainerSize(filed="actualContainerSize") private List<ContainerItem> containerItems; public int getActualContainerSize(){ return containerSize * 2 + 3; } 

Piscine constante


Chaque élément du pool de constantes est soit une description de la constante correspondante de type int, long, float, double, String, soit une description de l'un des composants de la classe Java - champs de classe (champs), méthodes, signatures de méthode, etc. Le terme "constante" signifie ici une valeur sans nom utilisée dans le code:


 if (intValue > 100500) 

Une valeur de 100500 sera représentée dans le pool constant sous la forme d'une instance de CONSTANT_Integer. La spécification JVM pour Java 12 définit 17 types qui peuvent être dans un pool constant.


Instances possibles d'éléments de pool de const
Type constantTag
CONSTANT_Class7
CONSTANT_Fieldref9
CONSTANT_Methodref10
CONSTANT_InterfaceMethodref11
CONSTANT_String8
CONSTANT_Integer3
CONSTANT_Float4
CONSTANT_Long5
CONSTANT_Double6
CONSTANT_NameAndType12
CONSTANT_Utf81
CONSTANT_MethodHandle15
CONSTANT_MethodType16
CONSTANT_Dynamic17
CONSTANT_InvokeDynamic18
CONSTANT_Module19
CONSTANT_Package20

Dans notre implémentation, nous allons créer une classe ConstantPoolItem dans laquelle il y aura une balise de champ à un octet, qui détermine la structure que nous lisons en ce moment. Pour chaque élément du tableau ci-dessus, créez une classe Java, un descendant de ConstantPoolItem. Un chargeur de fichiers binaires universel devrait être en mesure de déterminer la classe à utiliser en fonction d'une balise déjà lue.
(en général, une balise peut être une variable de tout type). Pour cela, définissez l'interface HasInheritor et implémentez cette interface dans la classe ConstantPoolItem:


 public interface HasInheritor<T> { public Class<? extends T> getInheritor() throws InheritorNotFoundException; public Collection<Class<? extends T>> getInheritors(); } 

 public class ConstantPoolItem implements HasInheritor<ConstantPoolItem> { private final static Map<Byte, Class<? extends ConstantPoolItem>> m = new HashMap<>(); static { m.put((byte) 7, ClassInfo.class); m.put((byte) 9, FieldRefInfo.class); m.put((byte) 10, MethodRefInfo.class); m.put((byte) 11, InterfaceMethodRefInfo.class); m.put((byte) 8, StringInfo.class); m.put((byte) 3, IntegerInfo.class); m.put((byte) 4, FloatInfo.class); m.put((byte) 5, LongInfo.class); m.put((byte) 6, DoubleInfo.class); m.put((byte) 12, NameAndTypeInfo.class); m.put((byte) 1, Utf8Info.class); m.put((byte) 15, MethodHandleInfo.class); m.put((byte) 16, MethodTypeInfo.class); m.put((byte) 17, DynamicInfo.class); m.put((byte) 18, InvokeDynamicInfo.class); m.put((byte) 19, ModuleInfo.class); m.put((byte) 20, PackageInfo.class); } @FieldOrder(index = 1) private byte tag; @Override public Class<? extends ConstantPoolItem> getInheritor() throws InheritorNotFoundException { Class<? extends ConstantPoolItem> clazz = m.get(tag); if (clazz == null) { throw new InheritorNotFoundException(this.getClass().getName(), String.valueOf(tag)); } return clazz; } @Override public Collection<Class<? extends ConstantPoolItem>> getInheritors() { return m.values(); } } 

Le chargeur universel instanciera la classe requise et continuera la lecture. Seule condition: les index des classes successives doivent avoir une numérotation de bout en bout avec la classe parente. Cela signifie que dans toutes les classes constantes de ConstantPoolItem, FieldOrder, l'annotation doit avoir un index supérieur à un, car dans la classe parente, nous lisons déjà le champ de balise avec le numéro "1".


Structure du fichier .class (suite)


Après la liste des éléments du pool constant dans le fichier .class, il y a un identifiant à deux octets qui définit les détails de cette classe - la classe est-elle une annotation, une interface, une classe abstraite, a-t-elle un drapeau final, etc. Ceci est suivi d'un identifiant à deux octets (une référence à un élément dans le pool constant) qui définit cette classe. Cet identifiant doit pointer vers un élément de type ClassInfo. La superclasse pour une classe donnée est définie de manière similaire (ce qui est indiqué après le mot "étend" dans la définition de classe). Pour les classes qui n'ont pas de superclasses définies explicitement, ce champ contient une référence à la classe Object.


En Java, toute classe ne peut avoir qu'une seule superclasse, mais le nombre
Il peut y avoir plusieurs interfaces que cette classe implémente:


  @FieldOrder(index = 9) private short interfacesCount; @FieldOrder(index = 10) @ContainerSize(fieldName = "interfacesCount") private List<Short> interfaceIndexList; 

Chaque élément dans interfaceIndexList représente un lien vers un élément dans le pool constant (comme spécifié
l'index doit être un élément de type ClassInfo).
Les variables de classe (propriétés, champs) et les méthodes sont représentées par les listes correspondantes:


  @FieldOrder(index = 11) private short fieldsCount; @FieldOrder(index = 12) @ContainerSize(fieldName = "fieldsCount") private List<Field> fieldList; @FieldOrder(index = 13) private short methodsCount; @FieldOrder(index = 14) @ContainerSize(fieldName = "methodsCount") private List<Method> methodList; 

Le dernier élément de la description du fichier Java .class est la liste des attributs de classe. Les attributs décrivant le fichier source lié à la classe, les classes imbriquées, etc. peuvent être répertoriés ici.


Le bytecode Java fonctionne avec des données numériques dans une représentation big-endian, nous utiliserons cette représentation par défaut. Pour les formats binaires avec des nombres little-endian, nous utiliserons l'annotation LittleEndian . Pour les chaînes qui n'ont pas de longueur prédéfinie, mais
sont lus avant le caractère terminal (comme les chaînes terminales nulles de type C), nous utiliserons
Annotation @StringTerminator:


  @FieldOrder(index = 2) @StringTerminator(0) private String nullTerminatedString; 

Parfois, dans les classes sous-jacentes, vous devez transmettre des informations d'un niveau supérieur. L'objet Method dans methodList ne contient pas d'informations sur le nom de la classe dans laquelle il se trouve; en outre, l'objet méthode ne contient pas son nom et sa liste de paramètres. Toutes ces informations sont présentées sous forme d'indices sur les éléments du pool constant. Cela suffit pour une machine virtuelle, mais nous aimerions implémenter les méthodes toString () afin qu'elles affichent les informations sur la méthode sous une forme conviviale, et non sous la forme d'index sur les éléments du pool constant. Pour ce faire, la classe Method doit obtenir une référence à ConstantPoolList et à une variable avec la valeur de thisClassIndex. Pour pouvoir transmettre des liens vers les niveaux d'imbrication sous-jacents, nous utiliserons l'annotation Inject :


  @FieldOrder(index = 14) @ContainerSize(fieldName = "methodsCount") @Inject(fieldName = "constantPoolList") @Inject(fieldName = "thisClassIndex") private List<Method> methodList; 

Dans la classe actuelle (ClassFile), les méthodes getter seront appelées pour les variables constantPoolList et thisClassIndex, et dans la classe réceptrice (dans ce cas, Method), les méthodes setter seront appelées (si elles sont présentes).


Chargeur de démarrage universel


Nous avons donc une interface HasInheritor et cinq annotations @FieldOrder, @ContainerSize, LittleEndian , Inject et @StringTerminator, qui nous permettent de décrire les structures binaires à un niveau d'abstraction élevé. Ayant une description formelle, nous pouvons la transmettre au chargeur universel, qui peut instancier la structure décrite, analyser le fichier binaire et le lire en mémoire.


Par conséquent, nous devrions pouvoir utiliser ce code:


  ClassFile classFile; try (InputStream is = new FileInputStream(inputFileName)) { Loader loader = new InputStreamLoader(is); classFile = (ClassFile) loader.load(); } 

Malheureusement, les développeurs de plates-formes Java sont un peu trop sophistiqués pour les valeurs de huit octets dans le pool.
des constantes sont fournies pour deux cellules, la première cellule doit contenir une valeur et la seconde reste
vide. Cela s'applique aux constantes longues et doubles.


Description de la spécification JVM

Toutes les constantes de 8 octets occupent deux entrées dans la table constant_pool de la classe
fichier. Si une structure CONSTANT_Long_info ou CONSTANT_Double_info est l'entrée
à l'index n dans la table constant_pool, la prochaine entrée utilisable dans la table est
situé à l'indice n + 2. L'index constant_pool n + 1 doit être valide mais est considéré
inutilisable.


Apparemment, les développeurs Java voulaient appliquer une sorte d'optimisation de bas niveau, mais plus tard
il a été reconnu que cette décision de conception a tourné
infructueux.

Rétrospectivement, faire des constantes de 8 octets prendre deux entrées de pool constantes était un mauvais choix.


Pour gérer ces cas spécifiques, nous allons ajouter l'annotation @EntrySize, que nous utiliserons,
pour baliser les constantes à huit octets:


 @EntrySize(value = 2, index = 1) public class EightByteNumberInfo extends ConstantPoolItem { @FieldOrder(index = 2) private int highBytes; @FieldOrder(index = 3) private int lowBytes; } 

L'attribut value indique le nombre de cellules que l'élément occupera, index - l'index de l'élément,
qui contient la valeur. les classes LongInfo et DoubleInfo étendront la classe EightByteNumberInfo.
Le chargeur de démarrage universel devra être étendu avec une fonctionnalité prenant en charge l'annotation @EntrySize.


  public ClassFileLoader(String fileName) { try { File f = new File(fileName); FileInputStream fis = new FileInputStream(f); loader = new EntrySizeSupportLoader(fis); } catch (FileNotFoundException e) { throw new RuntimeException(e); } } 

Après avoir chargé la classe avec ClassFileLoader, vous pouvez arrêter le débogueur et examiner la classe chargée dans l'inspecteur de variables dans l'EDI.


Le fichier de classe ressemblera à ceci:
image


Et Constant Pool est comme ça:
image


Conclusion


Quiconque peut lire jusqu'à la fin peut vouloir choisir le bytecode Java de ses propres mains. N'hésitez pas à accéder au github et à télécharger la description du fichier de classe Java en tant qu'ensemble de classes Java: https://github.com/esavin/annotate4j-classfile . Le chargeur universel et les annotations sont ici: https://github.com/esavin/annotate4j-core .


Pour télécharger un fichier de classe compilé, utilisez le chargeur annotate4j.classfile.loader.ClassFileLoader.


La plupart du code a été écrit pour Java 6, j'ai adapté uniquement le pool constant aux versions modernes. Je n'avais pas la force et le désir d'implémenter complètement le chargeur Java pour les opcodes Java, il n'y a donc que de petits développements dans cette partie.


En utilisant cette bibliothèque (partie principale), j'ai réussi à restaurer le fichier binaire avec les données de surveillance Holter (étude ECG de l'activité cardiaque quotidienne). En revanche, je n'ai pas pu décrypter le protocole binaire d'un système comptable écrit en Delphi. Je ne comprenais pas comment les dates sont transmises, et parfois une situation survient lorsque les données réelles ne correspondent pas à la structure construite sur les valeurs précédentes.


J'ai essayé de construire un modèle similaire au fichier de classe Java pour le format ELF (format exécutable sur Unix / Linux), mais je ne pouvais pas comprendre pleinement la spécification - cela s'est avéré trop vague pour moi. Le même sort est arrivé aux formats JPEG et BMP - tout le temps, je rencontrais des difficultés pour comprendre les spécifications.

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


All Articles