Hololive Dreams リバースエンジニアリング - ノートアーカイブ

Preface

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

Unity 6製スマホゲームのリソースシステムを対象にしたリバースエンジニアリングの記録。現状:アセット、モーション、顔、髪、カメラ、Live2Dはすべて成果物として出せる状態にあり、スプリングボーン物理だけがまだ解けていない。

ツールと章ごとの完全なログは https://git.siao.ai/siao/hohohololive にある(復号処理は含まない、理由は8章を参照)。

本文のコードスニペットはビルド時にSiaoHubから直接埋め込まれるもので、手貼りではないため原本のコードと乖離しない。SiaoHub側のファイルページにも「この記事から参照されている」という逆リンクが表示される。

命名は sssekai へのオマージュ —— あのプロジェクトと、そのノートアーカイブは、今回のリバースエンジニアリングで最も役に立った参考資料だった。

分析対象:game.qualiarts.hololive.dreams.com 1.0.0(iOS、復号済みIPA)、Unity 6000.3.0b1、IL2CPP metadata v39。


1. デバイス接続とIL2CPP metadata v39

1. 伝送:まず天井を見る

appコンテナ下の Library/octo/ はリソースダウンロードキャッシュで、3.4GB。サブディレクトリ v1/ には178個の .awb、1240個の .acb、176個の .usm があり、すべてCRIWAREの音声/動画フォーマットだ。

WiFi経由のSSHで scp を使い12MBを転送するのに11分かかった。最初の反応はcipherを変えること(デフォルトのものはハードウェアアクセラレーションがない)だったが、変えた瞬間に速くなったように見えた——それはローカルディスクバッファによる錯覚で、大きいファイルを転送すればすぐに地が出た。

だがもっと重要なのは、cipherが本当に効いていたとしても意味がないということだ。3.4GBはWiFi上でどう調整しても数十分単位になる。USB経由に切り替えた。

