2011
01.04

Voici donc la troisième et dernière partie de cette série d'articles sur la lecture de disques Wii en Python. Maintenant que tout le travail de documentation des différentes structures et des différents formats dont on a besoin a été fait, la dernière étape est de faire quelque chose de propre et d'utilisable à partir de tout ça. En effet, des bouts de code à balancer dans un shell Python c'est loin d'être propre, même si c'est bien pour expérimenter rapidement.

Le résultat de tout ça s'appelle wiiodfs, pour Wii Optical Disc FileSystem. Il s'agit d'une application capable de monter une image de disque Wii sous Linux, mais également d'une bibliothèque pouvant être utilisée dans d'autres programmes qui auraient besoin d'accèder à un disque de Wii. Il reste encore un peu de travail à faire au niveau du packaging mais 99% du code utile est là, utilisable et fonctionnel.

La première grande différence avec ce qui a été fait lors des deux articles précédents est qu'on ne peut pas se permettre de dumper toute la partition dans un fichier temporaire, ou dumper tous les fichiers dans un dossier. C'est sale, lent et ça a peu d'intérêt. Dans wiiodfs, toute la partie déchiffrement et accès aux fichiers est faite à la volée lorsqu'on en a besoin. Toute l'architecture de la bibliothèque est donc basée sur ce besoin.

wiiodfs est séparé en 4 couches de différents niveaux. La couche de niveau n peut utiliser toutes les couches de niveau inférieur. Voici ces différentes couches expliquées en détail :

  • L'accès brut à l'image disque, aux métadonnées et à la table des partitions/volume groups. Très simple, pas de déchiffrement, pas de gestion du filesystem, uniquement de quoi lire l'image d'un point A à un point B et de quoi récupérer les informations sur les partitions du disque.
  • L'accès brut aux données d'une partition déchiffrée. C'est surement la partie la plus intéressante donc je reviendrai dessus plus tard, mais elle va en gros lire les données brutes sur la partition, les déchiffrer et retourner ces données à l'utilisateur.
  • L'accès aux fichiers sur la partition avec une API facile à utiliser : on utilise Filesystem.open qui nous renvoie un objet qui se comporte comme un fichier Python, sur lequel on peut appeler les méthodes read ou seek par exemple. On peut également lister les dossiers, récupérer la taille d'un fichier, vérifier si un fichier existe, etc.
  • Enfin, une interface permettant d'utiliser le filesystem via PyFS, une bibliothèque dont le but est de définir une interface commune pour différentes classes de gestion des systèmes de fichier afin de pouvoir les utiliser uniformément.

Comme dit juste au dessus, je pense que la partie sur laquelle il faut le plus s'attarder est la deuxième couche, celle qui gère le déchiffrement des partitions. En effet, déchiffrer des blocs de 0x8000 octets n'est pas spécialement rapide et il faut donc un moyen de garder en cache les clusters déchiffrés pour ne pas déchiffrer 20x le même cluster à la suite. Pour cela, j'ai réimplémenté un cache de type LRU. Les caches LRU sont les caches les plus simples, qui gardent un certain nombre de valeurs dans leur ordre de dernière utilisation. Ainsi, toutes les valeurs les plus récemment utilisées sont dans le cache, et les autres sont éliminées du cache au fur et à mesure qu'elles ne sont plus utilisées. On pourrait surement faire beaucoup plus intelligent mais ça n'était pas mon but ici (et de toute façon c'est déjà bien rapide).

wiiodfs fournit également un script nommé wiiodmount qui permet donc de monter une image disque de Wii sur un dossier. Pour cela, le script utilise la 4ème couche ainsi qu'une fonctionnalité de PyFS qui permet d'exporter un filesystem conforme à l'API de PyFS via FUSE. C'est magique et ça marche plutôt très bien lors de mes tests.

Pour conclure cette série d'articles, je vais simplement dire que c'était une aventure très intéressante pour moi : je connaissais très peu la Wii il y a une semaine et l'implémentation de wiiodfs m'a permi de comprendre un certain nombre de notions utilisées par la console de Nintendo, par exemple tout ce qui concerne le chiffrement des données. wiiodfs est surement loin d'être fini ou même utilisable pour tout le monde (il faudrait que je prenne le temps de faire le packaging par exemple) mais c'est une implémentation simple et efficace d'un filesystem assez peu documenté, dont j'espère qu'elle sera utile à des gens 🙂

