Share Notes

chundev

View the Project on GitHub latteouka/share-notes

Unity IL2CPP + Frida 深潛——從 adb input tap 失效到 C# 層 popup 壓制

日期:2026-06-20 環境:Android Emulator (AVD) + Frida 16.x + Unity IL2CPP 遊戲


TL;DR

在 Android 模擬器上自動化操作 Unity 遊戲時,GPS 飄移觸發了「移動太快」dialog。我本來以為 adb input tap 點掉就好——結果完全沒反應。花了一整天才搞懂:Unity 2023+ 的 New Input System 有個已知 bug,會丟棄所有透過 Android framework 注入的 touch event。adb input tapsendeventmonkey——全部失效。最終解法是換一個思路:不去「點掉」popup,而是透過 Frida 注入 IL2CPP 層,直接 hook C# method 讓 popup 從根本不彈出。


目錄


背景

自動化操作一款 Unity 遊戲,需要在 Android 模擬器上模擬 GPS 移動。遊戲會偵測移動速度——每 0.5 秒移動 65m(≈ 468 km/h),遠超走路速度——就彈出「移動太快」的警告 dialog 要求確認。

過去在舊版 Unity 遊戲上,adb shell input tap x y 就能點掉這個 dialog。升級後這招突然失效了。我起初以為是座標算錯、時序問題、或遊戲更新了 UI layout,花了不少時間在錯誤的方向上打轉。


問題:adb input tap 對 Unity 遊戲完全失效

症狀很明確:dialog 彈出後,用任何 Android framework 層的 touch injection 都沒有反應。

# 全部試過,全部沒反應
adb shell input tap 540 1800        # framework 層注入
adb shell input swipe 540 1800 540 1800 100  # 模擬長按
adb shell monkey -p com.example.game -c android.intent.category.LAUNCHER 1

uiautomator dump 嘗試偵察 UI 結構:

$ adb shell uiautomator dump
$ adb shell cat /sdcard/window_dump.xml

結果只看到一個 android.view.SurfaceView(Unity 的渲染 surface),完全看不到 dialog 裡的按鈕——因為 Unity 用自己的 rendering pipeline 畫 UI,不走 Android 的 View hierarchy。

這不是座標問題,也不是時序問題。是 Unity 根本沒有收到 touch event。


漸進修正:四次嘗試,三次失敗

❌ 第一次:adb shell input tap

最直覺的做法。input tap 透過 Android 的 InputManager.injectInputEvent() 注入一個 MotionEvent,經 InputManagerService(Binder IPC)派送到目標 window。

在舊版 Unity 遊戲上這招有效,因為舊的 UnityEngine.Input 採用 polling 模型——每幀從 Android 的 InputEvent queue 讀取所有事件,不管來源。但 New Input System 改了架構(詳見下方機制拆解),注入的事件被過濾掉了。

❌ 第二次:sendevent 直寫 kernel device

繞過 framework,直接寫 /dev/input/eventN

# 取得 touchscreen 的 event device
adb shell getevent -l  # 找到 touchscreen device

# 手動送 touch down + touch up
adb shell sendevent /dev/input/event2 3 57 0    # ABS_MT_TRACKING_ID
adb shell sendevent /dev/input/event2 3 53 540  # ABS_MT_POSITION_X
adb shell sendevent /dev/input/event2 3 54 1800 # ABS_MT_POSITION_Y
adb shell sendevent /dev/input/event2 0 0 0     # SYN_REPORT
# ... touch up sequence

結果:同樣沒反應。雖然 sendevent 走的是更底層的路徑(直接寫 kernel 的 character device),但 Android 的 InputReader 讀到事件後還是會經過 InputDispatcher 派送到 window。Unity New Input System 在接收端的過濾邏輯一樣會把它擋掉——問題不在注入的路徑,而在接收端的裝置匹配。

❌ 第三次:UI Automator

嘗試用 Android 的測試框架:

$ adb shell uiautomator dump
UI hierchary dumped to: /sdcard/window_dump.xml

$ adb shell cat /sdcard/window_dump.xml | grep -o 'class="[^"]*"' | sort -u
class="android.view.SurfaceView"
class="android.view.View"
class="android.widget.FrameLayout"

