Rétro-ingénierie de Hololive Dreams — archive de notes

Preface

Cutoff: 26/08/23 Revision: 26/08/23

Journal de rétro-ingénierie du système de ressources d'un jeu mobile Unity 6. État actuel : les assets, animations, visages, cheveux, caméras et Live2D sont tous livrables ; la physique des os à ressort est la seule chose qui reste non résolue.

Les outils et le journal complet, chapitre par chapitre, sont sur https://git.siao.ai/siao/hohohololive (sans le déchiffrement, voir la raison en (8)).

Les extraits de code de cet article sont intégrés directement depuis SiaoHub au moment de la compilation, pas collés à la main — ils ne peuvent donc pas se désynchroniser du code source ; côté SiaoHub, la page du fichier indique aussi en retour qu'il est « référencé par cet article ».

Le nom rend hommage à sssekai — ce projet et son archive de notes ont été les références les plus utiles tout au long de cette rétro-ingénierie.

Cible d'analyse : game.qualiarts.hololive.dreams.com 1.0.0 (iOS, IPA déchiffré), Unity 6000.3.0b1, IL2CPP metadata v39.


(1) Connexion à l'appareil et metadata IL2CPP v39

1. Transfert : d'abord regarder le plafond

Dans le conteneur de l'app, Library/octo/ est le cache de téléchargement des ressources, 3,4 Go. Le sous-répertoire v1/ contient 178 fichiers .awb, 1240 .acb, 176 .usm — tous des formats audio/vidéo CRIWARE.

Sous SSH en WiFi, scp met 11 minutes pour transférer 12 Mo. Premier réflexe : changer de chiffrement (celui par défaut n'a pas d'accélération matérielle). Après le changement, ça semble instantanément plus rapide — un mirage dû à la mise en cache disque locale ; sur un gros fichier, la vérité ressort.

Mais plus important : même si le chiffrement avait vraiment fait une différence, ça n'aurait rien changé — 3,4 Go en WiFi, quel que soit le réglage, reste de l'ordre de plusieurs dizaines de minutes. Passage à l'USB :

iproxy 2222:22 &         # SSH
iproxy 27042:27042 &     # Frida
Chemin Mesuré Temps estimé pour 3,4 Go
WiFi SSH (chiffrement par défaut) ~18 Ko/s environ 2 jours
WiFi SSH (gcm) semble très rapide sur petit fichier, toujours lent sur gros fichier
USB (iproxy) 40–70 Mo/s environ 1 minute

Toute l'arborescence est récupérée en local via tar avant de toucher à quoi que ce soit. Plus tard, il faudra plusieurs fois vider le cache côté appareil pour observer le moment du téléchargement ; sans sauvegarde, chaque suppression serait irréversible.

2. Le metadata est chiffré, le binaire non

$ xxd -l 8 global-metadata.dat
00000000: 8f2b 0d1c ...       # attendu AF 1B B1 FA

