Hololive Dreams Reverse Engineering – Notizarchiv
Preface
Cutoff: 26/08/23 Revision: 26/08/23
Ein Reverse-Engineering-Protokoll zum Asset-System eines Unity-6-Mobile-Games. Aktueller Stand: Assets, Animationen, Gesicht, Haare, Kamera und Live2D sind vollständig lieferbar, die Spring-Bone-Physik ist das Einzige, was noch nicht gelöst ist.
Werkzeuge und das vollständige Kapitel-für-Kapitel-Logbuch liegen unter https://git.siao.ai/siao/hohohololive (ohne Entschlüsselung, Begründung siehe (8)).
Die Code-Ausschnitte in diesem Artikel sind zur Build-Zeit direkt aus SiaoHub eingebettet, nicht von Hand eingefügt, und geraten daher nicht vom Original-Code aus dem Takt; die Dateiseite auf SiaoHub markiert im Gegenzug, dass sie „von diesem Artikel referenziert" wird.
Der Name ist eine Verbeugung vor sssekai — dieses Projekt und sein Notizarchiv waren auf diesem Reverse-Engineering-Weg die nützlichste Referenz.
Analyseziel: game.qualiarts.hololive.dreams.com 1.0.0 (iOS, bereits entschlüsselte IPA), Unity 6000.3.0b1, IL2CPP metadata v39.
(1) Geräteverbindung und IL2CPP-Metadata v39
1. Übertragung: zuerst das Limit betrachten
Im App-Container ist Library/octo/ der Download-Cache für Assets, 3,4 GB groß. Das Unterverzeichnis v1/ enthält 178 .awb-, 1240 .acb- und 176 .usm-Dateien – alles CRIWARE-Audio-/Videoformate.
Über WLAN-SSH braucht scp für 12 MB elf Minuten. Die erste Reaktion: die Cipher wechseln (die Standard-Suite hat keine Hardwarebeschleunigung). Danach sieht es schlagartig schneller aus – das ist aber nur die Illusion des lokalen Festplatten-Caches. Bei großen Dateien zeigt sich der wahre Wert.
Wichtiger noch: Selbst wenn die Cipher wirklich etwas bringen würde, wäre es irrelevant – 3,4 GB über WLAN liegen so oder so im Bereich von Dutzenden Minuten. Also auf USB umsteigen:
iproxy 2222:22 & # SSH
iproxy 27042:27042 & # Frida| Pfad | Gemessen | Geschätzte Zeit für 3,4 GB |
|---|---|---|
| WLAN-SSH (Standard-Cipher) | ~18 KB/s | ca. 2 Tage |
| WLAN-SSH (gcm) | kleine Dateien wirken extrem schnell, große bleiben langsam | — |
USB (iproxy) |
40–70 MB/s | ca. 1 Minute |
Das komplette Verzeichnis wird als tar lokal gesichert, bevor irgendetwas weiter angefasst wird. Später muss der geräteseitige Cache mehrfach geleert werden, um den Download-Zeitpunkt zu beobachten – ohne Backup wäre jedes Leeren unwiderruflich.
2. Die Metadata ist verschlüsselt, die Binary nicht
$ xxd -l 8 global-metadata.dat
00000000: 8f2b 0d1c ... # erwartet AF 1B B1 FA
Der Magic-Wert stimmt nicht. Aber UnityFramework ist auf der Festplatte nicht verschlüsselt – die beiden muss man auseinanderhalten, was mir anfangs nicht gelang (siehe die Stolperfalle im Anhang).
Die Metadata muss zur Laufzeit entschlüsselt vorliegen, also wird der Speicher nach dem Magic-Wert durchsucht:
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--") durchsucht Segment für Segment, ein einziger Treffer:
magic = AF 1B B1 FA
version = 39
version = 39 ist eine gültige IL2CPP-Versionsnummer – das bestätigt den entschlüsselten Originalzustand statt eines Zufallstreffers.
3. Das Fehlschlagsmuster der Versions-Vortäuschung ist die eigentliche Antwort
Die neueste Version von Il2CppDumper unterstützt nur bis Version 31. Der Standardtrick ist, die Versionsnummer zu fälschen, weil sich das Format oft nicht ändert und nur die Nummer springt.
| Vorgetäuscht als | Ergebnis |
|---|---|
| 27 | fehlgeschlagen — Key-Konflikt |
| 29 | fehlgeschlagen — derselbe Key-Konflikt |
| 31 | fehlgeschlagen — derselbe Key-Konflikt |
Alle drei Versuche blieben an derselben Stelle hängen. Wäre es nur eine Versionssprung-Sache, müsste ein anderer alter Versionswert an einer anderen Stelle scheitern. Dass es dreimal identisch war, zeigt, dass die vom Parser gelesene Struktur genau an diesem Punkt von der Erwartung abweicht – das Format wurde also tatsächlich geändert.
Der Wert dieser Schlussfolgerung liegt darin, dass sie den ganzen Ast auf einmal schließt. „Noch eine Versionsnummer probieren" kostet jedes Mal nur dreißig Sekunden – deshalb ist es so leicht, endlos weiterzuprobieren.
Umgestiegen auf Il2CppInspectorRedux (LukeFZ-Fork), das eine vollständige Map von 560.000 Methodennamen auf virtuelle Adressen erzeugt.
4. Decompiler-Umgebung: die JVM umgehen
Ghidra braucht eine JVM, aber das AppleSystemPolicy-Kernelmodul dieser Maschine blockiert unsigniertes Java – das ist keine Einschränkung der Tool-Sandbox, sondern das System selbst; auch im eigenen Terminal direkt ausgeführt gibt es dasselbe Kill: 9.
Statt gegen das System anzukämpfen: rz-ghidra extrahiert den C++-Kern der Ghidra-Decompiler-Engine (SLEIGH + Decompiler) und kompiliert ihn als rizin-Plugin – zur Laufzeit wird keine JVM benötigt:
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/Stolperfalle 1: Es reicht nicht, nur die .dylib zu kopieren – die gebauten .sla-Dateien (kompilierte sleigh-Sprachspezifikationen) müssen zurück in den Quellbaum, in dieselbe Ebene wie die .slaspec, und SLEIGHHOME muss gesetzt werden. Die Fehlermeldung erwähnt nicht, dass eine Datendatei fehlt.
Stolperfalle 2: Fällt af in rizin -q -c "s <addr>; af; pdg" an einer Adresse in den Bereich einer anderen, früheren und größeren Funktion, übernimmt es deren bestehende Grenzen und dekompiliert unzusammenhängenden Funktionsinhalt. Ich habe deshalb einmal eine Adresse fälschlich für die von IL2CPP gemeinsam genutzte generische Dictionary-Lookup-Logik gehalten und zeitweise geglaubt, ein kritischer Wert stamme aus einer Tabellensuche statt berechnet zu sein.
Status: abgeschlossen.
References
(2) Schutzschicht lokalisieren: die offizielle Verschlüsselung ist gar nicht aktiv
Dieses Spiel nutzt die CRIWARE-Audio-Middleware, und CRIWARE bietet offizielle Verschlüsselung. In der Method-Map taucht tatsächlich auf:
Vision.Sound.CriWareDecrypter.Initialize(string key, bool enableAtom, bool enableMana)
Sieht danach aus, als wäre das die Lösung. Hook drauf, um die tatsächlichen Aufrufparameter zu sehen:
[+] CriWareDecrypter.Initialize
key = "..." (nicht leer)
enableAtom = false
enableMana = false
Beide Schalter stehen auf false – die offizielle Verschlüsselung ist überhaupt nicht aktiviert. Der Key wird zwar übergeben, aber niemand nutzt ihn. Was die Assets tatsächlich schützt, ist eine andere, selbstgebaute Schicht des Spiels (Vision.Octo.ResourceDecrypter).
Davor habe ich mehrere Stunden mit der CRIWARE-Dokumentation verbracht. Der Hook, der die tatsächlichen Werte zeigt, hat keine zehn Minuten gedauert.
Korrektur (zum Zeitpunkt der Analyse): Anfangs habe ich anhand oberflächlicher Merkmale (Bundles haben nicht das Klartext-Präfix der Audio-Gruppe, hohe Entropie am Anfang) entschieden, dass Audio und Bundle zwei getrennte Mechanismen sind, und bin dadurch einen großen Umweg gegangen (LZ4, AES, GPU-Hooks, Metal-Capture ausprobiert). Der eigentliche Durchbruch war die Rückkehr zu den Grundlagen: ein Known-Plaintext-Angriff – beide nutzen tatsächlich dasselbe Schema, nur Präfix und Startpunkt unterscheiden sich. Lehre: Oberflächliche Unterschiede sind nicht dasselbe wie mechanistische Unterschiede, und „sieht anders aus" wird leicht zum Grund, die Verifikation abzubrechen.
Status: abgeschlossen.
(3) Asset-Katalog und die jailbreak-freie Offline-Kette
1. Größenargument: warum kein passiver Hook
Zum Entschlüsseln wird für jede Datei der Original-Dateiname (address) benötigt. Der Dateiname steckt nicht in der verschlüsselten Datei, sondern im Asset-Katalog des Spiels.
Der passive Ansatz: die Entschlüsselungsfunktion hooken, das Spiel spielen und protokollieren, was jeweils geladen wird. Das funktioniert garantiert, technisch ohne jedes Risiko.
Das Problem ist die Größenordnung:
Verschlüsselte Dateien insgesamt 1467
Abdeckung durch passiven Hook abhängig davon, wie weit man spielt
Geschätzter Zeitaufwand hunderte Stunden
Abdeckungsgarantie keine (zeitlich begrenzte Event-Assets werden vielleicht nie ausgelöst)
Wenn die Kosten eines Weges „Zeit × Glück" sind und es keine Abdeckungsgarantie gibt, ist das kein Weg, sondern eine schiefe Ebene.
2. Erst messen, dann entscheiden, ob es sich lohnt
octo/pdb/5/100001/octocacheevai, 4,4 MB. Zuerst die Entropie berechnen:
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/byteVolle Punktzahl 8,0 – bestätigt echte Verschlüsselung statt nur Kompression oder Serialisierungsformat. Der Aufwand lohnt sich, bedeutet aber auch: keine Möglichkeit einer statischen Analyse.
3. Die entschlüsselte Form lokalisieren
Der Index muss zur Laufzeit entschlüsselt genutzt werden, also existiert die entschlüsselte Form zwangsläufig im Prozessspeicher. Man muss die Verschlüsselung gar nicht selbst brechen – nur die entschlüsselte Gestalt finden.
Die erste Version versuchte, „In-Place-Entschlüsselung" abzufangen: read() hooken, die Buffer-Adresse notieren, nach ein paar Sekunden Verzögerung denselben Bereich zurücklesen. Komplett falsch – der native Buffer wird nach Rückkehr der Funktion sehr schnell wiederverwendet (siehe Anhang).
Stattdessen: 60 Sekunden warten, bis der Index vollständig geladen ist, und dann den gesamten Prozessspeicher nach einem Dateinamen-String durchsuchen, von dem bekannt ist, dass er darin vorkommt.
Treffer-Block 16 MB
Trefferanzahl 3563
Dieser Block ist der vollständige entschlüsselte Asset-Katalog, im Protobuf-Format.
4. Eintragsstruktur
1a <len> # Eintrag (length-delimited submessage)
08 <varint> # 1 id -> Cache-Verzeichnisname = ("A"|"R") + id, hex-kodiert
12 <len> <bytes> # 2 name -> address (Original-Dateiname)
18 <varint> # 3 size -> Klartext-Byteanzahl
2a 20 <32 bytes> # 5 md5 -> Cache-Dateiname
3a <len> <bytes> # 7 objectName -> CDN-Objektschlüssel, 6-stelliger Zufallsstring
Beispiel (VisionProject.acf, Gesamtlänge 0x42 = 66 Bytes, Summe der Felder passt exakt):
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. Verifikation: Abgleich, nicht „sieht richtig aus"
Die Verzeichnisnamen im octo-Cache sind hex-kodiertes ASCII – 413138363439 entschlüsselt sich zu A18649. Damit lässt sich ("A"|"R") + id gegen die 4942 lokalen Cache-Dateien einzeln abgleichen:
id stimmt überein 4929
id stimmt nicht 13
nicht im Katalog 0
-> 99.74%
Alle 13 Abweichungen sind Abschneidungen an Speicherblockgrenzen: Am Anfang der address mischen sich Rauschzeichen wie * oder # ein (Beispiel: *vo_live_cmn_chr_0). Das ist ein Grenzproblem des Dumps, kein Problem der Parser-Logik – herausgefiltert wird einfach mit der Regel „das erste Zeichen von address muss alphanumerisch sein".
Dieser Schritt ist der wichtigste im ganzen Abschnitt. Wenn ein Parser einen „String, der wie ein Dateiname aussieht" ausgibt, liest er womöglich nur Rauschen. Man braucht eine unabhängige Quelle zum Abgleich – hier die Verzeichnisnamen des lokalen Caches.
Am Ende steht eine vollständige Zuordnungstabelle von Hash zu address mit 16.823 Einträgen, Batch-Abdeckung 99,93 %, 1595 Dateien, 1,5 GB.
6. objectName → CDN
Beim früheren Reversing von OctoAPI.DecryptAes war bereits eine CDN-Vorlage aufgetaucht:
https://asset.game-hololive-dreams.com/{o}
Damals war unklar, was {o} ist – wurde als Ablenkung abgehakt. Erst nach dem Entschlüsseln der Eintragsstruktur war klar: {o} ist genau das objectName-Feld 7.
GET https://asset.game-hololive-dreams.com/UFfHjj
User-Agent: UnityPlayer/6000.3.0b1
-> 1462529 bytes, md5 = 94bb0bfa4f2a82415c87fff62763ef6a
Der md5-Wert im Katalog wird über den Chiffretext berechnet, sodass die Integrität direkt nach dem Download geprüft werden kann, ohne vorher zu entschlüsseln.
Das bedeutet: Jailbreak wird nur noch für einen einzigen Zweck gebraucht – einmalig den Katalog zu beschaffen.
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. Feldweises Vorwärtsparsing: eine Annahme führte zu 94 % Fehlquote
Die erste Parser-Version nahm an, dass objectName (Feld 7) direkt auf md5 (Feld 5) folgt. Ergebnis: von 608 mdl_chr-Einträgen wurden nur 2 erfasst.
Dazwischen liegt ein wiederholtes Feld 6:
2a 20 <md5> 30 ca8402 30 e19402 30 809502 ... 3a 06 "SswxO0" 42 23 <address>
\____ 6 = abhängige Asset-IDs (repeated varint) ____/ \_ 7 = objectName
Jedes 3D-Modell hängt von Texturen und Materialien ab, deshalb hat jeder Eintrag Feld 6 – und alle wurden übersprungen. Umgestellt auf korrektes feldweises Vorwärtsparsing: entlang des Wire-Type schrittweise Tag → Länge → Wert vorrücken:
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
Für field == 7 gilt zusätzlich die Anforderung „Länge stimmt und alles ist druckbares ASCII", weil das Abschneiden an Speicherdump-Grenzen dazu führen kann, dass beim letzten Eintrag nur ein halbes Feld gelesen wird. Nach der Korrektur:
Vor der Korrektur 33799 / 36119 Einträge mit objectName
Nach der Korrektur 36119 / 36119
Nebenbei wurde auch die Abhängigkeitsliste (Feld 6) mit ausgelesen – praktisch für spätere Vorgänge, bei denen die Abhängigkeiten gleich mitgeholt werden.
Bei Protobuf ist die Feldreihenfolge nicht garantiert, und repeated-Felder haben zudem beliebige Länge. Felder über einen „Offset" zu lokalisieren wettet auf Implementierungsdetails des Serializers – verliert man diese Wette, gibt es keinen Fehler, sondern nur weniger Treffer. Eine Quote wie 2/608 fällt deutlich auf; 33799/36119 wäre nicht unbedingt aufgefallen.
8. Praxistest
Zu laden 944 Einträge (3D + fehlendes Live2D + mot_define), 1,01 GB
Erfolgreich 944
Übersprungen 0
Fehlgeschlagen 0
Vollständige Offline-Kette: CDN-Download → Verifikation → Entschlüsselung → Extraktion.
Status: abgeschlossen.
References
(4) 3D-Modelle: geriggtes glTF mit Skelettbindung
1. Vertex-Stream-Layout
stride(s) = Σ dimension × sizeof(format)
offset(0) = 0
offset(s) = align16(offset(s-1) + vertexCount × stride(s-1))
Gemessen am Character-Body:
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)
Verifikationsmethode: für jedes Mesh „berechnete Gesamtlänge vs. tatsächliche m_DataSize" vergleichen – stimmt in allen Fällen byteweise überein.
2. Zwei echte Bugs
(1) Die Anzahl der Knochen-Einflüsse ist nicht fix 4. Ursprünglich wurde angenommen, ch12/ch13 seien immer dim4, direkt [:, :4]:
| Mesh | ch12 | ch13 | Tatsächlich |
|---|---|---|---|
Geo_Body_LOD0 |
dim4 | dim4 | 4-Knochen-Mischung |
Geo_Eye_LOD0 |
dim2 | dim2 | 2-Knochen-Mischung |
Geo_Brow_LOD0 / Geo_Iris_LOD0 |
keins | dim1 | starre Einzelknochenbindung, Gewicht immer 1 |
Bei Meshes mit dim2 liefert [:, :4] nur ein 2 Spalten breites Array, wird aber als VEC4 deklariert → die Gewichtssumme wird −2,37 bis 3,02, der Joint-Index 63471. Augenbrauen und Iris wurden komplett als unrigged übersprungen, weil „kein ch12" vorhanden war. Korrektur: immer mit Nullen auf 4 Spalten auffüllen; fehlt ch12, wird es als starre Bindung behandelt und Slot 0 auf Gewicht 1,0 gesetzt.
(2) Vertexdaten werden auf zwei Arten gespeichert. Charaktermodelle sind eingebettet in m_VertexData.m_DataSize, aber Szenenteile liegen meist in einer externen Stream-Datei (.resS), auf die mesh.m_StreamData mit Pfad/Offset/Größe verweist. Ohne Behandlung ergab fbx_mdl_env_* durchweg 0 extrahierte Meshes. Behoben mit UnityPy.helpers.ResourceReader.get_resource_data().
3. Koordinatensystem-Konvertierung
Unity-Linkssystem → glTF-Rechtssystem, per Spiegelung der X-Achse M = diag(-1,1,1):
| Objekt | Transformation |
|---|---|
| Position / Normale / Tangente | (x,y,z) → (-x,y,z) |
| Rotationsquaternion | (x,y,z,w) → (x,-y,-z,w) |
| Inverse Bindungsmatrix | M' = S·M·S, S = diag(-1,1,1,1) |
| UV | v → 1-v (Unity-Ursprung unten links, glTF oben links) |
| Dreiecks-Wicklung | invertiert (Vorzeichenwechsel der Determinante) |
Herleitung für den Quaternion-Fall: R' = M R M, wobei M eine uneigentliche Transformation ist (det = −1). Das macht aus „Rotation um Achse a um θ" eine „Rotation um (aₓ, -a_y, -a_z) um θ" – eingesetzt in q = (sin(θ/2)·a, cos(θ/2)) ergibt sich die Formel.
Diese Regeln stehen im Docstring der Implementierung, nicht nur im Logbuch, weil sie bei jeder Änderung dieser Datei erneut geprüft werden müssen:
## 座標系轉換
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. Eine Stelle, an der eine Schlussfolgerung zweimal falsch war: die Outline-Hülle
Geo_Body_LOD0 hat 3 Submeshes mit 13932 / 240 / 13932 Flächen – Submesh 0 und 2 sind vollkommen identisch. Der Indexpuffer 41796 + 720 + 41796 = 84312 füllt exakt 168624 Bytes (uint16), das ist also kein Parserfehler, sondern echte Daten.
Erste Schlussfolgerung (falsch): Diese doppelten Submeshes hatten in renderer.m_Materials kein zugehöriges Material, also wurde geschlossen: „Unity rendert es deshalb nicht" – und „kein Material" wurde als Ausschlusskriterium verwendet.
Die Wahrheit: Das Material war die ganze Zeit da, nur als gemeinsames Material in einem abhängigen Bundle – ohne geladene Abhängigkeit lässt sich der PPtr einfach nicht auflösen. Nach Hinzufügen von load_bundle_with_deps() tauchen die Namen sofort auf:
Materialien: ['m_eye', 'm_bdy', 'm_bdyco', 'SubMeshOutlineMaterial', 'm_fef']
Geo_Body_LOD0 sub0 Flächen=13932 Material=m_bdy
Geo_Body_LOD0 sub1 Flächen= 240 Material=m_bdyco
Geo_Body_LOD0 sub2 Flächen=13932 Material=SubMeshOutlineMaterial <- Outline-Hülle
Inverted-Hull-Outline aus dem Toon-Rendering: dieselbe Geometrie, entlang der Normalen nach außen verschoben und mit umgedrehten Flächen, sodass nur die Rückseite gezeichnet wird.
„Dieses Ding hat kein X, also benutzt die Engine es nicht" – wenn X etwas ist, das erst über Bundle-Grenzen hinweg aufgelöst wird, kann die Prämisse dieses Satzes schlicht sein, dass man die Abhängigkeit nicht geladen hat.
5. BlendShape → glTF morph target
Gesichtsausdrücke laufen nicht über das Skelett, sondern über Blendshapes – und zwar nur auf den Gesichts-Meshes:
| Mesh | Kanalanzahl |
|---|---|
Geo_Eye_LOD0 |
16 |
Geo_Brow_LOD0 |
14 |
Geo_Iris_LOD0 |
2 |
Geo_Body_LOD0/1 |
0 (Körperverformung läuft komplett über das Skelett) |
In Summe 32 – exakt gleich viele wie die Kurven mit typeID 137 im Animations-Clip, was sich gegenseitig bestätigt.
Unity speichert das sparse: channels[] verweist auf einen Abschnitt von shapes[], shapes[i] wiederum auf einen Abschnitt von vertices[], wobei jeder Vertex seinen ursprünglichen Mesh-Index mitführt. glTF-Morph-Targets brauchen ein dichtes Verschiebungsarray in Mesh-Länge, also wird alles einzeln zurück expandiert (die Verschiebung braucht ebenfalls die X-Spiegelung).
channel.frameCount > 1 bedeutet eine progressive Verformung; ein glTF-Target kann nur eine Form darstellen, also wird das letzte Frame genommen. In der Praxis hat dieses Spiel durchweg frameCount = 1.
Status: abgeschlossen.
(5) 3D-Animation: CRC32-Rückwärtssuche und mot_define
1. Der Durchbruch war MonoBehaviour, nicht AnimationClip
genericBindings von AnimationClip speichert nur Hashes:
{'path': 1182008026, 'attribute': 1661978518, 'typeID': 137}Zuerst wurden CRC32-Werte der Knochenpfade aus skeleton.json der Charaktere berechnet und verglichen – 608 Skelette × alle Pfadformen, 0 Treffer.
Die eigentliche Antwort steckt im MonoBehaviour desselben Bundles (VisionActorMotionDefine): sein baseAnimation.bindings listet jede Bindung im Klartext:
{"name": "Geo_Eye_LOD0", "path": "Root_Body/Geo_Eye_LOD0",
"type": "UnityEngine.SkinnedMeshRenderer",
"properties": ["blendShape.b_eye.eye_001", "..."]}Der Animator-Root heißt Root_Body, nicht der Bundle-Name – das war der Grund für die fehlenden Treffer.
Die Hash-Funktion ist bestätigt CRC32(Klartext):
| String | CRC32 | Vorkommen im Clip |
|---|---|---|
b_eye.eye_001 |
1661978518 | erster Blendshape-Eintrag (ohne blendShape.-Präfix) |
m_FadeFactor |
682354173 | 6× = 6 DecalProjector |
bakeAnimationWeight |
3202011236 | 44× = 44 Swing-Bones |
Die Bindings aus allen 637 Bundles wurden zu einer globalen Rückwärtssuchtabelle zusammengeführt (das Wörterbuch eines einzelnen Bundles reicht nur für dessen eigene Einträge), was 520 Pfade / 198 Property-Namen ergab.
Das ist der übertragbarste Punkt dieses Abschnitts: Wenn ein Hash nicht passt, zuerst die Form des Eingabestrings anzweifeln, nicht die Hash-Funktion. Ich habe deutlich mehr Zeit mit „ist es vielleicht doch ein anderer Hash" verbracht als mit „ist vielleicht der Pfad-Präfix anders" – und die Antwort war Letzteres.
Status: abgeschlossen.
(6) Live2D: die native Schicht angreifen, nicht die obere
1. Drei Sackgassen
Der Reihe nach ausprobiert, alle gescheitert: die Vermutung, es sei LZ4-Kompression; der Fund von Octo.dll/Octo/Loader/OctoAPI.DecryptAes (irreführender Erfolg – der entschlüsselt API-Pakete, nicht Assets); das Hooken der Cubism-SDK-API auf IL2CPP-Ebene.
Die Art, wie der dritte Versuch scheiterte, zeigte die eigentliche Ursache: Cubism Core ist eine native C-Bibliothek, direkt statisch in UnityFramework eingebunden, ohne eigenes .framework – auf IL2CPP-Ebene gibt es also überhaupt nichts zu hooken.
2. Umstieg auf native Exportsymbole
Module.enumerateExports() listet alle nativen Exporte mit dem Präfix csm auf (44 insgesamt):
csmGetVersion
csmGetMocVersion / csmGetLatestMocVersion
csmHasMocConsistency
csmReviveMocInPlace ← Schlüsselfunktion
csmInitializeModelInPlace
csmUpdateModel
csmGetDrawable*-Familie
csmReviveMocInPlace(void* address, unsigned int mocSize) nimmt zwei Parameter: die Speicheradresse der bereits entschlüsselten, parsebaren moc3-Daten, und deren Größe. Es muss weder verstanden werden, ob die obere Schicht C# oder IL2CPP ist, noch der Verschlüsselungsalgorithmus selbst angefasst werden – sobald diese native Funktion aufgerufen wird, sind die Daten im Speicher zu diesem Zeitpunkt zu 100 % korrektes, verwendbares moc3 im Klartext.
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));
}
});Durch Module.findExportByName statt fest kodierter Adress-Offsets ist der Hook von Natur aus immun gegen ASLR und Adressverschiebungen bei Neustart.
3. Die allgemeine Form
Die übertragbare Form dieses Abschnitts verdient eine eigene Formulierung: die native Funktion finden, die den Klartext als Parameter entgegennimmt, und dort warten. Egal wie viele Schichten Verschlüsselung, Obfuskation oder Managed Runtime obendrauf liegen – die Daten müssen der Engine letztlich in einer Form übergeben werden, die sie versteht. Dieser Übergabepunkt ist die kostengünstigste Stelle, und er ändert sich naturgemäß nicht mit, wenn sich das Verschlüsselungsschema ändert.
Nach dem Beschaffen von .moc3 / .model3 / .physics3 lassen sich diese direkt im Cubism Editor öffnen; die Materialien müssen anhand der Informationen aus BuildModelData umbenannt werden.
Status: abgeschlossen (Textur-Atlas separat, siehe unten).
4. Ungelöst: der Textur-Atlas
Der zum moc3 gehörende Texture-Atlas wurde bis heute nicht erbeutet. Der Reihe nach wurden sieben Wege versucht, alle gescheitert (allesamt Frida-Hooks + manuelles Auslösen im Spiel); erst der Umstieg auf rein statische Lokalisierung des Ladepfads führte zur tatsächlichen Datenquelle. Zwischendurch wurde set_MainTexture fälschlich als korrekter Ansatzpunkt gehalten und erst nach weiterem Dekompilieren wieder verworfen.
Korrektur: Die Annahme zu
set_MainTextureerwies sich als falsch. Zum damaligen Zeitpunkt „sah es genau danach aus", und der Hook lieferte tatsächlich etwas – nur war das nicht das Gesuchte. Dass ein Hook etwas liefert, heißt nicht, dass er am richtigen Ort sitzt.
Status: unvollständig. Verbleibende Option ist Xcode Metal Frame Capture.
References
- https://github.com/OpenL2D/moc3ingbird (ImHex-Pattern für moc3)
(7) Spring-Bone-Physik (unvollständig)
Rocksaum, Bänder und Haare werden nicht mit der Animation mitgeliefert – das Baking deckt nur 51 Humanoid-Knochen ab, während das Modell tatsächlich 126 hat. Die fehlenden 75 (Rocksaum 31, Wangen 10, Bänder 8 …) werden zur Laufzeit vom Swing/Quartz-System des Spiels berechnet.
Aber die Parameter werden mitgeliefert, eingebettet im MonoBehaviour jedes Modell-Bundles:
ActorSwingDynamicBone sitzt auf _sim-Knochen der simulierte Knochen
ActorSwingStaticBone sitzt auf Körperknochen Kollisionskörper
ActorSwingChain sitzt auf hips Kettenstruktur
QuartzDriverSkirtBone sitzt auf _ast-Knochen prozeduraler Hilfsknochen
(Diese lassen sich nicht über die address im Katalog finden, weil address keinen MonoBehaviour-Klassennamen enthält – derselbe Fehler wie ursprünglich bei der Avatar-Suche.)
Der offline nachgebildete Integrator:
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 Gravitation
danach: harte Bone-Length-Constraint, dann Umrechnung in die Rotation des Elternknochens
CalcStiffnessPendulum (dynamicType == 0, betrifft 6900/6940 Knochen):
delta = rotate(parent.worldRot, boneAxis) Ruherichtung des Knochens
cos = |dot(cur, rest)| / (|cur|·|rest|) Winkel zwischen den beiden Positionsvektoren
p = max(0, cos - (1 - range)) / range × pendulum
return delta × (stiffness - p) × 0.01
Die Koordinaten liegen im Animator-Root-Space, nicht im Weltkoordinatensystem – daher lässt sich direkt mit der Skeletthierarchie aus dem GLB rechnen.
Die Haupt-Integrationsschleife in der Implementierung:
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
Aktueller Stand
Verifiziert wird per Frame-für-Frame-Vergleich gegen die Ground Truth des Spiels – bake_swing_truth.py zeichnet die lokale Rotation der _sim-Knochen desselben Clips aus dem Spiel auf und berechnet frameweise den Winkel:
Winkel = 2 · arccos(|dot(q_sim, q_truth)|)
Das Vorzeichen des Quaternions beeinflusst die Pose nicht, daher wird der Absolutwert genommen.
Fehler / Bewegungsamplitude der Ground Truth 110% (100% = Spring-Bone bewegt sich überhaupt nicht)
Immer noch geringfügig schlechter als „gar nichts tun". Das Symptom lässt sich auf einen einzigen Punkt eingrenzen: 1,50-facher Überschwung. Vier bereits implementierte, aber standardmäßig deaktivierte Optionen (jeweils benachteiligt, weil nachgelagert noch Dämpfung fehlt):
--quartz QuartzDriverSkirtBone treibt die _ast-Kettenwurzel 142%
--delta-current delta nutzt aktuelle statt Ruherichtung 153%
--collision Kugel vs. Kegel-Kapsel 202%
--vel Kraft wird in den Geschwindigkeitszustand akkumuliert 147%
Noch nicht implementiert: Wind (CalcWindPower, gemessen windPower = 0,7 ist aktiv), der vierte Glättungsdurchgang der Kettenknochen sowie der Multi-Varianten-_ast-Antrieb.
Korrektur: Die damalige Einschätzung „Wind verstärkt das Schwingen, Richtung stimmt nicht" führte dazu, dass es zurückgestellt wurde – das war eine Einschätzung auf einem Modell mit noch vier weiteren Bugs, muss neu getestet werden. Ein Ausschluss auf einer fehlerhaften Baseline zählt nicht als Ausschluss.
Status: unvollständig. Das ist der einzige noch offene Punkt im gesamten Projekt.
(8) Umfang der Veröffentlichung
Der Entschlüsselungsalgorithmus wurde vollständig gelöst und als vollständige mathematische Spezifikation sowie als ausführbares Tool niedergeschrieben. Dieser Teil ist weder hier noch im öffentlichen Repo enthalten.
Der Grund ist rechtlicher Natur. §80-2 des taiwanesischen Urheberrechtsgesetzes verbietet die Bereitstellung von „Geräten, Ausrüstung, Bauteilen, Techniken oder Informationen, die hauptsächlich der Umgehung von Kopierschutzmaßnahmen dienen" – das Wort „Informationen" umfasst nicht nur Code, sondern auch ein hinreichend klar geschriebenes Spezifikationsdokument, aus dem sich die Umsetzung nach der Lektüre selbst herleiten lässt. Absatz 3 desselben Paragrafen enthält eine Ausnahme für „Reverse Engineering zur Erzielung von Interoperabilität zwischen Informationen", die aber gemeinhin so verstanden wird, dass sie das Durchführen von Reverse Engineering abdeckt, nicht die öffentliche Veröffentlichung der Umgehungsmethode. Ein Graubereich, und ich bin kein Anwalt.
(Deshalb unterscheidet sich dieser Artikel in diesem Punkt vom Notizarchiv von sssekai – dort werden die vollständige Key-Table und AES-Key/IV veröffentlicht, hier nicht. Das ist meine vorsichtige Einschätzung für meine eigene Rechtsordnung, keine Bewertung der Vorgehensweise anderer.)
Die Werkzeuge werden getrennt veröffentlicht: Die Hälfte, die das Format parst (AssetBundle-Extraktion, Mesh/Skelett, glTF-Konvertierung, Live2D- und 3D-Animations-Dekodierung), ist Interoperabilitätsarbeit und öffentlich unter https://git.siao.ai/siao/hohohololive; die Entschlüsselungshälfte nicht.
Bei der Art der Trennung selbst gab es eine Wendung, die es wert ist, festgehalten zu werden. Ursprünglich war der übliche Ansatz geplant – „Key entfernen, Algorithmus behalten" –, aber das ging nicht auf: Der Key wird aus der address abgeleitet, das Ableitungsverfahren selbst ist der Key, es gibt kein eigenständiges Geheimnis, das man entfernen könnte. Und die einzige magische Konstante in der Implementierung ist ein einzelnes Byte; der bekannte Klartext steht im Dateikopf, 256 Möglichkeiten sind eine Brute-Force-Sache im Mikrosekundenbereich. Nur diese Konstante zu verdecken hätte den Aufwand um null gesenkt, aber etwas erzeugt, das „verdeckt aussieht, es aber nicht ist". Deshalb wurde die gesamte Schicht auf einmal entfernt.
Es wird kein Asset-Material veröffentlicht. Das Urheberrecht an den Spielressourcen liegt beim Publisher – kein einziges Byte davon befindet sich an irgendeiner Stelle, die ich veröffentliche.
Anhang: Stolperfallen
Übertragungsgeschwindigkeit mit kleinen Dateien testen. Nach dem Cipher-Wechsel wurde eine kleine Datei getestet, die im Moment des Schreibens in den Festplatten-Cache bereits als „übertragen" galt. Eine Verbesserung nach einer Änderung zu sehen, heißt nicht, dass die Änderung sie verursacht hat.
234 MB unnötigen Speicher gedumpt. UnityFramework war auf der Festplatte von Anfang an nicht verschlüsselt. Schlimmer noch: Die Speicherversion war sogar unbrauchbar:
Festplatte __TEXT dicht gepackt, file offset == vaddr - base
Speicher __TEXT page-aligned, die Differenz ist das Alignment-Padding der Segmente
Die Adressen passten nicht zusammen, das Parsing-Tool ging direkt kaputt. Verschlüsselt war die Metadata, nicht die Binary – ohne diese Unterscheidung wurden auf beide dieselben Gegenmaßnahmen angewendet.
Verzögertes Lesen des nativen Buffers. Der Buffer des nativen read() wird nach der Rückkehr sehr schnell wiederverwendet – verzögertes Lesen liefert nur unzusammenhängende Reste. Das wurde zeitweise fälschlich für Lookup-Logik, UTF16-Strings und bplist gehalten. Der Dump muss synchron in onLeave erfolgen:
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)); // sofort, nicht verzögerbar
}
});FD-Wiederverwendung verunreinigt die Tracking-Tabelle. Der Hook auf open() merkt sich interessante fds, räumt sie aber nicht bei close() auf. Sobald das System dieselbe fd-Nummer einer anderen Datei neu zuweist, wird unzusammenhängender Inhalt (UnityFS-Header, bplist00) fälschlich der Zieldatei zugeschrieben:
Interceptor.attach(Module.findExportByName(null, "close"), {
onEnter(args) { tracked.delete(args[0].toInt32()); }
});Die letzten beiden sind am wichtigsten festzuhalten, weil es nicht heißt „ich habe falsch geschlossen", sondern die Beobachtungsmethode selbst erzeugte Fake-Daten. Die Fake-Daten sahen exakt wie echte Daten aus, und ich habe sogar mehrere Erklärungen dafür konstruiert.
Werkzeuge
| Zweck | Werkzeug |
|---|---|
| USB-Portweiterleitung | libimobiledevice / iproxy |
| Dynamische Instrumentierung | Frida (Python-API, nicht CLI) |
| IL2CPP-Parsing | Il2CppInspectorRedux (LukeFZ-Fork) |
| Dekompilierung | rizin + rz-ghidra |
| Unity-Assets | UnityPy (FALLBACK_UNITY_VERSION = "6000.3.0b1") |
- Frida über die Python-API nutzen, nicht über die CLI. Die CLI hat Probleme mit Attach-Timeouts;
device.spawn() → attach() → resume()ist deutlich stabiler. - Die CLI von
Il2CppInspectorReduxkann scheinbar einfrieren. Der eingebaute SignalR-Webdienst kann den gesamten Prozess blockieren lassen, wobei alle Threads in__psynch_cvwaitidle sind. Mitsample <pid>zeigt sich, dass er nicht beschäftigt ist, sondern wartet. Eine leichtgewichtigere Ausgabeoption umgeht das Problem.