只看到 SurfaceView,看不到任何 Unity 內部的 UI 元素(沒有 Button、沒有 TextView、沒有 dialog)。UI Automator 只能操作 Android View hierarchy 裡的元素,而 Unity 的 UGUI/UI Toolkit 完全在 SurfaceView 內部用 OpenGL/Vulkan 自行渲染——對 Android 來說就是一塊不透明的 texture。

這也排除了 AccessibilityService 方案——dispatchGesture() 雖然走不同的注入路徑,但 Unity 的 UI 元素不在 accessibility tree 裡,連定位按鈕位置都沒辦法。

✅ 第四次:轉換思路——不點掉,讓它不彈出

三次失敗後我意識到一件事:問題不在「怎麼點」,而在「要不要點」。

如果能從 Unity 的 C# 層直接讓速度偵測回傳 false,dialog 根本不會彈出,就不需要操心怎麼模擬觸控了。這需要:

  1. 一個能注入任意 code 到 Unity 進程的工具(→ Frida)
  2. 一個能從 native code 找到 C# method 位址的方式(→ IL2CPP metadata)
  3. 一個能替換 C# method 實作的方法(→ frida-il2cpp-bridge)

機制拆解:Unity New Input System 為什麼吃掉你的 touch event

這是整個問題的根本原因,值得深入理解。

舊系統:UnityEngine.Input(polling 模型)

Android InputEvent queue
    ↓ JNI 讀取
Unity native runtime(C++ 層)
    ↓ 每幀寫入靜態變數
C# managed 層:Input.GetTouch()、Input.mousePosition

架構單純——native 層直接從 Android framework 收事件,不做裝置辨識、不過濾來源。不管你的 touch event 是真手指、是 adb input tap、還是 sendevent,只要進了 Android 的 InputEvent queue 就會被讀到。

新系統:InputSystem package(event-driven pipeline)

Android OS 產生 InputEvent
    ↓
Unity native backend(C++,隨 Editor 版本走)
    ↓ InputDeviceDescription + InputDeviceMatcher 比對
    ↓ 不匹配 → 丟棄 ← 問題在這裡
Managed 層:InputDevice → InputControl tree → InputAction

新系統分三層:

職責
Native backend 平台特定 C++ code,收集裝置資訊和 input data,以 raw unmanaged memory buffer 送給 managed 層
Low-level layer 解析 event buffer,透過 InputDeviceMatcher 比對 interfaceNamedeviceClasscapabilities 等 metadata,建立對應的 InputDevice 實例
High-level layer 套用 layout、建立 InputControl 樹、提供 InputAction 抽象

關鍵差異在中間層的 InputDeviceMatcher。它會檢查事件的 device source 和 descriptor——透過 injectInputEvent() 注入的觸控事件,其裝置描述可能與已註冊的 Touchscreen 裝置不匹配(device ID 不同、source flags 不符),導致事件被靜默丟棄

這是 Unity 的 bug

Unity Issue Tracker UUM-33878 記錄了這個問題:

Input Handling “Both” 模式的地雷

Unity 的 Active Input Handling 設定有三個選項:Old、New、Both。”Both” 同時啟用兩套系統,Unity 官方明確警告 Android 上不支援 “Both” 模式。但不少遊戲仍然使用它——而即使設為純 “New”,在未修復版本中 adb input tap 仍然不被接受。


機制拆解:Android 模擬器的 GPS 不是 mock location

這是整條攻擊鏈的起點——為什麼我們能在模擬器上假裝在走路,而遊戲偵測不到是假的。

模擬器架構

Android Emulator 本質是 QEMU 虛擬機 + Google 自研的 goldfish 虛擬硬體

元件 職責
QEMU CPU / 記憶體 / 磁碟虛擬化
goldfish 虛擬 GPS、相機、感測器等硬體
emulator console TCP port 5554,接受 geo fix 等控制指令
QEMU pipe guest ↔ host 高速通訊通道

adb emu geo fix 的完整路徑

adb emu geo fix 121.5 25.0
emulator console (TCP 5554)
    ↓
