ClaudeDocs/RE_NOTES_LIVE2D.md

Hololive Dreams — Live2D模型逆向紀錄 (第二階段)

延續 RE_NOTES.md 音效/影片破解後,針對Live2D角色模型(.moc3)的獨立逆向工程。 這是與音效加密完全不同的第二套機制,最終繞過加密演算法本身,改用「攔截原生API」直接取得解密後模型。


背景

嘗試路徑與踩坑

1. 猜測是LZ4壓縮 (失敗)

Unity官方AssetBundle常用LZ4壓縮,高熵特徵符合,但 lz4.frame.decompress() 直接報錯 ERROR_frameType_unknown,排除。

2. 尋找Octo.dll/Octo/Loader/OctoAPI.DecryptAes (誤導性成功)

IL2CPP method map中找到明確的AES解密函式 OctoAPI_DecryptAes (0x08EBDE2C), Frida hook成功攔截到輸入輸出:

輸入: 80 bytes 密文
輸出: 53 bytes → "https://asset.game-hololive-dreams.com/{o}..."

這其實是CDN資源URL範本的解密,不是模型檔案本身——是完全不同用途的AES呼叫, 容易誤以為找對了函式,實際上文不對題。教訓: 抓到「有輸出」不代表抓到「對的東西」, 要驗證輸出內容語意是否符合預期。

3. 嘗試Hook IL2CPP層的Cubism SDK API (失敗,找到根因)

在map中找到標準Cubism Unity SDK的5個相關方法並全部Hook:

CubismMoc.CreateFrom       0x08AFD25C
CubismMoc.get_Bytes        0x08AFD4F8
CubismMoc.get_UnmanagedMoc 0x08AFD508
CubismMoc.AcquireUnmanagedMoc 0x08AFD538
CubismModel.InstantiateFrom   0x08AFD890

玩家實際操作、角色確實有渲染,但5個Hook全部零觸發根因: 這款遊戲的Cubism整合方式繞過了標準C# Wrapper層的這幾個入口, 或是這些方法在IL2CPP編譯後因為shared generic合併/inline,實際執行路徑不經過這裡。

4. 改攻原生Cubism Core函式庫 (成功)

Live2D Cubism Core本身是一個原生C函式庫,直接靜態連結進UnityFramework(沒有獨立.framework)。 用Frida Module.enumerateExports()列出所有以csm開頭的原生匯出符號(共44個),確認找到:

csmGetVersion
csmGetMocVersion / csmGetLatestMocVersion
csmHasMocConsistency
csmReviveMocInPlace   ← 關鍵函式
csmInitializeModelInPlace
csmUpdateModel
csmGetDrawable*系列 (取頂點/貼圖/顏色等渲染資料)

csmReviveMocInPlace(void* address, unsigned int mocSize) — 原生函式, 直接吃「已經解密好、可解析的moc3資料所在記憶體位址 + 大小」兩個參數。 不需要理解上層是C#還是IL2CPP,也不需要破解加密演算法本身—— 只要這個原生函式被呼叫,代表當下記憶體裡的資料就是100%正確可用的明文moc3。

const target = Module.findExportByName("UnityFramework", "csmReviveMocInPlace");
Interceptor.attach(target, {
  onEnter: function(args) {
    const addr = args[0], size = args[1].toInt32();
    const data = addr.readByteArray(size);   // 直接就是完整明文.moc3內容
    send({event: "csmReviveMocInPlace", size: size}, data);
  }
});

Module.findExportByName而非硬編位址offset,天生免疫ASLR/重啟位址偏移問題, 比之前手動計算uf.base.add(offset)的方式更穩定可靠。

成果

Hook掛上後,玩家實際操作App(切換角色頁、進劇情觸發演出),每次載入新模型就直接攔截到完整明文moc3:

檔案 大小 Magic Version
001.moc3 1,361,408 bytes MOC3 5
002.moc3 2,060,992 bytes MOC3 5
003.moc3 2,088,704 bytes MOC3 5
004.moc3 1,849,984 bytes MOC3 5