Finissons par des liens et des remerciements :

  • Wiiodfs, dépot Mercurial du projet.
  • Wiibrew, une de mes principales sources de documentation.
  • Dolphin, un émulateur GC/Wii assez génial et libre.

Pendant les prochains jours j'essaierai de prendre le temps d'écrire de nouveaux articles sur les formats de fichier que j'ai trouvé dans le FS de Tales of Symphonia : Dawn of the New World. Des trucs comme les textures, les modèles, les sons, les musiques, etc. Stay tuned!

2011
01.02

Comme promis, cette deuxième partie va parler du système de fichiers utilisé sur les DVD de jeux de Wii, et plus précisément sur leur partition de données. La partie 1 de cette série de 3 articles présentait comment accèder à cette partition de données à partir d'un DVD et comment la déchiffrer. Pour cet article nous allons supposer que la partition de données a été déchiffrée et dumpée afin de se simplifier la tâche, mais ça n'est pas nécessaire (on peut déchiffer à la volée). Commençons par de la théorie 🙂

Lorsque la Wii charge un jeu sur DVD, elle commence par charger un bootloader très léger qui se trouve à l'adresse 0x2440. Ce bootloader va ensuite charger un fichier DOL, en gros l'OS tournant derrière le jeu, qui va lui même charger l'exécutable du jeu qui se trouve sur le système de fichiers du DVD (dont le nom dépend de l'ID du jeu). Le bootloader et l'OS ne sont pas sur le système de fichiers et ne nous intéressent pas tant que ça : pour les jeux commerciaux, ce sont toujours les mêmes (ils font partie du SDK de Nintendo). À côté de ces deux composants il y a ce que l'on va tenter d'ouvrir : le système de fichiers.

Sa structure est très simple, et c'est plutôt normal pour un système de fichiers minimaliste en lecture seule (pas de fragmentation à gérer vu qu'on peut l'éviter lorsqu'on build le FS). Déjà, toutes les données sont rassemblées à un endroit et toutes les métadonnées sont rassemblées à un autre endroit. Les métadonnées sont contenues dans ce qu'on appelle la FST pour FileSystem Table et contient l'ensemble des informations sur les fichiers. En pratique, ces informations sont juste le nom du fichier et l'adresse et la taille des données dans le cas d'un fichier classique, ou le nom et la liste des fils pour un dossier. L'emplacement de cette FST n'est pas fixe : il est indiqué à l'offset 0x424. Comme la Wii compte en blocs de 32 bits et que l'on veut une adresse en octets, on multiplie par 4 l'entier trouvé à l'adresse 0x424.