Le magic ne correspond pas. Mais UnityFramework sur disque n'est pas chiffré — il faut bien distinguer les deux ; au début je ne l'ai pas fait (voir l'Annexe, pièges rencontrés).

Au runtime, le metadata est forcément déchiffré ; il suffit de scanner la mémoire à la recherche du magic :

import frida
 
MAGIC = b"\xaf\x1b\xb1\xfa"
 
def on_message(msg, data):
    if msg["payload"].get("event") == "metadata":
        open("global-metadata-decrypted.dat", "wb").write(data)
 
dev = frida.get_usb_device()
pid = dev.spawn(["game.qualiarts.hololive.dreams.com"])
ses = dev.attach(pid)
scr = ses.create_script(open("dump.js").read())
scr.on("message", on_message)
scr.load()
dev.resume(pid)

Process.enumerateRanges("r--") scanne segment par segment, une seule adresse correspond :

magic   = AF 1B B1 FA
version = 39

version = 39 est un numéro de version IL2CPP valide, ce qui confirme que c'est bien la version déchiffrée authentique, pas une coïncidence.

3. Le mode d'échec du camouflage de version est la vraie réponse

La dernière version d'Il2CppDumper ne supporte que jusqu'à la version 31. L'approche standard consiste à modifier le numéro de version pour tromper l'outil, car le format ne change souvent pas — seul le numéro saute.

Déguisé en Résultat
27 Échec — conflit de key
29 Échec — même conflit de key
31 Échec — même conflit de key

Les trois fois, ça plante au même endroit. Si ce n'était qu'un saut de numéro, changer vers différentes anciennes versions devrait planter à des endroits différents ; les trois résultats identiques indiquent que la structure lue par le parseur diverge de l'attendu exactement à ce point — c'est un format réellement mis à jour.

L'intérêt de cette conclusion, c'est qu'elle ferme toute une branche d'un coup. « Essayer encore un numéro de version » ne coûte que trente secondes à chaque fois, donc il est facile de continuer indéfiniment.

Passage à Il2CppInspectorRedux (fork de LukeFZ), qui produit une table complète de 560 000 correspondances entre noms de méthodes et adresses virtuelles.

4. Environnement de décompilation : contourner la JVM

Ghidra a besoin d'une JVM, mais le module noyau AppleSystemPolicy de cette machine bloque le java non signé — ce n'est pas une restriction du bac à sable de l'outil, c'est le système lui-même ; même exécuté directement dans son propre terminal, c'est le même Kill: 9.

Pas la peine de se battre contre le système. rz-ghidra extrait le cœur C++ du moteur de décompilation de Ghidra (SLEIGH + decompiler) et le compile en plugin rizin, qui n'a pas besoin de JVM à l'exécution :

git clone --recurse-submodules https://github.com/rizinorg/rz-ghidra.git
cd rz-ghidra && mkdir build && cd build
cmake -DCMAKE_BUILD_TYPE=Release .. && make -j$(sysctl -n hw.ncpu)
cp *.dylib /opt/homebrew/lib/rizin/plugins/

Piège 1 : copier le .dylib ne suffit pas — il faut aussi remettre le .sla compilé (la spécification du langage sleigh compilée) dans l'arbre source, au même niveau que le .slaspec, et définir SLEIGHHOME. Le message d'erreur ne mentionne pas qu'il manque un fichier de données.

Piège 2 : si af dans rizin -q -c "s <addr>; af; pdg" tombe, pour une adresse donnée, à l'intérieur de la plage d'une autre fonction plus ancienne et plus grande, il réutilise les limites existantes et décompile le contenu d'une fonction non pertinente. Cela m'a fait croire à tort qu'une certaine adresse correspondait à la logique de recherche dans un Dictionary générique partagé par IL2CPP, et j'ai un temps pensé qu'une valeur clé provenait d'une recherche en table plutôt que d'un calcul.

Statut : terminé.

References


(2) Localisation de la couche de protection : le chiffrement officiel n'est jamais activé

Ce jeu utilise le middleware audio CRIWARE, qui dispose d'un chiffrement officiel. La method map en contient bien un :

Vision.Sound.CriWareDecrypter.Initialize(string key, bool enableAtom, bool enableMana)

Ça a tout l'air d'être ça. On y accroche un hook pour voir les paramètres d'appel réels :

[+] CriWareDecrypter.Initialize
    key         = "..." (non vide)
    enableAtom  = false
    enableMana  = false

Les deux interrupteurs sont à false : le chiffrement officiel n'est jamais activé — la key est bien passée, mais personne ne l'utilise. Ce qui protège réellement les ressources, c'est une autre couche maison du jeu (Vision.Octo.ResourceDecrypter).

Avant ça, j'ai passé plusieurs heures à étudier la documentation de CRIWARE. Un simple hook pour voir les valeurs réelles a pris moins de dix minutes.

Correction (au moment de l'analyse) : au début, en me fiant à l'apparence (les bundles n'ont pas le préfixe en clair du groupe audio, entropie élevée dès le début), j'ai conclu que l'audio et les bundles étaient deux mécanismes distincts, ce qui m'a fait faire un grand détour (essayé LZ4, AES, hook GPU, capture Metal). La vraie percée est venue en revenant aux fondamentaux avec une attaque à texte clair connu — les deux partagent en fait le même système, seuls le préfixe et le point de départ diffèrent. Leçon : une différence en surface n'est pas une différence de mécanisme, et « ça a l'air différent » devient trop facilement une raison d'arrêter de vérifier.

Statut : terminé.


(3) Catalogue de ressources et chaîne hors ligne sans jailbreak

1. Argument d'échelle : pourquoi ne pas utiliser un hook passif

Le déchiffrement nécessite le nom de fichier original de chaque fichier (address). Ce nom n'est pas dans le fichier chiffré, il est dans le catalogue de ressources du jeu.

Approche passive : hooker la fonction de déchiffrement, jouer au jeu, enregistrer tout ce qui se charge. Ça marchera à coup sûr, aucun risque technique.

Le problème, c'est l'échelle :

Nombre total de fichiers chiffrés     1467
Couverture du hook passif             dépend de ce qu'on joue
Heures estimées                       plusieurs centaines
Garantie de couverture                aucune (les ressources d'événements limités dans le temps peuvent ne jamais se déclencher)

Quand le coût d'une voie est « temps × chance » et qu'elle n'offre aucune garantie de couverture, ce n'est pas une voie, c'est une pente glissante.

2. Mesurer d'abord, décider ensuite si ça vaut le coup

octo/pdb/5/100001/octocacheevai, 4,4 Mo. On calcule d'abord l'entropie :

from collections import Counter
import math
 
d = open(path, "rb").read()
c = Counter(d)
H = -sum((n / len(d)) * math.log2(n / len(d)) for n in c.values())
# -> 8.0 bits/byte

Score maximal de 8,0 : confirme que c'est un vrai chiffrement, pas seulement de la compression ou un format de sérialisation — ça vaut la peine d'y consacrer des efforts, et ça signifie aussi qu'il n'y a aucune possibilité d'analyse statique.

3. Localiser la forme déchiffrée

L'index doit forcément être déchiffré au runtime pour être utilisé, donc sa forme déchiffrée est nécessairement présente dans la mémoire du process. Pas besoin de casser son chiffrement, il suffit de trouver à quoi il ressemble une fois déchiffré.

Première tentative : capturer le « déchiffrement en place » — hooker read(), noter l'adresse du buffer, puis relire le même bloc après quelques secondes de délai. Complètement raté — le buffer natif est très vite récupéré et réaffecté après le retour de la fonction (voir l'Annexe).

Changement d'approche : attendre 60 secondes que l'index soit complètement chargé, puis scanner toute la mémoire du process à la recherche d'une chaîne de nom de fichier dont on sait qu'elle doit y figurer.

Bloc trouvé          16 Mo
Nombre d'occurrences  3563

Ce bloc est le catalogue de ressources complet une fois déchiffré, au format protobuf.

4. Structure d'une entrée

1a <len>              # entrée (sous-message délimité par longueur)
  08 <varint>         # 1  id          -> nom du répertoire cache = ("A"|"R") + id, puis encodé en hex
  12 <len> <bytes>    # 2  name        -> address (nom de fichier original)
  18 <varint>         # 3  size        -> taille en octets du texte clair
  2a 20 <32 bytes>    # 5  md5         -> nom du fichier cache
  3a <len> <bytes>    # 7  objectName  -> clé objet CDN, chaîne aléatoire de 6 caractères

Exemple concret (VisionProject.acf, longueur totale de l'entrée 0x42 = 66 octets, la somme champ par champ correspond exactement) :

1a 42  08 01  12 11 "VisionProject.acf"  18 89 77  2a 20 "bd19...8afb"  3a 06 "VQHQAP"
       id=1        name(17)              size=15241   md5(32)            objectName(6)

5. Validation : réconcilier les comptes, pas « ça a l'air correct »

Le nom de répertoire du cache octo est de l'ASCII encodé en hex — 413138363439 se décode en A18649. On peut donc réconcilier ("A"|"R") + id un par un avec les 4942 fichiers cache locaux :

id correspondants     4929
id non correspondants   13
absents du catalog       0
                     -> 99.74%

Les 13 non-correspondances sont toutes dues à des troncatures en bordure de bloc mémoire : des caractères de bruit comme * ou # s'immiscent au début de l'address (par exemple : *vo_live_cmn_chr_0). C'est un problème de bordure du dump, pas de logique de parsing — il suffit de filtrer avec la règle « le premier caractère d'address doit être alphanumérique ».

C'est l'étape la plus importante de toute cette section. Quand un parseur produit « une chaîne qui ressemble à un nom de fichier », il se peut qu'il ne fasse que lire du bruit. Il faut une source indépendante pour réconcilier les comptes — ici, les noms de répertoire du cache local.

Table de correspondance finale : 16823 entrées hash → address, avec une couverture de lot de 99,93%, 1595 fichiers, 1,5 Go.

6. objectName → CDN

En rétro-ingénierant OctoAPI.DecryptAes plus tôt, j'avais déjà obtenu un template CDN :

https://asset.game-hololive-dreams.com/{o}

À l'époque, je ne savais pas ce qu'était {o} et je l'avais pris pour un faux indice. Ce n'est qu'en résolvant la structure des entrées que j'ai compris : {o} est exactement l'objectName du field 7.

GET https://asset.game-hololive-dreams.com/UFfHjj
    User-Agent: UnityPlayer/6000.3.0b1
-> 1462529 bytes, md5 = 94bb0bfa4f2a82415c87fff62763ef6a

Le md5 dans le catalog est calculé sur le texte chiffré, donc l'intégrité peut être vérifiée directement après téléchargement, sans avoir besoin de déchiffrer d'abord.

Ce qui signifie que le jailbreak ne sert plus qu'à une seule chose : obtenir le catalog une fois.

DEFAULT_URL_FORMAT = "https://asset.game-hololive-dreams.com/{o}"
USER_AGENT = "UnityPlayer/6000.3.0b1"
 
 
def url_for(entry: dict, url_format: str = DEFAULT_URL_FORMAT) -> str:
    return url_format.replace("{o}", entry["object_name"])

在 SiaoHub 檢視 siao/hohohololive/hohohololive/cdn.py L21-26

7. Parsing par avancement de champ : une hypothèse qui a fait rater 94% des entrées

La première version du parseur supposait que objectName (field 7) suivait immédiatement md5 (field 5). Résultat : sur 608 entrées mdl_chr, seules 2 ont été capturées.

Entre les deux se glisse un field 6 répété :

2a 20 <md5>  30 ca8402  30 e19402  30 809502 ...  3a 06 "SswxO0"  42 23 <address>
             \____ 6 = id des assets dépendants (varint répété) ____/  \_ 7 = objectName

Chaque modèle 3D dépend de textures et de matériaux, donc tous ont un field 6, et tous étaient sautés. Passage à un parsing par avancement de champ en bonne et due forme — avancer champ par champ selon le wire type : tag → longueur → valeur :

        while p < n:
            tag = blob[p]
            field, wire = tag >> 3, tag & 7
            p += 1
            if wire == 0:                       # varint
                v = shift = 0
                while p < n:
                    b = blob[p]
                    v |= (b & 0x7F) << shift
                    p += 1
                    if not (b & 0x80):
                        break
                    shift += 7
                if field == 6:
                    deps.append(v)
                else:
                    break
            elif wire == 2:                     # length-delimited
                if p >= n:
                    break
                ln2 = blob[p]
                p += 1
                if ln2 & 0x80:                  # 兩位元組長度, 已超出本筆範圍
                    break
                val = blob[p:p + ln2]
                p += ln2
                if field == 7 and len(val) == ln2 and all(0x20 <= c < 0x7F for c in val):
                    obj = val.decode("ascii")
                break
            else:
                break
        out.append({"id": oid, "address": address, "size": size,
                    "md5": md5.decode(), "object_name": obj,
                    "dependencies": deps})

在 SiaoHub 檢視 siao/hohohololive/hohohololive/catalog.py L112-145

La branche field == 7 exige en plus que « la longueur corresponde et que tout soit de l'ASCII imprimable », car les troncatures de bordure du dump mémoire peuvent faire que la dernière entrée ne lise qu'un demi-champ. Après correction :

Avant correction  33799 / 36119 entrées ont un objectName
Après correction  36119 / 36119

Au passage, la liste des dépendances (field 6) a aussi été résolue, ce qui facilite plus tard un « téléchargement avec les dépendances ».

L'ordre des champs en protobuf n'est pas garanti, et les champs repeated sont de longueur arbitraire. Localiser un champ par « décalage » revient à parier sur les détails d'implémentation du sérialiseur ; quand le pari est perdu, ça ne plante pas, ça capture juste moins — et un ratio comme 2/608 est flagrant, alors que 33799/36119 ne serait pas forcément remarqué.

8. Test en conditions réelles

À télécharger   944 entrées (3D + Live2D manquant + mot_define), 1,01 Go
Réussi          944
Ignoré            0
Échoué            0

Chaîne hors ligne complète : téléchargement CDN → vérification → déchiffrement → extraction.

Statut : terminé.

References


(4) Modèles 3D : glTF avec rigging du squelette

1. Disposition des flux de sommets

stride(s) = Σ dimension × sizeof(format)
offset(0) = 0
offset(s) = align16(offset(s-1) + vertexCount × stride(s-1))

Mesuré sur le body d'un personnage :

stream0 stride 40  ch0 Position(3f)  ch1 Normal(3f)  ch2 Tangent(4f)
stream1 stride 16  ch3 Color(4×UNorm8) ch4 UV0(2×half) ch7 UV3(2f)
stream2 stride 32  ch12 BlendWeight(4f) ch13 BlendIndices(4×uint32)

Méthode de validation : pour chaque mesh, comparer « longueur calculée » vs m_DataSize réel — correspondance exacte, octet par octet, dans tous les cas.

2. Deux vrais bugs

(1) Le nombre d'os influents n'est pas toujours 4. J'avais supposé que ch12/ch13 étaient toujours en dim4, avec un simple [:, :4] :

Mesh ch12 ch13 Réel
Geo_Body_LOD0 dim4 dim4 mélange de 4 os
Geo_Eye_LOD0 dim2 dim2 mélange de 2 os
Geo_Brow_LOD0 / Geo_Iris_LOD0 absent dim1 os unique rigide, poids toujours 1

Pour les mesh en dim2, [:, :4] ne récupère qu'un tableau de largeur 2 mais le déclare comme VEC4 → la somme des poids devient −2,37 ~ 3,02, avec un index de joint de 63471. Les sourcils et l'iris, faute de ch12, étaient entièrement traités comme non riggés et sautés. Correction : toujours compléter avec des zéros jusqu'à 4 ; en l'absence de ch12, traiter comme un rigging rigide avec le poids du slot 0 fixé à 1,0.

(2) Les données de sommets ont deux modes de stockage. Les modèles de personnages sont intégrés dans m_VertexData.m_DataSize, mais les éléments de scène sont pour la plupart dans un fichier de flux externe (.resS), indiqué par path/offset/size dans mesh.m_StreamData. Sans traitement particulier, fbx_mdl_env_* produisait 0 mesh sur tout le lot. Passage à UnityPy.helpers.ResourceReader.get_resource_data() pour les récupérer.

3. Conversion de système de coordonnées

Unity (système gaucher) → glTF (système droitier), via une réflexion sur l'axe X M = diag(-1,1,1) :

Élément Conversion
Position / normale / tangente (x,y,z) → (-x,y,z)
Quaternion de rotation (x,y,z,w) → (x,-y,-z,w)
Matrice de bind inverse M' = S·M·S, S = diag(-1,1,1,1)
UV v → 1-v (origine Unity en bas à gauche, glTF en haut à gauche)
Sens des triangles inversé (le déterminant change de signe)

Dérivation de la ligne du quaternion : R' = M R M, où M est une transformation impropre (det = −1), qui transforme « rotation de θ autour de l'axe a » en « rotation de θ autour de (aₓ, -a_y, -a_z) » ; en substituant dans q = (sin(θ/2)·a, cos(θ/2)), on obtient le résultat.

Ces règles sont écrites dans le docstring de l'implémentation, pas seulement dans le journal, car ce sont des prémisses à revérifier à chaque modification de ce fichier :

## 座標系轉換
 
Unity 是左手系 (Y-up, Z-forward),glTF 是右手系 (Y-up, Z-back)。
以鏡射 X 軸 M = diag(-1,1,1) 轉換:
 
    位置/法線/切線   (x,y,z) -> (-x, y, z)
    旋轉四元數       (x,y,z,w) -> (x, -y, -z, w)
        推導: R' = M R M, M 為非正常變換 (det=-1),
        使繞軸 a 轉 θ 變成繞 (ax,-ay,-az) 轉 θ
    逆綁定矩陣       M' = S · M · S     (S = diag(-1,1,1,1))
    三角形環繞順序   必須反轉 (行列式變號)

在 SiaoHub 檢視 siao/hohohololive/hohohololive/gltf.py L27-37

4. Un endroit où le raisonnement s'est trompé deux fois : la coque de contour

Geo_Body_LOD0 a 3 sous-mesh, avec des nombres de faces de 13932 / 240 / 13932 — le sous-mesh 0 et le 2 sont identiques. Le buffer d'index 41796 + 720 + 41796 = 84312 remplit exactement 168624 octets (uint16), donc ce n'est pas une erreur de parsing, c'est une donnée réelle.

Premier raisonnement (faux) : ces sous-mesh dupliqués n'ont pas de matériau correspondant dans renderer.m_Materials, donc j'ai conclu qu'« Unity ne les rend donc pas », en utilisant « pas de matériau » comme critère d'exclusion.

La vérité : le matériau était toujours là, simplement placé dans un matériau partagé d'un bundle dépendant — sans charger la dépendance, le PPtr ne pouvait tout simplement pas se résoudre. Après ajout de load_bundle_with_deps(), les noms apparaissent directement :

Matériaux : ['m_eye', 'm_bdy', 'm_bdyco', 'SubMeshOutlineMaterial', 'm_fef']
  Geo_Body_LOD0 sub0 faces=13932 matériau=m_bdy
  Geo_Body_LOD0 sub1 faces=  240 matériau=m_bdyco
  Geo_Body_LOD0 sub2 faces=13932 matériau=SubMeshOutlineMaterial   <- coque de contour

Contour en inverted-hull typique du rendu cartoon : la même géométrie, extrudée le long des normales puis retournée pour ne dessiner que les faces arrière.

« Cet élément n'a pas de X, donc le moteur ne l'utilise pas » — quand X est quelque chose résolu à travers plusieurs bundles, la prémisse de cette phrase peut simplement être qu'on n'a pas chargé les dépendances.

5. BlendShape → glTF morph target

Les expressions faciales ne passent pas par le squelette mais par des blendshapes, et uniquement sur les mesh du visage :

Mesh Nombre de canaux
Geo_Eye_LOD0 16
Geo_Brow_LOD0 14
Geo_Iris_LOD0 2
Geo_Body_LOD0/1 0 (déformation du corps entièrement via le squelette)

Total : 32 — exactement le même nombre que les courbes de typeID 137 dans les clips d'animation, ce qui se confirme mutuellement.

Unity stocke ça de façon creuse (sparse) : channels[] pointe vers une plage de shapes[], shapes[i] pointe à son tour vers une plage de vertices[], chaque sommet portant son propre index dans le mesh d'origine. Le morph target de glTF a besoin d'un tableau de déplacements dense, de même longueur que le mesh, donc il faut tout redéployer un par un (les déplacements subissent aussi la réflexion en X).

channel.frameCount > 1 indique une déformation progressive ; un target glTF ne peut représenter qu'une seule forme, donc on prend la dernière frame. En pratique, dans ce jeu, tout est frameCount = 1.

Statut : terminé.


(5) Animation 3D : résolution inverse CRC32 et mot_define

1. La percée vient de MonoBehaviour, pas d'AnimationClip

Les genericBindings d'AnimationClip ne stockent que des hash :

{'path': 1182008026, 'attribute': 1661978518, 'typeID': 137}

Au départ, j'ai calculé le CRC32 des chemins d'os du skeleton.json du personnage pour comparer — 608 squelettes × toutes les formes de chemin possibles, 0 correspondance.

La vraie réponse se trouve dans le MonoBehaviour du même bundle (VisionActorMotionDefine) : son baseAnimation.bindings liste chaque liaison en clair :

{"name": "Geo_Eye_LOD0", "path": "Root_Body/Geo_Eye_LOD0",
 "type": "UnityEngine.SkinnedMeshRenderer",
 "properties": ["blendShape.b_eye.eye_001", "..."]}

Le nœud racine de l'Animator s'appelle Root_Body, pas le nom du bundle — c'est là la raison de la non-correspondance.

La fonction de hachage est confirmée être CRC32(texte clair) :

Chaîne CRC32 Apparition dans le clip
b_eye.eye_001 1661978518 première entrée blendshape (sans le préfixe blendShape.)
m_FadeFactor 682354173 6 fois = 6 DecalProjector
bakeAnimationWeight 3202011236 44 fois = 44 os swing

En rassemblant les bindings des 637 bundles dans une table de résolution inverse globale (le dictionnaire d'un seul bundle ne suffit qu'à résoudre ses propres entrées), on obtient 520 chemins / 198 noms de propriétés.

C'est le point le plus réutilisable de cette section : quand un hash ne correspond pas, il faut d'abord soupçonner la forme de la chaîne d'entrée, pas la fonction de hachage. J'ai passé bien plus de temps sur « et si c'était en fait un autre algorithme de hash » que sur « et si le préfixe du chemin était différent » — la réponse était pourtant la seconde hypothèse.

Statut : terminé.


(6) Live2D : attaquer la couche native, pas la couche supérieure

1. Trois voies sans issue

Essayées dans l'ordre, toutes échouées : supposer une compression LZ4 ; chercher Octo.dll/Octo/Loader/OctoAPI.DecryptAes (succès trompeur — il déchiffre les paquets API, pas les ressources) ; hooker l'API du Cubism SDK au niveau IL2CPP.

La façon dont la troisième voie échoue pointe vers la cause racine : Cubism Core est une bibliothèque C native, liée statiquement directement dans UnityFramework, sans .framework séparé — il n'y a donc tout simplement rien à hooker au niveau IL2CPP.

2. Attaquer plutôt les symboles exportés natifs

Module.enumerateExports() liste tous les exports natifs commençant par csm (44 au total) :

csmGetVersion
csmGetMocVersion / csmGetLatestMocVersion
csmHasMocConsistency
csmReviveMocInPlace   ← fonction clé
csmInitializeModelInPlace
csmUpdateModel
famille csmGetDrawable*

csmReviveMocInPlace(void* address, unsigned int mocSize) prend deux paramètres : l'adresse mémoire des données moc3 déjà déchiffrées et analysables, plus leur taille. Pas besoin de comprendre si la couche supérieure est en C# ou en IL2CPP, ni de toucher à l'algorithme de chiffrement lui-même — il suffit que cette fonction native soit appelée pour que les données en mémoire à ce moment soient un moc3 en clair 100% correct et utilisable.

const target = Module.findExportByName("UnityFramework", "csmReviveMocInPlace");
Interceptor.attach(target, {
  onEnter(args) {
    const addr = args[0], size = args[1].toInt32();
    send({event: "csmReviveMocInPlace", size}, addr.readByteArray(size));
  }
});

En utilisant Module.findExportByName plutôt qu'un offset d'adresse codé en dur, on est naturellement immunisé contre l'ASLR et les décalages d'adresse au redémarrage.

3. Forme générale réutilisable

La forme réutilisable de cette section mérite d'être écrite à part : trouver la fonction native qui « prend le texte clair en paramètre », et attendre là. Peu importe le nombre de couches de chiffrement, d'obfuscation ou de runtime managé au-dessus, les données doivent finir par être remises au moteur sous une forme qu'il comprend. Ce point de passage est l'endroit au coût le plus faible, et il ne change naturellement pas avec les révisions du schéma de chiffrement.

Une fois obtenus, les .moc3 / .model3 / .physics3 peuvent être ouverts directement dans Cubism Editor ; les matériaux doivent être renommés selon les informations de BuildModelData.

Statut : terminé (l'atlas de textures est traité séparément, voir ci-dessous).

4. Non résolu : l'atlas de textures

L'atlas de textures dédié au moc3 n'a toujours pas été obtenu. Sept voies essayées dans l'ordre, toutes échouées (toutes des hooks Frida + déclenchement manuel en jouant) ; c'est finalement une localisation purement statique du chemin de chargement qui a permis de trouver la vraie source des données. Entre-temps, set_MainTexture avait un temps été pris pour le bon point d'accroche, avant d'être infirmé après davantage de décompilation.

Correction : l'hypothèse set_MainTexture s'est révélée fausse. Sur le moment, ça « avait bien l'air d'être ça », et le hook remontait effectivement quelque chose — sauf que ce n'était pas ce qu'il fallait. Récupérer quelque chose via un hook ne veut pas dire hooker au bon endroit.

Statut : non terminé. L'option restante est Xcode Metal Frame Capture.

References


(7) Physique des os à ressort (non terminé)

Les jupes, rubans et cheveux ne sont pas livrés avec l'animation — le baking ne couvre que 51 os humanoid, alors que le modèle en a réellement 126 ; les 75 manquants (31 pour la jupe, 10 pour les joues, 8 pour les rubans…) sont calculés au runtime par les systèmes Swing/Quartz du jeu.

Mais les paramètres, eux, sont bien livrés, intégrés dans le MonoBehaviour de chaque bundle de modèle :

ActorSwingDynamicBone   attaché aux os _sim    os simulé
ActorSwingStaticBone    attaché aux os du corps  collisionneur
ActorSwingChain         attaché à hips           structure en chaîne
QuartzDriverSkirtBone   attaché aux os _ast      os auxiliaire procédural

(Ces éléments sont introuvables en cherchant dans l'address du catalog, parce que l'address ne contient pas le nom de classe du MonoBehaviour — la même erreur que celle commise au départ en cherchant l'Avatar.)

Intégrateur reconstitué hors ligne :

step    = min(dt, 1/60) × 40
inertia = (pos - prevPos) × (1 - damping)²
force   = CalcStiffnessPendulum(...) + childSpeed × spring
newPos  = pos + inertia + force × step
newPos.y -= mass × 0.01            gravité
                                   ensuite : contrainte rigide de longueur d'os, puis conversion en rotation de l'os parent

CalcStiffnessPendulum (dynamicType == 0, représente 6900/6940 os) :

delta = rotate(parent.worldRot, boneAxis)      direction de repos de l'os
cos   = |dot(cur, rest)| / (|cur|·|rest|)      angle entre les deux vecteurs de position
p     = max(0, cos - (1 - range)) / range × pendulum
return delta × (stiffness - p) × 0.01

Les coordonnées sont dans l'espace racine de l'animator, pas en coordonnées monde, donc il suffit de calculer directement avec la hiérarchie du squelette du GLB.

 
            for _ in range(n_sub + warm):
                cur, pv = pos[ni], prev[ni]
                inertia = (cur - pv) * (1.0 - damping) ** 2
                # cos 是 prevPos 與 pos 的夾角 (呼叫端 childTx=-0xf0=prevPos,
                # childDefaultTx=(s13,s11,s12)=pos)。0x02793a04 把 -0xf0
                # 寫回 child.selfTx.translation, 證實它就是子骨的位置。
                if pen <= 1e-5 or rng <= 1e-5:
                    p_term = 0.0
                else:
                    na, nb = np.linalg.norm(pv), np.linalg.norm(cur)
                    cosv = abs(float(np.dot(pv, cur))) / max(na * nb, 1e-9)
                    p_term = max(0.0, cosv - (1.0 - rng)) / rng * pen
                # delta = rotate(**當前**的 selfTx.rotation, boneAxis) —— §35 釘死:
                # selfTx 是迴圈攜帶狀態, 每個子步讀到的是上一子步的模擬結果,
                # 不是動畫給的靜止姿勢。骨的當前方向就是 (pos - 骨位置) 正規化。
                # 用動畫的 rest_dir 等於憑空多給一個遊戲裡沒有的角度回復力。
                if args.delta_current:
                    cd = cur - anchor      # §39: 原本用 WT[ni], 與骨長約束的基準不一致
                    cn = float(np.linalg.norm(cd))
                    dvec = cd / cn if cn > 1e-9 else rest_dir
                else:
                    dvec = rest_dir
                force = dvec * (stiff - p_term) * 0.01 + csp
                if args.vel == "off":
                    new = cur + inertia + force * step
                else:
                    # §36: 0x02793408/0x02793410 讀寫 [x20+0x54] 這個累加器 ——
                    #   fmul s1, s0, s5             力 × step
                    #   fmul v13.2s, v0.2s, v2.s[0] × swingPowerWeight (實測 1.0)
                    #   fadd v4.2s, v13.2s, v0.2s   累加進狀態
                    # 力不是加到位置, 是加進狀態; 狀態才改位置。
                    vel[ni] = vel[ni] * (1.0 - args.vel_damp) + force * step
                    if args.vel == "add":
                        new = cur + inertia + vel[ni] * step
                    else:
                        new = cur + vel[ni] * step
                new[1] -= mass * 0.01 * args.gravity_scale  # 重力 (每個子步)

在 SiaoHub 檢視 siao/hohohololive/sim_swing.py L705-742

État actuel

La méthode de validation consiste à comparer image par image avec la vérité terrain du jeu — bake_swing_truth.py enregistre depuis le jeu la rotation locale des os _sim pour le même clip, et calcule l'angle image par image :

angle = 2 · arccos(|dot(q_sim, q_truth)|)

Le signe du quaternion n'affecte pas la pose, d'où la valeur absolue.

erreur / amplitude de mouvement de référence   110%      (100% = os à ressort totalement immobile)

Toujours légèrement pire que « ne rien faire du tout ». Le symptôme converge vers un seul facteur : un survirage (over-swing) de 1,50 fois. Quatre éléments implémentés mais désactivés par défaut (chacun pénalisé par un manque d'amortissement en aval) :

--quartz          QuartzDriverSkirtBone pilote la racine de chaîne _ast   142%
--delta-current   delta utilise la direction actuelle au lieu de la direction de repos   153%
--collision       sphère vs capsule conique                                202%
--vel             la force s'accumule dans l'état de vitesse                147%

Encore non implémenté : le vent (CalcWindPower, mesuré avec windPower = 0.7 activé), la quatrième passe de lissage des chaînes d'os, et le pilotage multi-variantes de _ast.

Correction : la conclusion initiale « le vent augmente le balancement, la direction est fausse » a mené à la mise de côté de cette piste — mais ce jugement a été fait sur un modèle qui avait encore quatre bugs, il faut retester. Une exclusion faite sur une base de référence erronée n'en est pas une.

Statut : non terminé. C'est le seul point encore ouvert de tout le projet.


(8) Portée de la publication

L'algorithme de déchiffrement a été résolu et rédigé sous forme de spécification mathématique complète et d'outil exécutable. Cette partie n'est pas ici, ni dans le repo public.

La raison est légale. L'article 80-2 de la loi taïwanaise sur le droit d'auteur (著作權法) interdit de fournir « des équipements, appareils, pièces, techniques ou informations utilisés principalement pour contourner des mesures anti-copie » — le mot « informations » couvre non seulement le code, mais aussi un document de spécification suffisamment clair pour qu'un lecteur puisse le réimplémenter lui-même. Le troisième alinéa du même article prévoit une exception pour « la rétro-ingénierie effectuée aux fins d'interopérabilité entre informations », mais l'interprétation communément admise est qu'elle couvre le fait de pratiquer la rétro-ingénierie, pas de publier la méthode de contournement. Zone grise, et je ne suis pas juriste.

(C'est donc sur ce point que cet article diffère de l'archive de notes de sssekai — là-bas, la key table complète et les AES key/iv sont publiées, pas ici. C'est un jugement conservateur sur ma propre juridiction, pas une critique de la façon de faire des autres.)

L'outil est publié en deux parties séparées : la moitié qui parse les formats (extraction AssetBundle, mesh/squelette, conversion glTF, décodage Live2D et animation 3D) relève de l'interopérabilité et est publique sur https://git.siao.ai/siao/hohohololive ; la moitié déchiffrement ne l'est pas.

La façon même de découper mérite d'être notée. L'intention initiale était l'approche classique « retirer la clé, garder l'algorithme », mais ça ne tient pas : la clé est dérivée de l'address, la procédure de dérivation est elle-même la clé, il n'y a pas de secret indépendant à retirer. Et la seule constante magique de l'implémentation est un seul octet, avec le texte clair connu écrit dans l'en-tête — les 256 possibilités s'épuisent par force brute en microsecondes. Masquer uniquement cette constante réduirait l'effort d'attaque à zéro, tout en produisant quelque chose qui « a l'air masqué mais ne l'est pas ». Donc toute la couche est retirée d'un bloc.

Aucun asset n'est publié : le droit d'auteur des ressources du jeu appartient à l'éditeur, pas un seul octet ne se trouve dans quoi que ce soit que je publie.


Annexe : pièges rencontrés

Tester la vitesse de transfert avec un petit fichier. Après avoir changé de chiffrement, j'ai testé avec un petit fichier, qui a semblé « terminé » dès l'instant où il était écrit dans le cache disque. Observer une amélioration après un changement ne signifie pas que ce changement en est la cause.

Dumper 234 Mo de mémoire inutiles. UnityFramework sur disque n'était de toute façon pas chiffré. Pire, la version en mémoire s'est révélée inutilisable :

Disque    __TEXT compact, file offset == vaddr - base
Mémoire   __TEXT aligné sur les pages, les deux diffèrent du padding d'alignement de chaque segment

Les adresses ne correspondent pas, l'outil de parsing plante directement. Ce qui était chiffré, c'était le metadata, pas le binaire — sans faire la distinction, j'ai appliqué à tort les mêmes méthodes anti-chiffrement aux deux.

Lire un buffer natif en différé. Le buffer de read() natif est rapidement récupéré et réaffecté après le retour de la fonction ; lire en différé ne récupère que des résidus non pertinents — j'ai un temps cru à tort à de la logique de lookup, des chaînes UTF16, du bplist. Il faut dumper de façon synchrone au moment même de onLeave :

Interceptor.attach(Module.findExportByName(null, "read"), {
  onEnter(args) { this.buf = args[1]; },
  onLeave(ret) {
    const n = ret.toInt32();
    if (n > 0) send({tag: "read", n}, this.buf.readByteArray(n));  // maintenant, pas en différé
  }
});

La réutilisation de fd pollue la table de suivi. Le hook open() note les fd intéressants mais ne les nettoie pas dans close(). Une fois que le système réattribue le même numéro de fd à un autre fichier, du contenu sans rapport (en-têtes UnityFS, bplist00) est pris pour le contenu du fichier ciblé :

Interceptor.attach(Module.findExportByName(null, "close"), {
  onEnter(args) { tracked.delete(args[0].toInt32()); }
});

Les deux derniers cas sont les plus dignes d'être notés, car ce n'est pas « j'ai mal raisonné » — c'est la méthode d'observation elle-même qui fabriquait de fausses données. Ces fausses données ressemblaient exactement à de vraies données, et j'ai même construit plusieurs explications autour d'elles.


Outils

Usage Outil
Redirection de port USB libimobiledevice / iproxy
Instrumentation dynamique Frida (API Python, pas le CLI)
Parsing IL2CPP Il2CppInspectorRedux (fork de LukeFZ)
Décompilation rizin + rz-ghidra
Assets Unity UnityPy (FALLBACK_UNITY_VERSION = "6000.3.0b1")
  • Utiliser l'API Python de Frida, pas le CLI. Le CLI a des problèmes de timeout d'attach ; device.spawn() → attach() → resume() est bien plus stable.
  • Le CLI de Il2CppInspectorRedux peut sembler figé. Le service web SignalR intégré peut bloquer tout le process, avec tous les threads inactifs dans __psynch_cvwait. sample <pid> montre bien qu'il n'est pas occupé, il attend. Passer à une option de sortie plus légère permet de contourner le problème.

References