逆向一個 Unity 6 手遊時,我四次以為自己成功了

前陣子花了一段時間逆向一款 Unity 手遊的資源系統,想弄清楚它的資源是怎麼包裝和保護的。

這篇不寫最後的結論——寫中間的四次誤判。四次我都以為問題解決了,四次都是假的。真正有價值的不是最後那個答案,是「怎麼發現自己剛才錯了」。

(先講清楚:這篇沒有解密演算法,也沒有金鑰推導。原因寫在最後。)


一、傳輸變快了,但不是我做的事讓它變快

第一關是把裝置上的資源撈出來。WiFi 上跑 SSH,scp 傳 12MB 花了 11 分鐘——慢到不能用。

合理的假設是加密開銷:預設的 cipher 沒有硬體加速。換成 [email protected] 之後,速度瞬間變快。問題解決,繼續往下。

不對。

那波「快」是本地磁碟緩衝造成的假象。傳大檔的時候原形畢露,WiFi 依然慢。我換 cipher 這件事對真實傳輸速度幾乎沒有影響,只是剛好在換完之後測了一個小檔,而小檔在寫進磁碟快取的瞬間就「傳完」了。

真正的解法是完全換一條路——走 USB:

iproxy 2222:22 &         # SSH 埠轉發
iproxy 27042:27042 &     # Frida 預設埠

USB 隧道穩定在 40–70MB/s,3.4GB 一分鐘出頭。

教訓不是「USB 比 WiFi 快」,那是廢話。教訓是:一個改動之後看到改善,不代表是那個改動造成的。要驗證,測的樣本得大到不會落進快取裡。


二、我 dump 了 234MB 記憶體,而且它讓後面的工具壞掉

IL2CPP 的逆向需要兩樣東西

和 metadata。

global-metadata.dat 的 magic number 對不上標準格式(AF 1B B1 FA),確認是加密的。所以要動態 dump——用 Frida attach 上去,在記憶體裡掃 magic number,在 0x10b800000 找到唯一命中,讀出來確認 version = 39,是合法的 IL2CPP 版本號,代表這是解密後的真身。

metadata 拿到了。那順手把整個 UnityFramework 的記憶體映像也 dump 下來吧,234MB,分塊寫出。多一份總不會錯。

會錯。

磁碟上的那個 UnityFramework 本來就沒加密。我完全不需要 dump 它。而且更糟的是——記憶體版本反而不能用:載入後的 segment 是 page 對齊的,磁碟上的是緊密排列,位址對不起來,後面的解析工具直接壞給我看。

那個「多一份總不會錯」的直覺,讓我多花時間產出了一個比原始檔案更差的東西。

教訓:先確認哪一部分真的被保護了,再決定要動態 dump 什麼。 加密的是 metadata,不是 binary。我沒分清楚,就對兩者都用了對付加密的手段。


三、版本號偽裝失敗了三次,而失敗本身就是答案

metadata 是 version 39。Il2CppDumper 最新版只支援到 31。

第一個念頭是這種工具常見的狀況:格式其實沒怎麼變,只是版本號跳號,把它改成 31 騙過去就好。

改成 27——失敗。改成 29——失敗。改成 31——失敗。

一般到這裡會覺得「這條路不通,換別的」。但值得多看一眼的是失敗的方式:三次都掛在同一個 key 衝突的位置

如果只是版本號詐騙,改成不同的舊版本應該會在不同地方壞掉,或至少壞得不一樣。三次一模一樣,代表解析器讀到的結構在那個點就跟它的預期分岔了——這是真正更新過的格式,不是換個標籤的舊格式。

這個判斷有價值,因為它把「再多試幾個版本號」這條路徹底關掉了,不用再浪費時間。換工具(Il2CppInspectorRedux,一個支援較新格式的 fork)才是唯一的路。

一個假設死掉的時候,要記錄它是怎麼死的。 「試過了不行」和「試過了,而且失敗模式證明了 X」是兩種不同價值的資訊。第二種能讓下一個人不用重試。


四、我研究了半天的那層加密,根本沒有被啟用

拿到完整的 method map(56 萬個方法名稱對虛擬位址)之後,開始找解密相關的函式。

