DEV Community

Cover image for D’une image nommée .iso à un premier probe en Haskell
Karim DRIDI
Karim DRIDI

Posted on

D’une image nommée .iso à un premier probe en Haskell

À propos de cette série. Cette série raconte la construction progressive, en Haskell, d’outils consacrés à l’étude et à la transformation des formats binaires employés par les jeux FromSoftware sur PlayStation 3. Elle prend pour premier corpus la version PlayStation 3 originale de Demon’s Souls et avance par enquêtes reproductibles : observer une structure, établir ce qui est documenté, distinguer les faits des hypothèses, puis implémenter la plus petite capacité nécessaire à l’étape suivante.


Dans ce premier article, nous allons déterminer ce qu’un fichier nommé image.iso permet réellement d’affirmer. Nous commencerons par observer ses métadonnées au moyen de lectures précisément bornées, avant de reproduire cette reconnaissance dans un prototype direct écrit en Haskell.

Le nom image.iso constitue une hypothèse, non une preuve de format. Une extension est une convention du système de fichiers hôte ; elle ne garantit pas le contenu du fichier. Nous allons donc rechercher une structure définie par ISO 9660, lire plusieurs champs précis, puis reconnaître deux informations supplémentaires : un marqueur Joliet et un enregistrement propre aux disques PlayStation 3.

Notre périmètre restera strictement en lecture seule. Nous ne monterons pas l’image, ne parcourrons aucun répertoire, n’extrairons rien, ne déchiffrerons rien et n’écrirons aucun octet. À la fin, notre prototype affichera seulement :

ps3.product-id=BLES-00932
iso9660.volume-id=PS3VOLUME
iso9660.logical-block-size=2048
joliet.level=1
Enter fullscreen mode Exit fullscreen mode

Ces quatre lignes sont un résultat de reconnaissance borné, non un certificat de conformité de l’image entière.

De la position logique à l’offset

Un fichier binaire est une suite d’octets. Nous appelons offset la position, comptée depuis zéro, d’un octet dans cette suite.

ISO 9660 organise les informations qui nous intéressent en secteurs logiques de 2 048 octets. Une LBA (Logical Block Address, adresse logique de bloc) est le numéro d’un tel secteur : le secteur de LBA 0 commence à l’offset 0, celui de LBA 1 à l’offset 2 048, et ainsi de suite. La formule que nous réutiliserons est :

offset absolu = LBA × taille du secteur + offset relatif du champ
Enter fullscreen mode Exit fullscreen mode

Selon ECMA-119, la norme qui décrit ISO 9660, les secteurs de LBA 0 à 15 forment la System Area, ou zone système. La séquence des descripteurs de volume commence à la LBA 16. La page ISO 9660 d’OSDev fournit une table pratique pour s’orienter ; ECMA-119 demeure la source normative.

Nous commençons par deux constantes Shell :

IMAGE=image.iso # Remplacer au besoin par un chemin absolu vers l’image locale.
SECTOR=2048
Enter fullscreen mode Exit fullscreen mode

Toutes les commandes xxd de cet article emploient -s pour fixer l’offset et -l pour borner la quantité lue. L’option -g 1 sépare les octets à l’affichage. Aucune de ces commandes ne modifie l’image ; nous n’utiliserons notamment jamais xxd -r.

Reconnaître le descripteur principal de volume

Un descripteur de volume (Volume Descriptor) est un secteur de métadonnées. La séquence qui les contient commence à la LBA 16 et doit comprendre au moins un descripteur principal de volume (Primary Volume Descriptor). Dans notre image, ce descripteur principal est précisément le premier élément de la séquence.

Tous les descripteurs ISO 9660 commencent par le même en-tête de sept octets. Les offsets de l’article sont comptés depuis zéro ; la colonne « Position ECMA-119 » reprend la numérotation à partir de 1 employée par la norme.

Offset relatif Position ECMA-119 Taille Nom canonique du champ Rôle
0 1 1 Volume Descriptor Type nature du descripteur
1 2 à 6 5 Standard Identifier identifiant CD001
6 7 1 Volume Descriptor Version version de la structure

Le descripteur principal occupe exactement 2 048 octets. Sa disposition complète est la suivante. Les champs que notre probe décodera ne sont donc pas les seuls renseignements disponibles dans ce secteur.

Offset relatif Position ECMA-119 Taille Nom canonique du champ Encodage ou fonction
0 1 1 Volume Descriptor Type entier 8 bits, valeur 1
1 2 à 6 5 Standard Identifier caractères CD001
6 7 1 Volume Descriptor Version entier 8 bits, valeur 1
7 8 1 Unused Field octet nul
8 9 à 40 32 System Identifier caractères a
40 41 à 72 32 Volume Identifier caractères d, complétés à largeur fixe
72 73 à 80 8 Unused Field octets nuls
80 81 à 88 8 Volume Space Size entier 32 bits both-endian
88 89 à 120 32 Unused Field octets nuls
120 121 à 124 4 Volume Set Size entier 16 bits both-endian
124 125 à 128 4 Volume Sequence Number entier 16 bits both-endian
128 129 à 132 4 Logical Block Size entier 16 bits both-endian
132 133 à 140 8 Path Table Size entier 32 bits both-endian
140 141 à 144 4 Location of Occurrence of Type L Path Table numéro de bloc, little-endian
144 145 à 148 4 Location of Optional Occurrence of Type L Path Table numéro de bloc, little-endian
148 149 à 152 4 Location of Occurrence of Type M Path Table numéro de bloc, big-endian
152 153 à 156 4 Location of Optional Occurrence of Type M Path Table numéro de bloc, big-endian
156 157 à 190 34 Directory Record for Root Directory enregistrement du répertoire racine
190 191 à 318 128 Volume Set Identifier caractères d
318 319 à 446 128 Publisher Identifier caractères a
446 447 à 574 128 Data Preparer Identifier caractères a
574 575 à 702 128 Application Identifier caractères a
702 703 à 739 37 Copyright File Identifier identifiant de fichier
739 740 à 776 37 Abstract File Identifier identifiant de fichier
776 777 à 813 37 Bibliographic File Identifier identifiant de fichier
813 814 à 830 17 Volume Creation Date and Time date et heure décimales
830 831 à 847 17 Volume Modification Date and Time date et heure décimales
847 848 à 864 17 Volume Expiration Date and Time date et heure décimales
864 865 à 881 17 Volume Effective Date and Time date et heure décimales
881 882 1 File Structure Version entier 8 bits
882 883 1 champ réservé octet nul
883 884 à 1395 512 Application Use contenu non spécifié par ECMA-119
1395 1396 à 2048 653 champ réservé octets nuls