iproxy 2222:22 &         # SSH
iproxy 27042:27042 &     # Frida
パス 実測 3.4GB推定時間
WiFi SSH(デフォルトcipher) ~18 KB/s 約2日
WiFi SSH(gcm) 小さいファイルは一見速いが、大きいファイルはやはり遅い
USB(iproxy 40–70 MB/s 約1分

何かを動かす前に、まるごと tar でローカルに引き上げておく。この後、デバイス側のキャッシュを消してダウンロードのタイミングを観測する必要が何度もあったが、バックアップがなければ消すたびに後戻りできない。

2. metadataは暗号化されている、binaryはされていない

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

magicが合わない。しかしディスク上の UnityFramework は暗号化されていない——この2つを混同してはいけない。私は最初混同していた(付録「踏んだ罠」を参照)。

metadataは実行時には必ず復号されているはずなので、メモリをスキャンして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--") で1ページずつスキャンし、単一のアドレスでヒットした。

magic   = AF 1B B1 FA
version = 39

version = 39 は正当なIL2CPPバージョン番号であり、これが偶然のヒットではなく復号後の本物であることを示している。

3. バージョン偽装の失敗パターンこそが答え

Il2CppDumper の最新版は31までしか対応していない。定石はバージョン番号を偽装してごまかすことだ——フォーマット自体は変わらず番号だけ飛んでいることが多いから。

偽装先 結果
27 失敗 —— keyの衝突
29 失敗 —— 同じkeyの衝突
31 失敗 —— 同じkeyの衝突

3回とも同じ場所で止まった。単に番号が飛んでいるだけなら、違う古いバージョンに変えれば違う場所で壊れるはずだ。3回まったく同じということは、パーサが読んでいる構造がその時点で期待値から分岐しているということ——つまり本当にフォーマットが更新されている。

この判断の価値は、一手で分岐全体を閉じられる点にある。「もう一つ別のバージョン番号を試す」のは毎回30秒しかかからないので、いくらでも続けてしまいがちだ。

Il2CppInspectorRedux(LukeFZ fork)に切り替え、56万件のメソッド名から仮想アドレスへの完全なマップを出力した。

4. 逆コンパイル環境:JVMを迂回する

Ghidraは JVM を必要とするが、このマシンの AppleSystemPolicy カーネルモジュールが未署名のjavaをブロックしている——これはツールのサンドボックス制限ではなくシステムそのものによるもので、自分のターミナルで直接実行しても同じく Kill: 9 になる。

システムと戦わない。rz-ghidra はGhidraの逆コンパイルエンジンのC++コア(SLEIGH + decompiler)を取り出してrizinプラグインとしてビルドしたもので、実行時にJVMを必要としない。

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/

罠その1.dylib をコピーするだけでは不十分で、ビルドで生成された .sla(コンパイル済みsleigh言語仕様)をソースツリー内の .slaspec と同じ階層に置き、SLEIGHHOME を設定する必要がある。エラーメッセージはデータファイルが足りないとは教えてくれない。

罠その2rizin -q -c "s <addr>; af; pdg"af が、あるアドレスがそれより前にある別の大きな関数の範囲に収まってしまう場合、既存の境界をそのまま使ってしまい、無関係な関数の内容が逆コンパイルされる。これが原因で、あるアドレスをIL2CPP共有の汎用Dictionary検索ロジックだと誤判定し、一時期はある重要な値が計算値ではなくルックアップによるものだと思い込んでいた。

状態:完了。

References


2. 保護層の見極め:公式暗号化はそもそも有効になっていない

このゲームはCRIWAREの音声ミドルウェアを使っており、CRIWAREには公式の暗号化機能がある。method mapには確かに次のものがある。

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

見るからにそれっぽい。実際の呼び出し引数をフックして確認した。

[+] CriWareDecrypter.Initialize
    key         = "..." (空ではない)
    enableAtom  = false
    enableMana  = false

両方のスイッチがfalseで、公式の暗号化はそもそも有効化されていなかった——keyは渡されているが誰も使っていない。実際にリソースを保護しているのはゲーム自作の別レイヤー(Vision.Octo.ResourceDecrypter)だった。

これに気づく前、CRIWAREのドキュメントを何時間も読み込んでいた。実際の値をフックして確認するのに10分もかからなかった。

訂正(分析当時):最初は表面的な観察(bundleには音声系のような平文プレフィックスがなく、先頭のエントロピーが高い)だけで、音声とbundleは別々の2つの仕組みだと結論づけ、大回りをした(LZ4、AES、GPU hook、Metal captureを試した)。本当のブレイクスルーは基本に立ち返って既知平文攻撃をやることだった——実は両者は同じ仕組みを共有していて、違うのはプレフィックスと開始位置だけだった。教訓:表面上の違いは仕組みの違いを意味しない。「違って見える」は検証を止める理由に簡単になってしまう。

状態:完了。


3. リソースカタログと脱獄不要のオフラインチェーン

1. 規模の論証:なぜ受動的フックを使わないか

復号にはファイルごとの元のファイル名(address)が必要になる。ファイル名は暗号化ファイルの中にはなく、ゲームのリソースカタログの中にある。

受動的な方法:復号関数をフックし、ゲームをプレイして、ロードされたものを記録していく。技術的には確実に成功するし、リスクもない。

問題は規模だ。

暗号化ファイル総数      1467
受動フックのカバー率    どこまでプレイしたか次第
推定所要時間            数百時間
カバー率の保証          なし(期間限定イベントのリソースは永遠にトリガーされない可能性がある)

あるルートのコストが「時間 × 運」で、しかもカバー率の保証がないとき、それはルートではなく傾斜(スロープ)だ。

2. まず測ってから、やる価値があるか決める

octo/pdb/5/100001/octocacheevai、4.4MB。まずエントロピーを計算する。

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

満点の8.0で、これが単なる圧縮やシリアライズフォーマットではなく本物の暗号化であることが確認できた——労力をかける価値がある一方、静的解析だけで解ける可能性がないことも意味する。

3. 復号後の形を特定する

インデックスは実行時に必ず復号されて使われるので、復号後の形は必ずプロセスのメモリ上に存在する。暗号を解く必要はなく、復号後の姿を見つけるだけでいい。

最初のバージョンは「その場での復号」を捕まえようとした:read() をフックしてバッファのアドレスを記録し、数秒待ってから同じ場所を読み直す。これは完全に間違っていた——ネイティブバッファは関数から戻った直後にすぐ回収され、別の用途に使い回される(付録参照)。

やり方を変えた:60秒待ってインデックスが完全にロードされるのを待ち、プロセスメモリ全体に対して、中に含まれているはずの既知のファイル名文字列でスキャンをかける。

ヒット領域    16 MB
ヒット件数    3563

その領域こそが復号済みの完全なリソースカタログで、protobuf形式だった。

4. エントリ構造

1a <len>              # エントリ (length-delimited submessage)
  08 <varint>         # 1  id          -> キャッシュディレクトリ名 = ("A"|"R") + id、その後hexエンコード
  12 <len> <bytes>    # 2  name        -> address(元のファイル名)
  18 <varint>         # 3  size        -> 平文のバイト数
  2a 20 <32 bytes>    # 5  md5         -> キャッシュファイル名
  3a <len> <bytes>    # 7  objectName  -> CDNオブジェクトキー、6文字のランダム文字列

実例(VisionProject.acf、エントリ全体の長さ 0x42 = 66 bytes、各フィールドを足すとぴったり一致する)。

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. 検証:「合っていそう」ではなく突き合わせる

octoキャッシュのディレクトリ名はhexエンコードされたASCIIで——413138363439 を解くと A18649 になる。つまり ("A"|"R") + id を使えば、ローカルにある4942個のキャッシュファイルと1件ずつ突き合わせられる。

idが一致        4929
idが不一致        13
catalogにない      0
                     -> 99.74%

不一致の13件はすべてメモリ領域の境界での切り詰めが原因だった:addressの先頭に *# などのノイズ文字が混入していた(例:*vo_live_cmn_chr_0)。これはdumpの境界の問題であってパースロジックの問題ではなく、「addressの先頭文字は英数字でなければならない」というフィルタで除去できる。

このステップが一連の作業の中で最も重要だ。パーサが「ファイル名っぽい文字列」を出力しているとき、それは単にノイズを読んでいるだけかもしれない。突き合わせできる独立した情報源が必要で——ここではローカルキャッシュのディレクトリ名がそれにあたる。

最終的にhashとaddressの完全な対応表が16823件、バッチカバー率99.93%、1595ファイル、1.5GBになった。

6. objectName → CDN

以前 OctoAPI.DecryptAes を逆解析していたときに、CDNのテンプレートを一つ解いていた。

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

その時は {o} が何なのか分からず、ただの目くらましだと思っていた。エントリ構造を解いてから分かった:{o} はまさにフィールド7の objectName だった。

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

catalog内のmd5は暗号文に対して計算されているので、ダウンロード後にそのまま完全性を検証でき、事前に復号する必要はない。

つまり脱獄が必要なのは「catalogを一度取得する」という一点だけになる。

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. フィールド前進パース:一つの仮定が94%の取りこぼしを生んだ

最初のバージョンのパーサは、objectName(フィールド7)が md5(フィールド5)の直後に来ると仮定していた。結果、608件の mdl_chr のうちわずか2件しか取れなかった。

間には繰り返されるフィールド6が挟まっていた。

2a 20 <md5>  30 ca8402  30 e19402  30 809502 ...  3a 06 "SswxO0"  42 23 <address>
             \____ 6 = 依存アセットid (repeated varint) ____/  \_ 7 = objectName

3Dモデルはそれぞれテクスチャとマテリアルに依存しているので、全部フィールド6を持っており、全部スキップされていた。まっとうなフィールド前進パースに切り替えた——wire typeに従ってtag→長さ→値の順に1フィールドずつ進める。

        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

field == 7 の条件にはさらに「長さが一致し、かつ全て印字可能なASCIIであること」を追加した。メモリdumpの境界切り詰めによって最後のエントリが半端なフィールドとして読まれてしまうことがあるからだ。修正後:

修正前  33799 / 36119 件にobjectNameあり
修正後  36119 / 36119

ついでに依存リスト(フィールド6)も解けたので、以降「依存も一緒に取得する」のが簡単になった。

protobufのフィールド順序に保証はなく、repeated フィールドの長さも任意だ。「オフセット量」でフィールドを特定するのはシリアライザの実装詳細に賭けているようなもので、賭けに負けてもエラーにはならず、ただ取りこぼすだけになる——そして2/608のような比率は一目瞭然だが、33799/36119は気づかれないまま終わってもおかしくない。

8. 実測

ダウンロード対象   944件 (3D + 不足しているLive2D + mot_define), 1.01 GB
成功              944
スキップ            0
失敗                0

完全なオフラインチェーン:CDNダウンロード → 検証 → 復号 → 抽出。

状態:完了。

References


4. 3Dモデル:ボーンバインド付きglTF

1. 頂点ストリームレイアウト

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

キャラクター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)