4個模型全部驗證通過標準Live2D Cubism 4/5 MOC3檔頭,可直接被官方Cubism Viewer/SDK讀取。

存放於 live2d_models/

尚未完成: 貼圖(texture atlas)提取

moc3只有骨骼/形變資料,角色外觀貼圖(texture atlas)是分開儲存的,目前尚未成功攔截

已嘗試且失敗的路徑(依序,全部靠Frida hook + 玩家配合觸發)

  1. UnityPy掃描1098個Unity AssetBundle — 只找到UI圖示/特效貼圖(511個Texture2D), 完全沒有角色本體貼圖。這批bundle開頭都是明文UnityFS,代表角色貼圖走的是別的、 仍加密/隱藏的資源桶。
  2. OctoAPI.DecryptAes hook — 抓到的是CDN網址解密,跟貼圖無關(詳見上方第2節)。
  3. IL2CPP層 Texture2D.LoadRawTextureData/LoadImage Injected方法 hook (0x09E93694 / 0x09F44A0C) — 掛上後零觸發。Unity的AssetBundle反序列化管線 對Texture2D不走這兩個公開Scripting API,是引擎內部C++直接建構,繞過腳本層。
  4. UNITY_LZ4_decompress_safe hook — 函式本身被大量呼叫(每次128KB固定分塊), 但這是整個引擎共用的LZ4,訊號太雜;且bundle是跨多個chunk拼接,單一次呼叫抓不到完整內容。
  5. 記憶體暴力掃描UnityFS字串 — 兩次嘗試:
    • 第一次(嚴格條件: magic在buffer開頭64bytes內) → 0命中
    • 第二次(放寬條件、含唯讀區段) → 654命中,但集中在同一塊29MB區域, dump後檢查header全部是0——證實這只是引擎內部拿"UnityFS"當格式辨識用的字串常數 到處複製,不是真正的bundle資料。教訓: 命中不代表是對的,一定要驗證後續header/內容是否合理。
  6. Objective-C層Metal texture上傳函式 hook(MTKTextureUploader.copyBytes:toTexture:...AGXTextureLayout.copyFromLinearBytes:...) — 兩個都掛上成功(hooked: true), 但玩家實際觸發角色渲染後零命中這一步被使用者明確指出是在瞎猜——純粹憑函式名稱關鍵字搜尋、沒有真正驗證這是否為 遊戲實際使用的呼叫路徑,也沒有工具能證實。已停止繼續盲猜這個方向。

7. 停止瞎猜,改用反編譯追真實呼叫鏈 (成功找到正確路徑)

被使用者指出前面幾次是盲猜函式名稱後,改變方法論:從已知的C# Cubism SDK入口, 用rz-ghidra反編譯實際程式碼,追蹤真正的資料流向,而非憑名稱猜測

  1. 在map中找到 Live2D.Cubism.Rendering.CubismRenderer 的貼圖相關方法:
    CubismRenderer.set_MainTexture(Texture2D)         0x08AE1838
    CubismRenderer.get_MainTexture()                  (get)
    CubismRenderer.ApplyMainTexture()                 0x08AE1994
    CubismRenderer.TryInitializeMainTexture()          0x08AE2C70
    
  2. 反編譯 TryInitializeMainTexture (0x08AE2C70),證實(非猜測):
    • 該函式讀取 this+0x80 欄位(即已儲存的Texture2D參考)
    • 若欄位是null,會呼叫 sym.func.08ae1838(this, 0)set_MainTexture
    • 證明 set_MainTexture 就是Texture2D物件被賦值進CubismRenderer的那個確切時刻, 第二個參數(args[1])就是一個已經完全載入好、可直接使用的Texture2D物件參考
  3. 因為拿到的是活的Texture2D物件參考(不是原始bytes),不需要再去猜「像素資料在哪裡」—— 直接主動呼叫Unity自己的 UnityEngine.ImageConversion.EncodeToPNG(Texture2D) (0x09F44568), 把這個物件參考丟進去,引擎自己吐出PNG bytes。完全不需要理解貼圖格式(ASTC/ETC2/壓縮與否)、 不需要碰GPU層、不需要猜測native函式——直接借用官方API的既有功能。