Les catégories de caractères a et d sont définies par ECMA-119. Elles imposent un répertoire de caractères plus précis que la simple propriété « octet ASCII ». Nous ne décoderons ici que les champs nécessaires au résultat final, mais les offsets du tableau permettent d’examiner les autres sans rechercher de nouveau la disposition générale.

La LBA 16 commence à :

16 × 2 048 = 32 768 = 0x8000
Enter fullscreen mode Exit fullscreen mode

Nous lisons donc exactement ses sept premiers octets :

xxd -g 1 -l 7 -s $((16 * SECTOR)) "$IMAGE"
Enter fullscreen mode Exit fullscreen mode

La sortie observée dans notre image est :

00008000: 01 43 44 30 30 31 01                             .CD001.
Enter fullscreen mode Exit fullscreen mode

Nous pouvons maintenant situer chaque valeur au lieu de reconnaître seulement une chaîne plausible :

  • 00008000 est l’offset absolu affiché en hexadécimal ; 0x8000 vaut bien 32 768 ;
  • le premier octet, 01, est le type du descripteur ; le type 1 désigne le descripteur principal de volume ;
  • 43 44 30 30 31 est l’encodage ASCII (American Standard Code for Information Interchange) de l’identifiant standard CD001 ;
  • le dernier 01 est la version commune du descripteur, ici 1.

C’est le type, et non l’octet de version, qui établit la nature principale de ce descripteur. À ce stade, nous avons reconnu un en-tête attendu ; nous n’avons pas validé tout le secteur ni le système de fichiers.

Si une autre image produit des octets différents, la règle réutilisable n’est pas de rechercher visuellement le mot CD001 n’importe où. Il faut retrouver, à l’offset exact de la LBA 16, le triplet structuré « type 1, identifiant CD001, version prise en charge ».

Le champ Volume Identifier

Le champ anglais canonique Volume Identifier désigne l’identifiant textuel du volume. Dans le descripteur principal de volume, il commence à l’offset relatif 40 et occupe 32 octets. Sa position absolue vaut donc :

16 × 2 048 + 40 = 32 808 = 0x8028
Enter fullscreen mode Exit fullscreen mode

Nous lisons les 32 octets, sans davantage :

xxd -g 1 -l 32 -s $((16 * SECTOR + 40)) "$IMAGE"
Enter fullscreen mode Exit fullscreen mode

Nous observons :

00008028: 50 53 33 56 4f 4c 55 4d 45 20 20 20 20 20 20 20  PS3VOLUME
00008038: 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20
Enter fullscreen mode Exit fullscreen mode

Les octets 50 53 33 56 4f 4c 55 4d 45 donnent PS3VOLUME en ASCII. Les octets 20 qui suivent sont des espaces de remplissage jusqu’à la largeur fixe de 32 octets. Pour l’affichage, nous retirerons uniquement ces espaces terminaux. Nous ne supprimerons ni un éventuel espace initial, ni des octets arbitraires qui appartiendraient réellement au champ d’une autre image.

Le champ Volume Space Size et ses deux ordres d’octets

Le champ Volume Space Size, à l’offset relatif 80, indique un nombre de blocs logiques, pas encore une taille en octets. ECMA-119 l’enregistre deux fois sur quatre octets : d’abord en little-endian, puis en big-endian.

Ces deux termes décrivent l’ordre des octets d’un entier multioctet. En little-endian, l’octet de poids faible vient en premier ; en big-endian, l’octet de poids fort vient en premier. Nous séparons ici les deux copies afin que leur ordre reste visible :

xxd -g 1 -l 4 -s $((16 * SECTOR + 80)) "$IMAGE"
xxd -g 1 -l 4 -s $((16 * SECTOR + 84)) "$IMAGE"
Enter fullscreen mode Exit fullscreen mode

Les sorties observées sont :

00008050: a0 27 43 00                                      .'C.
00008054: 00 43 27 a0                                      .C'.
Enter fullscreen mode Exit fullscreen mode

La colonne de droite est seulement une tentative de représentation ASCII par xxd ; elle n’a aucune signification pour ce champ numérique.

Pour quatre octets b0 b1 b2 b3, les formules sont :

little-endian = b0 + b1×256 + b2×65 536 + b3×16 777 216
big-endian    = b0×16 777 216 + b1×65 536 + b2×256 + b3
Enter fullscreen mode Exit fullscreen mode

Appliquons-les aux octets réellement lus. La copie little-endian donne :

0xa0 + 0x27×0x100 + 0x43×0x10000 + 0x00×0x1000000
= 0x4327a0
Enter fullscreen mode Exit fullscreen mode

La copie big-endian donne directement la même écriture numérique :

00 43 27 a0 = 0x4327a0
Enter fullscreen mode Exit fullscreen mode

Or 0x4327a0 vaut 4 401 056. Le volume annonce donc 4 401 056 blocs logiques. Sur une autre image, il faut appliquer les mêmes formules à ses propres octets, puis comparer les deux résultats. Un lecteur ne choisit pas la copie qui paraît la plus vraisemblable : leur désaccord constitue une incohérence du champ both-endian.

Le champ Logical Block Size

Il nous manque la taille d’un bloc logique. Le champ Logical Block Size se trouve à l’offset relatif 128. Il occupe quatre octets : une copie little-endian de deux octets, puis une copie big-endian de deux octets.

xxd -g 1 -l 2 -s $((16 * SECTOR + 128)) "$IMAGE"
xxd -g 1 -l 2 -s $((16 * SECTOR + 130)) "$IMAGE"
Enter fullscreen mode Exit fullscreen mode

Nous observons :

00008080: 00 08                                            ..
00008082: 08 00                                            ..
Enter fullscreen mode Exit fullscreen mode

Pour deux octets b0 b1 :

little-endian = b0 + b1×256
big-endian    = b0×256 + b1
Enter fullscreen mode Exit fullscreen mode