検証方法:各メッシュについて「計算した合計長 vs 実際の m_DataSize」を突き合わせ、すべてバイト単位で一致することを確認した。

2. 実在した2つのバグ

(1) ボーン影響数は4固定ではない。 最初はch12/ch13が常にdim4だと仮定して、そのまま [:, :4] としていた。

メッシュ ch12 ch13 実際
Geo_Body_LOD0 dim4 dim4 4ボーンブレンド
Geo_Eye_LOD0 dim2 dim2 2ボーンブレンド
Geo_Brow_LOD0 / Geo_Iris_LOD0 なし dim1 剛体の単一ボーン、ウェイトは常に1

dim2のメッシュに対して [:, :4] を使うと、2幅の配列しか取れていないのにVEC4だと宣言してしまい→ウェイトの合計が−2.37〜3.02、jointインデックスが63471になってしまう。眉と虹彩は「ch12がない」という理由でバインドなしとして丸ごとスキップされていた。修正:常に4幅までゼロ埋めする。ch12がない場合は剛体バインドとみなし、0番目のスロットのウェイトを1.0にする。

(2) 頂点データには2種類の保存方式がある。 キャラクターモデルは m_VertexData.m_DataSize に内蔵されているが、シーンパーツの多くは外部ストリームファイル(.resS)に置かれておりmesh.m_StreamData がpath/offset/sizeを指し示している。これを処理していなかったため、fbx_mdl_env_* は一括で0メッシュとして解けていた。UnityPy.helpers.ResourceReader.get_resource_data() を使って取得するように変更した。