const setMainTexAddr = uf.base.add(0x08AE1838);
const encodeToPng = new NativeFunction(uf.base.add(0x09F44568), "pointer", ["pointer"]);

Interceptor.attach(setMainTexAddr, {
  onEnter: function(args) {
    const texPtr = args[1]; // Texture2D* value,已完全載入好的物件
    const byteArrPtr = encodeToPng(texPtr);   // 主動呼叫IL2CPP方法,取得PNG bytes
    const length = byteArrPtr.add(0x18).readU64().toNumber();
    const data = byteArrPtr.add(0x20).readByteArray(length);
    send({event: "texture_png", len: length}, data);   // 直接就是可開啟的PNG檔案
  }
});

方法論教訓: 遇到「不知道資料在哪一層被處理」的情況時,與其猜測native/GPU層的函式, 不如往回找該資料最終會被賦值給哪個「已知、公開、有文件」的高階物件(這裡是Texture2D), 一旦抓到該物件的參考,直接呼叫它自己公開API(EncodeToPNG)即可, 比起去猜測底層native實作細節可靠得多、也更好維護。

腳本: hook_texture_encodepng.py

7.1 修正: set_MainTexture假設證實錯誤 (繼續反編譯後推翻)

實際掛上診斷Hook(純計數,不呼叫EncodeToPNG)驗證 set_MainTexture 在完整180秒操作視窗內 零次呼叫。回頭重新逐行檢視 TryInitializeMainTexture 反編譯結果,發現先前解讀有誤:

結論: 已達到單純反編譯法的實務極限。 Unity引擎核心的原生反序列化程式碼沒有匯出符號、 高度inline/優化,繼續往下追的成本效益已經不合理。建議在此改用Xcode Metal Frame Capture (ground truth,非猜測/反編譯)作為下一步。

8. 改用IL2CPP原生嵌入API主動查詢物件 (管線驗證成功)

被問「真的沒辦法了嗎」後重新思考:不需要攔截「texture欄位被寫入」那個瞬間, 可以改成主動查詢當下記憶體裡所有已存在的CubismRenderer物件,直接讀取它們的texture。

這需要用到Unity/IL2CPP官方公開的embedding API(il2cpp_*系列函式,設計給原生外掛使用):

用到的位址:

il2cpp_domain_get              0x002d4e18
il2cpp_domain_get_assemblies   0x002d4e24
il2cpp_assembly_get_image      0x002d487c
il2cpp_class_from_name         0x002d48b4
il2cpp_class_get_type          0x002d495c
il2cpp_type_get_object         0x002d53cc
il2cpp_array_length            0x002d4860
Object.FindObjectsOfType(Type) 0x09ED7AC8
CubismRenderer.get_MainTexture 0x08AE1830
ImageConversion.EncodeToPNG    0x09F44568

完整查詢鏈(全部用NativeFunction直接呼叫,不透過Hook攔截,隨時可主動呼叫):

il2cpp_domain_get()
  -> il2cpp_domain_get_assemblies(domain, &count)
  -> 逐一 il2cpp_assembly_get_image(assembly)
  -> il2cpp_class_from_name(image, "Live2D.Cubism.Rendering", "CubismRenderer")  (找到就停)
  -> il2cpp_class_get_type(cubismClass)
  -> il2cpp_type_get_object(il2cppType)          // 組出 System.Type 物件
  -> FindObjectsOfType(typeObj)                  // 拿到目前記憶體裡所有CubismRenderer實例陣列
  -> 對每個實例呼叫 get_MainTexture(instance)     // 拿Texture2D參考
  -> 對每個Texture2D呼叫 EncodeToPNG(texture)     // 直接編碼成PNG bytes

實測結果(2026-07-23): 完整跑過一次,零錯誤