La première copie, 00 08, vaut donc 0x00 + 0x08×0x100 = 0x0800. La seconde, 08 00, vaut également 0x0800. En décimal, 0x0800 vaut 2 048 octets par bloc logique.

Nous pouvons désormais reconstruire la taille annoncée de l’espace du volume :

4 401 056 × 2 048 = 9 013 362 688 octets
Enter fullscreen mode Exit fullscreen mode

Comparons cette valeur à la longueur du fichier hôte, toujours sans le modifier :

ls -l "$IMAGE"
Enter fullscreen mode Exit fullscreen mode

La sortie observée est :

-rw-r--r--@ 1 kdridi  staff  9013362688 Sep  2 02:02 image.iso
Enter fullscreen mode Exit fullscreen mode

Pour cet échantillon, la longueur du fichier est exactement égale à l’espace annoncé. C’est un contrôle de cohérence utile, pas une égalité universelle : un conteneur peut ajouter du remplissage ou des données après le volume. Dans le modèle direct que nous utilisons ici, un fichier plus court que l’espace annoncé serait en revanche insuffisant pour contenir ce volume.

Le résultat dépasse 4 Gio (gibioctets). Notre code Haskell devra donc convertir les deux facteurs vers un entier suffisamment large avant de les multiplier ; un entier limité à 32 bits ne convient pas à ce calcul.

Parcourir la séquence des descripteurs ISO 9660

Le secteur de LBA 16 n’est que le début d’une séquence. ISO 9660 autorise d’autres descripteurs dans les secteurs suivants et marque la fin de l’ensemble par un terminateur de séquence de descripteurs de volume (Volume Descriptor Set Terminator) de type 255.

Le premier octet de l’en-tête commun détermine la nature de chaque secteur de métadonnées :

Valeur du type Nom canonique Fonction
0 Boot Record informations destinées à un système d’amorçage
1 Primary Volume Descriptor description principale du volume et d’une arborescence
2 Supplementary Volume Descriptor ou Enhanced Volume Descriptor description d’une arborescence supplémentaire ; la version distingue les deux formes
3 Volume Partition Descriptor description d’une partition du volume
4 à 254 réservé aucune interprétation normalisée ici
255 Volume Descriptor Set Terminator fin de la séquence

Le terminateur possède lui-même une disposition complète particulièrement simple :

Offset relatif Taille Nom canonique du champ Valeur
0 1 Volume Descriptor Type 255
1 5 Standard Identifier CD001
6 1 Volume Descriptor Version 1
7 2 041 champ réservé octets nuls

Puisque les descripteurs occupent chacun un secteur et sont contigus à partir de la LBA 16, les sept octets que nous lirons à chaque itération se répartissent toujours ainsi :

Offset absolu pour la LBA n Taille Champ lu
n × 2 048 + 0 1 Volume Descriptor Type
n × 2 048 + 1 5 Standard Identifier
n × 2 048 + 6 1 Volume Descriptor Version

Pour explorer ce qui suit, nous regardons volontairement au-delà de la future condition d’arrêt :

for n in $(seq 17 25)
do
    xxd -g 1 -l 7 -s $(($n * SECTOR)) "$IMAGE"
done
Enter fullscreen mode Exit fullscreen mode

La sortie observée est :

00008800: 02 43 44 30 30 31 01                             .CD001.
00009000: ff 43 44 30 30 31 01                             .CD001.
00009800: 00 42 45 41 30 31 01                             .BEA01.
0000a000: 00 4e 53 52 30 33 01                             .NSR03.
0000a800: 00 54 45 41 30 31 01                             .TEA01.
0000b000: 00 00 00 00 00 00 00                             .......
0000b800: 00 00 00 00 00 00 00                             .......
0000c000: 00 00 00 00 00 00 00                             .......
0000c800: 00 00 00 00 00 00 00                             .......
Enter fullscreen mode Exit fullscreen mode

En réunissant l’en-tête déjà lu à la LBA 16 et les deux premiers résultats de cette boucle, nous obtenons la séquence ISO 9660 suivante :

LBA Offset absolu Sept premiers octets Interprétation
16 0x8000 01 43 44 30 30 31 01 descripteur principal, CD001, version 1
17 0x8800 02 43 44 30 30 31 01 descripteur supplémentaire, CD001, version 1
18 0x9000 ff 43 44 30 30 31 01 terminateur, CD001, version 1

À la LBA 17, 02 CD001 01 est l’en-tête d’un descripteur supplémentaire de volume (Supplementary Volume Descriptor). À la LBA 18, ff CD001 01 est le terminateur. La séquence ISO 9660 de notre image comprend donc, dans l’ordre observé, le descripteur principal à la LBA 16, un descripteur supplémentaire à la LBA 17 et le terminateur à la LBA 18.

La distinction entre exploration et lecture structurée est essentielle. La boucle Shell continue jusqu’à la LBA 25 pour découvrir les données voisines. Un scanner ISO 9660, lui, doit commencer à la LBA 16, avancer secteur par secteur, contrôler l’identifiant et la version, puis s’arrêter immédiatement au type 255. Il lui faut aussi une borne défensive : une image mal formée ne doit pas provoquer une lecture indéfinie si le terminateur manque.

Du descripteur supplémentaire au profil Joliet

Le type 2 ne signifie pas à lui seul « Joliet ». ECMA-119 définit un descripteur supplémentaire générique. La version 1 désigne le descripteur supplémentaire étudié ici ; la version 2 désigne un descripteur amélioré (Enhanced Volume Descriptor).

Le descripteur supplémentaire conserve l’essentiel de la disposition du descripteur principal, mais ajoute les informations nécessaires à l’interprétation de ses champs textuels. Voici sa structure complète :