這款遊戲用 CRIWARE 的音效中介軟體,而 CRIWARE 有官方的加密機制。method map 裡也確實有 CriWareDecrypter.Initialize(key, enableAtom, enableMana)。看起來就是它了。

hook 上去看實際呼叫參數:

enableAtom = false
enableMana = false

兩個都是 false。官方加密根本沒開。

真正在保護資源的是遊戲自製的另一層。我對著 CRIWARE 的文件和實作研究的那段時間,研究的是一個沒有被啟用的東西。

教訓:不要從「這個系統應該用什麼」推論「這個系統實際用了什麼」。 存在於 binary 裡的函式不代表它會被呼叫,被呼叫也不代表參數是你以為的那樣。hook 一下,看實際的值——這一步花不到十分鐘,而我在它前面花了幾個小時。


工具鏈

沒有一樣是自製的,全是公開工具:

用途 工具
USB 埠轉發 libimobiledevice / iproxy
動態 instrumentation Frida(Python API,不是 CLI)
IL2CPP 解析 Il2CppInspectorRedux(LukeFZ fork)
反編譯 rz-ghidra

兩個實作上的坑,記一下省得別人再踩:

  • Frida 用 Python API,不要用 CLI。 CLI 有 attach timeout 的問題,device.spawn() → attach() → resume() 這條路穩定得多。
  • Il2CppInspectorRedux 的 CLI 會假死。 內建的 SignalR web 服務有機會讓整個 process 卡住,所有執行緒 idle 在 __psynch_cvwait。用 sample <pid> 就能看出來它不是在忙、是在等。改用更輕量的輸出選項可以繞過。

為什麼這篇沒有演算法

解密演算法我確實解出來了,也寫成了完整的數學規格和可執行的工具。但那部分不會出現在這裡。

原因不是保密,是法律。台灣著作權法 §80-2 禁止提供「主要用於規避防盜拷措施之設備、器材、零件、技術或資訊」——注意「資訊」兩個字,它涵蓋的不只是程式碼,也涵蓋一份寫得夠清楚、讀完就能自己實作的規格文件。同條第三項確實有「為達成資訊間之相互操作性所為之還原工程」的例外,但那個例外一般理解是涵蓋還原工程,不是公開發佈規避方法。

灰色地帶,而且我不是律師。所以方法論公開,規避方法不公開——這篇裡的每一項技術(Frida、IL2CPP dump、hook 參數)本身都是公開的通用技術,跟這款遊戲的保護機制無關。

素材完全沒有公開:遊戲資源的版權屬於發行商,一個 byte 都不在任何我發佈的地方。

工具則是切開發佈——格式解析那一半(AssetBundle 抽取、mesh/骨架、glTF 轉檔、 Live2D 與 3D 動作解碼)是互通性工作,公開在 git.siao.ai/siao/hohohololive; 解密那一半沒有。

值得寫下來的是:我原本打算「拿掉金鑰、留下演算法」,但那個切法在這裡不成立。 這套機制的金鑰是從資產名推導出來的,推導程序本身就是金鑰,沒有獨立的秘密 可以拿掉。而實作裡唯一的魔術常數是單一位元組——已知明文就寫在檔頭,256 種 可能是微秒級的窮舉。只遮那一個常數,降低的工作量是零,卻會做出一個「看起來遮過、 其實沒有」的東西。那比兩個極端都糟。所以整層一起拿掉。


回頭看

四次誤判,四種不同的成因:

  1. 量測方法有問題 —— 樣本小到落進快取裡
  2. 對威脅模型的假設沒驗證 —— 以為 binary 加密了,其實只有 metadata
  3. 差點浪費掉一個有價值的失敗 —— 三次相同的失敗模式本身就是證據
  4. 把「應該用什麼」當成「實際用了什麼」 —— 存在的函式不代表被啟用

其中第三個最值得記。前面三個是「我錯了然後發現」,第三個是「我原本要把一個有用的資訊當成垃圾丟掉」——如果當時只記下「版本號偽裝行不通」,就會漏掉「而且失敗模式證明了格式真的變了」這件事,下次還會再試一次。

失敗的方式,通常比失敗本身有價值。