2010
12.13

Dans le cadre d'un des projets sur lesquels je travaille, je dois récupérer les données de localisation d'un programme depuis des DLL de ressources. C'est une méthode assez usuelle sous Windows pour gérer la localisation d'un projet : toutes les chaines sont référencées dans le code par un ID, et elles sont stockées dans une DLL à part qui peut être différente selon la langue. C'est un peu ce qui est fait sous Linux avec gettext, mais en moins complexe et légèrement plus intégré.

Sous Windows, tous les fichiers exécutables (EXE, DLL, OCX, SYS, etc.) sont stockés au format PE (Portable Executable). Comme avec ELF sous Linux, ce format est composé de différentes sections. Celle qui va nous intéresser dans cet article est la section .rsrc, qui contient toutes les ressources contenues dans un exécutable. Une ressource, ça peut être tout et n'importe quoi. Windows en a quelques types prédéfinis qu'il sait gérer (icone, description d'une interface graphique, string table) mais il y a un type passe partout, BINARY, qui permet de stocker ce que l'on veut.

Notre but est ici d'accèder à une ressource de type String Table dans une DLL, afin dans un premier temps d'afficher toutes les chaines, puis d'en récupérer une à partir de son ID.

Dans un premier temps, on va créer une DLL contenant une string table avec MinGW pour pouvoir tester avec des données que l'on connait.

$ cat > foo.rc <<EOF
#define IDS_HELLO 1
#define IDS_WORLD 2

STRINGTABLE
{
IDS_HELLO, "Hello"
IDS_WORLD, "World"
}
EOF
$ mingw32-windres foo.rc -o foo.o
$ mingw32-gcc -shared -o foo.dll foo.o
$ strings -e l foo.dll
Hello
World

La prochaine étape, c'est de lire la documentation du format PE qui se trouve sur le site de Microsoft. En vrai, ça n'est pas important car je vais détailler les choses ici. Commençons donc le parsing de notre PE.

À la recherche de .rsrc

On va commencer par lire notre fichier DLL. En entier car je suis un bourrin et car je n'ai pas envie de me faire chier pour cet article, mais on pourrait très bien être plus fin.

>>> dll = open('foo.dll', 'rb').read()

Un exécutable au format PE commence par du code compilé qui affiche « This program cannot be run in DOS mode. », ceci pour la compatiblité MS-DOS. À l'offset 0x3C se trouve l'offset du header PE.

>>> import struct
>>> (pe_header_offset,) = struct.unpack("B", dll[0x3C:0x3C+1])
>>> hex(pe_header_offset)
'0x80'

À l'adresse que l'on vient de lire se trouvent 4 octets magiques permettant de s'assurer que l'on est bien en présence d'un fichier PE :

>>> data = dll[pe_header_offset:pe_header_offset+4]
>>> (pe_magic_dword,) = struct.unpack("4s", data)
>>> pe_magic_dword
b'PE\x00\x00'

Ensuite se trouve le header PE, mesurant 20 octets, et qui ne contient pas énormément de choses qui nous intéressent ici. Il est suivi d'un header « optionnel » (qui est présent dans 99.9% des fichiers PE vu qu'il est obligatoire sous Windows), dont la taille est variable en fonction de son type. Sa taille est indiquée dans un des champs du header PE : SizeOfOptionalHeader, de 2 octets, à l'offset 16.

>>> data = dll[pe_header_offset+4+16:pe_header_offset+4+18]
>>> (opt_hdr_size,) = struct.unpack("H", data)

Après le header PE et le header optionnel se trouve la table des sections. Il va falloir la lire intégralement puis la parcourir afin de trouver la section .rsrc, contenant les ressources inclues dans un fichier PE.

>>> def read_section(idx):
...     offset = pe_header_offset + 4 + 20 + opt_hdr_size
...     sect_offset = idx * 40
...     real_off = offset + sect_offset
...     data = dll[real_off:real_off+40]
...     tup = struct.unpack('8sIIII', data[:24])
...     return SectionHeader(*tup)
...
>>> read_section(0)
SectionHeader(name=b'.text\x00\x00\x00', virtual_size=2820,
              virtual_address=4096, size_of_data=3072,
              pointer_to_data=1024)
>>> read_section(1)
SectionHeader(name=b'.data\x00\x00\x00', virtual_size=8,
              virtual_address=8192, size_of_data=512,
              pointer_to_data=4096)
>>> read_section(2)
SectionHeader(name=b'.rdata\x00\x00', virtual_size=272,
              virtual_address=12288, size_of_data=512,
              pointer_to_data=4608)
>>> read_section(3)
SectionHeader(name=b'.bss\x00\x00\x00\x00', virtual_size=104,
              virtual_address=16384, size_of_data=0,
              pointer_to_data=0)

Le nombre total de sections est inclus dans le header PE, à l'offset 2, et mesure deux octets :

>>> data = dll[pe_header_offset+4+2:pe_header_offset+4+4]
>>> (num_of_sections,) = struct.unpack('H', data)

Lisons donc toutes les sections, ou plutôt tous les headers de sections :

>>> sect_headers = [read_section(i) for i in range(num_of_sections)]

