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 🙂 .