20000 lieues sous le binaire...

Open Silicium n° 006 | mars 2013 | Etienne Dublé
Creative Commons
  • Actuellement 0 sur 5 étoiles
0
Merci d'avoir participé !
Vous avez déjà noté cette page, vous ne pouvez la noter qu'une fois !
Votre note a été changée, merci de votre participation !
L'objectif initial de cet article était de vous présenter un mini-OS embarqué dénommé « Contiki », et ce qu'on peut faire avec. Malheureusement, l'histoire a un peu dérivé. Un peu beaucoup. Donc, ne soyez pas surpris si on se retrouve à faire du reverse engineering sur un binaire ELF, ou même, soyons fous, de l'analyse de bytecode Python.

1. Les réseaux de capteurs

1.1. Survol

Si le monde continue à évoluer dans le même sens, on peut imaginer que de plus en plus d'objets seront bientôt « intelligents ». Ils seront ainsi capables de mesurer des choses, de communiquer avec un humain, ou avec un autre objet. On appelle cela l'« intelligence ambiante » ou, en parlant des réseaux d'objets, les « réseaux de capteurs ».

Ce contexte induit un certain nombre de problèmes. En particulier, tout cela risque d'être très coûteux en énergie. Il faut donc concevoir des techniques pour économiser cette énergie au mieux. Cette économie doit être pensée lors de la conception des appareils, bien sûr, mais également en ce qui concerne les protocoles réseau qu'ils vont utiliser pour échanger. Pour compliquer un peu plus, en général on s'intéresse plutôt à des réseaux ad hoc, sans fil, avec du duty-cycling (voir plus loin)... Comme ça, on est sûrs de ne pas s'ennuyer. :)

1.2. Le matériel

Pour concevoir les futures technologies adaptées à ce contexte, on construit des réseaux sans fil dont les nœuds sont de petites cartes électroniques. Elles embarquent un processeur (économe en énergie), de la mémoire (pas beaucoup, quelques kilo-octets en général), des interfaces de communication (une radio pour communiquer avec les autres, une interface série pour le développement...) et, bien sûr, des capteurs (température, accélération, etc.).