3. 座標系変換

Unityの左手系 → glTFの右手系へは、X軸のミラー M = diag(-1,1,1) で変換する。

対象 変換
位置 / 法線 / 接線 (x,y,z) → (-x,y,z)
回転クォータニオン (x,y,z,w) → (x,-y,-z,w)
逆バインド行列 M' = S·M·SS = diag(-1,1,1,1)
UV v → 1-v(Unityの原点は左下、glTFは左上)
三角形の巻き順 反転(行列式の符号が変わる)

クォータニオンの式の導出:R' = M R MM は非正規変換(det = −1)で、「軸 a の周りにθ回転」を「軸 (aₓ, -a_y, -a_z) の周りにθ回転」に変え、q = (sin(θ/2)·a, cos(θ/2)) に代入すれば得られる。

これらの規則はログだけでなく実装のdocstringにも書いてある。このファイルを触るたびに毎回確認し直す必要のある前提だからだ。

## 座標系轉換
 
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. 2度間違えた推論:アウトラインシェル

Geo_Body_LOD0 には3つのサブメッシュがあり、面数は13932 / 240 / 13932——0番目と2番目がまったく同じだ。インデックスバッファは41796 + 720 + 41796 = 84312で、ちょうど168624バイト(uint16)を埋め尽くす。つまりこれはパースミスではなく、本物のデータだった。

1度目の推論(誤り):これらの重複したサブメッシュは renderer.m_Materials に対応するマテリアルがなかったので、「マテリアルがないのでUnityはこれをレンダリングしない」と判断し、「マテリアルなし」を除外条件として使った。

真相:マテリアルはずっと存在していたが、依存bundleの中にある共有マテリアルとして置かれていて、依存関係をロードしていなかったのでPPtrが解決できなかっただけだった。load_bundle_with_deps() を追加したら名前がそのまま出てきた。

マテリアル: ['m_eye', 'm_bdy', 'm_bdyco', 'SubMeshOutlineMaterial', 'm_fef']
  Geo_Body_LOD0 sub0 面=13932 マテリアル=m_bdy
  Geo_Body_LOD0 sub1 面=  240 マテリアル=m_bdyco
  Geo_Body_LOD0 sub2 面=13932 マテリアル=SubMeshOutlineMaterial   <- アウトラインシェル

トゥーンレンダリングのinverted-hullアウトライン:同じジオメトリを法線方向に外側へ押し出し、面を反転させて裏面だけを描画する手法だ。

「このオブジェクトにはXがないのでエンジンはそれを使わない」——Xがbundleをまたいで解決される種類のものである場合、この文の前提は単に依存関係をロードしていないだけかもしれない。

5. BlendShape → glTF morph target

表情はボーンではなくblendshapeに依存していて、しかも顔のメッシュにしか存在しない

メッシュ チャンネル数
Geo_Eye_LOD0 16
Geo_Brow_LOD0 14
Geo_Iris_LOD0 2
Geo_Body_LOD0/1 0(体の変形はすべてボーン任せ)