La FST est construite avec une brique de base : le descripteur de fichier. Un descripteur pèse 12 octets et représente les métadonnées d'un fichier ou d'un dossier. Sur ces 12 octets, les 4 premiers sont l'offset vers le nom du fichier, les 4 suivants sont l'offset vers les données (toujours en bloc de 32 bits, donc à multiplier par 4 pour avoir en octets) et les 4 derniers sont la taille des données (en octets cette fois, la cohérence est d'ordre). Pour savoir si un descripteur représente un fichier ou un dossier, on regarde les 8 bits de poids fort de l'offset vers le nom du fichier : s'ils sont tous à 0, c'est un fichier, sinon c'est un dossier.

Qui dit système de fichier dit hiérarchie. Sauf que la FST est une structure linéaire : tous les descripteurs sont les uns à la suite des autres. Pour représenter la hiérarchie, les descripteurs de dossiers vont se servir de leur champ « taille » pour stocker le premier descripteur dont ils ne sont pas père. Par exemple, si la racine contient 3 fichiers, son champ « taille » vaudra 4. Un petit exemple en ASCII art :

+---------------------+
| Dossier 1           |
|   size = 7          |
+---------------------+
    +---------------------+
    | Fichier 1           |
    |   size = 42         |
    +---------------------+
    +---------------------+
    | Dossier 2           |
    |   size = 6          |
    +---------------------+
        +---------------------+
        | Fichier 2           |
        |   size = 1337       |
        +---------------------+
        +---------------------+
        | Fichier 3           |
        |   size = 1234       |
        +---------------------+
        +---------------------+
        | Fichier 4           |
        |   size = 4321       |
        +---------------------+
    +---------------------+
    | Fichier 5           |
    |   size = 101010     |
    +---------------------+

Enfin, le premier descripteur représente la racine, donc sa « taille » est le nombre total de descripteurs. Il n'a pas de nom. Après tous les descripteurs vient la zone où sont stockés les noms de fichiers. Tous les offsets de noms sont relatifs à cet emplacement. Et c'est tout ! Plutôt simple quand on compare à ce qui se fait ailleurs, et efficace pour le besoin d'un jeu de Wii. Passons donc à l'implémentation. Comme précédemment, je ne code rien de « propre » dans cet article : c'est uniquement un transcript d'une session interactive Python. Le beau code viendra dans la dernière partie. On va commencer par importer les modules et ouvrir l'image dumpée de la partition :

Python 2.7.1 (r271:86832, Dec 20 2010, 11:54:29) 
[GCC 4.5.1 20101125 (prerelease)] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> from collections import namedtuple
>>> from struct import unpack as up
>>> fp = open('tos-2-dumped.img', 'rb')

Ensuite, quelque chose que j'aurais déjà dû faire dans l'article précédent : une tout petite fonction bien utile qui seek puis qui lit. C'est très pratique et ça évite d'oublier de seek après un read :

>>> def read_at(off, len):
...     fp.seek(off)
...     return fp.read(len)

La première étape est de récupérer l'adresse de la FST. On n'oublie pas de la multiplier par 4 :

>>> fst_off = up('>L', read_at(0x424, 4))[0] * 4

On récupère ensuite l'adresse de la fin de la FST, donc du début de la zone qui contient les noms de fichiers. Pour cela, on récupère la taille du premier descripteur (le dossier racine), on la multiplie par 12 pour avoir la taille en octets de la FST et on l'ajoute à l'offset de la FST :

>>> str_off = fst_off + up('>8xL', read_at(fst_off, 12))[0] * 12

La zone contenant les noms de fichiers est une suite de chaines de longueur max 256 séparées par des \0. Python n'a rien de standard pour lire ça (malheureusement !), on va donc faire une petite fonction qui lit un nom de fichier à partir de son offset :

>>> def read_filename(off):
...     s = read_at(str_off + off, 256)
...     return s[:s.index('\0')]
... 
>>> read_filename(0)
'BATTLE'
>>> read_filename(7)
'BG'
>>> read_filename(10)
'btl000.brres'

On va ensuite faire une fonction qui lit un descripteur de fichier et tous ses fils récursivement pour construire l'arborescence. C'est pas forcément simple à comprendre à la première lecture, je commente tout ça après :

>>> def read_descr(idx):
...     descr = read_at(fst_off + 12 * idx, 12)
...     (name_off, data_off, size) = up('>LLL', descr)
...     data_off *= 4
...     
...     is_dir = bool(name_off & 0xFF000000)
...     name_off &= ~0xFF000000
...     name = read_filename(name_off) if idx else ''
...     
...     if not is_dir:
...         return idx + 1, name, (data_off, size)
...     else:
...         children = {}
...         idx += 1
...         while idx < size:
...             idx, child_name, child = read_descr(idx)
...             children[child_name] = child
...         return idx, name, children

Déjà, identifions le retour de la fonction : un triplet qui contient l'index du premier descripteur qui n'a pas été traité (ça nous permet de faciliter la récursion), le nom du fichier ou du dossier représenté par le descripteur, et enfin selon qu'il s'agisse d'un dossier ou d'un fichier, les fils ou l'emplacement des données. Ensuite, au niveau du code, on commence à lire les 12 octets du descripteur. On regarde si c'est un dossier ou nom et on récupére le vrai offset du nom avec un petit masque binaire, en n'oubliant pas que le nom ne veut rien dire pour la racine (idx == 0). Ensuite, si le descripteur est un fichier, on retourne directement son couple (data, size), sinon on va itérer sur tous les indexes entre le premier fils et le dernier fils et on va ajouter tous les descripteurs dans l'ensemble des fils. Et c'est fini !

Pour conclure cet article, on va dumper tous les fichiers et dossiers dans un répertoire sur notre PC. Il suffit de parcourir l'arbre que read_descr, appliqué à la racine, nous donne :

>>> from os.path import exists, join
>>> from os import mkdir
>>> def dump(name, data, where):
...     print 'dumping', name, 'to', where
...     if isinstance(data, dict): # directory
...         path = join(where, name)
...         if not exists(path):
...             mkdir(path)
...         for name, data in data.iteritems():
...             dump(name, data, path)
...     else:
...         print data[0], data[1]
...         data = read_at(data[0], data[1])
...         open(join(where, name), 'wb').write(data)

C'est gagné © ! Pour la prochaine et dernière partie nous verrons comment implémenter un filesystem sous Linux pour pouvoir faire simplement un mount sur notre image de DVD de Wii et y accèder comme on accèderait à une clé USB, sans avoir besoin de tout dumper.

2011
01.01

En cette nuit qui marque le passage vers l'année 2011, je me lance dans l'écriture d'une petite série d'articles sur la lecture d'un disque de Wii en Python. Lire un disque de Wii, pour moi, c'est être capable de lire tout d'abord le nom et l'ID du jeu, mais aussi être capable de lire le système de fichiers du disque pour accèder à l'exécutable ou aux données « brutes » du jeu. Je prévois pour cela 3 articles : le premier, celui ci, ira jusqu'à la lecture des secteurs décryptés de la partition de jeu du disque. Le deuxième parlera de la lecture du système de fichiers de la console : répertoires, fichiers, etc. Enfin, le troisième implémentera ce système de fichier via fuse-python ou PyFS selon mes envies pour pouvoir simplement le monter sur un système Linux. C'est parti !

Je n'ai pour le moment qu'une seule image sous la main : celle de Tales of Symphonia : Dawn of the new World (dont je ferai surement une review ici dans une semaine, par ailleurs), version PAL, dont l'ID est RT4PAF (sha1sum: b2fb05a7fdf172ea61b5d1872e6b121140c95822). Je vais donc me baser là dessus pour faire mes tests, puis si nécessaire fixer des choses quand je trouverai une autre image qui ne fonctionne pas. Au niveau de la documentation sur laquelle je me suis basé, il y a tout d'abord le code source de l'émulateur Dolphin (dans le dossier Source/Core/DiscIO) et le site WiiBrew, un wiki qui contient beaucoup d'informations techniques sur la Wii. Merci beaucoup à eux \o/

Quelques facts sur les disques de Wii : ils commencent par un header qui contient des metadata sur le jeu (son nom, son ID, le numéro du CD, le type de disque, etc.) à l'adresse 0. À l'adresse 0x40000 se trouvent 32 octets qui représentent la table des partitions du disque. En effet, un disque de Wii contient en général plusieurs partitions : une pour le jeu et d'autres qui contiennent par exemple les upgrades. Chacune de ces partitions a un type; celui qui nous intéressera est le type 0, partition de données. Enfin, cette partition commence elle même par un header qui contient notamment des informations nécessaires au déchiffrement des données du disque. En effet, toutes les données contenues dans une partition de Wii sont chiffrées en utilisant le bien connu algorithme AES avec une clé de 128 bits.

Tous les exemples que je donne ici seront uniquement des transcripts d'un shell Python. Je publierai un module qui marchera bien quand j'aurai tout fini et testé, donc surement en même temps que la partie 3 de cette série. Commençons par importer des modules utiles et ouvrons notre fichier :

Python 2.7.1 (r271:86832, Dec 20 2010, 11:54:29) 
[GCC 4.5.1 20101125 (prerelease)] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> from collections import namedtuple
>>> from struct import unpack as up
>>> from Crypto.Cipher import AES
>>> fp = open('tos-2.img', 'rb')

Ensuite on va lire le header. C'est plutôt simple car il a un format fixe et des champs de longueur non variables :

>>> DiscHeader = namedtuple('DiscHeader',
...     'disc_id game_code region_code maker_code disc_number disc_version '
...     'audio_streaming stream_bufsize wii_magic gc_magic title'
... )
>>> disc_hdr = DiscHeader(*up('>c2sc2sBBBB14xLL64s', fp.read(96)))
>>> disc_hdr
DiscHeader(disc_id='R', game_code='T4', region_code='P', maker_code='AF',
           disc_number=0, disc_version=0, audio_streaming=0,
           stream_bufsize=0, wii_magic=1562156707, gc_magic=0,
           title='Tales of Symphonia: Dawn of the New World' + n*'\x00')

Étape suivante, la table des partitions. On va déjà regarder à quoi s'attendre en utilisant xxd :

$ xxd -s 0x40000 -l 48 tos-2.img
0040000: 0000 0002 0001 0008 0000 0000 0000 0000  ................
0040010: 0000 0000 0000 0000 0000 0000 0000 0000  ................

Elle est organisée en 4 volume group qui contiennent plusieurs partitions. Une partition est donc indexée par son numéro de VG et son index dans le VG. La table que l'on vient de dumper contient pour chaque VG deux entiers de 32 bits : le premier est le nombre de partitions dans le VG, le deuxième est l'offset en blocs de 32 bits (et pas en octets, il faut donc multiplier par 4) de la table des partitions du VG. Ici, on a donc le premier VG qui contient 2 partitions, et la table qui décrit ces partitions se trouve à l'adresse 0x10008 * 4 = 0x40020 :

$ xxd -s 0x40020 -l 16 tos-2.img
0040020: 0001 4000 0000 0001 03e0 0000 0000 0000  ..@.............

Chaque entrée de cette table contient tout d'abord l'offset vers la partition (en blocs de 32 bits comme au dessus) mais également le type de partition. En gros, il faut retenir que type 1 = update firmware et type 0 = données du jeu. On a donc une partition d'update et une partition de données ici. On va coder un petit truc pour dumper toutes les entrées des VG avec Python histoire de pouvoir visualiser ça un peu mieux :

>>> PartEntry = namedtuple('PartEntry', 'offset type')
>>> def read_part_entry(offset):
...     fp.seek(offset)
...     (data_offset, type) = up('>LL', fp.read(8))
...     data_offset *= 4
...     return PartEntry(data_offset, type)
>>> 
>>> read_part_entry(0x40020)
PartEntry(offset=327680, type=1)
>>> read_part_entry(0x40028)
PartEntry(offset=260046848, type=0)
>>> 
>>> VGEntry = namedtuple('VGEntry', 'part_count table_offset')
>>> def read_vg_entry(offset):
...     fp.seek(offset)
...     (part_count, table_offset) = up('>LL', fp.read(8))
...     table_offset *= 4
...     return VGEntry(part_count, table_offset)
... 
>>> read_vg_entry(0x40000)
VGEntry(part_count=2, table_offset=262176)
>>> read_vg_entry(0x40008)
VGEntry(part_count=0, table_offset=0)
>>> 
>>> def read_part_table():
...     base_off = 0x40000
...     vgs = {}
...     for vg_num in xrange(4):
...         vg_ent = read_vg_entry(base_off + 8 * vg_num)
...         if vg_ent.part_count == 0:
...             continue
...         vgs[vg_num] = {}
...         for part_num in xrange(vg_ent.part_count):
...             off = vg_ent.table_offset + 8 * part_num
...             part = read_part_entry(off)
...             vgs[vg_num][part_num] = part
...     return vgs
... 
>>> read_part_table()
{0: {0: PartEntry(offset=327680, type=1),
     1: PartEntry(offset=260046848, type=0)}}

Concrétement, la partition d'upgrade firmware ne m'intéresse vraiment pas. Je vais me concentrer sur la partition qui contient les données du jeu, donc la deuxième partition du VG 0 (qui est de type 0, donc données de jeu). Comme à peu près tout sur la Wii, c'est chiffré en AES avec une clé qui est sur le DVD. Cette clé est elle même chiffrée avec une master key qui est dans le firmware de la console. Le tout est signé, mais comme on ne veut rien modifier on ignore totalement la signature : elle ne nous intéresse pas.

AES est un algorithme de chiffrement symétrique qui procède par blocs de 16 octets. Il peut être utilisé dans différents modes : ECB, CBC ou CFB. CBC, le mode que l'on va utiliser, prend en entrée la valeur précédente (ou, dans le cas du premier bloc, l'initial value aka. IV), le bloc courant et la clé. À partir de cela il chiffre ou déchiffre le bloc, qui devient la valeur précédente pour le bloc suivant, et ainsi de suite. Notre premier objectif va être de récupérer ce que l'on appelle la title key, qui est la clé de chiffrement de la partition de données. Elle est chiffrée avec un IV fourni sur le DVD (nommé title id) et une clé qui se trouve dans le firmware et qui est maintenant publique. Toutes les infos qu'ils nous faut sont donc à notre disposition, soit dans le header de la partition (appelé ticket) soit en dur. On va donc commencer par parser le header de la partition :