QEMU host 更新 goldfish GPS 虛擬硬體狀態
    ↓
QEMU pipe 通知 guest kernel
    ↓
Android LocationManager 收到新的 location fix
    ↓
所有 app 透過標準 Location API 拿到新座標

重點:這不是 mock location。 模擬器的 GPS 是虛擬硬體層級的,跟真手機的 GPS 晶片在同一個抽象層。Android framework 不會把它標記為 mock——Location.isFromMockProvider() 回傳 false。對 app 來說,這跟真 GPS 完全一樣。

這比任何 mock location app(需要開發者選項 + 會被 isFromMockProvider() 偵測到)都乾淨。模擬器的 GPS 欺騙是從虛擬硬體層面進行的,app 層級根本無法區分。

為什麼會觸發「移動太快」

模擬走路的實作:

# 蛇行路線 waypoints → 細分成每步 65m
waypoints = serpentine_waypoints(center, width, height, lane_spacing)
dense = densify(waypoints, step=65)

for lat, lon in dense:
    adb_emu_geo_fix(lon, lat)
    time.sleep(0.5)  # 2 Hz 更新頻率

每 0.5 秒移動 65m = 130 m/s = 468 km/h。遊戲的速度偵測閾值遠低於此,所以必定觸發「移動太快」警告。降低速度可以避免觸發,但會讓自動化效率大幅降低——所以壓制 popup 是更好的策略。


機制拆解:Frida 怎麼「寄生」進目標進程

要 hook Unity 的 C# method,首先需要一個能在目標進程內執行任意程式碼的工具。Frida 做的不是「外部控制」——它是真的把一個 agent 注入到目標進程裡,像寄生蟲一樣。

ptrace 注入的完整序列

frida-server 以 root 權限跑在 Android 上。attach 到目標進程時:

frida-server (root)
    ↓ ptrace(PTRACE_ATTACH, target_pid)
target process 暫停(收到 SIGSTOP)
    ↓ ptrace(PTRACE_GETREGSET) 保存完整暫存器狀態
    ↓ ptrace(PTRACE_SETREGSET) 修改 PC 和暫存器
    ↓   → 讓 target 自己執行 mmap(PROT_RWX) 分配記憶體
    ↓ ptrace(PTRACE_POKEDATA) 寫入 bootstrapper shellcode
    ↓ ptrace(PTRACE_CONT) 讓 target 執行 bootstrapper
    ↓
bootstrapper 在 target 內執行:
    → 解析 /proc/self/auxv
    → 定位 libc API(pthread_create、dlopen)
    → 建立 socketpair(AF_UNIX) 做 IPC
    → dlopen("frida-agent.so") 載入 agent
    → raise(SIGSTOP) 通知完成
    ↓
frida-server 還原原始暫存器,ptrace(PTRACE_DETACH)
target process 恢復正常執行
agent.so 繼續在 target 內運行

幾個關鍵的設計決策:

記憶體配置不是從外部 mmap。frida-server 不直接在 target 的地址空間分配記憶體——它透過 PTRACE_SETREGSET 把 mmap 參數塞入暫存器、PC 指向 libc 的 mmap,讓 target 自己執行 mmap()。這樣分配出的記憶體天然屬於 target process。

ptrace 只在注入瞬間使用。bootstrapper 完成後立即 detach,後續通訊走 Unix socket pair。這意味著注入完成後,ptrace(PTRACE_TRACEME) 這種自我保護手段無法偵測到 Frida——因為 ptrace 已經不在了。

通訊架構是三層的

Host PC (Python script)
    ↕ USB/TCP(DBus over WebSocket)
frida-server (Android, root daemon)
    ↕ Unix socketpair(DBus)
frida-agent.so (在 target process 內)
    → 內建 QuickJS 引擎執行 JavaScript
    → 透過 frida-gum C 函式操作 target 記憶體

spawn vs attach

# spawn:由 Frida 啟動 app,agent 在 main() 之前就位
pid = device.spawn(["com.example.game"])
session = device.attach(pid)
script = session.create_script(agent_code)
script.load()
device.resume(pid)  # 放行