Dans l'équipe, peu de gens s'intéressent effectivement aux capteurs présents sur ces cartes électroniques. En effet, on travaille plus souvent sur les couches réseau basses (jusqu'au routage), et les capteurs ne sont pas utiles à ce niveau. En revanche, on parle souvent de « capteur » au sujet de la carte électronique elle-même, j'adopterai donc parfois cette convention dans la suite.

Dans cette équipe de recherche, nous collaborons, entre autres, avec ST Microelectronics. Pour nos expérimentations, ils nous ont confié une cinquantaine de cartes (modèle MB851). Ces cartes sont munies principalement de :

- 1 CPU STM32 (processeur ARM déjà présenté dans ce magazine [ARTICLE_STM32]) ;

- 8 kilo-octets de RAM ;

- 128 kilo-octets de Flash ;

- une radio IEEE 802.15.41.

1.3. Économisons l'énergie...

Pour ce qui est de l'énergie, il y a une technique d'économie super efficace : éteindre le capteur ! En fait, je ne plaisante pas : l'idée est d'allumer le capteur périodiquement afin qu'il fasse son travail et communique avec les autres, puis de l'éteindre pendant une période plus importante. Ensuite, on essaie de diminuer au maximum la proportion du temps pendant laquelle le capteur est actif (souvent autour de 1%, parfois beaucoup moins, pour donner un ordre d'idée). Dans le jargon, on nomme cette technique « duty-cycling » (réveil périodique).

Quand j'ai parlé d'« éteindre » le capteur, en réalité, il y a plusieurs degrés. En général, on éteint au moins la radio, car c'est souvent l'élément qui consomme le plus. Si on veut économiser davantage, on peut même éteindre le CPU. Bien sûr, il faudra qu'un Timer reste actif quelque part pour déclencher le réveil.

Le « duty-cycling » ne pose pas trop de problèmes pour un capteur isolé. En revanche, quand on veut éteindre, 99% du temps, l'ensemble des capteurs dans un réseau sans fil ad hoc, on fait moins les malins... On peut se dire qu'il suffit de les synchroniser, mais les horloges sont assez imprécises... Et, s'ils sont « trop » synchronisés, on va provoquer des tas de collisions sur le canal radio.

1.4. Un mini-OS : Contiki

Il existe des mini-OS adaptés à ces architectures et à ce domaine de recherche. Contiki en est un, et j'ai l'impression qu'il est de plus en plus utilisé dans ce contexte [CONTIKI]. Voici, à mon avis, ses principaux avantages :

- Il a été porté sur de nombreuses plateformes utilisées communément par les équipes de recherche.

- Il fournit une pile IPv4, une pile IPv6, et des implémentations pour d'autres protocoles classiques du domaine.

- La communauté est active (comptez 10 ou 20 mails par jour sur la liste de diffusion [DIFFCONTIKI]).

1.5. Application pratique

OK OK, trêve de palabres. Je vous propose de commencer par un traditionnel « hello world » envoyé par un capteur MB851 sur sa liaison série.

En premier lieu, il nous faut une chaîne de cross-compilation adéquate pour notre processeur ARM. Pour ma part, j'ai installé celle de CodeSourcery [ARMLITE].

Ensuite, on peut cloner le dépôt de Contiki :

$ git clone https://github.com/contiki-os/contiki.git

Cloning into contiki...

[...]

$ cd contiki

$

Notre exemple « hello world » existe déjà dans ce dépôt, je ne vais donc pas trop me fatiguer : il n'y a plus qu'à le compiler pour notre architecture :

$ cd examples/hello-world

$ make TARGET=mbxxx

[...]

arm-none-eabi-gcc [...]

[...]

$ ls hello-world.mbxxx

hello-world.mbxxx

$

On peut remarquer la directive TARGET=mbxxx, qui nous permet d'indiquer notre architecture cible.

Note

Le code spécifique à chaque architecture (code de gestion des périphériques, etc.) est stocké dans un sous-répertoire du répertoire [contiki_root]/platform/, par exemple [contiki_root]/platform/mbxxx ici. On a également un sous-répertoire pour chaque CPU, dans [contiki_root]/cpu/.

Notre binaire hello-world.mbxxx est maintenant généré, on n'a plus qu'à flasher le capteur.

En reliant un capteur MB851 à notre machine de développement, avec un bête câble USB, Linux nous instancie une liaison série. En prime, cela permet de l'alimenter (sinon il faut mettre des piles).

Branchons-le et voyons ce que dit le noyau Linux :

$ dmesg | tail

[...]

[27535.615993] usb 2-1.2: new full-speed USB device number 7 using ehci_hcd

[27535.710715] cdc_acm 2-1.2:1.0: This device cannot do calls on its own. It is not a modem.

[27535.710726] cdc_acm 2-1.2:1.0: Control and data interfaces are not separated!

[27535.710767] cdc_acm 2-1.2:1.0: ttyACM0: USB ACM device

[27535.712795] input: STMicroelectronics STM32W Composite Device as /devices/pci0000:00/0000:00:1d.0/usb2/2-1/2-1.2/2-1.2:1.1/input/input19

[27535.713127] generic-usb 0003:0483:5741.0004: input,hidraw2: USB HID v1.11 Mouse [STMicroelectronics STM32W Composite Device ] on usb-0000:00:1d.0-1.2/input1

$

On voit que Linux reconnaît une liaison série USB de type CDC-ACM (souvent utilisée pour les modems USB). Apparemment, la carte ne respecte pas bien ce standard (les canaux Control et Data sont mélangés), mais heureusement Linux est assez tolérant2. Et même s'il croit avoir affaire à une souris USB (!!), ça marche quand même...

Bon allez, on flashe :

$ make TARGET=mbxxx hello-world.upload

arm-none-eabi-objcopy -O binary hello-world.mbxxx hello-world.bin

sudo ../../../tools/stm32w/stm32w_flasher/stm32w_flasher -f -r hello-world.bin -p /dev/ttyACM0

INFO: STM32W flasher utility version 2.0.0b2 for Linux

ERROR: Failed to detect port type for /dev/ttyACM0

ERROR: Trouble while resetting board on port : '/dev/ttyACM0'

ERROR: Error while initiliazing interface

make: *** [hello-world.upload] Erreur 255

rm hello-world.bin

$

???????

Comme on pouvait s'y attendre, quand je vous fais la démo, ça ne marche plus... Vive la loi de Murphy !

2. Il faut résoudre ce souci...

2.1. Diagnostic rapide

Vraiment étonnant... Avant-hier, j'ai montré tout ça sans souci à un nouveau collègue !...

Par contre... Maintenant que j'y pense, dans la soirée qui a suivi, je me rappelle d'un truc... C'est peut-être le seul truc dont je me rappelle de cette soirée d'ailleurs. Mais passons. Oui, c'est ça, j'ai mis à jour mon OS vers Ubuntu 12.04... C'est peut-être ça ?? En tout cas, je ne vois rien d'autre...

Voyons voir si ça marche avec ma machine virtuelle qui est restée en version Ubuntu 11.10.

[...]

vm_11.10 $ make TARGET=mbxxx hello-world.upload

[...]

sudo ../../tools/stm32w/stm32w_flasher/stm32w_flasher -f -r hello-world.bin -p /dev/ttyACM0

INFO: STM32W flasher utility version 2.0.0b2 for Linux

[...]

INFO: Programming user flash

INFO: Erasing pages from 0 to 31...done

INFO: Programming 32068/32068

INFO: Done                      

INFO: Resetting device

INFO: Done                   

[...]

vm_11.10 $

Effectivement, en 11.10, pas de souci.

2.2. Un binaire sans les sources

Bon, on va essayer d'investiguer un peu plus. Déjà, ce qu'on voit, c'est que derrière le système de Makefiles se cache le binaire [contiki_root]/tools/stm32w/stm32w_flasher/stm32w_flasher. C'est lui qui permet normalement de flasher les cartes, et qui ne fonctionne plus.

Et ce que je sais, c'est qu'il a été écrit par des gens de chez ST. Dans Contiki, on trouve donc ce binaire mais pas les sources. Au final, on laisse donc évoluer notre OS de développement (Ubuntu) sans jamais recompiler ce binaire... Il ne faut pas s'étonner de rencontrer des soucis ! Les chemins de certains fichiers requis ont très bien pu changer par exemple...

2.3. Des flashers alternatifs ?

Comme indiqué dans l'article traitant du processeur STM32 et paru dans ce même magazine [ARTICLE_STM32], il existe des flashers alternatifs. Malheureusement, ceux-ci ne fonctionnent pas avec nos modèles. A priori, nos cartes sont équipées d'un firmware incompatible (ces flashers interagissent avec un menu normalement accessible dans certaines conditions au démarrage de la carte ; mais ce menu n'existe pas sur nos modèles).

2.4. La voie diplomatique

C'est assez ennuyeux ce souci... On ne peut raisonnablement pas rester bloqué sur une version d'OS obsolète à cause de ce flasher... Je vais soumettre le problème à nos collègues de chez ST. Le mieux serait qu'ils nous donnent les sources de ce binaire. Sans ça, le diagnostic et la résolution du souci vont être plus compliqués...

2 semaines plus tard...

Bon, ça n'a pas avancé. D'après nos partenaires chez ST, le binaire a été construit par une autre équipe, en Italie, et ils n'ont pas pu récupérer les sources. J'ai aussi contacté la dernière personne qui a mis à jour ce binaire dans le dépôt de Contiki, un Italien de chez ST justement. Mais il ne travaille plus sur ce sujet. Et même si apparemment, il a transmis ma demande, je n'ai pas eu de nouvelles depuis.

2.5. La voie de l'explorateur

Il va falloir se débrouiller.

À notre disposition, on a déjà, pour simplifier :

- un environnement où le binaire fonctionne (Ubuntu 11.10) ;

- un autre où il ne fonctionne pas (Ubuntu 12.04).

C'est déjà pas mal.

A priori, en comparant les résultats de strace et/ou ltrace (voir encadré) sur ces deux plateformes, on pourra peut-être cibler le souci plus précisément. Allons-y...

$ ssh vm_11.10

[...]

vm_11.10 $ strace -f [...]/stm32w_flasher [...] 2>strace.txt 1>&2

vm_11.10 $ ltrace -f [...]/stm32w_flasher [...] 2>ltrace.txt 1>&2

vm_11.10 $ exit

$ scp vm_11.10:[...]/*trace.txt .

$ strace -f [...]/stm32w_flasher [...] 2>b_strace.txt 1>&2

$ ltrace -f [...]/stm32w_flasher [...] 2>b_ltrace.txt 1>&2

Note
strace & ltrace

Les outils de diagnostic strace et ltrace permettent de lister les « actions » effectuées par un processus en cours d'exécution. Dans le cas de strace, les « actions » tracées sont les appels système (c'est-à-dire les requêtes du processus vers le noyau Linux). Dans le cas de ltrace, il s'agit des appels de fonctions vers des bibliothèques partagées, principalement la libc.

Quand le problème observé au niveau application reste flou, ces informations bas niveau permettent souvent d'établir un diagnostic plus précis.

J'utilise presque toujours l'option -f, qui permet de tracer également les processus fils du processus initial (et les fils des fils, etc.). Après cette option, on tape la ligne de commandes du processus à observer. La trace est envoyée sur stderr. La combinaison 2>file 1>&2 permet ainsi de récupérer dans file à la fois la trace demandée et la sortie standard du processus.

Voilà, j'obtiens 2 fichiers de traces d'exécution valide (strace.txt et ltrace.txt) et 2 d'exécution non valide (b_strace.txt et b_ltrace.txt)3. Il ne reste plus qu'à comparer.

$ vim -d strace.txt b_strace.txt

$ vim -d ltrace.txt b_ltrace.txt

Dans les 2 cas, on obtient quasiment les mêmes lignes, à quelques exceptions près, en particulier :

- des choses générées aléatoirement comme des fichiers temporaires ;

- les numéros de PID.

Mais en parcourant les fichiers depuis le début, on voit bien les correspondances, et en faisant les remplacements adéquats dans un des 2 fichiers, on obtient une comparaison très claire via le mode diff de vim (voir figure 1)... Et on arrive vite au point de divergence entre les 2 exécutions... qui est apparemment... un signal SIGSEGV reçu par un processus fils dès qu'il démarre ! Sans signe avant-coureur évident... Tout cela ne nous aide pas tellement finalement.

Fig. 1 : Comparaison des fichiers *strace.txt via le mode diff de vim. On obtient quelque chose de très similaire avec les fichiers *ltrace.txt.

Par contre... Qu'est-ce que c'est que ces chemins de fichiers avec extension .py et .pyc (lignes 799 et 800 du fichier strace.txt par exemple) ??? On dirait... que le binaire extrait des fichiers Python dans un répertoire temporaire... sans doute pour les exécuter ensuite !!

2.6. Des outils pour empaqueter du Python

D'après Google, il existe des outils permettant d'empaqueter du code Python dans un fichier binaire exécutable. Le but est le suivant : le binaire généré intégrera un environnement minimal pour la version de Python utilisée, avec les bibliothèques partagées qu'il faut. En théorie, on pourra donc lancer ce binaire sur une machine où Python n'est pas installé, ou où la version installée n'est pas la bonne.

Donc si je résume... On imagine pouvoir améliorer la portabilité d'un logiciel alors même qu'on dissimule les sources !! On a devant nous un exemple qui prouve que c'est une drôle d'idée... Mais passons.

En tout cas, apparemment, l'outil le plus connu pour ce genre de contorsions se dénomme Freeze, il est même distribué en standard avec Python depuis un certain temps [FREEZE]. Donc il y a des chances que ce flasher ait été empaqueté de cette manière.

Si c'est le cas, peut-être qu'on pourra envisager d'extraire les sources Python et ainsi obtenir un flasher Python fonctionnel ??

2.7. Mais... c'est légal ton truc ??

Pour résumer, il y a une demi-heure, la question était « Pourquoi ce logiciel ne fonctionne pas ? ». Maintenant, c'est plutôt « Est-ce qu'on ne pourrait pas déduire de la structure interne de ce logiciel un moyen pour l'adapter à notre situation ? ». Et ça, ça s'appelle du reverse engineering, et ce n'est effectivement pas toujours légal4.

Cependant, notre but final n'a pas changé : il s'agit de résoudre un problème d'interopérabilité du logiciel. Là où d'autres pratiquent le reverse engineering en vue de contrefaçons ou dans un contexte d'espionnage industriel, notre situation paraît bien plus légitime. Et le droit français comme le droit européen nous donnent raison : ce genre de pratiques est, sous certaines conditions, autorisé en cas de problème d'interopérabilité [CLAUSES_REVERSE_ENG]. Donc nous sommes bien du bon côté de la frontière entre le Bien et le Mal... :)

Note

Ces clauses autorisent par exemple un développeur du projet LibreOffice à étudier la structure du format .doc conçu par Microsoft.

2.8. La voie du malchanceux

Si on connaît l'outil qui a permis de créer ce binaire, on trouvera sans doute un moyen pour extraire les fichiers empaquetés.

S'il s'agit effectivement de Freeze, il y a des chances qu'on trouve, dans ce binaire, des chaînes de caractères qui comportent ce mot « freeze ». En effet, quel développeur ne mentionnerait jamais le nom de son logiciel dans une trace de debug ou un nom de fonction ?

$ strings stm32w_flasher | grep -i freeze

$

Aucun résultat ?? Bon, même si la méthode est imprécise, j'ai maintenant de sérieux doutes sur le fait que ce soit bien Freeze qui ait été utilisé...

2.9. La voie du fan de LD_PRELOAD

Comme certains d'entre vous le savent déjà, j'aime beaucoup la technique consistant à modifier le comportement d'un binaire à l'exécution, en utilisant la variable d'environnement LD_PRELOAD. C'est donc la deuxième idée que j'ai eue pour extraire ce code Python.

Je ne parle pas encore couramment le log strace (ni le log ltrace), mais j'ai cru comprendre que l'exécution du binaire respecte plusieurs étapes, grosso modo :

- Extraire le code (Python principalement) dans un répertoire temporaire.

En faisant pointer la variable d'environnement LD_PRELOAD vers une bibliothèque de notre composition, on peut redéfinir des fonctions utilisées par le binaire [ARTICLE_PRELOAD]. Avec cette technique, il est donc facile de redéfinir la fonction qui supprime ce répertoire temporaire, pour qu'elle ne fasse rien dans ce cas précis. Et il ne nous restera plus qu'à cueillir le code Python !

2.10. La voie du chanceux

Mais... attendez... à la ligne 10 du fichier ltrace.txt, on voit :

[pid 5157] getenv("_MEIPASS2")                   = NULL

Ce nom de variable d'environnement _MEIPASS2 n'a pas l'air d'être généré aléatoirement, on dirait bien une constante... Peut-être que Google la connaît, cette constante ?

Bingo !! J'obtiens plusieurs pages web qui parlent d'un outil dénommé PyInstaller, un autre outil qui permet d'empaqueter du Python [PYINSTALLER]. Téléchargeons-le pour voir.

$ wget https://github.com/downloads/pyinstaller/pyinstaller/pyinstaller-1.5.1.tar.bz2

$ tar xfj pyinstaller-1.5.1.tar.bz2

$ cd pyinstaller-1.5.1/

$ ls

archive.py        Configure.py     […]    ArchiveViewer.py

[...]

$

Et re-bingo !! Cet outil ArchiveViewer.py doit, j'imagine, nous permettre d'extraire les fichiers empaquetés !

2.11. Extraction façon ArchiveViewer.py

Voyons ce que ça donne.

$ cd -

~/contiki/tools/stm32w/stm32w_flasher

$ ~/pyinstaller-1.5.1/ArchiveViewer.py ./stm32w_flasher

pos, length, uncompressed, iscompressed, type, name

[(0, 839745, 839745, 0, 'z', 'outPYZ1.pyz'),

(839745, 8250, 21160, 1, 'm', 'iu'),

[...]

(855264, 81, 76, 1, 's', 'useUnicode'),

(855345, 4663, 19323, 1, 's', 'stm32w_flasher'),

[...]

(3500830, 45135, 79476, 1, 'b', 'libz.so.1')]

? <Enter>              

U: go Up one level

O <nm>: open embedded archive nm

X <nm>: extract nm

Q: quit

? X stm32w_flasher

to filename? stm32w_flasher.py

? q

$

Petite vérification...

$ cat stm32w_flasher.py

import sys, os

import platform

[...]

$

Yes !! Et voilà un petit fichier source !

Allez hop, soyons fous, on le lance5 :

$ python2.6 stm32w_flasher.py

[...]

ImportError: No module named rs232_interface

$

Bon. Il faut aussi extraire ce module manquant.

2.12. Extraction façon ArchiveViewer.py, mais « human-assisted »

Ce module rs232_interface n'apparaît pas dans la liste donnée au départ par ArchiveViewer.py. Mais pendant que vous lisiez ce qui précède, j'ai pris un peu d'avance, et j'ai vu qu'il y avait une histoire d'archive imbriquée, qui contient elle-même des fichiers...

$ ~/pyinstaller-1.5.1/ArchiveViewer.py ./stm32w_flasher

pos, length, uncompressed, iscompressed, type, name

[(0, 839745, 839745, 0, 'z', 'outPYZ1.pyz'),

[...]

(3500830, 45135, 79476, 1, 'b', 'libz.so.1')]

? O outPYZ1.pyz

Warning: pyz is from a different Python version

Name: (ispkg, pos, len)

{'StringIO': (False, 111023L, 4605),

[...]

'rs232_interface': (False, 158473L, 17009),

[...]

'ymodem': (False, 12658L, 3377)}

? X

extract name? rs232_interface

to filename? rs232_interface.bin

? q

$ file rs232_interface.bin

rs232_interface.bin: data

$

Bon. On a bien quelque chose qui doit correspondre à notre module, par contre, à en croire le résultat de la commande file, cette fois ce n'est pas du code source... Je me suis dit que c'était peut-être le module compilé en bytecode (voir encadré). Mais apparemment, ce n'est pas le cas non plus. En effet, voilà ce que retournerait la commande file dans ce cas :

$ file test_module.pyc

test_module.pyc: python 2.6 byte-compiled

$

Note
Python et son bytecode

À l'image du Java, l'exécution d'un programme Python passe par une étape de transformation du code source dans un langage intermédiaire, le bytecode. Ainsi, si vous pratiquez un peu, vous avez sûrement remarqué que, pour chacun de vos fichiers sources <nom>.py, un fichier <nom>.pyc est automatiquement généré. Ce fichier contient le bytecode et il n'est re-généré qu'en cas de modification du fichier source correspondant. En fait, pour être précis, ces fichiers de bytecode sont générés pour tous les fichiers sources importés en tant que module ; donc, en gros, tous sauf votre fichier source « principal » (celui que vous lancez en ligne de commandes). C'est sans doute la raison pour laquelle on a pu extraire un fichier et un seul sous la forme de code source (stm32w_flasher.py).

Une fois généré, le fichier <nom>.pyc est suffisant pour décrire le module (le fichier source n'est plus nécessaire).

Le bytecode est un langage qui évolue au fur et à mesure des versions de Python. À deux reprises dans l'article, nous avons eu besoin d'un exemple de module compilé en bytecode version Python 2.6, test_module.pyc. Voici donc comment nous l'avons généré :

$ vi test.py

$ cat test.py

import test_module

test_module.f()

$ vi test_module.py

$ cat test_module.py

def f():

    print 'ok'

$ python2.6 test.py  # cette execution provoque la generation de test_module.pyc

ok

$

Donc laissez-moi regarder ça ce soir et on voit demain.

Le lendemain matin...

Bon, en fait, c'est bien du bytecode Python. La preuve :

$ python2.6

>>> import marshal

>>> f = open('rs232_interface.bin')

>>> code_obj = marshal.load(f)

>>> f.close()

>>> print code_obj

<code object <module

>>>

Le contenu de notre fichier est donc bien du bytecode Python sérialisé (le module marshal permet de lire ou écrire des objets dans ce format). C'est marrant, on voit même le chemin utilisé, sur la machine du développeur qui a écrit ce module !

Il est même possible d'exécuter le « code object » que l'on vient d'obtenir :

>>> exec code _obj

Traceback (most recent call last):

  File "<stdin>", line 1, in <module>

  File "/home/stuser/[...]/rs232_interface", line 3, in <module>

ImportError: No module named file_utils

>>>

Ce qu'on vient de faire (lecture du fichier + unmarshalling + exécution du code) est tout bêtement équivalent à l'instruction :

import rs232_interface

L'erreur affichée vient du fait que notre module en appelle apparemment un autre, file_utils, que l'on n'a pas encore extrait.

En fait, la différence entre notre fichier rs232_interface.bin et le fichier rs232_interface.pyc que l'on espérait obtenir est très minime. D'après ce que dit Ned Batchelder sur son blog [NEDBATCHELDER], les fichiers .pyc sont composés de :

- un « magic number » de 4 octets (référence la version de bytecode Python) ;

- une date de dernière modification (4 octets également) ;

- le bytecode du module sérialisé (on dit « marshalled »).

Donc, ce qu'il manque à notre fichier pour en faire un module standard compilé en bytecode, c'est juste les 8 premiers octets !

À mon avis, le plus simple est de bêtement copier les 8 premiers octets d'un autre fichier .pyc (attention, il faut du bytecode Python 2.6 pour que le « magic number » soit correct) :

$ head -c 8 test_module.pyc > header_pyc.bin

$ cat header_pyc.bin rs232_interface.bin > rs232_interface.pyc

$

Bon, il n'y a plus qu'à tester si Python peut importer ce fichier :

$ python2.6

>>> import rs232_interface

Traceback (most recent call last):

  File "<stdin>", line 1, in <module>

  File "/home/stuser/[...]/rs232_interface", line 3, in <module>

ImportError: No module named file_utils

>>>

Yes ! Si on omet le souci du module file_utils qui n'est pas encore extrait, notre fichier rs232_interface.pyc paraît bien fonctionnel !

Bon, il ne reste plus qu'à refaire la même opération pour obtenir un fichier file_utils.pyc, et ainsi de suite avec les éventuels modules encore manquants. Je vous rappelle quand c'est fait.

Quelques minutes plus tard...

2.13. Victoire !

Ça marche !!!!!!

Voilà les fichiers que j'ai au final :

$ ls

CompositeForSTM32W.bin messages.pyc rs232_interface.pyc ymodem.pyc

file_utils.pyc          prodeng.pyc   stm32w_flasher.py

$

Il y a donc :

- Le fichier source que l'on a extrait au départ (stm32w_flasher.py).

- 5 modules compilés en bytecode obtenus après extraction et recomposition (les fichiers *.pyc)6.

- Le fichier CompositeForSTM32W.bin qui est apparemment utilisé dans le code et que j'ai trouvé dans le répertoire de Contiki.

En fait, il y a 2 autres modules nécessaires, mais il ne s'agit pas de code écrit par ST ; ce sont les modules python-ftdi et python-serial, qui sont disponibles en tant que paquets sur mon Ubuntu 12.04.

Et regardez comme c'est beau :

$ python2.6 ./stm32w_flasher.py -f -r […]/hello-world.bin -p /dev/ttyACM0

INFO: STM32W flasher utility [...]

[...]

INFO: Done

$

2.14 Bilan et conclusion (temporaires...)

Nous avons donc une solution à notre problème. Mais maintenant que l'euphorie retombe, je me rends compte que notre solution a un défaut relativement important.

En effet, ce code étant en grande partie composé de bytecode Python 2.6, il ne peut tourner qu'avec cette version de Python. La preuve :

$ python2.7 stm32w_flasher.py

Traceback (most recent call last):

  File "stm32w_flasher.py", line 12, in <module>

    from rs232_interface import *

ImportError: Bad magic number in ./rs232_interface.pyc

$

L'interpréteur vérifie que sa version concorde avec la version du bytecode de chaque module, en lisant le « magic number » codé au début de chaque fichier .pyc. S'agissant de bytecode 2.6, ce test échoue avec un interpréteur en version 2.7...7

Et, la petite difficulté que je vous ai cachée, c'est que Python 2.6 n'est plus proposée par défaut sur les systèmes actuels. Sur le mien, la version par défaut est la 2.7, et j'ai installé la 2.6 à partir des sources il y a quelque temps, pour d'autres raisons. J'avais aussi installé, également à partir des sources, les deux modules Python dont dépend notre flasher, python-ftdi et python-serial. Ces modules sont pourtant bien disponibles dans les dépôts de Ubuntu 12.04, mais pour une version Python 2.7, bien sûr !

Devoir installer ces choses obsolètes sur des systèmes récents, uniquement pour pouvoir utiliser notre lot de capteurs ST, est relativement ennuyeux, et potentiellement problématique au niveau sécurité.

Donc : la situation s'est améliorée, c'est vrai, mais notre flasher a encore un problème d'interopérabilité avec les systèmes actuels...

Finalement, je dirais qu'on a bien gagné une bataille, mais on n'a pas gagné la guerre. Pour gagner la guerre, il faudrait analyser ce bytecode Python pour reconstituer les sources... Et autant dire que ce genre de choses, c'est du vrai reverse engineering, pas des petites bidouilles pour amuser la galerie ! Mais bon euh... en même temps c'est assez frustrant ce goût d'inachevé...

Allez chiche, on essaie ?

3. À la recherche d'une solution plus aboutie

3.1. Des outils pour faire le travail à notre place ?

Je vais déjà voir s'il existe des outils qui peuvent nous aider à décompiler ce bytecode, histoire de ne pas réinventer la roue.

Quelques jours plus tard...

Bon, il y a plusieurs outils, plus ou moins tous dérivés du même projet, je crois. Ce qui augmente la difficulté, c'est que le bytecode évolue en même temps que Python. Donc, par exemple, les outils pour Python 2.7 ou 2.5 ne fonctionnent pas bien avec du bytecode 2.6.

L'outil le plus probant que j'ai testé, avec du bytecode 2.6, est unpyclib [UNPYCLIB]. Mais dès que cela se complique un peu, il nous affiche des erreurs et n'arrive pas à décompiler.

Il va donc falloir mettre la main à la pâte...

3.2. Le bytecode Python : découverte

Tâchons déjà de voir à quoi ressemble ce bytecode, pour une fonction f() triviale.

$ python2.6

>>> def f(a):

...     print a+1

...

>>> f.func_code

<code object f at 0xb7841578, file "<stdin>", line 1>

>>>

En Python, tout est objet, y compris les fonctions. Et on voit ici que l'attribut func_code est un « code object », tout comme celui que nous avions obtenu pour le module rs232_interface précédemment.

On a choisi une fonction triviale, mais il n'en reste pas moins que le bytecode est une suite d'octets. De ce fait, sa lecture directe n'est pas très confortable pour un humain normal comme moi. Heureusement, il existe un module dis (fourni en standard) qui permet de le représenter de manière plus sympathique.

>>> import dis

>>> dis.disassemble(f.func_code)

  2           0 LOAD_FAST                0 (a)

              3 LOAD_CONST               1 (1)

              6 BINARY_ADD          

              7 PRINT_ITEM          

              8 PRINT_NEWLINE       

              9 LOAD_CONST               0 (None)

             12 RETURN_VALUE        

>>>

C'est relativement clair, non ? Heureusement, parce que ce format est peu documenté, à ma connaissance ! Voici ce que j'ai déduit de mes expérimentations.

La 1ère colonne renseigne le numéro de ligne dans le fichier source (ou, comme ici, dans la ligne de commandes). Ce numéro n'est indiqué que pour la première instruction de la ligne considérée. Ensuite, pour chaque instruction, on trouve son index (l'index de l'octet dans le « code object » peut-être ?), un mnémonique, éventuellement un argument (un entier), et parfois une indication entre parenthèses.

L'indication est en fait redondante avec l'argument, mais elle facilite la lecture. Par exemple, pour les instructions LOAD_CONST, l'argument est un index dans un tableau de constantes (voir ci-dessous) ; l'indication, elle, nous indique directement la constante correspondante.

>>> f.func_code.co_consts

(None, 1)

>>>

De même, pour indiquer la destination d'un saut ou la fin d'une boucle, l'argument est souvent un offset par rapport à l'instruction en cours ; l'indication nous indique alors directement l'index correspondant (en valeur absolue).

Pour revenir à notre exemple, les 2 premières instructions permettent donc de charger (mais charger où ?) la valeur de a et la constante 1, et la 3e doit, a priori, les récupérer pour faire l'addition. Avec un peu d'expérience, on devine que ce langage travaille sur une pile... Les instructions LOAD_* poussent donc des choses sur cette pile, et elles sont consommées par les instructions suivantes. Le bytecode fait ainsi partie des langages utilisant la Notation Polonaise Inversée, langages que vous avez rencontrés dans ce magazine récemment ([ARTICLE_PS], [ARTICLE_NIFE])...

Note
Que signifie FAST dans LOAD_FAST ?

Il y a 3 préfixes possibles pour les instructions LOAD_* et STORE_* ; suivant que la variable est constante, globale ou locale, le préfixe sera respectivement CONST, GLOBAL, ou... FAST. Pourquoi pas LOCAL ? Aucune idée.

Les autres mnémoniques sont, pour la plupart, assez explicites. Si vous avez d'autres doutes, sachez qu'ils sont documentés [DIS_MODULE].

On peut également remarquer qu'apparemment, en bytecode, toute fonction renvoie une valeur. Si, comme ici, cette valeur n'est pas précisée dans le code source, alors c'est None.

3.3. Petite phase de conception

Bon, ça n'a pas l'air très compliqué (oui, je sais, on dit toujours ça quand on se borne à un exemple trivial). Voyons combien de bytecodes on doit décompiler :

$ ls -l *.pyc

-rw-rw-r-- 1 etienne etienne 2903 2012-08-09 13:45 file_utils.pyc

-rw-rw-r-- 1 etienne etienne 1098 2012-08-09 13:44 messages.pyc

-rw-rw-r-- 1 etienne etienne 20422 2012-08-09 13:44 prodeng.pyc

-rw-rw-r-- 1 etienne etienne 50829 2012-08-05 14:34 rs232_interface.pyc

-rw-rw-r-- 1 etienne etienne 7684 2012-08-09 13:45 ymodem.pyc

$

Donc... à peu près 80 kilo-octets... quand même. Si on le fait à la main, ça risque de prendre longtemps ! Et on va forcément faire des erreurs...

Il faut clairement un outil pour automatiser. Par contre, je me rends compte que cette histoire m'a emmené bien loin du domaine des réseaux de capteurs ! Donc... je vais essayer de ne pas passer trop de temps sur ce sujet : dès que l'outil est suffisamment intelligent pour décompiler nos 5 modules de bytecode, on arrête et on passe à autre chose. C'est un développement test-driven, en quelque sorte, l'unique test étant la décompilation correcte de ces 5 modules ! :)

Je vois 2 voies possibles pour obtenir un tel outil :

- déboguer et améliorer unpyclib;

- écrire un autre outil from-scratch.

Paradoxalement, j'ai l'impression que je passerai moins de temps avec la 2ème solution... En effet, primo, les erreurs que j'ai vues étaient multiples, il y a donc beaucoup de choses à corriger. Secundo, sur les forums, certains utilisateurs de unpyclib se plaignaient que plusieurs choses un peu plus « avancées » n'étaient pas gérées, et j'imagine que, comme on n'est pas dans un cas trivial, on a des chances de les rencontrer. Tertio, il faut toujours un peu de temps pour maîtriser le code d'un autre... Quarto, j'ai quand même survolé le code de unpyclib, pour voir... et si j'étais l'auteur de unpyclib, je n'aurais pas du tout implémenté la chose de cette façon !!

Voilà quelques explications pour le dernier point. Dans le cas de unpyclib, on lit et on décompile tout le bytecode du fichier .pyc. J'imagine qu'il est beaucoup plus efficace de procéder plutôt comme ceci :

- importer le module correspondant à ce fichier.pyc ;

- explorer les objets créés par ce module (classes, fonctions, variables globales) en utilisant les capacités d'introspection de Python [INTROSPECTION].

Au final, on aura beaucoup moins de bytecode à décompiler : il faudra bien décompiler les fonctions, et les méthodes de chaque classe, mais pas la glue qui permet de les créer.

Donc, j'opte pour l'outil from-scratch. Je vais l'appeler pyc2py.

Quelques semaines plus tard...

3.4. Une solution aux petits oignons !

Ça y est !!!

Oublions un peu la technique (voir la partie 4), je vous montre déjà le résultat ; regardez le travail !

$ python2.6 [...]/pyc2py/src/main.py ./rs232_interface.pyc > rs232_interface.py

[… même chose pour les autres fichiers .pyc ... ]

$

Avec pyc2py, on arrive donc à décompiler sans erreur notre petit lot de fichiers de bytecode... :)

Et donc... Il ne reste plus qu'à essayer de flasher une carte en utilisant le code source obtenu. Par contre... Si notre code source ne fait pas exactement ce qui est prévu, ne risque-t-on pas de « briquer » la carte ?

Bon, de toute façon, on ne va pas s'arrêter si près du but... La chance sourit aux audacieux, dit-on.

$ python2.6 ./stm32w_flasher.py -f -r […]/hello-world.bin -p /dev/ttyACM0

[...]

INFO: Done

$

Ouf. Pas de souci apparemment.

Bon. Eh bien c'est impeccable tout ça ! Il ne reste plus qu'à tester en python2.7, mais normalement ce n'est qu'une formalité8 :

$ python2.7 ./stm32w_flasher.py [...]

[...]

INFO: Done

$

Yes !! Mission accomplie ! :)

4. Parlez-vous bytecode ?

Pour terminer, on va se mettre 10 minutes dans la peau de pyc2py pour entrevoir comment tout ceci fonctionne...

4.1. L'introspection pour les nuls

L'introspection, c'est assez simple, en tout cas pour l'utilisateur. C'est d'ailleurs pour cela que j'ai privilégié cette voie dans l'implémentation de pyc2py. Voici en gros ce que vous feriez si vous étiez à la place de pyc2py et que vous deviez décompiler le module messages du flasher.

$ python2.6

>>> import messages

>>> dir(messages)

['__builtins__', '__doc__', '__file__', '__name__', '__package__', 'errorMessage', 'infoMessage', 'sys', 'warningMessage']

>>> messages.infoMessage

<function infoMessage at 0xb73ebd4c>

>>> messages.errorMessage

<function errorMessage at 0xb73ee9cc>

>>> messages.warningMessage

<function warningMessage at 0xb73fcb54>

>>>

Voilà, le module messages du flasher comporte donc 3 fonctions. Facile, non ? Ah tiens, c'est quoi sys ?

>>> messages.sys

<module 'sys' (built-in)>

>>>

Ah ok, cela nous indique qu'il faudra faire un import sys au début du fichier source généré.

Il y a aussi des fonctionnalités d'introspection un peu plus avancées, accessibles en important le module inspect.

>>> import inspect

>>> f_args = inspect.getargspec(messages.infoMessage)

>>> inspect.formatargspec(f_args.args, f_args.varargs, f_args.keywords, f_args.defaults)

'(msg, header=True)'

>>>

Et voilà, une petite concaténation plus tard, on obtient le prototype complet de cette fonction :

def infoMessage(msg, header=True):

...

Bon, là, il faut que je vous avoue un truc. Ne croyez pas que je sois inspiré au point d'imaginer des lignes comme celle du formatargspec ! En fait, j'ai trouvé une antisèche bien pratique. Regardez :

>>> help(messages)

Help on module messages:

NAME

    messages

FILE

    [...]/messages.pyc

FUNCTIONS

    errorMessage(msg, header=True)

    infoMessage(msg, header=True)

    warningMessage(msg, header=True)

(END)

S'il y a une commande qui utilise beaucoup l'introspection, c'est bien la commande help() ! Et elle fait à peu près tout ce qu'on a besoin de faire, c'est-à-dire explorer les fonctions, les classes, les variables globales déclarées par le module !

Cette commande help() est principalement implémentée dans le fichier /usr/lib/python2.6/pydoc.py. En s'inspirant de ce fichier, écrire la partie « introspection » de pyc2py était clairement de la rigolade.

4.2. La décompilation pour les nuls

Décompiler du bytecode Python, c'est simple également, enfin... la plupart du temps. Je vous propose quelques devinettes.

4.2.1. Les sauts conditionnels

Devinette A : Quel est le code source de la fonction f() dont le bytecode est le suivant ?

  2           0 LOAD_FAST                0 (c)

              3 JUMP_IF_FALSE            9 (to 15)

              6 POP_TOP

  3           7 LOAD_CONST               1 ('c true')

             10 PRINT_ITEM          

             11 PRINT_NEWLINE       

             12 JUMP_FORWARD             6 (to 21)

        >>   15 POP_TOP             

  5          16 LOAD_CONST               2 ('c false')

             19 PRINT_ITEM          

             20 PRINT_NEWLINE       

        >>   21 LOAD_CONST               0 (None)

             24 RETURN_VALUE        

Petite note pour aider la compréhension : en Python 2.6, les sauts conditionnels JUMP_IF_TRUE et JUMP_IF_FALSE laissent la condition sur la pile. De ce fait, on voit par la suite des instructions POP_TOP (instruction qui supprime l'élément en haut de la pile).

On a un saut conditionnel sur la deuxième ligne, qui dépend d'une condition c (1ère ligne). Ensuite, on a 2 branches qui se rejoignent à la fin... Le code source était ainsi tout bêtement :

def f(c):

    if c:

        print 'c true'

    else:

        print 'c false'

Tout cela reste simple... La difficulté intervient quand plusieurs if sont imbriqués, avec ou sans clause else, parfois même avec un not sur la condition, ce qui inverse les sauts. La difficulté suivante est alors de repérer la fin de la construction dans le bytecode ; souvent, il s'agit du « moment où les 2 branches se rejoignent », comme ici. Mais qu'en est-il alors quand une des branches se termine inopinément par un break, un continue, ou un return ? Comment détecter la fin de la construction dans ce cas ?... Au final, c'est une des parties les plus complexes de la décompilation !

Voilà la devinette B... On reste dans les sauts conditionnels :

  2           0 LOAD_FAST                0 (c1)

              3 JUMP_IF_TRUE             7 (to 13)

              6 POP_TOP             

              7 LOAD_FAST                1 (c2)

             10 JUMP_IF_FALSE            9 (to 22)

        >>   13 POP_TOP             

  3          14 LOAD_CONST               1 ('ok')

             17 PRINT_ITEM          

             18 PRINT_NEWLINE       

             19 JUMP_FORWARD             1 (to 23)

        >>   22 POP_TOP             

        >>   23 LOAD_CONST               0 (None)

             26 RETURN_VALUE        

À voir les deux sauts conditionnels qui se suivent, on peut penser à 2 constructions if imbriquées. En réalité, il n'en est rien ! Regardons plus en détail. En fait, on suit l'algorithme décrit par la figure 2.

Fig. 2 : Devinette B : Algorithme décrit par le bytecode

Sur ce schéma, on voit bien que si c1 ou c2 est vraie, on exécute la même clause. Dans le cas contraire (les 2 sont fausses), on ne fait rien. Mine de rien, avec ces 2 phrases, je viens quasiment de vous décrire le code source initial ! Le voici :

def f(c1, c2):

    if c1 or c2:

        print 'ok'

Eh oui, tout est dans le or ! Comme dans la plupart des langages de programmation, on n'exécute pas la partie droite de la condition (ici c2) si la partie gauche est vraie (ici c1). En effet, si c'est le cas, on connaît déjà le résultat du or... De ce fait, le or n'apparaît pas directement dans le bytecode et, par contre, on voit apparaître ces 2 sauts conditionnels (alors qu'on a un seul if dans le code source !).

La gestion des and est similaire.

Notre décompilateur reconnaît bien évidemment ces opérations booléennes et leurs combinaisons. Si vous n'êtes pas convaincu de l'utilité d'un décompilateur automatique, regardez le bytecode généré par une condition comme (a and b) or (c and d and (not e))...

Pour terminer avec les sauts conditionnels, voici le bytecode de la devinette C, qui en introduit un dernier usage.

  2           0 SETUP_LOOP              33 (to 36)

        >>    3 LOAD_FAST                0 (n)

              6 LOAD_CONST               1 (0)

              9 COMPARE_OP               4 (>)

             12 JUMP_IF_FALSE           19 (to 34)

             15 POP_TOP             

  3          16 LOAD_FAST                0 (n)

             19 PRINT_ITEM          

             20 PRINT_NEWLINE       

  4          21 LOAD_FAST                0 (n)

             24 LOAD_CONST               2 (1)

             27 INPLACE_SUBTRACT    

             28 STORE_FAST               0 (n)

             31 JUMP_ABSOLUTE            3

        >>   34 POP_TOP             

             35 POP_BLOCK           

        >>   36 LOAD_CONST               0 (None)

             39 RETURN_VALUE

L'instruction SETUP_LOOP met la puce à l'oreille... Il s'agit d'une boucle. On peut ainsi remarquer l'instruction 31, JUMP_ABSOLUTE, qui permet de re-boucler au début. Et comme on a un saut conditionnel, on se dit que c'est sans doute un while... Et on a tout bon, voici le code initial :

def f(n):

    while n > 0:

        print n

        n -= 1

Comme vous le voyez, les sauts conditionnels sont utilisés dans différents cas de figure. Une des difficultés était donc de reconnaître ces différents usages, parfois dans des combinaisons relativement complexes (par exemple : while (c1 or (c2 and c3))).

4.2.2 Autres constructions

Voilà le bytecode de la devinette D...

  2           0 SETUP_LOOP              25 (to 28)

              3 LOAD_GLOBAL              0 (range)

              6 LOAD_FAST                0 (a)

              9 CALL_FUNCTION            1

             12 GET_ITER            

        >>   13 FOR_ITER                11 (to 27)

             16 STORE_FAST               1 (i)

  3          19 LOAD_FAST                1 (i)

             22 PRINT_ITEM          

             23 PRINT_NEWLINE       

             24 JUMP_ABSOLUTE           13

        >>   27 POP_BLOCK           

        >>   28 LOAD_CONST               0 (None)

             31 RETURN_VALUE        

Celle-ci est facile... On a le SETUP_LOOP à nouveau, mais vous vous doutez bien que je ne vais pas vous remettre un while. Et avec le FOR_ITER un peu plus loin, il n'y a plus guère de suspense. Voici donc le code :

def f(a):

    for i in range(a):

        print i

Remarquez également l'instruction CALL_FUNCTION qui nous permet de construire l'expression range(a). En pratique, on la rencontre très souvent, puisqu'elle sert également à appeler les méthodes des objets.

Pour la devinette E, j'ai juste introduit une petite variation dans le code de la devinette D. Voici le bytecode obtenu :

  2           0 BUILD_LIST               0

              3 DUP_TOP             

              4 STORE_FAST               1 (_[1])

              7 LOAD_GLOBAL              0 (range)

             10 LOAD_FAST                0 (a)

             13 CALL_FUNCTION            1

             16 GET_ITER            

        >>   17 FOR_ITER                13 (to 33)

             20 STORE_FAST               2 (i)

             23 LOAD_FAST                1 (_[1])

             26 LOAD_FAST                2 (i)

             29 LIST_APPEND         

             30 JUMP_ABSOLUTE           17

        >>   33 DELETE_FAST              1 (_[1])

             36 PRINT_ITEM          

             37 PRINT_NEWLINE       

             38 LOAD_CONST               0 (None)

             41 RETURN_VALUE

On n'a plus le SETUP_LOOP, mais on a toujours le FOR_ITER ! Étonnant, non ? Voici le code source :

def f(a):

    print [ i for i in range(a) ]

C'est ce qu'on appelle une list comprehension, en Python...

Et voici le résultat d'une autre variation très similaire (devinette F).

  2           0 LOAD_GLOBAL              0 (list)

              3 LOAD_CONST               1 (<code object <genexpr> at 0xb76b9f50, file "<stdin>", line 2>)

              6 MAKE_FUNCTION            0

              9 LOAD_GLOBAL              1 (range)

             12 LOAD_FAST                0 (a)

             15 CALL_FUNCTION            1

             18 GET_ITER            

             19 CALL_FUNCTION            1

             22 CALL_FUNCTION            1

             25 PRINT_ITEM          

             26 PRINT_NEWLINE       

             27 LOAD_CONST               0 (None)

             30 RETURN_VALUE        

Là, le bytecode fait carrément référence à un « code object » interne !! Pourtant, comme je vous disais, je n'ai pas changé grand chose dans le code source :

def f(a):

    print list(i for i in range(a))

Cette fois, il s'agit d'une generator expression.

Il n'était pas nécessaire que pyc2py soit capable de décompiler toutes ces écritures quelque peu exotiques. J'ai donc travaillé uniquement celles que j'ai rencontrées dans le code du flasher. Manque de chance, il y avait des generator expressions ! Donc, en quelque sorte, je l'ai rencontré cette devinette F... Et je ne vous cache pas qu'elle m'a occupé un certain moment. Et quand j'ai compris de quoi il s'agissait, il a fallu se résoudre à gérer ce cas, analyser de manière récursive le bytecode du « code object » interne, etc. Un grand moment. ;)

Une autre construction employée dans le flasher était le bien connu try...except...[finally...]. Je ne vais pas le détailler ici car sa gestion est assez similaire à la gestion du if, en plus simple.

4.2.3. Des petites subtilités à gérer

Bien évidemment, la gestion des différentes structures constitue le gros du travail pour un décompilateur comme pyc2py. Néanmoins, on tombe assez rapidement sur d'autres petites subtilités qui obligent, elles aussi, à échauffer quelques neurones. En voici un exemple.

Souvent, à la lecture du bytecode, détecter les bornes d'une ligne de code source n'est pas trivial. Pour certaines instructions, comme les STORE_* (instruction d'affectation à une variable) ou encore l'instruction RETURN_VALUE, on sait qu'on va terminer la ligne de code source. À l'inverse, parfois, on sait que l'on doit lire d'autres instructions pour compléter cette ligne. Et enfin, cas le plus problématique, parfois on ne sait pas si la ligne de code source est complète ou partielle.

Prenons un exemple :

>>> def pretty_print(arg):

...     print 'result =', arg

...

>>> def my_program():

...     pretty_print(compute())

...

>>> dis.disassemble(my_program.func_code)

  2           0 LOAD_GLOBAL              0 (pretty_print)

              3 LOAD_GLOBAL              1 (compute)

              6 CALL_FUNCTION            0

              9 CALL_FUNCTION            1

             12 POP_TOP             

             13 LOAD_CONST               0 (None)

             16 RETURN_VALUE        

>>>

Ici, en lisant le 1er appel à l'instruction CALL_FUNCTION, on pourrait imaginer qu'il s'agit de la fin de la ligne de code source (cette ligne serait donc un simple appel de procédure compute()). Ce n'est pas le cas. En réalité, la ligne de code source s'arrête après le 2ème appel CALL_FUNCTION. Pour les instructions de ce genre, il faut donc attendre la suite pour valider la bonne hypothèse, ce qui complexifie un peu l'analyse.

Allez, une dernière devinette pour la forme (devinette G) : pourquoi y a-t-il une instruction POP_TOP à l'indice 12 ?

D'après notre code source, la fonction pretty_print() ne renvoie pas de valeur... Mais rappelez-vous, en bytecode, ce genre de fonction renvoie None ! C'est donc ce None qu'il faut supprimer pour nettoyer la pile...

Vous me direz que ce n'est pas très utile puisque, juste après, on remet None sur la pile pour la valeur de retour de my_program() ! Apparemment, le bytecode n'est pas trop optimisé à ce sujet. Cela dit, pour nous c'est une bonne chose, parce qu'un code non optimisé est infiniment plus facile à analyser pour un décompilateur.

4.3. Imperfections...

Tous ces glorieux détails d'implémentation cachent bien évidemment quelques imperfections.

En particulier, du fait que je voulais juste décompiler un unique programme, je n'ai pas cherché à être exhaustif en écrivant pyc2py. Il est donc possible que vous rencontriez des limitations en essayant de décompiler un autre programme. J'espère cependant avoir couvert une grande majorité des cas (en Python 2.6 en tout cas) en décompilant 80 kilo-octets de bytecode...

Par ailleurs, le code généré peut paraître verbeux dans certains cas. Par exemple, toutes les opérations binaires sont parenthésées (pour éviter les problèmes de priorité d'opérateurs), et on peut rencontrer des directives inutiles (par exemple un return None à la fin d'une procédure, ou un continue à la fin d'une boucle for). Les défauts de ce genre ne sont pas si difficiles à corriger, mais je les ai considérés comme négligeables dans le sens où le code généré reste exact.

4.4. Le futur...

Mon activité autour de pyc2py étant très en marge de mes activités habituelles, je ne peux pas passer beaucoup plus de temps autour de cet outil. En revanche, si quelqu'un est intéressé par ce sujet, je me ferai un plaisir de lui présenter les choses un peu plus en détail.

Bilan et Conclusion

Nous voilà au terme de cette petite aventure. Et finalement, on dispose maintenant d'une solution bien finalisée... Pourtant, rappelez-vous, on était en plein brouillard au départ ! Heureusement, ce n'était pas aussi compliqué que l'on pouvait le craindre. À mon avis, c'est souvent le cas. Le risque, face à un problème qui paraît compliqué, est donc d'abandonner avant même d'avoir commencé à explorer un peu...

Au final, la résolution complète de ce problème m'a pris à peu près l'équivalent d'un mois à temps plein (je partage mon temps entre 2 équipes de recherche) ; ce n'est pas négligeable, mais de toute façon, il nous fallait une solution, et je pense avoir développé de nouvelles compétences. Donc peut-être que je ferai plus vite la prochaine fois. ;)

Message à Jean-Baptiste & Tristan : merci pour la relecture !

Note
Épilogue

Notre principal interlocuteur chez ST Microelectronics m'a autorisé à publier un patch pour le dépôt officiel de Contiki, afin que chacun puisse utiliser le code Python obtenu, en lieu et place du binaire défaillant. Ce patch a été intégré récemment [COMMIT_FLASHER].

Ce code Python pourra donc évoluer, peut-être avec de nouvelles fonctionnalités, une compatibilité Python 3, etc. D'autre part, on va pouvoir utiliser ce code dans des contextes intéressants. En particulier, on essaie actuellement de monter un projet de testbed pour réseaux de capteurs ; les nœuds du testbed seront des petites cartes raspberry pi, déployées dans le bâtiment, sur lesquelles seront connectés un ou plusieurs capteurs via USB. J'ai vérifié que le flasher tournait correctement sur le raspberry pi, et qu'ensuite on pouvait communiquer avec le capteur sur la liaison série-USB. On peut donc instrumenter tous nos capteurs à distance, en utilisant les raspberry pi comme passerelle bon marché.

Liens

[ARTICLE_STM32] Friedt Jean-Michel, Goavec-Merou Gwenhaël, « Le microcontrôleur STM32 : un coeur ARM Cortex-M3 », GNU/Linux Magazine 148 (avril 2012)

[CONTIKI] http://www.contiki-os.org/

[DIFFCONTIKI] https://lists.sourceforge.net/lists/listinfo/contiki-developers

[ARMLITE] http://www.codesourcery.com/sgpp/lite/arm/portal/package8736/public/arm-none-eabi/arm-2011.03-42-arm-none-eabi.bin

[FREEZE] http://wiki.python.org/moin/Freeze

[ARTICLE_PRELOAD] Étienne Dublé, « Le monde merveilleux de LD_PRELOAD », Open Silicium 4 (fin 2011)

[PYINSTALLER] http://www.pyinstaller.org

[NEDBATCHELDER] http://nedbatchelder.com/blog/200804/the_structure_of_pyc_files.html

[ARTICLE_PS] Friedt Jean-Michel, « Introduction au langage Postscript », GNU/Linux Magazine 152 (sept. 2012) – La partie 1 est une bonne introduction à la notation polonaise inversée.

[ARTICLE_NIFE] Patrick Foubet, « Nife : du Forth pour l'embarqué », GNU/Linux Magazine 149 (juin 2012)

[INTROSPECTION] http://fr.wikipedia.org/wiki/R%C3%A9flexivit%C3%A9_%28informatique%29#Introspection_et_intercession

[DIS_MODULE] http://docs.python.org/release/2.6.8/library/dis.html

[UNPYCLIB] http://pypi.python.org/pypi/unpyclib/

[PYC2PY] https://github.com/eduble/pyc2py

[CLAUSES_REVERSE_ENG]

Droit français : http://www.legifrance.gouv.fr/affichCodeArticle.do?cidTexte=LEGITEXT000006069414&idArticle=LEGIARTI000006278920 – Voir partie IV.

Droit européen : http://eur-lex.europa.eu/LexUriServ/LexUriServ.do?uri=OJ:L:2009:111:0016:01:EN:HTML – Voir parties 13, 14, 15.

[COMMIT_FLASHER] https://github.com/contiki-os/contiki/commit/7905316c541baba2edaf095656f506e4d9a45cd6