Offset relatif Taille Nom canonique du champ Différence ou fonction principale
0 1 Volume Descriptor Type valeur 2
1 5 Standard Identifier caractères CD001
6 1 Volume Descriptor Version 1 pour la forme supplémentaire, 2 pour la forme améliorée
7 1 Volume Flags propriétés des séquences d’échappement
8 32 System Identifier caractères interprétés selon la forme du descripteur
40 32 Volume Identifier caractères d1 dans la forme supplémentaire
72 8 Unused Field octets nuls
80 8 Volume Space Size entier 32 bits both-endian
88 32 Escape Sequences désignation des jeux de caractères
120 4 Volume Set Size entier 16 bits both-endian
124 4 Volume Sequence Number entier 16 bits both-endian
128 4 Logical Block Size entier 16 bits both-endian
132 8 Path Table Size entier 32 bits both-endian
140 4 Location of Occurrence of Type L Path Table numéro de bloc, little-endian
144 4 Location of Optional Occurrence of Type L Path Table numéro de bloc, little-endian
148 4 Location of Occurrence of Type M Path Table numéro de bloc, big-endian
152 4 Location of Optional Occurrence of Type M Path Table numéro de bloc, big-endian
156 34 Directory Record for Root Directory enregistrement du répertoire racine de cette arborescence
190 128 Volume Set Identifier caractères d1
318 128 Publisher Identifier caractères a1
446 128 Data Preparer Identifier caractères a1
574 128 Application Identifier caractères a1
702 37 Copyright File Identifier identifiant de fichier en caractères d1
739 37 Abstract File Identifier identifiant de fichier en caractères d1
776 37 Bibliographic File Identifier identifiant de fichier en caractères d1
813 17 Volume Creation Date and Time date et heure décimales
830 17 Volume Modification Date and Time date et heure décimales
847 17 Volume Expiration Date and Time date et heure décimales
864 17 Volume Effective Date and Time date et heure décimales
881 1 File Structure Version entier 8 bits
882 1 champ réservé octet nul
883 512 Application Use contenu non spécifié par ECMA-119
1395 653 champ réservé octets nuls

La section 8.5.6 d’ECMA-119 place donc le champ Escape Sequences à l’offset relatif 88, sur 32 octets. Ce champ sert à désigner des jeux de caractères ; il ne contient pas nécessairement le nom d’une extension.

La spécification historique Microsoft Joliet utilise précisément ce mécanisme pour décrire un ensemble de fichiers dont les noms emploient UCS-2. UCS-2 (Universal Character Set, 2-byte form) est un codage historique fondé sur des unités de code de 16 bits. Nous devons donc examiner le champ avant de classifier le descripteur.

Son offset absolu est :

17 × 2 048 + 88 = 34 904 = 0x8858
Enter fullscreen mode Exit fullscreen mode

Cette arithmétique permet notamment d’éviter une erreur de transcription entre les offsets relatifs 87 et 88. Lisons le champ complet :

xxd -g 1 -l 32 -s $((17 * SECTOR + 88)) "$IMAGE"
Enter fullscreen mode Exit fullscreen mode

Nous observons :

00008858: 25 2f 40 00 00 00 00 00 00 00 00 00 00 00 00 00  %/@.............
00008868: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
Enter fullscreen mode Exit fullscreen mode

Les trois premiers octets, 25 2f 40, représentent %/@ en ASCII ; les 29 octets suivants sont nuls.

La chaîne documentaire qui donne son sens à cette observation mérite d’être conservée. ISO 2022 définit un mécanisme de désignation des jeux de caractères. ISO 2375 en organise l’enregistrement, et le registre ISO-IR associe des séquences complètes à trois niveaux d’UCS-2 :

Registre Jeu de caractères Séquence complète enregistrée
ISO-IR 162 UCS-2 niveau 1 ESC 25 2F 40
ISO-IR 174 UCS-2 niveau 2 ESC 25 2F 43
ISO-IR 176 UCS-2 niveau 3 ESC 25 2F 45

ESC désigne ici l’octet de contrôle d’échappement. Ces enregistrements datent de 1992 et 1993, avant la publication de Joliet en 1995. Le champ du descripteur supplémentaire contient les trois octets qui suivent ESC ; Microsoft n’a donc pas inventé une étiquette privée appelée « Joliet », mais a sélectionné des identifiants de jeux de caractères déjà enregistrés.

La spécification Joliet associe ces valeurs aux niveaux suivants :

Valeur dans le champ Niveau Joliet
%/@ 1
%/C 2
%/E 3

Dans notre image, %/@ constitue donc un marqueur Joliet de niveau 1. Cette conclusion reste étroite : le marqueur indique comment interpréter le descripteur et l’arborescence qui lui est associée, mais il ne prouve pas que cette arborescence entière respecte Joliet. Il faudrait la parcourir et la valider pour l’affirmer, ce que nous ne ferons pas dans cet article.

Un lecteur général devrait également définir une politique pour d’autres séquences enregistrées. Notre probe reconnaîtra seulement %/@, %/C et %/E. Une autre valeur restera le champ d’un descripteur supplémentaire reconnu, avec un jeu de caractères non pris en charge ; elle ne sera ni devinée ni silencieusement reclassée.

Les descripteurs de reconnaissance voisins

La boucle exploratoire précédente a aussi montré des secteurs dont l’en-tête de sept octets ressemble extérieurement à celui d’ISO 9660, mais relève d’une autre norme. ECMA-167 décrit chaque descripteur de reconnaissance sur un secteur entier selon cette disposition générale :

Offset relatif Taille Nom canonique du champ Fonction
0 1 Structure Type type de structure ECMA-167
1 5 Standard Identifier identifiant tel que BEA01, NSR03 ou TEA01
6 1 Structure Version version de la structure
7 2 041 Structure Data contenu propre au type de descripteur

Dans notre image, les sept premiers octets donnent :

LBA 19 : 00 42 45 41 30 31 01  → BEA01
LBA 20 : 00 4e 53 52 30 33 01  → NSR03
LBA 21 : 00 54 45 41 30 31 01  → TEA01
LBA 22 : 00 00 00 00 00 00 00  → secteur non reconnu
Enter fullscreen mode Exit fullscreen mode

Ces identifiants ne prolongent pas la séquence ISO 9660, terminée à la LBA 18. Ils appartiennent aux descripteurs de reconnaissance définis par ECMA-167. Leur premier octet est un type de structure ECMA-167, non un type de descripteur ISO 9660, même si les deux en-têtes observés occupent sept octets.

BEA01 ouvre une Extended Area et TEA01 la ferme. ECMA-167 précise que la Volume Recognition Sequence se termine au premier secteur qui n’est pas un descripteur valide ; dans cet échantillon, c’est le secteur nul de LBA 22. La forme BEA01NSR03TEA01 est compatible avec le motif de reconnaissance demandé par UDF (Universal Disk Format) 2.50, mais NSR03 ne détermine pas à lui seul une révision UDF exacte et ne valide aucun système de fichiers UDF.