# attach:對已在跑的 app 注入,可能錯過初始化階段
session = device.attach("com.example.game")

自動化場景一律用 spawn——確保 hook 在遊戲任何邏輯執行之前就位。attach 模式可能錯過 SSL pinning setup、root detection、class loader 初始化等早期動作。

值得一提的是 Android 上的 Zygote 機制:所有 app 由 Zygote process fork 而來。Frida 可以用 child gating 機制 hook fork(),偵測到 fork 時暫停 child process 讓你注入。但 Android 10+ 引入的 USAP(Unspecialized App Process,預 fork 的空 process 池)打破了這個假設。替代方案是 ZygiskFrida——透過 Magisk 的 Zygisk 在 fork 時直接注入 gadget,完全不需要 ptrace。

另一個選項是 frida-gadget:把 Frida agent 直接嵌入 APK(或用 LD_PRELOAD),不需要 root、不用 frida-server、不走 ptrace。代價是需要重新打包 APK(破壞簽名)。適合不想 root 但可以接受重新簽名的場景。


機制拆解:IL2CPP——C# 變成 native code 之後怎麼逆向

Unity 把 C# 轉成 native code,聽起來逆向難度應該很高。但 IL2CPP 有一個設計上的「弱點」——它保留了完整的 C# metadata。

為什麼 Unity 要做 IL2CPP

五股壓力在 2014-2015 年匯聚:

  1. Apple iOS 64-bit 強制令(2015-02-01 起新 app 必須支援 64-bit)
  2. iOS 禁止 JIT(W^X + 強制程式碼簽章,Mono Full AOT 有嚴重限制)
  3. Mono 授權僵局(Unity 的 Mono fork 凍結在 .NET 3.5 長達 6-7 年)
  4. 瀏覽器 NPAPI 消亡(Chrome 2015 年移除 NPAPI,需要 Emscripten → WebGL)
  5. 效能(native code 比 Mono JIT 快約 1.5-2x)

2014-05-20 Unity 首次公開 IL2CPP,2015-01-23 首次出貨 iOS 64-bit 支援。到 2019 年 iOS 上的 Mono backend 被完全移除,Android 也因 Google Play 64-bit 強制令而實質必須用 IL2CPP。

編譯管線

C# source code
    ↓ Roslyn 編譯
CIL bytecode(.NET DLL,Assembly-CSharp.dll)
    ↓ UnityLinker 裁剪未使用的型別和方法
    ↓ il2cpp.exe 轉換(使用 Mono.Cecil 讀取 assemblies)
C++ source code(il2cpp_codegen_xxx 風格的函式呼叫)
    ↓ 平台原生 C++ 編譯器(NDK Clang for Android)
native .so(libil2cpp.so)+ global-metadata.dat

生成的 C++ 長這樣:

// C#: public static float Area(float radius) { return PI * radius * radius; }
// ↓ IL2CPP 轉換後
extern "C" float CircleFunctions_Area_m4188038(
    Il2CppObject* __this,       // static method 也有這個參數(unused)
    float ___radius0,
    const MethodInfo* method    // 隱藏參數,永遠在最後
) {
    return 3.14159f * ___radius0 * ___radius0;
}

注意最後的 MethodInfo* method 參數——每個 IL2CPP 方法都有這個隱藏參數。這是 runtime 用來支援反射、泛型解析的。

global-metadata.dat:逆向的金鑰

這個檔案是 IL2CPP 的「弱點」所在。它包含:

內容 說明
Metadata strings 所有 class、method、field、namespace 的名稱(null-terminated UTF-8)
String literals C# 硬編碼的字串常數
Type definitions 每個 class/struct/interface 的定義(flags、field/method 數量、vtable info)
Method definitions 方法簽名(declaring type、return type、參數、vtable slot)
Assembly/Image definitions 組件和映像定義

檔案以 magic number 0xFAB11BAF 開頭,後接 version number 和一系列 (offset, size) pairs 指向各資料表。全檔使用 little-endian 32-bit 寬度,資料表之間用 index 互相參照(不是指標),所以可以跨平台共用同一份 metadata。

