6 Manipulation de bytecode Python
Table des matières
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 engineering.
Le principe du reverse engineering 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 engineering 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,
É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
É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
É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 (sifilenameout
estNone
), 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
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 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 |
Exemple du contenu du programme /home/partage/I22/prog_2.pyc :
Cette suite d'octets possède ainsi la structure suivante :
Toujours dans le même module python bytecode.py :
É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
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 moduletime
, modifiez la fonctionaffiche_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
Déterminez l'adresse de début du champs code et ainsi déduire le type de sous-structure de ce champs.
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
Déterminez l'adresse de début du champs constantes et ainsi déduire le type de sous-structure de ce champs.
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
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.
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.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.
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)
Modifiez votre programme change_valeur.py pour dorénavant demander une clé de cryptage et utiliser votre fonction
xor()
du modulebytecode
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
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.
- 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.
Sachant que le module
opcode
fournit la listeopname
qui contient à l'indice de l'opcode son nom humainement lisible, ajoutez à votre module bytecode.py la fonctionaffiche_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 …
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.