assemblies: 262
found CubismRenderer class: 0x13b271770
found CubismRenderer instances: 0   (當下title畫面沒有角色,屬預期)

成功解析262個assembly、正確拿到CubismRenderer的IL2CPP Class物件、FindObjectsOfType呼叫 無crash無錯誤返回。整條pipeline技術上完全驗證可行,只差「呼叫當下記憶體裡要有角色實例」 這個時機條件——下次有角色畫面開著時執行 dump_all_textures.py, 理論上會一次抓出當下所有角色的完整貼圖atlas,不需要精準卡在某個瞬間攔截。

這條路徑比前面所有Hook猜測都更可靠: 不是在賭「猜對了某個函式會被呼叫」, 而是「主動去問IL2CPP執行環境:現在記憶體裡有哪些CubismRenderer物件」, 是查詢而非攔截,時機掌握在自己手上。

腳本: dump_all_textures.py (可獨立執行,不需要respawn重啟遊戲,但目前為求開機穩定性 仍先kill+spawn+resume+等15秒;若改成直接attach現有已在角色畫面的進程,理論上也可行, 但attach既有繁忙進程偶爾會遇到frida timeout,需要多試幾次)

9. IL2CPP查詢管線實測 — 找出真正的根本原因 (最終結論)

被問「真的沒辦法了嗎」後,把第8節的查詢pipeline接上真實觸發的角色場景實測:

根本原因確認: 呼叫EncodeToPNG本身在丟一個managed例外,不是呼叫方式錯誤。 最合理且有技術根據的解釋:手機遊戲的AssetBundle貼圖幾乎必定關閉「Read/Write Enabled」 (業界標準記憶體優化),這種貼圖上傳GPU後,CPU端完全不會保留像素副本, 呼叫Texture2D.EncodeToPNG()對不可讀貼圖必定丟UnityException: Texture is not readable

這也回頭解釋了本文件第5節「記憶體掃描找UnityFS」、第6節「LZ4/Metal native hook」 全部找不到解碼後像素資料的原因——根本沒有CPU端的像素副本存在,不是找不到,是真的不存在

最終結論: 像素資料只存在GPU顯存(VRAM)裡,唯一能拿到的方法是直接讀GPU顯存內容 ——也就是Xcode Metal Frame Capture。這不再是「保守備案」,而是基於實測排除所有 CPU端路徑後,唯一technically correct的方法。

10. 純靜態定位載入路徑 → 找到真正的資料來源 (Opus重做,大突破)

換Opus後、依使用者要求「先定位再Hook、別亂槍」,改用嚴謹靜態分析:

步驟1: 靜態找AssetBundle載入入口 — 在map中找到AssetBundleLoadInterceptor(3種多載: byte[]/Stream/path)、 OctoAssetBundleStream(自訂解密Stream)。反編譯OctoAssetBundleStream.Read(0x09B3848C)確認它 helper->vtable[0x338](buffer,offset,count)即時解密填入buffer——這是「解密後UnityFS交給Unity」的交接點。

步驟2: 一次性診斷12個載入入口 — 掛上所有Load路徑,實測發現角色資源只走AssetBundle.LoadFromFile(Async), 從兩個位置:

步驟3: moc3當時間錨點 — 同時HookcsmReviveMocInPlace(每次模型載入觸發)+全部file路徑, 精準定位Pekora(角色ID 00019)模型bundle的實際路徑。確認裝置上該檔案已是UnityFS(解密)。

步驟4: 讀出runtime貼圖名字 — 用IL2CPP embedding API(il2cpp_class_from_name等,從Mach-O 本地符號表挖出未匯出的位址)+ Object.GetName,查詢執行中660個CubismRenderer, 得知Pekora的Live2D atlas名為 t_live2d_00019-nrml-0016-00_texture_00(327個部位共用同一張)。

步驟5: 全面掃描磁碟 — 重新從裝置拉取當前完整octo快取(4942檔),用UnityPy掃描:

最終結論(Live2D)