合計32——モーションclip内のtypeID 137のカーブ数とぴったり一致し、互いに裏付けとなっている。

Unityはスパースな形で保存している:channels[]shapes[] の一区間を指し、shapes[i] がさらに vertices[] の一区間を指し、各頂点は元のメッシュのインデックスを自前で持っている。glTFのmorph targetにはメッシュと同じ長さの密な変位配列が必要なので、1件ずつ展開し直す(変位にも同じX軸ミラーを適用する)。

channel.frameCount > 1 は段階的な変形を意味するが、glTFの1つのtargetは1つの形状しか表現できないので、最後のフレームを採用する。実測では本作はすべて frameCount = 1 だった。

状態:完了。


5. 3Dモーション:CRC32の逆引きと mot_define

1. ブレイクスルーはMonoBehaviourであってAnimationClipではなかった

AnimationClipgenericBindings にはハッシュしか保存されていない。

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

最初はキャラクターの skeleton.json にあるボーンパスでCRC32を計算して照合しようとしたが、608体のスケルトン × すべてのパス表記、ヒット0件だった。

本当の答えは同じbundle内のMonoBehaviour(VisionActorMotionDefine)にあった。その baseAnimation.bindings には各バインディングが平文で列挙されている。

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

Animatorのルートノードは**Root_Body**という名前で、bundle名ではない——これが照合できなかった原因だった。

ハッシュ関数がCRC32(平文)であることを確認した。

文字列 CRC32 clip内での出現
b_eye.eye_001 1661978518 blendshapeの最初のエントリ(blendShape. プレフィックスは付かない
m_FadeFactor 682354173 6回 = 6個のDecalProjector
bakeAnimationWeight 3202011236 44回 = 44本のswing bone

637個のbundleのbindingsをすべて集めてグローバルな逆引きテーブルにした(単一bundleの辞書だけでは自分の分しか解けない)結果、520個のパス/198個のプロパティ名が得られた。

この節で最も再利用できるポイントはここだ:ハッシュが合わないときは、まず入力文字列の形を疑うべきで、ハッシュ関数そのものを先に疑ってはいけない。 「実は別のハッシュ関数なのでは」に費やした時間は、「パスのプレフィックスが違うのでは」に費やした時間よりずっと多かったが、答えは後者だった。

状態:完了。


6. Live2D:上位層ではなくネイティブ層を攻める

1. 行き詰まった3つのルート

順番に試して、すべて失敗した:LZ4圧縮だろうという推測。Octo.dll/Octo/Loader/OctoAPI.DecryptAes を探す(誤解を招く形での成功——それが復号しているのはAPIパケットであってリソースではなかった)。IL2CPP層のCubism SDK APIをフックする。

3つ目の失敗の仕方が根本原因を示していた:Cubism CoreはネイティブのC言語ライブラリで、独立した .framework を持たずに UnityFramework に直接静的リンクされている。つまりIL2CPP層にはそもそもフックできるものが存在しない。

2. ネイティブのエクスポートシンボルを攻める

Module.enumerateExports()csm から始まるネイティブエクスポートをすべて列挙する(合計44個)。

csmGetVersion
csmGetMocVersion / csmGetLatestMocVersion
csmHasMocConsistency
csmReviveMocInPlace   ← 鍵となる関数
csmInitializeModelInPlace
csmUpdateModel
csmGetDrawable* 系列

csmReviveMocInPlace(void* address, unsigned int mocSize) は「すでに復号済みでパース可能なmoc3データが置かれているメモリアドレス + サイズ」という2つの引数を受け取る。上位層がC#なのかIL2CPPなのかを理解する必要はなく、暗号化アルゴリズム自体に触れる必要もない——この関数が呼ばれた瞬間、メモリ上にあるデータは100%正しく使える平文のmoc3だからだ。

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));
  }
});

ハードコードしたアドレスoffsetではなく Module.findExportByName を使うことで、ASLRや再起動によるアドレスのずれの影響を生まれつき受けない。

3. 汎用的な形

この節の再利用可能な形は単独で書き出す価値がある:「平文を引数として受け取るネイティブ関数」を見つけて、そこで待ち構える。 上位層に暗号化・難読化・マネージド実行環境が何層重なっていようと、データは最終的にエンジンが理解できる形でエンジンに渡さなければならない。その受け渡し地点こそが最もコストの低いポイントであり、しかも暗号方式が変わっても自然と影響を受けない。