On peut donc maintenant facilement trouver la section que l'on souhaite : .rsrc :

>>> for sh in sect_headers:
...     if sh.name.startswith(b'.rsrc'):
...             rsrc = sh
...
>>> rsrc
SectionHeader(name=b'.rsrc\x00\x00\x00', virtual_size=140,
              virtual_address=36864, size_of_data=512,
              pointer_to_data=7680)

Et on va maintenant lire tout son contenu, qui va de la position pointer_to_data à pointer_to_data + size_of_data.

>>> start_addr = rsrc.pointer_to_data
>>> end_addr = rsrc.pointer_to_data + rsrc.size_of_data
>>> rsrc_data = dll[start_addr:end_addr]

À l'intérieur de la section

La section .rsrc contient principalement une structure hiérarchique (un arbre quoi) qui permet d'y trouver facilement ce que l'on y cherche. Pour citer la documentation (en traduisant), « Le design général supporte 2**31 niveaux de hiérarchie. En pratique, Windows en utilise 3. ». Ces trois niveaux sont le type de ressource, son ID et la langue de la ressource. Pour les chaines contenues dans une string table, le type est la constante RT_STRING (RT = Resource Type), définie dans winuser.h comme valant 6. L'ID (deuxième niveau) est l'ID de la chaine recherchée, divisée par 16, plus un (string_id / 16 + 1). Enfin, la langue par défaut est Anglais US, dont l'ID peut être trouvé dans cette liste : 1033.

En feuille de l'arbre se trouve un bloc de 16 chaines précédées de leur taille sur 2 octets. Les chaines ne contiennent pas de terminateur nul (\0), on a de toute façon leur taille. Maintenant, parsing time ! On commence par définir les quelques structures qui vont nous servir :

>>> TreeNode = namedtuple(
...     'TreeNode',
...     'characteristics timestamp ver_major ver_minor '
...     'name_entries id_entries'
... )
>>> TreeEntry = namedtuple(
...     'TreeEntry',
...     'label address'
... )
>>> TreeLeaf = namedtuple(
...     'TreeLeaf',
...     'address size'
... )

Quelques fonctions pour parser ces noeuds :

>>> def read_node(offset):
...     data = rsrc_data[offset:offset+16]
...     return TreeNode(*struct.unpack('IIHHHH', data))
...
>>> def read_entry(offset):
...     data = rsrc_data[offset:offset + 8]
...     return TreeEntry(*struct.unpack('II', data))
... 
>>> def read_leaf(offset):
...     data = rsrc_data[offset:offset+8]
...     return TreeLeaf(*struct.unpack('II', data))
... 

Et enfin, des fonctions de plus haut niveau, qui lisent respectivement une feuille de l'arbre et son arborescence :

>>> def read_data(addr):
...     leaf = read_leaf(addr)
...     data_off = leaf.address - rsrc.virtual_address
...     return rsrc_data[data_off:data_off+leaf.size]
... 
>>> def read_tree(offset):
...     node = read_node(offset)
...     child_off = offset + 16
...     
...     # Skip the named childs
...     child_off += 8 * node.name_entries
...     
...     childs = {}
...     for i in range(node.id_entries):
...         entry = read_entry(child_off)
...         child_off += 8
...         
...         # If the MSB is 1, it is an internal node
...         if entry.address & 0x80000000:
...             child = read_tree(entry.address & ~0x80000000)
...         else:
...             child = read_data(entry.address)
...         childs[entry.label] = child
...     return childs

On confirme le bon fonctionnement sur notre DLL de test :

>>> read_tree(0)
{6: {1: {1033: b'...'}}}

Il reste donc les données en feuille de l'arbre à parser. C'est en fait très simple, comme dit plus haut : 16 chaines UTF-16 préfixées par leur taille.

>>> def read_table_chunk(chunk):
...     li = []
...     while chunk:
...         (size,) = struct.unpack('H', chunk[:2])
...         chunk = chunk[2:]
...         string = chunk[:2*size]
...         li.append(string.decode('utf-16'))
...         chunk = chunk[2*size:]
...     return li
... 
>>> read_table_chunk(read_tree(0)[6][1][1033])
['', 'Hello', 'World', '', '', '', '', '', '', '', '', '', '', '', '', '']

Avec tout ça c'est presque fini, plus qu'à faire une fonction qui récupère une string depuis son ID. Trivial !

>>> def resource_string(id):
...     tree = read_tree(0)
...     chunk_id = id // 16 + 1
...     if 6 not in tree:
...         raise ValueError("no string table found")
...     if chunk_id not in tree[6]:
...         raise IndexError("string chunk not found")
...     if 1033 not in tree[6][chunk_id]:
...         raise ValueError("wrong language for the string table")
...     chunk = read_table_chunk(tree[6][chunk_id][1033])
...     return chunk[id % 16]
... 
>>> resource_string(1)
'Hello'
>>> resource_string(2)
'World'

Et voilà ! Tout cela est bien entendu améliorable pour tenir en compte de pas mal de choses (les différentes langues par exemple), mais les bases sont là.

No Comment.

Add Your Comment
*