Architecture des ordinateurs TP-20

by Joseph Razik, on 2023-04-14

6   Manipulation de bytecode Python

6.1   Objectif

L'objectif de ce TP est de manipuler une forme de langage assembleur qu'est le bytecode Python et faire ce que l'on appelle du reverse ingineering.

Le principe du reverse ingineering est de réussir à comprendre le fonctionnement d'un objet (ici un programme) en n'ayant à sa disposition que le programme « compilé », c'est-à-dire, pas de documentation technique, pas d'accès au code source.

Dans ce sujet, nous allons faire du reverse ingineering sur 3 cas pratiques simples :

  • correction d'une erreur de transmission
  • modification des caractéristiques d'un personnage dans un jeu
  • modification du code du programme (cracking)

6.2   Préambule

L'interprète Python utilise ce que l'on appelle une machine virtuelle pour exécuter les instructions que vous lui donnez. Cette machine virtuelle introduit une abstraction intermédiaire avec le vrai processeur physiquement présent dans l'ordinateur et son propre langage machine. Le but de cette abstraction est d'obtenir un code proche de la machine optimisé et portable. Le jeu d'instruction de la machine virtuelle Python est limité, comme pour un processeur. On appelle ces instructions des opcodes.

Ainsi, avant d'exécuter un code en langage Python, celui-ci est traduit en bytecode (code binaire de la machine virtuelle). L'accès à ce bytecode est indirect car il n'a pas pour but d'être manipulé directement par l'utilisateur.

Python met à notre disposition un outil intermédiaire qui permet d'accéder non pas au bytecode mais au code assembleur associé. Ce code assembleur de la machine virtuelle Python n'est que la traduction directe en code d'instructions humainement lisibles du bytecode.

Le module dis met ainsi à notre disposition l'instruction dis() qui retourne la séquence en assembleur Python d'une suite d'instructions Python. Pour accéder au pur bytecode d'une séquence d'instruction il faut par contre creuser un peu afin d'obtenir la séquence d'octets de la fin de l'exemple suivant. On peut vérifier avec les opcodes données en fin de sujet que cette séquence d'octets correspond bien aux opcodes de programme désassemblé.

>>> from dis import dis

>>> instructions = """
a = 0
b = a + 2
print(b)
"""

>>> dis(instructions)

2           0 LOAD_CONST               0 (0)
            2 STORE_NAME               0 (a)

3           4 LOAD_NAME                0 (a)
            6 LOAD_CONST               1 (2)
            8 BINARY_ADD
           10 STORE_NAME               1 (b)

4          12 LOAD_NAME                2 (print)
           14 LOAD_NAME                1 (b)
           16 CALL_FUNCTION            1
           18 POP_TOP
           20 LOAD_CONST               2 (None)
           22 RETURN_VALUE


 >>> code = dis.Bytecode(instructions).codeobj.co_code
 b'd\x00Z\x00e\x00d\x01\x17\x00Z\x01e\x02e\x01\x83\x01\x01\x00d\x02S\x00'

 >>> # on sépare chaque octet que l'on affiche en hexadécimal
 >>> ', '.join(list(map(hex, code)))
 '0x64, 0x0, 0x5a, 0x0, 0x65, 0x0, 0x64, 0x1, 0x17, 0x0, 0x5a, 0x1, 0x65, 0x2, 0x65, 0x1, 0x83, 0x1, 0x1, 0x0, 0x64, 0x2, 0x53, 0x0'

La façon habituelle d'accéder au bytecode est d'importer le module contenant votre code. En effet, pour optimiser les importations et exécutions de modules externes, Python génère des fichiers bytecodes. Ceci évite de refaire les étapes de lecture, interprétation et optimisation de code en langage Python. Ces fichiers de bytecode sont placés dans le répertoire __pycache__ avec l'extension caractéristique pyc. Cette fois-ci par contre, son contenu n'est pas humainement lisible mais est composé d'une suite d'octets.

>$ cat exemple.py
a = 0
b = a + 2
print(b)