Cette observation n’aide pas à produire nos quatre lignes. Nous n’implémenterons donc aucun parseur UDF dans ce probe.

Revenir à la zone système : les indices PlayStation 3

Les seize premiers secteurs sont réservés par ISO 9660 à la zone système, mais ECMA-119 ne définit pas la disposition PlayStation 3 que nous y observons. Aucune spécification publique Sony normative de ces champs n’a été identifiée. Leur interprétation repose sur la convergence d’implémentations indépendantes — notamment RPCS3, PS3 Disc Dumper et ps3netsrv — ainsi que sur l’observation de notre image. Ce sont des preuves d’implémentation non normatives, pas une norme Sony.

Les 512 premiers octets du secteur 1 suivent la disposition observée et corroborée ci-dessous. Le reste de ce secteur est nul dans notre image ; cette dernière propriété reste une observation locale.

Offset relatif Taille Contenu ou fonction Statut dans cet article
0x000 0x010 (16) PlayStation3 suivi de quatre octets nuls reconnu par le probe
0x010 0x020 (32) identifiant produit complété par des espaces reconnu par le probe
0x030 0x010 (16) octets nuls observé, non utilisé
0x040 0x1b0 (432) informations de disque dont la sémantique n’est pas établie ici opaque
0x1f0 0x010 (16) valeur opaque décrite comme un hachage par ps3netsrv opaque
0x200 0x600 (1 536) fin du secteur, nulle dans l’image étudiée observation locale

Le marqueur de plateforme

À la LBA 1, nous lisons d’abord un champ de 16 octets :

xxd -g 1 -l 16 -s $((1 * SECTOR)) "$IMAGE"
Enter fullscreen mode Exit fullscreen mode

La sortie réelle est :

00000800: 50 6c 61 79 53 74 61 74 69 6f 6e 33 00 00 00 00  PlayStation3....
Enter fullscreen mode Exit fullscreen mode

Les douze premiers octets codent PlayStation3 en ASCII ; quatre octets nuls complètent le champ. Nous parlons d’un champ de 16 octets, non d’un texte arbitraire de 16 caractères.

L’identifiant produit

Le champ adjacent commence 16 octets plus loin et en occupe 32 :

xxd -g 1 -l 32 -s $((1 * SECTOR + 16)) "$IMAGE"
Enter fullscreen mode Exit fullscreen mode

Nous observons :

00000810: 42 4c 45 53 2d 30 30 39 33 32 20 20 20 20 20 20  BLES-00932
00000820: 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20
Enter fullscreen mode Exit fullscreen mode

Les dix premiers octets donnent BLES-00932 ; vingt-deux espaces complètent le champ. Notre reconnaissance minimale exigera le marqueur PlayStation3 complet, puis retirera seulement les espaces terminaux de l’identifiant produit. Cette combinaison suffit à la ligne que nous voulons rapporter, mais ne valide pas toutes les structures propres à un disque PlayStation 3.

La table de plages du secteur 0

Le début du secteur 0 contient une autre structure corroborée par les implémentations citées. Avant de lire ses valeurs, nous pouvons en représenter la disposition générale :

Offset relatif Taille Contenu ou fonction
0x000 4 nombre d’enregistrements, entier non signé 32 bits big-endian
0x004 4 champ réservé, attendu à zéro
0x008 + 8 × i 4 LBA de début incluse de l’enregistrement i, big-endian
0x00c + 8 × i 4 LBA de fin incluse de l’enregistrement i, big-endian
0x008 + 8 × count à 0x7ff variable reste du secteur, hors de la table décrite

Les quatre premiers octets forment donc le compteur ; les quatre suivants sont réservés et attendus à zéro.

xxd -g 1 -l 4 -s $((0 * SECTOR + 0)) "$IMAGE"
xxd -g 1 -l 4 -s $((0 * SECTOR + 4)) "$IMAGE"
Enter fullscreen mode Exit fullscreen mode

Les sorties observées sont :

00000000: 00 00 00 03                                      ....
00000004: 00 00 00 00                                      ....
Enter fullscreen mode Exit fullscreen mode

00 00 00 03, lu en big-endian, vaut 3 : trois enregistrements suivent. Ce nombre n’est pas le nombre de blocs du volume ; ce dernier reste 0x004327a0. Chaque enregistrement occupe huit octets et contient deux entiers big-endian de 32 bits : une LBA de début et une LBA de fin incluse.

for n in $(seq 0 2)
do
    echo "Plage $(($n + 1))"
    echo -n "LBA start: "
    xxd -g 1 -l 4 -s $((0 * SECTOR + 8 + $n * 8 + 0)) "$IMAGE"
    echo -n "LBA stop : "
    xxd -g 1 -l 4 -s $((0 * SECTOR + 8 + $n * 8 + 4)) "$IMAGE"
    echo ""
done
Enter fullscreen mode Exit fullscreen mode

Nous obtenons :

Plage 1
LBA start: 00000008: 00 00 00 00                                      ....
LBA stop : 0000000c: 00 00 45 bf                                      ..E.

Plage 2
LBA start: 00000010: 00 01 04 00                                      ....
LBA stop : 00000014: 00 01 08 ff                                      ....

Plage 3
LBA start: 00000018: 00 41 27 80                                      .A'.
LBA stop : 0000001c: 00 43 27 9f                                      .C'.
Enter fullscreen mode Exit fullscreen mode

La formule big-endian donnée plus haut s’applique à chaque groupe de quatre octets. Par exemple, 00 01 04 00 devient 0x00010400. Puisque les bornes sont inclusives, la longueur d’une plage est fin - début + 1. Les trois plages non protégées observées sont donc :

Plage Début inclus Fin incluse Longueur en secteurs
1 0x00000000 0x000045bf 0x000045c0
2 0x00010400 0x000108ff 0x00000500
3 0x00412780 0x0043279f 0x00020020

Elles sont non inversées, strictement ordonnées, sans chevauchement et contenues dans le volume. Comme Volume Space Size est un compte de 0x004327a0 blocs, les LBA valides vont de zéro à 0x0043279f ; la dernière borne incluse vaut exactement 0x004327a0 - 1.

Les intervalles complémentaires entre ces plages sont les intervalles protégés dérivés :

Intervalle Début inclus Fin incluse
1 0x000045c0 0x000103ff
2 0x00010900 0x0041277f