>>> Ticket = namedtuple('Ticket',
...     'enc_tit_key tit_id data_off data_len'
... )
>>> part = read_part_table()[0][1]
>>> fp.seek(part.offset)
>>> ticket = Ticket(*up('>447x16s13x16s204xLL', fp.read(704)))

Pour une raison louche que je ne connais pas (ping Nintendo ? 🙂 ), seuls les 8 premiers octets de l'IV sont utilisées, les 8 autres sont mis à 0. Ça ne change pas grand chose en vrai. On va donc se servir de tout ce qu'on a pour initialiser notre décodeur AES et décrypter la clé :

>>> master_key = '\xeb\xe4\x2a\x22\x5e\x85\x93\xe4'
>>> master_key += '\x48\xd9\xc5\x45\x73\x81\xaa\xf7'
>>> 
>>> iv = ticket.tit_id[:8] + '\x00' * 8
>>> 
>>> aes = AES.new(master_key, AES.MODE_CBC, iv)
>>> key = aes.decrypt(ticket.enc_tit_key)
>>> key
'U\x84\xfb\x8b\x10\xdfu=B;\xdcyF\xd4G\x9d'

Voilà, nous sommes maintenant en mesure de déchiffrer le contenu de la partition. Les données en elles mêmes commencent après le ticket, à un offset qui se trouve dans ce ticket. À partir de là, on trouve une succession de clusters de 0x8000 octets. Ces clusters sont séparés en tout d'abord 0x400 octets de hashs pour vérifier l'intégrité des données, et 0x7C00 de données chiffrées. La clé de chiffrement est celle que l'on a récupéré précédemment, l'IV se trouve dans le bloc d'intégrité, à l'offset 0x3D0 par rapport au début de la partition. Avec tout ça il est aisé de coder la fonction de lecture d'un cluster :