資產 狀態 位置/方法
moc3模型 ×4 ✅ 已取得 native csmReviveMocInPlace hook
角色全身立繪 ×19 (4096²) ✅ 已取得 octo快取解密後UnityPy抽出 img_chr_full_2d
Live2D runtime atlas t_live2d_* ⚠️ 僅存GPU 來源bundle只在記憶體解密、從不落地

關鍵技術發現: 這遊戲的資源分兩種落地策略:

  1. 大部分資源(音效/影片/角色立繪/UI): 存取時原地解密到磁碟→可直接從octo快取UnityPy抽取
  2. Live2D模型專用atlas(t_live2d_*): 走純記憶體解密路徑,解密後直接上傳GPU, CPU端副本立即釋放,磁碟上永遠是加密態→標準逆向拿不到,只能GPU readback或Metal Frame Capture

img_chr_full_2d_00019(已取得)是Pekora的完整角色美術;t_live2d_00019(GPU-only)是moc3模型 實際綁定、UV對應的atlas版本。要pixel-perfect還原可動的Live2D模型需要後者,但前者已是完整可用的角色圖。

產出:

剩餘唯一選項: Xcode Metal Frame Capture (取得moc3專用atlas)

放棄繼續猜測native hook點,改用Apple官方GPU除錯工具,是ground truth而非猜測:

角色名稱對應關係未知

目前4個moc3檔案只用流水號命名,不知道哪個對應哪個角色/服裝/表情狀態, 需要在Hook的同時額外記錄當下畫面的角色資訊,或在native call前後找出附近的角色ID/名稱字串。

觸發覆蓋率限制

目前只能觸發使用者「已解鎖」的少數角色與劇情點,覆蓋率有限, 之後要抓特定角色需玩家實際導覽到該角色出現的畫面觸發。

核心方法論總結(可複用到其他遊戲/其他資源類型)