Cette dérivation décrit la carte de protection prévue pour le disque original. Elle ne permet pas de conclure que les octets de l’image locale sont actuellement chiffrés ou déjà déchiffrés. Comme notre probe ne rapporte aucune plage et n’effectue aucun déchiffrement, cette table reste une observation et n’entre pas dans le code.

Reproduire les lectures directement dans GHCi

Nous avons maintenant une suite précise de règles à transcrire. GHCi est l’interpréteur interactif de Glasgow Haskell Compiler : il permet de saisir des définitions Haskell et d’en voir immédiatement le résultat. Nous allons y construire un prototype direct, sans infrastructure générale de décodage binaire.

Depuis le même terminal, nous rendons le chemin accessible au processus, puis lançons GHCi avec le paquet bytestring :

export IMAGE="${IMAGE:-image.iso}"
ghci -package bytestring
Enter fullscreen mode Exit fullscreen mode

L’invite ressemble à :

ghci>
Enter fullscreen mode Exit fullscreen mode

Un ByteString est une suite compacte d’octets. Nous utiliserons sa variante stricte parce que chacune de nos lectures est petite et bornée. Les interfaces employées sont documentées dans System.IO, Data.ByteString et Data.Word.

Saisie multiligne dans GHCi. Les blocs qui suivent sont présentés comme du code Haskell ordinaire, sans commandes propres à l’interpréteur. Pour saisir une définition multiligne directement dans GHCi, commencez par entrer :{ sur une ligne isolée, saisissez ou collez la définition, puis terminez par :}, également sur une ligne isolée. Ces deux commandes sont volontairement omises des blocs afin de ne pas les confondre avec la syntaxe du langage Haskell.

Octets bruts, puis décodage endian

Commençons par les imports communs :

import Control.Monad (unless, when)
import Data.Bits (Bits, shiftL, (.|.))
import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as BSC
import Data.Word (Word8, Word16, Word32)
import System.Environment (getEnv)
import System.IO (Handle, IOMode(ReadMode), SeekMode(AbsoluteSeek), hFileSize, hSeek, withBinaryFile)
Enter fullscreen mode Exit fullscreen mode

Avant toute entrée-sortie, reprenons quatre octets synthétiques. Leur type ne leur attribue encore ni signification ni ordre :

let rawBytes = BS.pack [0x00, 0x00, 0x00, 0x03]
BS.unpack rawBytes
Enter fullscreen mode Exit fullscreen mode

GHCi affiche :

[0,0,0,3]
Enter fullscreen mode Exit fullscreen mode

L’endianness n’est pas une propriété du ByteString ni un mode de lecture du fichier. C’est une règle du champ que nous sommes en train de décoder. Nous définissons deux transformations privées :

decodeBE :: (Bits a, Num a) => BS.ByteString -> a
decodeBE =
    BS.foldl'
        (\acc byte -> shiftL acc 8 .|. fromIntegral byte)
        0

decodeLE :: (Bits a, Num a) => BS.ByteString -> a
decodeLE =
    BS.foldr'
        (\byte acc -> shiftL acc 8 .|. fromIntegral byte)
        0
Enter fullscreen mode Exit fullscreen mode

La même suite brute donne des valeurs différentes selon l’interprétation :

decodeBE rawBytes :: Word32
decodeLE rawBytes :: Word32
Enter fullscreen mode Exit fullscreen mode

Résultat :

3
50331648
Enter fullscreen mode Exit fullscreen mode

50331648 vaut 0x03000000. Le format, et non une préférence du programmeur, détermine quel décodeur convient. Ces fonctions n’imposent pas elles-mêmes une largeur ; dans ce prototype, cette garantie appartient à la frontière de lecture qui les précède.

Une lecture exacte et bornée

BS.hGet peut renvoyer moins d’octets que demandé si la fin du fichier survient. Nous devons contrôler la longueur avant tout BS.index ou décodage. Les offsets restent des Integer, afin de ne pas limiter artificiellement l’adressage aux fichiers de moins de 4 Gio.

sectorSize :: Integer
sectorSize = 2048

readExactlyAt :: Handle -> Integer -> Int -> IO BS.ByteString
readExactlyAt handle offset width
    | offset < 0 = ioError (userError "negative read offset")
    | width < 0  = ioError (userError "negative read width")
    | otherwise  = do
        hSeek handle AbsoluteSeek offset
        bytes <- BS.hGet handle width
        if BS.length bytes == width
            then pure bytes
            else ioError
                (userError
                    ("short read at offset " <> show offset
                        <> ": expected " <> show width
                        <> " bytes, got " <> show (BS.length bytes)))
Enter fullscreen mode Exit fullscreen mode

ReadMode exprime l’intention de lecture seule. withBinaryFile referme le handle même si une exception survient. Vérifions cette frontière avec les sept octets déjà observés :

imageFile <- getEnv "IMAGE"

headerBytes <- withBinaryFile imageFile ReadMode $ \handle ->
    readExactlyAt handle (16 * sectorSize) 7

BS.length headerBytes
BS.unpack headerBytes
Enter fullscreen mode Exit fullscreen mode

Nous retrouvons :

7
[1,67,68,48,48,49,1]
Enter fullscreen mode Exit fullscreen mode

Les décimaux 67 68 48 48 49 sont les codes ASCII de CD001. Si GHCi affiche parfois un ByteString textuel sous la forme "\SOHCD001\SOH", SOH n’est pas un octet ajouté : c’est le nom d’affichage du caractère de contrôle 0x01.

Décoder les champs utiles du descripteur principal

Nous donnons maintenant un type aux trois composants de l’en-tête, puis un type aux trois informations ISO que notre lecture conservera :

data DescriptorHeader = DescriptorHeader
    { headerType       :: Word8
    , headerIdentifier :: BS.ByteString
    , headerVersion    :: Word8
    }
    deriving (Eq, Show)

data IsoInfo = IsoInfo
    { isoVolumeIdentifier :: BS.ByteString
    , isoVolumeSpaceSize  :: Word32
    , isoLogicalBlockSize :: Word16
    }
    deriving (Eq, Show)

decodeDescriptorHeader :: BS.ByteString -> DescriptorHeader
decodeDescriptorHeader bytes =
    DescriptorHeader
        { headerType = BS.index bytes 0
        , headerIdentifier = BS.take 5 (BS.drop 1 bytes)
        , headerVersion = BS.index bytes 6
        }