Metadata 格式隨 Unity 版本演進,幾個重要的版本:

Metadata Version Unity 版本 重大變更
24 2017.1 新增 exported type definitions(長期穩定版本)
24.2 2019.1 per-assembly 資料重組(Il2CppCodeGenModule
27 2020.2 metadata usage 搬到 binary
29 2021.2 custom attributes 改為 blob-based 序列化

Runtime 載入時用 mmap() + PROT_READ + MAP_PRIVATE 做 demand paging(不一次全讀入記憶體),metadata 解析是 lazy 的——encoded token 在首次存取時才 atomically 解析。

Unity 官方從未加密這個檔案。 Unity 工程師在論壇明確說過:「IL2CPP is not meant to be an obfuscation tool。」所有加密都是第三方遊戲公司自己做的:

加密手法 已知案例
單位元組 XOR Arknights、Call of Duty: Mobile
分段式 XOR Garena Free Fire
Header 欄位重排 + 垃圾欄位 miHoYo(原神、崩壞3rd)
自訂 metadata loader 原神(il2cpp_init_security
假 metadata 檔案 鬼谷八荒(真實資料藏在 resources.resource.resdata

但這些保護都有一個共同弱點:到了 runtime,metadata 一定會被解密載入記憶體。frida-il2cpp-bridge 直接在 runtime 查詢,繞過所有檔案層級的保護。

逆向工具生態

工具 類型 核心能力
Il2CppDumper 靜態 解析 binary + metadata,輸出 dummy DLL、IDA/Ghidra scripts
Cpp2IL 靜態 超越 metadata 提取——用自研 IR 重建 CIL method bodies
frida-il2cpp-bridge 動態 Runtime dump/trace/hook,不需要 global-metadata.dat 檔案

frida-il2cpp-bridge 的獨特之處:它不去解析 metadata 檔案,而是直接呼叫 IL2CPP 的 exported C APIil2cpp_domain_get_assembliesil2cpp_class_from_name 等)。這意味著即使遊戲加密了 metadata 檔案,frida-il2cpp-bridge 也不受影響——因為到了 runtime,metadata 已經被解密載入記憶體了。


解法:用 frida-il2cpp-bridge hook C# method

偵察:找到速度偵測的 class

先用 frida-il2cpp-bridge 遍歷所有 class,搜尋跟 speed/warning 相關的名稱:

import "frida-il2cpp-bridge";

Il2Cpp.perform(() => {
    Il2Cpp.domain.assemblies.forEach(assembly => {
        assembly.image.classes.forEach(klass => {
            if (klass.name.toLowerCase().includes("speed")) {
                console.log(`${klass.namespace}.${klass.name}`);
                klass.methods.forEach(m => console.log(`  ${m.name}`));
            }
        });
    });
});

找到目標 class 及其方法:

Niantic.Ichigo.Location.SpeedMonitor
  ├── get_CanShowSpeedWarning  → bool property getter,控制能不能彈
  ├── get_IsPlayerSpeeding     → bool property getter,是否判定超速
  ├── IsSpeeding               → bool,速度判定邏輯
  ├── Suppress                 → void,遊戲自己的壓制方法
  └── ShowSpeedWarningAsync    → Task,實際彈出 popup

第一版:只 hook 一個 method

const sm = Il2Cpp.domain.assembly("Assembly-CSharp")
    .image.class("Niantic.Ichigo.Location.SpeedMonitor");

sm.method("get_CanShowSpeedWarning").implementation = function () {
    return false;
};

→ 有效!popup 不彈了。但偶爾還是會閃一下——因為 get_IsPlayerSpeeding 回傳 true 的瞬間,UI 可能已經開始準備渲染了。

第二版:封三個入口

sm.method("get_CanShowSpeedWarning").implementation = function () {
    return false;
};
sm.method("get_IsPlayerSpeeding").implementation = function () {
    return false;
};
sm.method("IsSpeeding").implementation = function () {
    return false;
};

→ 完全壓制。遊戲從根本認為玩家沒有在超速——dialog 永遠不觸發,連閃一下都沒有。

底層原理:method.implementation = fn 做了什麼

method.implementation 的 setter 呼叫 Frida 的 Interceptor.replace()。底層原理:

  1. 讀取原始 method 的 native function pointer(從 MethodInfo struct 中找到 methodPointer 欄位)
  2. NativeCallback 建立新的 native function,JS callback 會被包裝成符合 IL2CPP calling convention 的 C 函式
  3. Interceptor.replace() 覆寫原始函式的 prologue:
    • ARM64:覆寫前 16 bytes 為 LDR X16, [PC, #8]; BR X16; .quad <replacement_addr>
    • x86_64:覆寫前 5 bytes 為 JMP rel32(或 16 bytes 的 full 64-bit redirect)
  4. 原始 prologue 被搬到 on_invoke_trampoline(經 Relocator 改寫 PC-relative 指令),供需要時呼叫原始函式

當遊戲呼叫 get_CanShowSpeedWarning() 時,CPU 跳到函式開頭,立即被重導向到我們的 JavaScript callback——經 QuickJS 引擎執行後回傳 false。整個過程對遊戲的其他部分完全透明。

methodPointer 的動態定位

frida-il2cpp-bridge 不硬編碼 MethodInfo struct 的 layout(因為不同 Unity 版本可能不同)。它用一個巧妙的自動偵測機制:

  1. 取得 System.Reflection.Module.FilterTypeName——一個任何 .NET runtime 都存在的 delegate
  2. 從 delegate 讀取已知的 method_ptrmethod 指標
  3. 從 struct base 向前掃描至多 512 bytes,找到 readPointer() 匹配已知 method_ptr 的位置
  4. 快取 offset,之後所有 method 都用同一個 offset

這讓 frida-il2cpp-bridge 能支援 Unity 5.3.0 到 6000.3.x 的所有版本,不需要為每個版本維護 struct layout 定義。


常見誤解

誤解 實際情況 為什麼會搞混
adb input tap 在所有 Android app 上都有效」 Unity New Input System 會靜默丟棄注入的 touch event 這在舊版 Unity 和所有非 Unity app 上確實有效,所以直覺上很難想到是 Unity 的問題
sendeventinput tap 更底層,一定行」 兩者最終都經過 Android InputDispatcher,Unity 的過濾在接收端 sendevent 確實繞過了 framework 的注入 API,但 Unity 的過濾不在注入路徑上
「IL2CPP = 安全,C# 變 native code 就逆向不了」 global-metadata.dat 保留完整的 class/method 名稱,加上 runtime API 可直接查詢 IL2CPP 的設計目標是效能和跨平台,從來不是安全。Unity 官方明確說「不是混淆工具」
「模擬器的 GPS = mock location,app 能偵測到」 模擬器 GPS 是虛擬硬體層級,isFromMockProvider() 回傳 false Mock location app 確實會被偵測,但模擬器的 goldfish GPS 不走 mock location 路徑
「Frida 需要一直用 ptrace,容易被偵測」 ptrace 只在注入瞬間使用,完成即 detach,後續走 Unix socket 很多文章把 Frida 描述為「ptrace-based debugger」,但實際上它只在初始階段用 ptrace

替代方案比較

除了 Frida + frida-il2cpp-bridge,還有哪些方案可以達成同樣目的?

方案 原理 優勢 劣勢 適用場景
Frida + frida-il2cpp-bridge Runtime inline hook,替換 native function prologue 不改 APK、支援所有 Unity 版本、腳本可動態調整 需要 root(或 gadget)、每次啟動要重新注入 快速原型、動態分析、自動化
Xposed / LSPosed 替換 ArtMethod entry point 指標 持久化(隨 Zygote fork)、不需每次重新注入 只能 hook Java/ART 方法,無法 hook IL2CPP native code Java 層 hook(如 Android framework API)
APK 反編譯 + Smali 修改 反編譯 → 修改 bytecode → 重打包 不需 root、修改持久化 IL2CPP 遊戲的邏輯在 native .so 裡不在 DEX 裡,改 Smali 改不到 純 Java/Kotlin app
Binary patch(直接改 .so) 用 hex editor 或工具修改 libil2cpp.so 的指令 不需 root、修改持久化 破壞 APK 簽名(需 resign)、每次遊戲更新要重做、容易改錯 確定的單點修改
GameGuardian ptrace + /proc/mem 記憶體搜尋和修改 不需逆向知識、UI 操作 只改記憶體值不改邏輯、無法 hook function、每次要手動搜尋 改數值(金幣、血量)
BepInEx / MelonLoader Managed mod framework,用 Cpp2IL + Il2CppInterop 建立 proxy assemblies 完整的 C# modding 環境、可寫複雜 mod 設置複雜、需要針對遊戲版本調整 大型 mod 開發

何時不用 Frida:


邊界與陷阱

⚠️ 陷阱 1:不是所有 popup 都能壓制

實測發現壓制某些 popup 會導致「couldn’t connect to server」:

popup 壓制安全性 原因
SpeedMonitor(移動太快) ✅ 安全 純 client-side UI 判斷,server 不知道有沒有彈
SafetyWarning(Do not trespass) ✅ 安全 AlreadyShownToday 是本地 flag
CheatingWarningService(作弊警告) ❌ 危險 server 發警告後等 client ACK,壓制 = 沒回應 = 斷線
FeaturedProductPopupService(商店推薦) ⚠️ 小心 可能干預商店邏輯和收據驗證

判斷規則:純 client-side 的 UI 開關可以壓制,跟 server 有 handshake 的流程不能碰。 不確定時,先用 Il2Cpp.trace() 觀察 method 呼叫順序,看有沒有 network call 跟著 popup 一起被觸發。

⚠️ 陷阱 2:Virtual method dispatch 與 inline 的影響

Frida 修補的是原生函式的 prologue。如果遊戲的 C++ 編譯器做了以下優化,hook 可能失效:

情境 hook 觸發?
Base method 被 hook,Derived override ✅ 是(共用同一個 native 函式)
Base method 被 hook,Derived override ❌ 否(不同的 native 函式,要分別 hook)
Method 被 C++ 編譯器 inline ❌ 否(沒有獨立函式入口點)
sealed class 的虛擬呼叫被 devirtualize ❌ 可能被 inline,無法 hook

實務建議:hook property getter(如 get_CanShowSpeedWarning)通常比 hook caller 更可靠,因為 getter 是獨立函式且通常不會被 inline(有反射需求)。

⚠️ 陷阱 3:Frida 偵測

遊戲可能有反作弊機制偵測 Frida。常見手段:

反偵測方案:

⚠️ 陷阱 4:method.implementationIl2Cpp.trace() 衝突

兩者都用 Interceptor.replace(),對同一個 method 只有最後一個生效。先 trace 再 replace implementation 會覆蓋 trace;反之亦然。偵察和正式 hook 要分兩次跑,不能在同一個 script 裡。


學到的事

重點速查

更大的圖景

這個案例展示了一個反覆出現的工程模式:當你在某個抽象層碰壁,往往不是該層的解法不夠好,而是問題根本不該在這層解決。

我在 Android framework 層花了很多時間嘗試不同的 touch injection 方式——input tapsendeventmonkey、UI Automator——它們全部失敗,因為問題出在 Unity 的接收端,不在 Android 的發送端。真正的突破來自於「換一層思考」:既然按不掉 popup,那就讓它不要彈出。

這個思路轉換——從「怎麼模擬操作」到「怎麼修改行為」——在自動化和逆向工程中是通用的。當你發現自己在同一個抽象層嘗試了三種以上的方式都行不通,那很可能是時候往上或往下一層看了。

另一個值得帶走的觀察:安全性不在於編譯方式,而在於 metadata 管理。 IL2CPP 把 C# 編譯成 native code,直覺上增加了逆向難度——但實際上所有的 class 和 method 名稱都原封不動地躺在 global-metadata.dat 裡,而且 runtime 必須提供查詢 API。這跟很多系統的安全假設類似:加密了傳輸但 key 寫在 config 裡、混淆了程式碼但 log 把所有函式名印出來。真正的安全邊界不在「資料長什麼樣」,而在「誰能在什麼時候存取什麼」。


參考資料