>>> def read_cluster(idx):
...     data_offset = part.offset + ticket.data_off * 4
...     cluster_offset = data_offset + idx * 0x8000
...     fp.seek(cluster_offset)
...     data_enc = fp.read(0x8000)
...     iv = data_enc[0x3D0:0x3E0]
...     aes = AES.new(key, AES.MODE_CBC, iv)
...     return aes.decrypt(data_enc[0x400:])

Testons ça en décodant les 20 premiers clusters et en regardant leur contenu :

>>> for i in xrange(20):
...     open('/tmp/cluster%d' % i, 'wb').write(read_cluster(i))

$ strings /tmp/cluster* | less
[...]
This Apploader built %s %s for RVL
APPLOADER WARNING >>> Older version of DEVKIT BOOT PROGRAM.
APPLOADER WARNING >>> Use v1.07 or later.
APPLOADER ERROR >>> FSTLength(%d) in BB2 is greater than FSTMaxLength(%d)
APPLOADER ERROR >>> Debug monitor size (%d) should be a multiple of 32
APPLOADER ERROR >>> Simulated memory size (%d) should be a multiple of 32
[...]

Une seule chose à dire : success ! A priori il y a toutes les chances que les données soient bien déchiffrées (ça marche pour les 20 premiers clusters). On va terminer en faisant un dump complet de la partition pour pouvoir étudier ça plus facilement après. On a également la taille de la partition dans le ticket, donc c'est parti !

>>> nclusters = ticket.data_len * 4 / 0x8000
>>> out_fp = open('/path/to/tos-2-dumped.img', 'wb')
>>> for i in xrange(nclusters):
...     print '%f%%' % (i * 100.0 / nclusters)
...     out_fp.write(read_cluster(i))

Et voilà qui conclut cette première partie. Comme dit plus haut, dans la deuxième partie je vais me concentrer sur le système de fichier de la partition que je viens de récupérer. Ça promet 🙂 .