.moc3 / .model3 / .physics3 を取得したら、そのままCubism Editorで開ける。マテリアルは BuildModelData の情報に合わせてリネームする必要がある。

状態:完了(テクスチャatlasは別扱い、下記参照)。

4. 未解決:テクスチャatlas

moc3専用のtexture atlasはまだ取得できていない。順番に7つのルート(すべてFrida hook + プレイヤーの操作でトリガー)を試してすべて失敗し、最後に純粋な静的解析でロードパスを特定して初めて本当のデータソースにたどり着いた。途中、set_MainTexture を正しいフック地点だと判断したが、その後の逆コンパイルで覆った。

訂正set_MainTexture という仮定は誤りだと判明した。当時は「見るからにそれだ」と思え、フックしても実際に何かが取れた——ただしそれは求めていたものではなかった。何かがフックできたことと、正しい場所をフックできたことは別だ。

状態:未完了。残る選択肢はXcodeのMetal Frame Captureだ。

References


7. スプリングボーン物理(未完了)

スカート、リボン、髪はアニメーションと一緒には出荷されていない——ベイクされているのはhumanoidの51本のボーンだけで、モデルには実際には126本あり、残りの75本(スカート31、頬10、リボン8……)はゲーム側のSwing/Quartzが実行時に計算している。

しかしパラメータは出荷されている。各モデルbundle内のMonoBehaviourに埋め込まれている。

ActorSwingDynamicBone   _simボーンに付く      シミュレートされるボーン
ActorSwingStaticBone    体のボーンに付く      コリジョン
ActorSwingChain         hipsに付く            チェーン構造
QuartzDriverSkirtBone   _astボーンに付く      プロシージャルな補助ボーン

(catalogのaddressでこれらを検索しても見つからないのは、addressにMonoBehaviourのクラス名が含まれていないからだ——これは最初にAvatarを探したときに犯したのと同じ間違いだった。)

オフライン再現用のインテグレーター。

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            重力
                                   この後:ボーン長の硬拘束、続いて親ボーンの回転に変換

CalcStiffnessPendulumdynamicType == 0、6900/6940本のボーンを占める)。

delta = rotate(parent.worldRot, boneAxis)      ボーンの静止方向
cos   = |dot(cur, rest)| / (|cur|·|rest|)      2つの位置ベクトルのなす角
p     = max(0, cos - (1 - range)) / range × pendulum
return delta × (stiffness - p) × 0.01

座標系はanimator root spaceであって世界座標ではないので、そのまま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

現況

検証方法はゲームの真値とフレーム単位で突き合わせること——bake_swing_truth.py がゲームから同一clipの _sim ボーンのlocal rotationを録画し、フレームごとになす角を計算する。

角度 = 2 · arccos(|dot(q_sim, q_truth)|)

クォータニオンの符号は姿勢に影響しないので絶対値を取る。

誤差 / 真値のモーション振幅   110%      (100% = スプリングボーンがまったく動かない状態)

それでも「まったく何もしない」よりわずかに悪い。 症状は単一の項に収束した:過振動1.50倍。すでに実装済みだがデフォルトでオフになっている4項目(それぞれ下流にまだ減衰が足りないせいでペナルティを受けている)。

--quartz          QuartzDriverSkirtBoneが_astチェーンの根を駆動   142%
--delta-current   deltaに静止方向ではなく現在の方向を使う         153%
--collision       球 vs 円錐カプセル                              202%
--vel             力を速度状態に累積する                          147%

まだ未実装:風(CalcWindPower、実測では windPower = 0.7 が有効になっている)、チェーンボーン平滑化の4パス目、複数バリアントの _ast 駆動。

訂正:当初は「風は揺れを増やすはずで、方向が合わない」と判断して保留にしていた——それはまだ4つのバグが残っているモデルの上での判断であり、やり直すべきだ。 誤った基準線の上で行った除外は、除外したことにならない。

状態:未完了。 これがプロジェクト全体で唯一まだ開いている項目だ。


8. 公開範囲

復号アルゴリズムは解読済みで、完全な数学的仕様と実行可能なツールにもなっている。その部分はここには載せていないし、公開repoにも入れていない。