>$ python3 -m exemple
2
>$ cat __pycache__/exemple.cpython-38.pyc
U
Y2aã@sdZedZeedS)ééN)ÚaÚbÚprint©rrú/tmp/exemple.py<module>^[[?1;2c

>$ python3.8 __pycache__/exemple.cpython-38.pyc
2

>$ od .... __pycache__/exemple.cpython-38.pyc
... 64 00 5a 00 65 00 64 01 17 00 5a 01 65 02 65 01 ...

Ce fichier bytecode Python est un fichier binaire et suit un format prédéfini qui dépend de la version de l'interprète Python qui l'a généré.

Comme le montre l'exemple précédent, il est tout aussi exécutable que son fichier source et on y retrouve bien la même séquence de bytecode qu'avec le module dis.


Dans ce TP nous allons manipuler ce type de fichier pré-compilé et pour cela il faut en connaître le format.



6.3   Magic Number

Le magic number est une séquence d'octets placée en début de fichier, en général binaire, qui permet d'identifier le type de contenu d'un fichier. Ainsi par exemple le shell Linux distingue un script shell par la présence de la séquence 0x2321 qui correspond à la séquence de caractères ASCII #!. Un exécutable compilé classique Linux commence par la séquence 0x7f454c46 qui correspond à la séquence de caractères ASCII ELF (Executable and Linkable Format ). Toute autre séquence sera considérée par le shell comme correspondant à un fichier non exécutable.

Python utilise le même principe pour différencier la version de l'interprète qui a généré le fichier bytecode. Les 2 premiers octets du fichier bytecode vont correspondre à ce magic number (au format entier non signé) suivis de deux autres octets fixes correspondant aux caractères \n\r.

Voici une liste non exhaustive de différentes valeurs de magic number et la version de Python généralement correspondante :

Nombre Version
20121 1.5
50428 1.6
50823 2.0
60202 2.1
60717 2.2
62021 2.3
62061 2.4
62131 2.5
62161 2.6
62211 2.7
3131 3.0
3151 3.1
3180 3.2
3230 3.3
3310 3.4
3350 3.5
3379 3.6
3394 3.7
3413 3.8
3425 3.9


Dans un même module python bytecode.py,

  1. Écrivez la fonction magic_number(filename) qui prend en paramètre un nom de fichier bytecode et qui retournera le magic number de celui-ci. Sur l'exemple précédent, le comportement sera le suivant :

    >>> magic_number('__pycache__/exemple.cpython-35.pyc')
    3350
    
  2. Écrivez la fonction affiche_version(magic_number) qui affichera la version de Python correspondante à la valeur passée en paramètre. On se contentera de la liste des versions donnée ci-avant. Par ailleurs vous prendrez garde à un dictionnaire et surtout pas une dizaine de conditionnelles (if) dans votre fonction. Le comportement sera le suivant :

    >>> afficher_version(3350)
    Python 3.5
    
    >>> afficher_version(1234)
    Version inconnue
    
  3. Écrivez la fonction set_magic_number(valeur, filename, filenameout=None) qui va modifier la valeur du magic number du fichier bytecode donné en paramètre et sauver la modification soit dans le même fichier (si filenameout est None), soit dans le fichier de nom filenameout. Réfléchissez à ce que signifie « modifier » et comment le réaliser avec les instructions existantes.

    >>> magic_number('__pycache__/exemple.cpython-38.pyc')
    3350
    
    >>> set_magic_number(1234,'__pycache__/exemple.cpython-38.pyc', 'test.pyc')
    >>> magic_number('test.pyc')
    1234
    

    Notez que de le fichier modifié n'est plus exécutable pour Python.

    $> python3.8 test.pyc
    RuntimeError: Bad magic number in .pyc file
    
  4. Le fichier /home/partage/I22/prog_1.pyc a été généré par l'interprète Python 3.8 mais lors du transfert du fichier, les deux premiers octets ont été modifiés. Or, la structure du fichier est fortement dépendante de la version et très rarement compatible d'une version à une autre.

    À l'aide de vos fonctions précédentes, restaurez le magic number de ce fichier par rapport à la version de Python correspondante et testez que la version modifiée est bien exécutable avec le bon interprète Python.



6.4   Format du fichier pré-compilé

Dans cet exercice, vous allez analyser et afficher des informations que l'on peut extraire d'un programme pré-compilé en Python. De cet analyse nous pourrons en déduire des informations intéressantes pour pouvoir comprendre et modifier ce programme.

La structure d'un fichier pyc de Python 3.8 est la suivante :

Champs Taille (octets) Description
magic number 4 Indique la version de l'interprète Python
flags 4 Option pour l'entête (généralement à 0)
date [1] 4 Date de création du fichier pyc
taille src [1] 4 Taille en octet du fichier source
sous-entête 1 + 6*4 Informations diverses
code variable Structure contenant le bytecode du programme
constantes variable structure contenant les valeurs de constantes
variables variable structure contenant les noms de variables
autres infos variable Informations diverses

[1](1, 2) Les champs date et taille sont remplacés par un unique champs hashcode de 8 octets si le champs flag est différent de 0.

La plupart des champs de taille variable sont des champs eux-mêmes décomposés en une sous-structure comme celle-ci :

type nombre d'éléments (n) élément 1 élément 2 élément n

Ces champs sont en fait des des conteneurs, i.e. qu'ils contiennent d'autres éléments ayant leur propre type.

Le codage du nombre d'éléments et le codage des éléments eux-mêmes vont dépendre du type.

Type Description du type Codage du nb. (o) Description du nombre éléments
0x73 suite d'octets 4 nombre d'octets opcodes
0x29 tuple 1 nombre d'éléments éléments
0xA9 tuple 1 nombre d'éléments éléments
0x7A string 1 nombre de caractères caractères
0xFA string 1 nombre de caractères caractères
0x5A string 1 nombre de caractères caractères
0xDA string 1 nombre de caractères caractères

D'autres types sont terminaux : ils contiennent directement la valeur et n'ont donc pas de champs indiquant le nombre d'éléments.

Type Desc. type contenu
0xE9 entier valeur entière signée sur 4 octets
0x69 entier valeur entière signée sur 4 octets
0x4E None aucun
0x72 Référence valeur entière sur 4 octets

Toujours dans le même module python bytecode.py :

  1. Écrivez la fonction affiche_structure(filename) qui va afficher différentes informations sur la structure du fichier passé en paramètre. La première information sera le magic number pour obtenir un affichage comme dans l'exemple fictif suivant:

    >>> affiche_structure('exemple.pyc')
    Magic number: 62211 - Python 2.7
    
  2. La prochaine information à afficher sera la date de création du fichier .pyc. Généralement les dates ne sont pas enregistrées sous leur forme textuelle (lundi 1 janvier 2000, 0:01) mais avec un codage particulier. Celui le plus souvent rencontré est ce qu'on appelle le temps Posix (ou Unix) qui est défini par le nombre de secondes depuis le 1er janvier 1970 0:00. En utilisant la fonction ctime() du module time, modifiez la fonction affiche_structure(filename) pour ajoutez cette donnée comme dans l'exemple suivant (conservez la date en anglais):

    >>> affiche_structure('exemple.pyc')
    Magic number: 62211 - Python 2.7
    Date de création: Fri Sep  3 22:36:57 2021
    
  3. Déterminez l'adresse de début du champs code et ainsi déduire le type de sous-structure de ce champs.

  4. Complétez la fonction affiche_structure(filename) afin d'afficher les informations suivantes comme dans d'exemple fictif ci-dessous (l'adresse à afficher correspond à la position en octet dans de le fichier).

    >>> affiche_structure('exemple.pyc')
    Magic number: 62211 - Python 2.7
    Date de création: Fri Sep  3 22:36:57 2021
    Partie code: adresse=42, longueur=2 octets
    
  5. Déterminez l'adresse de début du champs constantes et ainsi déduire le type de sous-structure de ce champs.

  6. Sachant que les valeurs d'initialisation des variables (entières, chaînes de caractères, etc) sont considérées comme des constantes du programme assemblé, complétez la fonction affiche_structure(filename) afin d'afficher en plus les informations suivantes:

    • adresse et nombre d'éléments de la partie constantes
    • pour chaque élément : son numéro, son adresse et sa valeur.

    Exemple fictif :

    >>> affiche_structure('exemple.pyc')
    Magic number: 62211 - Python 2.7
    Date de création: Fri Sep  3 22:36:57 2021
    Partie code: adresse=42, longueur=2 octets
    Partie constantes: adresse=55, 2 éléments
           élément 0: Hello! (adresse=57)
           élément 1: None (adresse=65)
    

6.5   Modification de valeurs

Le but dans cet exercice va être d'utiliser les informations extraites précédemment pour modifier celles qui nous intéressent, et cela donc sans avoir accès au code source du programme mais uniquement au bytecode de celui-ci.

L'application sera de modifier les caractéristiques fixes d'un jeu trop difficile à notre goût et que l'on veut rendre plus facile.

Nous allons utiliser le fichier /home/partage/I22/prog_2.pyc qui a été généré par l'interprète Python 3.8 et affiche les informations suivantes à son exécution :

>$ python3.8 prog_2.pyc
Degats: 5
Armure: 2
Vie: 10
  1. Copiez ce fichier pyc dans votre répertoire I22 et écrivez le programme change_valeur.py qui va écrire une valeur entière codée sur 4 octets à une adresse particulière en octets dans un fichier binaire. Le reste du fichier restera inchangé (ce n'est pas une insertion mais une substitution). Les paramètres seront demandés à l'utilisateur. Attention : l'adresse à donner est l'adresse de la valeur à mettre, pas l'adresse de l'élément comme l'indique la fonction affiche_structure(filename).

    L'objectif sera de modifier le fichier prog_2.pyc à l'aide de votre programme pour que le nombre de points de vie soit dorénavant égale à 100 et obtenir comme dans l'exemple fictif suivant :

>$ python3.8 change_valeur.py
Nom du fichier à modifier ? prog_2.pyc
Adresse à modifier ? 42
Nouvelle valeur ? 100
Nom du fichier à créer ? mon_prog_2.pyc

>$ python3.8 mon_prog_2.pyc
Degats: 5
Armure: 2
Vie: 100


6.6   Modification de valeur cryptée

Le concepteur du programme précédent a remarqué qu'il était trop facile de modifier son programme pour augmenter les caractéristiques du personnage. Il a donc décidé dans une nouvelle version de compliquer quelque peu les choses en cryptant les valeurs initiales pour qu'elles ne soient plus aussi facilement modifiables. À l'utilisation, le programme a le même comportement :

>$ python3.8 prog_3.pyc
Degats: 5
Armure: 2
Vie: 10

Après analyse du bytecode des instructions, vous avez remarqué que le cryptage utilisé correspond à une opération XOR (ou exclusif) bit-à-bit avec une clef. Par exemple, si la clef vaut 42, au lieu de conserver la valeur 10, la valeur correspondant à 10 XOR 42 = 32 sera conservée. L'avantage de ce cryptage est qu'il est réversible par la même opération : 32 XOR 42 = 10.

  1. Dans votre fichier bytecode.py, ajoutez la fonction xor(valeur, cle) qui retourne la valeur encodée ou décodée selon la clé donnée en paramètre.

  2. Dans la structure du bytecode Python, déterminez l'adresse de début du champs variables et en déduire le type de sous-structure de ce champs.

  3. Sachant que la partie variables contient les noms des variables associées aux valeurs définies dans la partie constantes, complétez la fonction affiche_structure(filename) afin d'ajouter les informations de la partie variables, selon ce schéma :

    • adresse et nombre d'éléments de la partie variables
    • pour chaque élément : son numéro, son adresse et sa valeur

    Exemple fictif :

    >>> affiche_structure('exemple.pyc')
    Magic number: 62211 - Python 2.7
    Date de création: Fri Sep  3 22:36:57 2021
    Partie code: adresse=42, longueur=2 octets
    Partie constantes: adresse=55, 2 éléments
           élément 0: Hello! (adresse=57)
           élément 1: None (adresse=65)
    Partie variables: pos=80, 1 éléments
           élément 0: print (adresse=90)
    
  4. Modifiez votre programme change_valeur.py pour dorénavant demander une clé de cryptage et utiliser votre fonction xor() du module bytecode afin d'écrire la valeur encodée. On se rappellera que 0 est un élément neutre pour l'opérateur XOR pour les cas où un codage n'est pas nécessaire.

>$ python3.8 change_valeur.py
Nom du fichier à modifier ? prog_3.pyc
Adresse à modifier ? 42
Nouvelle valeur ? 100
Clé de cryptage ? 0
Nom du fichier à créer ? mon_prog_3.pyc
  1. Copiez le fichier /home/partage/I22/prog_3.pyc et utilisez vos fonctions définies précédemment afin de modifier le nombre de points de vie pour qu'il soit dorénavant égale à 200 et obtenir :

    >$ python3.8 mon_prog_3.pyc
    Degats: 5
    Armure: 2
    Vie: 200
    


6.7   Recherche d'un code d'accès

Dans cet exercice, le contexte est légèrement différent mais utilise les mêmes techniques. Le programme /home/partage/I22/prog_4.pyc est un programme de contrôle d'accès par mot de passe. Voici un exemple d'exécution :

>$ python3.8 prog_4.pyc
Code d'acces ? 1234
Acces refuse.

L'objectif sera de retrouver et donner le bon code d'accès au programme. Cependant, la petite difficulté est que le code d'accès n'apparaît pas en clair dans le programme. En effet, sachant qu'il ne faut jamais conserver les mots de passe en clair, le concepteur de ce programme a appliqué le même type de cryptage que dans le programme précédent : opération XOR avec une clef.

  1. Utilisez les fonctions que vous avez précédemment écrites pour analyser le bytecode de ce programme et trouver le code d'accès malgré son cryptage. Quel est ce code d'accès ?


6.8   Vers le cracking de code

Plutôt que de mémoriser le mot de passe du programme précédent et le donner à chaque utilisation nous décidons d'utiliser une autre manière de contourner cette autorisation d'accès : la modification d'instructions du code.

Intrinsèquement, l'autorisation d'accès correspond à une instruction de test conditionnel du type if test_booléen: … else: ….

L'idée ici est de retrouver l'opcode Python correspondant à ce test et à le modifier pour inverser l'opération ou les blocs des parties « alors » et « sinon » ou une autre modification qui amène au même résultat. Ainsi, soit le programme modifié acceptera n'importe quel mot de passe (sauf le bon) ou même n'en demandera plus selon les modifications faites.

Pour vous aider, voici un extrait du code en assembleur Python qui correspond à ce programme :


5           8 LOAD_NAME                2 (int)
           10 LOAD_NAME                3 (input)
           12 LOAD_CONST               2 ("Code d'acces ? ")
           14 CALL_FUNCTION            1
           16 CALL_FUNCTION            1
           18 STORE_NAME               4 (rep)

7          20 LOAD_NAME                4 (rep)
           22 LOAD_NAME                0 (KEY)
           24 BINARY_XOR
           26 LOAD_NAME                1 (passw)
           28 COMPARE_OP               2 (==)
           30 POP_JUMP_IF_FALSE       42


La table suivante décrit quelques instructions opcode de la machine virtuelle Python :

Op. name Opcode argument Description
NOP 0x09   Ne fait rien
POP_TOP 0x01   Dépile l'élément en tête de pile (TOS)
BINARY_XOR 0x41   Exécute TOS = TOS1 ^ TOS
RETURN_VALUE 0x53   Retourne la valeur en tête de pile à la fonction appelante
STORE_NAME 0x5A namei Exécute name = TOS. namei est l'indice de name dans variables
LOAD_CONST 0x64 consti Place sur la pile la constante constantes[consti]
LOAD_NAME 0x65 namei Place sur la pile la valeur associée avec variables[namei]
COMPARE_OP 0x6B opname Effectue l'opération booléenne donné par cmp_op[opname]
JUMP_FORWARD 0x6E delta Incrémente le compteur ordinal de delta
POP_JUMP_IF_FALSE 0x72 target Si TOS est faux, change le compteur ordinal à target. TOS est retiré
POP_JUMP_IF_TRUE 0x73 target Si TOS est vrai, change le compteur ordinal à target. TOS est retiré
CALL_FUNCTION 0x83 nbargs Appelle un objet appelable, nbargs décrivant le nombre d'arguments positionnels

La liste des opérations de comparaison pour l'opcode COMPARE_OP, dans cet ordre, est la suivante :

cmp_op = ['<', '<=', '==', '!=', '>', '>=', 'in', 'not in', 'is', 'is not']

Depuis la version 3.6, les instructions sont codées sur 2 octets: l'opcode codé sur 1 octet et le paramètre éventuel sur 1 octet (ou la valeur 0).

Dans son fonctionnement, la machine virtuelle Python utilise une pile (stack) sur laquelle elle place les données qu'elle a besoin de traiter.

Les opérations binaires retire le sommet (TOS) de la pile et son successeur dans la pile (TOS1). Elles effectuent l'opération puis place le résultat en sommet de pile.

Pour l'instruction d'appel de fonction avec arguments positionels, l'appel se configure ainsi (depuis la version 3.6):

  • l'octet de paramètre indique le nombre d'arguments positionnels,
  • la pile contient les arguments donnés par mots-clés à son sommet, suivis des arguments positionnels, suivi de l'objet à appeler,
  • chaque mot-clé est représenté par deux valeurs sur la pile : le nom et la valeur (au dessus),
  • les arguments positionnels sont empilés dans l'ordre de la signature de la fonction (le dernier au dessus),
  • la valeur de retour sera placée sur la pile.
  1. Sachant que le module opcode fournit la liste opname qui contient à l'indice de l'opcode son nom humainement lisible, ajoutez à votre module bytecode.py la fonction affiche_code(filename) qui affichera le code assembleur humainement lisible d'un fichier .pyc comme par exemple :

    >>> affiche_code('prog_4.pyc')
    Magic number: 3351
    Partie code: adresse=33, longueur=73 octets
    
    038 - LOAD_CONST 0
    041 - STORE_NAME 0
    044 - LOAD_CONST 1
    047 - STORE_NAME 1
    …
    

  2. Copiez le programme /home/partage/I22/prog_4.pyc et créer le programme crack.py qui va modifier les instructions bytecode (de la partie code) de prog_4.pyc afin de contourner le test de restriction d'accès.


6.9   Nota bene

Dans ce TPs, la manipulation et l'analyse, bien que réelle, a été volontairement limitée mais recèle encore bien d'autres mystères qui nécessitent l'analyse du code source de l'interprète CPython.