當「先找出加密演算法再解密」這條路遇到瓶頸(多層加密、原生層混淆、無法定位確切函式)時, 可以改用「找到資料被正確使用的那一刻,直接在那裡攔截明文」的策略:

  1. 找出目標資料格式本身的公開/開源函式庫(這裡是Live2D Cubism Core SDK)
  2. 該函式庫必定有「反序列化/初始化」的入口函式,且該函式收到的參數一定是已完全解密的原始資料
  3. 原生C函式庫通常會在二進位中保留清楚的匯出符號名稱(即使上層C#/IL2CPP呼叫路徑複雜難追), 直接用Module.enumerateExports()枚舉搜尋比追IL2CPP呼叫鏈更有效率
  4. Hook該入口函式即可攔截明文,完全不需要理解中間經過了幾層加密、用了什麼演算法

11. 通用Bundle解密 — 最終完全破解 (2026-07-24)

使用者質疑「檔案有存手機、只是沒解密落地,能不能反推加密方式做通用解密」——方向完全正確

已知明文攻擊: 所有UnityFS bundle前30 bytes固定(UnityFS\0\0\0\0\x08 + 5.x.x\0 + 0.0.0\0+nulls)。 對加密bundle做 密文 XOR 已知明文 還原keystream,分析發現:

取得hash→address對照: 重新解析先前dump的octo catalog記憶體(protobuf), 這次抽出全部36184筆(不只音效),涵蓋 img/mdl/fbx/live2d 等所有bundle類型。

通用解密驗證: 對加密bundle用 octo_decrypt.decrypt_bundle(cipher, address)200/200 解出合法UnityFS

最終成果(Live2D完整破解):

結論: octo_decrypt.py 現在是完整通用解密器——音效(decrypt_bytes)+ bundle(decrypt_bundle), 配合catalog的hash→address對照,可離線解密任何octo資源,不需要Frida/GPU/實機。 之前「只在記憶體/GPU」的判斷是被「解密不落地」誤導——資料一直加密躺在磁碟,只是需要正確的key。


12. Physics 還原 (physics3.json) — 完成

物理被 Cubism Unity SDK 烘焙成 CubismPhysicsController._rig(CubismPhysicsRig) 序列化資料。 IL2CPP build 無 typetree, 但 Cubism SDK 開源, 依其欄位佈局手動反序列化即可:

結果: 40 physics settings / 107 input / 57 output / 97 vertices, Input/Output 語意合理 (ParamEyeLOpen→ParamHighlightRSwing)。 80 個模型全數還原成標準 physics3.json。實作見 hohohololive/live2d_physics.py

13. Motion 還原 (motion3.json) — 待實作

動作曲線在 AnimationClip 的 Unity 壓縮串流格式 (m_MuscleClip: StreamedClip/DenseClip/ConstantClip), m_FloatCurves 為空; 綁定 (m_ClipBindingConstant.genericBindings, 35條) 用 CRC32 hash 標識參數。 還原需實作 Unity 動畫解壓器 (AssetStudio 等級 ~300行) + hash→ParamId 反查 (可借 Live2DMotionDefine MonoBehaviour 內的可讀 Parameters/ParamXXX 字串)。 資料已解密保存, 列為後續。

13.1 Motion 還原 (motion3.json) — 完成

自行實作 Unity 壓縮 AnimationClip 解碼器:

驗證: Pekora anger-01 → Duration 1.967s, 35 curves, 時間全落 [0,1.967], ParamAngleY∈-11.52,6.02、ParamEyeOpen∈[0,1] — 語意正確。 104 個情緒動作(anger/cry/happy/doya…)全轉出標準 motion3.json。實作見 hohohololive/live2d_motion.py

最終狀態

資產 狀態
moc3 + 貼圖 atlas
physics3.json ✅ (byte-perfect, 80模型)
motion3.json ✅ (104動作)
expression (exp3.json) ✅ (412個)

13.2 Expression 還原 (exp3.json) — 完成

同 physics 手法 (Cubism SDK 佈局手動反序列化): CubismExpressionData: Type(str), FadeInTime(f), FadeOutTime(f), Parameters[] SerializableExpressionParameter: Id(str), Value(f), Blend(int: 0=Overwrite/1=Add/2=Multiply) 驗證: anger 表情 24 參數 (ParamEyeOpen/Smile…) Value/Blend 語意正確, 解析落點準確。 412 個表情全轉出。實作見 hohohololive/live2d_expression.py

✅ Live2D 完全破解

moc3 + 4096貼圖 + physics3(80) + motion3(104) + exp3(412) —— 官方 Cubism 完整可動模型全要素到齊。


補記:資料來源與完整性(手機快取 vs CDN)

最初的 Live2D 抽取是在做出 CDN 下載器之前跑的,所以那批資料 100% 來自手機的 octo 快取 —— 也就是「這台裝置實際下載過的部分」。 之後雖然從 CDN 補下載了缺的一半,卻一直沒有重跑抽取。

類別 catalog 手機快取 CDN 下載 舊抽取 重跑後
live2d_mdl 170 80 90 80 170
live2d_mot 160 104 56 104 159
live2d_exp 844 412 432 412 844

動作差的那 1 個是 live2d_mot_idle-01_lv03 —— 該 bundle 裡只有 MonoBehaviour,根本沒有 AnimationClip,不是抽取失敗。

教訓:資料的「完整度」取決於當時的來源,補了來源就要重跑產物。 這批舊資料看起來一切正常(80 個模型全部可用、路徑驗證全過), 只是少了一半——沒有任何錯誤訊息會提醒你這件事。

為此改動的工具

python -m hohohololive build-live2d "octo_now/v1,_cdn_cache" \
    --catalog catalog_full.json -o _extracted_live2d_full

重新部署到 KKKanade 後:170 個模型、170/170 對到角色名、路徑零缺檔。 新增的 7 位角色(00009 紫咲シオン、00038 沙花叉クロヱ、04004 Gawr Gura、 04005 Watson Amelia、04009 Ceres Fauna、04011 Nanashi Mumei、 06001 火威青)以貼圖圖集逐一辨識並用編號序列交叉驗證。