理由は法律だ。台湾著作権法(Taiwan Copyright Act)第80条の2は、「主にコピー防止措置を回避するために用いられる設備、機材、部品、技術または情報」を提供することを禁じている——ここでいう「情報」という語は、コードだけでなく、十分に明確に書かれていて読めばそのまま自分で実装できてしまう仕様書も含む。同条第3項には「情報間の相互運用性を達成するために行われるリバースエンジニアリング」を例外とする規定があるが、一般的な理解では、これは行うことをカバーするのであって、回避手段を公開・頒布することをカバーするものではない。グレーゾーンであり、私は弁護士ではない。

(したがって本稿とsssekaiのノートアーカイブはこの点で異なる——あちらは完全なkey tableとAESのkey/ivを公開しているが、こちらはしない。これは自分がいる法域についての保守的な判断であって、他の人のやり方に対する評価ではない。)

ツールは分割して公開している:フォーマット解析の半分(AssetBundleの抽出、mesh/スケルトン、glTF変換、Live2Dと3Dモーションのデコード)は相互運用性のための作業であり、https://git.siao.ai/siao/hohohololive で公開している。復号の半分は公開していない。

この切り分け方自体に、記しておく価値のある転換がある。当初はよくある「鍵だけ抜いてアルゴリズムを残す」形にするつもりだったが、それは成立しなかった——鍵は address から導出されるもので、導出手順そのものが鍵であり、切り離せる独立した秘密が存在しない。そして実装内の唯一のマジック定数は単一バイトで、既知平文がファイルヘッダにそのまま書かれているため、256通りの全探索はマイクロ秒単位で終わる。その定数だけを隠しても作業量はまったく減らず、「隠したように見えて実は隠せていない」ものができるだけだ。だからレイヤーごと丸ごと外した。

素材は一切公開していない。ゲームリソースの著作権は発行元に帰属し、1バイトたりとも私が公開している場所には置いていない。


付録:踏んだ罠

小さいファイルで転送速度を測った。 cipherを変えた後、小さいファイルで測定したところ、ディスクキャッシュに書き込まれた瞬間に「転送完了」してしまった。ある変更の後に改善が見えたからといって、その変更が原因だとは限らない。

不要な234MBのメモリをdumpしてしまった。 ディスク上の UnityFramework はそもそも暗号化されていなかった。さらに悪いことに、メモリ版はむしろ使えなかった。

ディスク   __TEXTは詰めて配置、file offset == vaddr - base
メモリ     __TEXTはページ整列、両者の差は各セクションの整列パディング分

アドレスが噛み合わず、パースツールがそのまま壊れる。暗号化されているのはmetadataであってbinaryではないのに、その区別をつけずに両方に暗号対策の手法を使ってしまっていた。

ネイティブバッファの読み取りを遅延させた。 ネイティブの read() のバッファは、関数から戻った直後にすぐ回収されて別の用途に使い回される。遅延して読んだものはすべて無関係な残骸で——一時期はそれをルックアップロジック、UTF16文字列、bplistだと誤判定していた。onLeave のその場で同期的にdumpする必要がある。

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));  // その場で、遅延不可
  }
});

fdの使い回しが追跡テーブルを汚染した。 open() をフックして関心のあるfdを記録していたが、close() でクリアしていなかった。システムが同じfd番号を別のファイルに再割り当てすると、無関係な内容(UnityFS ヘッダ、bplist00)を対象ファイルの内容だと誤認してしまう。

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

後の2つが最も記録しておく価値がある。それらは「推論を間違えた」のではなく、観測方法そのものが偽データを作り出していたからだ。偽データは本物のデータとまったく見分けがつかず、それに対していくつも説明をこしらえてしまっていた。


ツールチェーン

用途 ツール
USBポートフォワーディング libimobiledevice / iproxy
動的インストルメンテーション Frida(Python API、CLIではない)
IL2CPP解析 Il2CppInspectorRedux(LukeFZ fork)
逆コンパイル rizin + rz-ghidra
Unityアセット UnityPyFALLBACK_UNITY_VERSION = "6000.3.0b1"
  • FridaはPython APIを使い、CLIは使わない。 CLIにはattach timeoutの問題があり、device.spawn() → attach() → resume() の方がずっと安定する。
  • Il2CppInspectorRedux のCLIは見かけ上フリーズすることがある。 内蔵のSignalR webサービスがプロセス全体を止めてしまうことがあり、すべてのスレッドが __psynch_cvwait でidleになる。sample <pid> を使えば、それが処理中ではなく待機中であることが分かる。より軽量な出力オプションに変えることで回避できる。

References