Enter fullscreen mode Exit fullscreen mode

decodeDescriptorHeader emploie BS.index, qui serait partiel sur une entrée trop courte. Il reste privé et ne reçoit ici que des données dont la largeur a déjà été vérifiée par readExactlyAt.

La fonction suivante lit exactement le secteur de 2 048 octets à la LBA 16, contrôle son en-tête, découpe les trois champs, décode séparément les deux copies numériques et vérifie leur égalité. Pour le contrôle de cohérence avec le fichier hôte, elle refuse seulement une longueur insuffisante ; elle accepte d’éventuelles données terminales.

readPrimaryVolumeDescriptor :: Handle -> IO IsoInfo
readPrimaryVolumeDescriptor handle = do
    descriptor <- readExactlyAt handle (16 * sectorSize) 2048
    let header = decodeDescriptorHeader descriptor

    unless
        (headerType header == 1
            && headerIdentifier header == BSC.pack "CD001"
            && headerVersion header == 1)
        (ioError (userError "no supported Primary Volume Descriptor at LBA 16"))

    let volumeField = BS.take 32 (BS.drop 40 descriptor)
        spaceField  = BS.take 8  (BS.drop 80 descriptor)
        blockField  = BS.take 4  (BS.drop 128 descriptor)

        (spaceLEBytes, spaceBEBytes) = BS.splitAt 4 spaceField
        (blockLEBytes, blockBEBytes) = BS.splitAt 2 blockField

        spaceLE = decodeLE spaceLEBytes :: Word32
        spaceBE = decodeBE spaceBEBytes :: Word32
        blockLE = decodeLE blockLEBytes :: Word16
        blockBE = decodeBE blockBEBytes :: Word16

    unless (spaceLE == spaceBE)
        (ioError (userError "inconsistent Volume Space Size copies"))
    unless (blockLE == blockBE)
        (ioError (userError "inconsistent Logical Block Size copies"))
    when (blockLE == 0)
        (ioError (userError "zero Logical Block Size"))

    hostSize <- hFileSize handle
    let declaredSize = toInteger spaceLE * toInteger blockLE
    when (hostSize < declaredSize)
        (ioError
            (userError
                ("truncated image: declared " <> show declaredSize
                    <> " bytes, host file has " <> show hostSize)))

    pure
        IsoInfo
            { isoVolumeIdentifier = BSC.dropWhileEnd (== ' ') volumeField
            , isoVolumeSpaceSize = spaceLE
            , isoLogicalBlockSize = blockLE
            }
Enter fullscreen mode Exit fullscreen mode

Exécutons ce bloc sur l’image :

isoInfo <- withBinaryFile imageFile ReadMode readPrimaryVolumeDescriptor
isoInfo
Enter fullscreen mode Exit fullscreen mode

La sortie importante rassemble les valeurs reconstruites dans le Shell :

IsoInfo {isoVolumeIdentifier = "PS3VOLUME", isoVolumeSpaceSize = 4401056, isoLogicalBlockSize = 2048}
Enter fullscreen mode Exit fullscreen mode

Ce résultat signifie que les contrôles explicitement codés ont réussi. Il ne prétend pas valider les nombreux champs du descripteur que nous n’avons pas lus.

Parcourir directement et sans débordement la séquence ISO 9660

Nous généralisons maintenant la lecture des sept octets d’en-tête à une LBA quelconque. Le scanner part de 16, exige CD001 et la version 1 à chaque étape, inclut le terminateur dans son résultat et s’y arrête. La limite de 64 descripteurs est un choix défensif du probe : elle transforme l’absence de terminateur proche en erreur au lieu de poursuivre sans borne.

readDescriptorHeader :: Handle -> Integer -> IO DescriptorHeader
readDescriptorHeader handle lba =
    decodeDescriptorHeader
        <$> readExactlyAt handle (lba * sectorSize) 7

scanIsoSequence :: Handle -> IO [(Integer, DescriptorHeader)]
scanIsoSequence handle = go 16 64 []
  where
    go lba remaining accumulated
        | remaining == 0 =
            ioError (userError "ISO descriptor scan limit reached")
        | otherwise = do
            header <- readDescriptorHeader handle lba
            unless
                (headerIdentifier header == BSC.pack "CD001"
                    && headerVersion header == 1)
                (ioError
                    (userError
                        ("invalid ISO descriptor header at LBA " <> show lba)))

            let located = (lba, header)
            if headerType header == 255
                then pure (reverse (located : accumulated))
                else go (lba + 1) (remaining - 1) (located : accumulated)
Enter fullscreen mode Exit fullscreen mode

Nous n’avons plus à connaître d’avance le nombre de descripteurs :

descriptors <- withBinaryFile imageFile ReadMode scanIsoSequence
map (\(lba, header) -> (lba, headerType header)) descriptors
Enter fullscreen mode Exit fullscreen mode

Nous obtenons :

[(16,1),(17,2),(18,255)]
Enter fullscreen mode Exit fullscreen mode

Le secteur 19 n’est jamais consommé par ce scanner ISO 9660. C’est exactement la différence observée entre la boucle Shell exploratoire et la règle du format.

Examiner tous les descripteurs supplémentaires et classifier Joliet

ECMA-119 autorise zéro ou plusieurs descripteurs supplémentaires. Sélectionner seulement le premier serait donc une hypothèse injustifiée. Nous lisons le champ Escape Sequences complet de chacun d’eux et conservons aussi les valeurs que notre petit probe ne sait pas classifier.

data JolietLevel
    = JolietLevel1
    | JolietLevel2
    | JolietLevel3
    deriving (Eq, Show)

data SupplementaryFinding
    = JolietFinding Integer JolietLevel
    | UnsupportedCharacterSet Integer BS.ByteString
    deriving (Eq, Show)

classifySupplementary
    :: Integer
    -> BS.ByteString
    -> SupplementaryFinding
classifySupplementary lba field =
    let prefix = BS.take 3 field
        zeroPadding = BS.all (== 0) (BS.drop 3 field)
        level
            | prefix == BSC.pack "%/@" = Just JolietLevel1
            | prefix == BSC.pack "%/C" = Just JolietLevel2
            | prefix == BSC.pack "%/E" = Just JolietLevel3
            | otherwise                 = Nothing
    in case (level, zeroPadding) of
        (Just jolietLevel, True) -> JolietFinding lba jolietLevel
        _ -> UnsupportedCharacterSet lba field

