chundev
日期:2026-06-20 環境:Android Emulator (AVD) + Frida 16.x + Unity IL2CPP 遊戲
在 Android 模擬器上自動化操作 Unity 遊戲時,GPS 飄移觸發了「移動太快」dialog。我本來以為 adb input tap 點掉就好——結果完全沒反應。花了一整天才搞懂:Unity 2023+ 的 New Input System 有個已知 bug,會丟棄所有透過 Android framework 注入的 touch event。adb input tap、sendevent、monkey——全部失效。最終解法是換一個思路:不去「點掉」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,花了不少時間在錯誤的方向上打轉。
症狀很明確: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。
最直覺的做法。input tap 透過 Android 的 InputManager.injectInputEvent() 注入一個 MotionEvent,經 InputManagerService(Binder IPC)派送到目標 window。
在舊版 Unity 遊戲上這招有效,因為舊的 UnityEngine.Input 採用 polling 模型——每幀從 Android 的 InputEvent queue 讀取所有事件,不管來源。但 New Input System 改了架構(詳見下方機制拆解),注入的事件被過濾掉了。
繞過 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 在接收端的過濾邏輯一樣會把它擋掉——問題不在注入的路徑,而在接收端的裝置匹配。
嘗試用 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 根本不會彈出,就不需要操心怎麼模擬觸控了。這需要:
這是整個問題的根本原因,值得深入理解。
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 就會被讀到。
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 比對 interfaceName、deviceClass、capabilities 等 metadata,建立對應的 InputDevice 實例 |
| High-level layer | 套用 layout、建立 InputControl 樹、提供 InputAction 抽象 |
關鍵差異在中間層的 InputDeviceMatcher。它會檢查事件的 device source 和 descriptor——透過 injectInputEvent() 注入的觸控事件,其裝置描述可能與已註冊的 Touchscreen 裝置不匹配(device ID 不同、source flags 不符),導致事件被靜默丟棄。
Unity Issue Tracker UUM-33878 記錄了這個問題:
Unity 的 Active Input Handling 設定有三個選項:Old、New、Both。”Both” 同時啟用兩套系統,Unity 官方明確警告 Android 上不支援 “Both” 模式。但不少遊戲仍然使用它——而即使設為純 “New”,在未修復版本中 adb input tap 仍然不被接受。
這是整條攻擊鏈的起點——為什麼我們能在模擬器上假裝在走路,而遊戲偵測不到是假的。
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 是更好的策略。
要 hook Unity 的 C# method,首先需要一個能在目標進程內執行任意程式碼的工具。Frida 做的不是「外部控制」——它是真的把一個 agent 注入到目標進程裡,像寄生蟲一樣。
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:由 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 但可以接受重新簽名的場景。
Unity 把 C# 轉成 native code,聽起來逆向難度應該很高。但 IL2CPP 有一個設計上的「弱點」——它保留了完整的 C# metadata。
五股壓力在 2014-2015 年匯聚:
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 用來支援反射、泛型解析的。
這個檔案是 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 API(il2cpp_domain_get_assemblies、il2cpp_class_from_name 等)。這意味著即使遊戲加密了 metadata 檔案,frida-il2cpp-bridge 也不受影響——因為到了 runtime,metadata 已經被解密載入記憶體了。
先用 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
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 的 setter 呼叫 Frida 的 Interceptor.replace()。底層原理:
MethodInfo struct 中找到 methodPointer 欄位)NativeCallback 建立新的 native function,JS callback 會被包裝成符合 IL2CPP calling convention 的 C 函式Interceptor.replace() 覆寫原始函式的 prologue:
LDR X16, [PC, #8]; BR X16; .quad <replacement_addr>JMP rel32(或 16 bytes 的 full 64-bit redirect)on_invoke_trampoline(經 Relocator 改寫 PC-relative 指令),供需要時呼叫原始函式當遊戲呼叫 get_CanShowSpeedWarning() 時,CPU 跳到函式開頭,立即被重導向到我們的 JavaScript callback——經 QuickJS 引擎執行後回傳 false。整個過程對遊戲的其他部分完全透明。
frida-il2cpp-bridge 不硬編碼 MethodInfo struct 的 layout(因為不同 Unity 版本可能不同)。它用一個巧妙的自動偵測機制:
System.Reflection.Module.FilterTypeName——一個任何 .NET runtime 都存在的 delegatemethod_ptr 和 method 指標readPointer() 匹配已知 method_ptr 的位置這讓 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 的問題 |
「sendevent 比 input 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:
實測發現壓制某些 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 一起被觸發。
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(有反射需求)。
遊戲可能有反作弊機制偵測 Frida。常見手段:
/proc/self/maps 尋找 frida-agent、LIBFRIDA 等字串/proc/<pid>/fd/ 尋找 linjector named pipeptrace(PTRACE_TRACEME) 自我保護(但 Frida 完成注入後已 detach,此招無效)反偵測方案:
frida-server --listen=unix:/path/to/sock:改用 Unix socket 避開 port 掃描method.implementation 和 Il2Cpp.trace() 衝突兩者都用 Interceptor.replace(),對同一個 method 只有最後一個生效。先 trace 再 replace implementation 會覆蓋 trace;反之亦然。偵察和正式 hook 要分兩次跑,不能在同一個 script 裡。
Android 模擬器的 GPS 是虛擬硬體層級的欺騙——不走 mock location API,isFromMockProvider() 回傳 false。這是比任何 mock location app 都乾淨的 GPS 偽造方式,因為它從硬體抽象層就是「真的」。
Unity New Input System 改變了 Android touch event 的接收機制——從無差別 polling 變成帶裝置匹配的 event-driven pipeline。這導致所有 Android framework 層的 touch injection 方式失效。這是 Unity 的 bug(UUM-33878),在 2023.2.0a17 修復。
IL2CPP 的安全性靠的不是 native code,而是 metadata 保護——global-metadata.dat 保留了完整的 C# 類別和方法名稱,Unity 官方不加密。frida-il2cpp-bridge 甚至不讀檔案,直接呼叫 runtime API 查詢——繞過所有檔案層級的保護。
Frida 的 ptrace 只是注入的「敲門磚」——完成即 detach,後續靠 agent.so 在 target 進程內獨立運行。這是它跟傳統 debugger 的根本區別:debugger 全程 ptrace 控制,Frida 是「放一個間諜進去然後斷開連線」。
壓制 client-side UI 和壓制 server-ack 流程是完全不同的風險等級——前者安全(server 不知道 popup 有沒有彈),後者可能觸發斷線或封號(server 等 ACK 等不到)。判斷方式:用 Il2Cpp.trace() 觀察 method 呼叫有沒有伴隨 network call。
inline hook 的成敗取決於 C++ 編譯器的優化——如果方法被 inline、devirtualize、或跟其他方法共享同一個 native 函式入口,hook 就會失效。property getter 通常比深層邏輯方法更安全,因為反射需求讓編譯器不敢 inline。
這個案例展示了一個反覆出現的工程模式:當你在某個抽象層碰壁,往往不是該層的解法不夠好,而是問題根本不該在這層解決。
我在 Android framework 層花了很多時間嘗試不同的 touch injection 方式——input tap、sendevent、monkey、UI Automator——它們全部失敗,因為問題出在 Unity 的接收端,不在 Android 的發送端。真正的突破來自於「換一層思考」:既然按不掉 popup,那就讓它不要彈出。
這個思路轉換——從「怎麼模擬操作」到「怎麼修改行為」——在自動化和逆向工程中是通用的。當你發現自己在同一個抽象層嘗試了三種以上的方式都行不通,那很可能是時候往上或往下一層看了。
另一個值得帶走的觀察:安全性不在於編譯方式,而在於 metadata 管理。 IL2CPP 把 C# 編譯成 native code,直覺上增加了逆向難度——但實際上所有的 class 和 method 名稱都原封不動地躺在 global-metadata.dat 裡,而且 runtime 必須提供查詢 API。這跟很多系統的安全假設類似:加密了傳輸但 key 寫在 config 裡、混淆了程式碼但 log 把所有函式名印出來。真正的安全邊界不在「資料長什麼樣」,而在「誰能在什麼時候存取什麼」。
injectInputEvent() 的實作,理解 Android touch injection 路徑