Unity IL2CPP iOS IPA 研究筆記:從 Mach-O、runtime resolver 到 pre-sign hook
以前在 Android 上研究 Unity 遊戲時,我的主要工具鏈是 APK、Riru、Il2CppDumper、Magisk 與重新簽署。那套流程整理在早期的 Riru-Il2CppDumper 使用筆記。
這次換成 Apple Silicon Mac、iPhone、IPA 與 Unity 6 後,我原本以為只是把 libil2cpp.so 換成 UnityFramework ,實際做下來才發現真正困難的地方已經變成:
App 內有不只一個 Mach-O,必須先確認誰負責啟動、誰承載 IL2CPP、誰是注入模組。
App Store 下載的 IPA 不一定能直接做靜態分析,必須逐一確認
cryptid。global-metadata.dat可能不是 Il2CppDumper 能直接解析的標準格式。已修改過的樣本可能先在
UnityFramework寫入 relay,再由 dylib 初始化 dispatcher;只換 dylib 會直接閃退。iOS 的 runtime inline hook 會碰到 code signing,
DobbyHook()回傳成功也不代表目標頁面真的能安全執行。
所以這篇不是一份「照著輸入幾個命令就完成」的工具筆記,而是我目前整理出的研究方法:先建立可信樣本、辨識 patch 模型、用 runtime IL2CPP API 解析目標,再決定 hook 應該在簽名前還是執行期完成。
和 2021 年 Android 筆記有什麼不同
Android 常見流程 | iOS/IPA 對應問題 |
|---|---|
從 APK 找 | 先分辨 launcher 與 |
讀取 | metadata 可能另有封裝,無法直接 dump |
Magisk/Riru 在 runtime 注入 | 側載前須完成 Mach-O 與簽章處理 |
修改 ELF 後重新簽 APK | 修改 Mach-O 後須由 Sideloadly 等工具完整重簽 |
以固定 offset hook | 版本更新後應重新解析 method,不能沿用 RVA |
Android 裝置作為主要測試環境 | Apple Silicon Mac 可先做快速啟動與互動測試 |
如果目標是先取得官方 IPA、解包並確認檔案結構,可以先看 使用 ipatool 下載、解壓與驗證 App Store IPA 。本文從「已經有可研究的 .app 」開始。
先建立可重現的樣本
第一個原則是: 不要直接修改唯一一份來源 App。
我會將工作區分成三層:
版本報告至少保留:
App 版本、build number、最低 iOS 版本與 Unity 版本。
launcher、
UnityFramework、原有 dylib、metadata 的 SHA-256。每個 Mach-O 的 UUID、架構、
cryptid、依賴與 install name。原始簽章狀態與 entitlements;公開筆記時移除 Team ID、裝置 ID 與帳號資訊。
實際測試使用的 macOS/iOS、安裝工具版本與最後採用的 Bundle ID。
這些資料不是附帶文件,而是所有 RVA、patch bytes 與 crash log 能否互相比對的前提。
解包後先分清楚四個角色
Unity iOS App 常見的四個研究對象如下:
對象 | 常見位置 | 主要用途 |
|---|---|---|
launcher |
| 啟動 App、載入 framework 與 dylib |
|
| Unity engine 與 IL2CPP native code 主體 |
|
| IL2CPP 型別與方法 metadata;路徑依版本而異 |
app-local dylib/framework |
| 原廠元件或第三方注入模組 |
不要只對 launcher 執行一次 otool 就下結論。launcher 可能只是很薄的入口,真正的 gameplay code 與 patch 都在 UnityFramework。
最小靜態盤點
下面是一組適合放進版本報告的基本檢查。變數請換成自己的檔名:
cryptid 1 代表該 Mach-O 仍是 FairPlay 加密狀態;即使 IPA 可以解壓,也不等於 binary 已適合靜態分析。要作為研究基線,應確認實際承載 IL2CPP 的檔案是 cryptid 0 ,而不是只檢查 launcher。
Metadata 失效時改走 runtime IL2CPP API
這次遇到的 global-metadata.dat 並非 Il2CppDumper 能直接接受的標準格式,但 UnityFramework 仍匯出了完整的 il2cpp_* API。這時與其猜測 metadata 加密方式,我更傾向先做 runtime resolver:
constructor 不應直接安裝所有 hook。Unity domain、scene 與 UIApplication lifecycle 可能尚未就緒;比較穩定的做法是啟動狀態機,定期檢查 readiness,並以 atomic flag 保證同一個 registry 只安裝一次。
每個方法應使用完整查詢條件:
assembly/image 名稱。
namespace、class 與 method 名稱。
參數數量與各參數型別。
回傳型別;有 overload 時尤其重要。
解析成功後還要驗證:
簽章只能得到唯一匹配。
MethodInfo的 native pointer 位於UnityFramework可執行區段。dladdr()回報的 image 確實是UnityFramework。hook backend 回報成功,且 original pointer 非空。
任何一步失敗都只停用該功能並顯示原因,不回退到上一版 RVA。這樣版本更新時,失敗會是可診斷的 unavailable ,而不是在未知位置寫入指令後閃退。
最大的坑:來源 UnityFramework 可能早已被 prepatch
我一開始做了最直覺的實驗:移除來源 mod,再放入自製 dylib。App 可以啟動到某些畫面,進入特定流程時卻跳到接近 null 的位址。
最後還原出的控制流不是「乾淨 Unity + dylib runtime hook」,而是:
只移除原 mod 時,Unity 內的 relay 仍存在,但 writable slot 沒有人初始化。等到 gameplay 第一次走到該 callsite,便會經由空 dispatcher 跳到無效位址。
這種問題很容易被誤判為「登入 SDK 閃退」、「Dobby 不相容」或「少了一個 framework」。真正有效的證據鏈應該包含:
crash 的 fault address 與 exception registers。
fault 前的
PC、LR與反組譯。LR正規化成UnityFrameworkRVA 後落在哪個 callsite。callsite 是否先跳到 relay。
relay 是否讀取一個原本應由舊 dylib 初始化的 slot。
看到這條完整鏈,才知道應該還原 Unity bytes、保留相容 loader,或重建原 dispatcher,而不是繼續盲改 entitlements。
Relay 與 trampoline 不能一律當成 8 bytes
另一個容易踩到的坑,是假設所有 patch 都覆寫相同寬度。
我在同一個樣本裡還原出兩種模型:
模型 | 案例數 | 原 callsite 覆寫寬度 | 特徵 |
|---|---|---|---|
inline relay | 105 | 8 bytes | 保存兩條原指令,再跳回 continuation |
relocated trampoline | 10 | 4 bytes | 保存一條原指令,trampoline 另有重定位語意 |
也就是說,115 筆 registry 實際對應 125 個 patch site,而且不能用同一種 restore 規則處理。
更麻煩的是 AArch64 的 PC-relative 指令。從 relocated trampoline 讀到的 B、 BL 或條件分支,不能直接把 raw bytes 複製回原 callsite;同一組 immediate 放到另一個位址,目標也會跟著改變。正確流程是先推導語意,再以原 callsite 為基準重新編碼。
我現在會在版本鎖定 registry 保存:
UnityFrameworkSHA-256 與 UUID。callsite RVA、目前 bytes、預期原始 bytes。
patch model 與 overwrite width。
continuation、relay/trampoline RVA。
PC-relative 指令的目標與必要 invariant。
還原工具要採 all-or-nothing
pre-sign 修補工具應先完成所有驗證,才寫入 staging:
驗證來源 SHA-256 與 Mach-O UUID。
驗證每個 RVA 都能透過 segment 的
vmaddr/fileoff/filesize映射到檔案。驗證所有 current bytes 與預期一致。
驗證 patch site 唯一、互不重疊,數量符合 registry。
任一項不符就拒絕產出,不能只修成功的一半。
全部寫入後重新掃描,記錄新 SHA-256 與實際修補數量。
這比「看到像 branch 就改回 NOP」慢一些,但可以避免產生一個能簽、能裝、卻在深層流程才爆炸的混合樣本。
用 Mac-first A/B 階梯縮短測試週期
Apple Silicon Mac 可以直接執行部分側載的 iOS App,因此很適合做第一層 smoke test。不必每次都先拿 iPhone 安裝、點擊與匯出 crash report。
我使用的版本階梯如下:
階段 | 唯一新增的變數 | 驗收重點 |
|---|---|---|
source baseline | 不修改 | 確認來源本身可進入目標流程 |
packaging control | 只解包、重封與重簽 | 排除封裝工具造成的差異 |
loader-only | 只載入最小 dylib | 驗證 load command 與初始化時機 |
framework removal | 移除可疑 framework | 分離 UI/Auth 與實際 patch 依賴 |
restored loader | 還原既有 callsite 或相容 dispatcher | 驗證 prepatch 假設 |
observer | overlay + resolver,不裝 gameplay hook | 驗證 lifecycle 與 IL2CPP 查詢 |
functional | 每次只開一組 hook | 定位 code signing 或 ABI 問題 |
「看到標題畫面」不算所有版本的通過。測試里程碑必須覆蓋這次改動會經過的控制流:如果 crash 發生在登入,就測到登入;如果只在歌曲開始後觸發,就一定要實際開始歌曲。
Mac 與 iPhone 的 termination label 可能不同,因此我不直接比對錯誤文字,而是:
確認 crash log 內的 binary UUID 與實際安裝檔一致。
確認
UnityFramework實際載入路徑。將 fault virtual address 正規化成 module RVA。
比較兩端是否落在相同 callsite、relay 或 code page。
Mac 測試通過後再上 iPhone,可以節省大量來回;但 Mac pass 仍不能取代真機最後驗收。
Dobby 回傳成功,不代表 hook 能執行
在 observer 版中,13 個方法都能唯一解析;functional 版甚至可能顯示 13/13 installed,但進入 gameplay 後仍收到 CODESIGNING Invalid Page。
原因是 hook 有多個階段:
只記錄 DobbyHook() 的回傳值,會把「建立 trampoline 成功」與「已安全修改 file-backed __TEXT 」混在一起。診斷紀錄至少要包含:
backend 與版本。
runtime page size。
target module、RVA 與所在 VM region。
每一個 stage 的開始/成功/失敗。
Dobby status 與 original pointer。
crash 時 fault page 是否屬於
UnityFramework的 signed code。
第一版最好保留 JSONL 診斷檔,判定類 hook 則只累計統計,不要對每個 note 都同步寫檔。
為什麼可安裝的 mod 不代表有 JIT
這次另一個誤區,是看到原有 mod 可以執行,就推論它一定具備特殊 entitlements 或 JIT。
Custom Entitlements 是簽署時向系統宣告 App 所需 capability;最終是否生效,仍取決於 signing identity 與 provisioning profile。它不是繞過 code signing 的開關。
Apple 的 allow-jit entitlement 允許 App 以 MAP_JIT 建立可寫、可執行的記憶體,主要服務 JIT compiler。它不等於可以任意改寫已簽署、file-backed 的 UnityFramework.__TEXT。
我的 positive control 最後顯示,原 mod 能執行的關鍵是:Unity executable bytes 在 Sideloadly 最終簽署前就已被 patch;runtime dylib 主要負責初始化 writable dispatcher 與 records。這證明的是 pre-sign static patch 可行 ,不是 runtime JIT 已解鎖。
因此 iOS 上較穩定的設計通常是:
在 staging 內驗證並完成版本鎖定的 Mach-O patch。
清除舊 code signature。
封裝 unsigned IPA。
交給側載工具一次完成最終簽署。
runtime 模組只操作資料、dispatcher 與已規劃的可寫區域。
若真的需要 runtime code generation,再個別研究 MAP_JIT 、write protection 與 provisioning;不要把它當成修復所有 inline hook 的通用選項。
可重現封裝流程
我目前會把封裝工具固定成以下步驟:
驗證輸入版本、SHA-256 與 UUID,不符立即停止。
將來源 App 複製到新的 staging。
依 registry 驗證並執行必要的 pre-sign patch。
用 Mach-O 工具移除不需要的 load command 或舊 code signature。
刪除不再使用的 framework,放入自製 dylib。
檢查 dylib install name 與所有相依 framework 都存在。
正規化 executable 權限,移除舊
_CodeSignature。封裝成 unsigned IPA,輸出 manifest 與 SHA-256。
由同一版側載工具簽署並安裝;不在封裝腳本內混入帳號資料。
封裝前的最低驗收包括:
launcher 的相依項目不再包含已刪除的 framework。
UnityFramework仍能找到自製 dylib。IPA 內只有一份同名 dylib。
沒有依賴不存在的 app-local framework 或未預期的 Swift runtime。
所有 registry site 的 post-patch bytes 都符合預期。
staging 與 dist 的 hash 已寫入版本報告。
處理 Mach-O load command 時可使用 LIEF 的 Mach-O modification API ,但仍要在輸出後用 otool、 codesign 與實際安裝交叉驗證。
最後心得
從 Android 的 Riru/Il2CppDumper 轉到 iOS,最重要的轉變不是換一套工具,而是把研究對象視為一條完整供應鏈:
只要其中一層沒有被版本化,就很容易把不同原因混在一起:metadata 解析失敗被當成方法不存在、空 dispatcher 被當成登入 SDK 問題、signed code page 被當成 Dobby ABI 問題。
目前對我最有用的三個原則是:
所有 offset 都要綁定 SHA-256 與 UUID。
observer 先證明解析與 lifecycle,functional 再一次增加一組 hook。
能在簽署前完成的 executable patch,就不要假設 runtime 一定能改 signed
__TEXT。
這樣即使下一版遊戲更新、method RVA 全部改變,舊報告仍然能告訴我「上一版為什麼成立」,而不是留下一包只能祈禱它剛好還能跑的固定 offset。