readSupplementaryFindings
    :: Handle
    -> [(Integer, DescriptorHeader)]
    -> IO [SupplementaryFinding]
readSupplementaryFindings handle allDescriptors =
    mapM readOne supplementaryLbas
  where
    supplementaryLbas =
        [ lba
        | (lba, header) <- allDescriptors
        , headerType header == 2
        ]

    readOne lba = do
        field <- readExactlyAt handle (lba * sectorSize + 88) 32
        pure (classifySupplementary lba field)
Enter fullscreen mode Exit fullscreen mode

Appliquons cette lecture à tous les candidats trouvés :

supplementaryFindings <- withBinaryFile imageFile ReadMode $ \handle ->
    readSupplementaryFindings handle descriptors

supplementaryFindings
Enter fullscreen mode Exit fullscreen mode

La sortie situe le résultat autant qu’elle le classe :

[JolietFinding 17 JolietLevel1]
Enter fullscreen mode Exit fullscreen mode

Le constructeur UnsupportedCharacterSet évite de transformer une séquence inconnue en absence de descripteur. Quant à JolietFinding, il atteste seulement le marqueur, jamais la conformité de l’arborescence.

Reconnaître le minimum propre à la PlayStation 3

Pour produire l’identifiant produit, 48 octets suffisent : le champ marqueur de 16 octets, puis le champ produit de 32 octets. Nous ne faisons pas dépendre cette reconnaissance de la table de plages, dont notre sortie n’a pas besoin.

readPs3ProductId :: Handle -> IO (Maybe BS.ByteString)
readPs3ProductId handle = do
    fields <- readExactlyAt handle sectorSize 48
    let marker = BS.take 16 fields
        productField = BS.drop 16 fields
        expectedMarker =
            BSC.pack "PlayStation3" <> BS.replicate 4 0

    pure
        (if marker == expectedMarker
            then Just (BSC.dropWhileEnd (== ' ') productField)
            else Nothing)
Enter fullscreen mode Exit fullscreen mode

Exécutons-la :

ps3ProductId <- withBinaryFile imageFile ReadMode readPs3ProductId
ps3ProductId
Enter fullscreen mode Exit fullscreen mode

Nous retrouvons :

Just "BLES-00932"
Enter fullscreen mode Exit fullscreen mode

Nothing signifierait seulement que ce marqueur d’implémentation n’a pas été reconnu. Cela ne suffirait pas à classifier universellement l’entrée comme « non-PlayStation 3 ».

Agréger les observations et les rendre dans un ordre stable

Il reste à ouvrir l’image une fois, à effectuer les lectures précédentes par offsets absolus, puis à construire les lignes reconnues. La structure finale conserve les descripteurs supplémentaires non pris en charge, même si le rendu demandé n’émet que les niveaux Joliet reconnus.

data ProbeResult = ProbeResult
    { resultPs3ProductId       :: Maybe BS.ByteString
    , resultIso9660            :: IsoInfo
    , resultSupplementary      :: [SupplementaryFinding]
    }
    deriving (Eq, Show)

probe :: FilePath -> IO ProbeResult
probe path =
    withBinaryFile path ReadMode $ \handle -> do
        isoDescriptors <- scanIsoSequence handle
        recognizedIso <- readPrimaryVolumeDescriptor handle
        supplementary <-
            readSupplementaryFindings handle isoDescriptors
        productId <- readPs3ProductId handle
        pure
            ProbeResult
                { resultPs3ProductId = productId
                , resultIso9660 = recognizedIso
                , resultSupplementary = supplementary
                }

renderJolietLevel :: JolietLevel -> String
renderJolietLevel JolietLevel1 = "1"
renderJolietLevel JolietLevel2 = "2"
renderJolietLevel JolietLevel3 = "3"

renderProbeResult :: ProbeResult -> [String]
renderProbeResult result =
    maybe
        []
        (\productId ->
            ["ps3.product-id=" <> BSC.unpack productId])
        (resultPs3ProductId result)
    ++ [ "iso9660.volume-id="
            <> BSC.unpack
                (isoVolumeIdentifier (resultIso9660 result))
       , "iso9660.logical-block-size="
            <> show (isoLogicalBlockSize (resultIso9660 result))
       ]
    ++ [ "joliet.level=" <> renderJolietLevel level
       | JolietFinding _ level <- resultSupplementary result
       ]
Enter fullscreen mode Exit fullscreen mode

Lançons enfin le probe et son rendu :

result <- probe imageFile
mapM_ putStrLn (renderProbeResult result)
Enter fullscreen mode Exit fullscreen mode

La sortie est exactement :

ps3.product-id=BLES-00932
iso9660.volume-id=PS3VOLUME
iso9660.logical-block-size=2048
joliet.level=1
Enter fullscreen mode Exit fullscreen mode

La portée exacte de ces quatre lignes

La première ligne repose sur un marqueur et une disposition de champs corroborés par plusieurs implémentations PlayStation 3 non normatives. Les deuxième et troisième lignes proviennent de champs précis du descripteur principal de volume ISO 9660, après contrôle de l’en-tête, des deux copies endian et de la taille minimale du fichier. La quatrième rapporte un marqueur Joliet de niveau 1 trouvé dans l’unique descripteur supplémentaire observé, après parcours borné de toute la séquence ISO 9660.

Elles ne prouvent ni la conformité complète à ISO 9660 ou Joliet, ni la validité des répertoires et des noms UCS-2, ni une révision UDF, ni l’état de chiffrement de l’image. Elles n’autorisent encore aucun parcours de répertoire, aucune extraction, aucun déchiffrement et aucune écriture. Elles disent seulement que quatre informations ont été reconnues selon les règles et les limites que nous avons rendues explicites.

Notre code direct révèle enfin une répétition concrète : plusieurs lectures fixes reviennent à combiner un offset, une largeur et une opération de décodage, puis à interpréter le résultat. Un deuxième article pourra partir de cette duplication observée — non d’une abstraction décidée à l’avance — pour étudier comment décrire ces opérations et leur donner plusieurs interprétations plus générales.

Top comments (0)