diff --git a/README.md b/README.md
index 50637ec..5201c2b 100644
--- a/README.md
+++ b/README.md
@@ -193,6 +193,13 @@ references/ loaded on demand, one topic each
Begins with the snapshot-decoding front end it depends on (aotopsy or
blutter) because the pool listing is an input, not something this skill
produces itself
+ unity-runtime.md Unity from the two files that decide everything: Mono
+ (`Assembly-CSharp.dll`) versus IL2CPP (`libil2cpp.so` plus
+ `global-metadata.dat`), dumping the pair with Il2CppDumper and
+ importing the tables into IDA in the order that works, the
+ dnSpy-Unity-mono patch that makes a retail Mono player accept a
+ debugger, a run-time class lookup over frida-il2cpp-bridge, and
+ the audit probes when the deliverable is a report
native-and-so.md .so hosts, DT_NEEDED vs JNI_OnLoad, relocation limits,
relocation-free bootstrapping, replacing Java methods natively,
and which ABI/library is *actually loaded and executing*
@@ -200,6 +207,10 @@ references/ loaded on demand, one topic each
mechanisms, how to tell which one actually fires, how to find the
site, forged section headers, function boundaries from
PT_GNU_EH_FRAME, scanner traps, and neutralising safely
+ ida-so-debugging.md attaching a real debugger instead of hooking: IDA's `dbgsrv` with
+ the ABI match, `am start -D` plus the JDWP handshake that actually
+ releases the process, and the `extractNativeLibs="false"` failure
+ where the library is mapped from `base.apk` and no breakpoint binds
detection-and-anti-analysis.md when the app fights back or the tool cannot run here: telling
detection apart from a broken environment, deciding by cost instead
of escalating, recognising an environment where dynamic analysis
@@ -591,10 +602,11 @@ whose measurements disagreed with them. A **hardware pass** then added the layer
software tooling: **hardware watchpoints** with their honest `TracerPid` ledger, **ETE/TRBE
differential tracing** with its five gates, and **kernel-side injection templates** — every
host-checkable piece measured, every device-bound piece labelled `unverified` until a gate
-opens. Unity/IL2CPP logic recovery,
-React Native/Hermes bytecode internals, and defeating a server-side authority are **not**
-covered, and the skill is written to say so and stop rather than apply the nearest
-documented procedure to a target it was not written for.
+opens. Unity/IL2CPP logic recovery and
+attaching a real debugger to a `.so` are **documented routes, not measurements** — absorbed from
+public write-ups and labelled `unverified` throughout. React Native/Hermes bytecode internals and
+defeating a server-side authority are **not** covered, and the skill is written to say so and stop
+rather than apply the nearest documented procedure to a target it was not written for.
Four qualifications that the Coverage section states in full and that belong here too:
diff --git a/README.zh-CN.md b/README.zh-CN.md
index e587bc8..5c08a7b 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -115,6 +115,11 @@ references/ 按需加载,每个文件一个主题
识别业务逻辑的三种签名、定位、打补丁。
开篇先讲它所依赖的 snapshot 解码前端(aotopsy 或
blutter),因为 pool 列表是输入,不是本 skill 自己产出的东西
+ unity-runtime.md 用两个决定一切的文件进入 Unity:Mono(`Assembly-CSharp.dll`)
+ 与 IL2CPP(`libil2cpp.so` + `global-metadata.dat`)的区分、
+ 用 Il2CppDumper 导出这一对文件、按能成功的顺序把表导入 IDA、
+ 让零售 Mono 播放器接受调试器的 dnSpy-Unity-mono 补丁、
+ 基于 frida-il2cpp-bridge 的运行时类查找,以及交付物是报告时的审计探针
native-and-so.md .so 宿主、DT_NEEDED vs JNI_OnLoad、重定位限制、
免重定位的 bootstrap、用 native 替换 Java 方法,
以及*实际加载并执行*的是哪个 ABI/库
@@ -122,6 +127,10 @@ references/ 按需加载,每个文件一个主题
如何判断实际触发的是哪一个、如何定位现场、
伪造的 section header、由 PT_GNU_EH_FRAME 推出的
函数边界、扫描器陷阱,以及安全地失效化
+ ida-so-debugging.md 接一个真正的调试器而不是 hook:ABI 匹配的 IDA `dbgsrv`、
+ `am start -D` 加真正放行进程的 JDWP 握手,以及
+ `extractNativeLibs="false"`——库直接从 `base.apk` 映射、
+ 断点无处可绑的失败
detection-and-anti-analysis.md 当 app 反击,或工具在这里跑不起来时:把检测与
环境损坏区分开、按成本而不是按升级来决策、认出
动态分析根本无效的环境,并把「挡住我的分析」这个问题
@@ -454,7 +463,7 @@ export APKREV_TOOLS=
: # POSIX
为处理你自己的应用、你被授权分析的样本,以及 CTF/竞赛沙箱而构建。它不含任何内置的第三方二进制,也不含任何目标特定数据。
-它覆盖什么、刻意不覆盖什么,写在 `SKILL.md` 顶部 **Coverage** 一节。简版:仅 Android(不含 iOS),并且在经过真实打磨的层上深入 —— dex 补丁、重打包、壳与自定义加载器、native 篡改响应,以及 Flutter/Dart AOT。一次扩展迭代加入了第二梯队的已记录路线:重打包受阻时的**模块侧交付**、**抽取壳恢复**及其 VMP 边界、用于「调用而不是阅读」的**模拟执行与实时 RPC**、针对 OLLVM 的**指令级追踪**、REST 之外的**协议逆向**、当用户态 hooking 可证明够不到时用的**内核路线**图,以及**设备上工具**。随后一次**基准迭代**把公开目标放到这些路线之下(`tests/benchmark.md`):它加入了 **Java2C 判别**(那种会让 agent 去追一个从不存在的解密 DEX 的误诊)、**split APK / App Bundle 处理**、**无 schema 的 protobuf 解码**、一个 **Dex-VMP 差分**测试台,以及**带版本闸门的内核模块模板** —— 并纠正了先前两条与自身测量结果不一致的说法。随后一次**硬件迭代**加入了软件工具之下的那一层:带诚实 `TracerPid` 账本的**硬件观察点**、带五道闸门的 **ETE/TRBE 差分追踪**,以及**内核侧注入模板** —— 凡是宿主可验证的部分都实测过,凡是设备绑定的部分在闸门打开前一律标注 `unverified`。Unity/IL2CPP 逻辑恢复、React Native/Hermes 字节码内部,以及击败服务端权威**不**在覆盖范围内,skill 的写法就是明说这一点并停下,而不是把最近的一条已记录流程套用到一个它并非为之编写的目标上。
+它覆盖什么、刻意不覆盖什么,写在 `SKILL.md` 顶部 **Coverage** 一节。简版:仅 Android(不含 iOS),并且在经过真实打磨的层上深入 —— dex 补丁、重打包、壳与自定义加载器、native 篡改响应,以及 Flutter/Dart AOT。一次扩展迭代加入了第二梯队的已记录路线:重打包受阻时的**模块侧交付**、**抽取壳恢复**及其 VMP 边界、用于「调用而不是阅读」的**模拟执行与实时 RPC**、针对 OLLVM 的**指令级追踪**、REST 之外的**协议逆向**、当用户态 hooking 可证明够不到时用的**内核路线**图,以及**设备上工具**。随后一次**基准迭代**把公开目标放到这些路线之下(`tests/benchmark.md`):它加入了 **Java2C 判别**(那种会让 agent 去追一个从不存在的解密 DEX 的误诊)、**split APK / App Bundle 处理**、**无 schema 的 protobuf 解码**、一个 **Dex-VMP 差分**测试台,以及**带版本闸门的内核模块模板** —— 并纠正了先前两条与自身测量结果不一致的说法。随后一次**硬件迭代**加入了软件工具之下的那一层:带诚实 `TracerPid` 账本的**硬件观察点**、带五道闸门的 **ETE/TRBE 差分追踪**,以及**内核侧注入模板** —— 凡是宿主可验证的部分都实测过,凡是设备绑定的部分在闸门打开前一律标注 `unverified`。Unity/IL2CPP 逻辑恢复与**给 `.so` 接真实调试器**属于**已记录但未实测**的路线 —— 吸收自公开文章,全文标注 `unverified`。React Native/Hermes 字节码内部,以及击败服务端权威**不**在覆盖范围内,skill 的写法就是明说这一点并停下,而不是把最近的一条已记录流程套用到一个它并非为之编写的目标上。
Coverage 一节完整陈述、同时也应当放在这里的四条限定:
diff --git a/skills/apk-reverse/SKILL.md b/skills/apk-reverse/SKILL.md
index e5289b0..a75d218 100644
--- a/skills/apk-reverse/SKILL.md
+++ b/skills/apk-reverse/SKILL.md
@@ -138,10 +138,12 @@ snapshot dump you must obtain elsewhere and naming the front end matters · extr
stops at the VMP boundary · a Stalker trace is not guaranteed on every device · a kernel-side answer
requires a module this skill only scaffolds.
-**Not covered — say so rather than improvise:** Unity / IL2CPP · React Native / Hermes / Cordova ·
-iOS · defeating a server-side authority · an off-the-shelf unpacker or an anti-detection arms race ·
-building and shipping a kernel module. Reasons and evidence boundaries are in
-`references/coverage-and-limits.md`; **silence in that record is not support.**
+**Not covered — say so rather than improvise:** React Native / Hermes / Cordova · iOS · defeating a
+server-side authority · an off-the-shelf unpacker or an anti-detection arms race · building and
+shipping a kernel module. **Documented but never measured here** — Unity (Mono and IL2CPP) and
+attaching a real debugger to a `.so`: a route, not a result (`unity-runtime.md`,
+`ida-so-debugging.md`). Reasons and evidence boundaries are in `references/coverage-and-limits.md`;
+**silence in that record is not support.**
**The fallback, as an instruction — this is a rule, not advice.** If the target does not match the
covered list, or no symptom-index row matches, then **stop and classify before choosing a branch**:
@@ -242,6 +244,8 @@ unread in this repository.
| The value you need (sign, token, risk parameter) is computed in **JavaScript** — a WebView or embedded JS engine — by an entry call fed a huge hex/base64 opcode literal: a JS virtual machine | `jsvmp-recovery.md` — de-obfuscate and **call it** first, then the stack/register-VM procedures and the four JS traps; `emulation-and-rpc.md` owns the call-don't-read route |
| You are reading a decompiled **Kotlin** app and every class name is a meaningless short token (`nq.e`, `C0123x`) | `kotlin-name-recovery.md` — the original FQNs survive R8 inside `@DebugMetadata`/`@Metadata`; `scripts/kotlin_name_recovery.py` rebuilds the mapping from any jadx export |
| A store build arrives as `base.apk` + `split_config.*.apk`, or a rebuilt build is refused **as a set** although every file verifies on its own | `split-apk.md` — one keystore across every member for `pm install-multiple`, and check that a merge is legal before trusting a merged single APK |
+| A debugger lists and attaches to the process, but the target `.so` is absent from the module list and every breakpoint in it refuses or never hits, while its feature works | `ida-so-debugging.md` §the `.so` was never extracted — `android:extractNativeLibs="false"` maps the library from `base.apk`, so there is no `/data/app//lib//` file to bind to. Not an anti-debug finding |
+| A Unity package has no `Assembly-CSharp.dll` — only `libil2cpp.so` and `global-metadata.dat` — and every Java-layer hook turns out to be glue | `unity-runtime.md` — that is IL2CPP, the normal shipping configuration, not a stripped build: dump the pair with Il2CppDumper and import `script.json` before `stringliteral.json`, or the database is silently half-named |
## Gates — clear these before you patch, in order
diff --git a/skills/apk-reverse/evidence/capability-matrix.json b/skills/apk-reverse/evidence/capability-matrix.json
index 138c8ac..64bda01 100644
--- a/skills/apk-reverse/evidence/capability-matrix.json
+++ b/skills/apk-reverse/evidence/capability-matrix.json
@@ -657,7 +657,7 @@
"limits": "The offline candidate path from an APK and the authoritative live read are both documented in the reference; neither has a public-target measurement here. The failure shape is a rebuilt APK that installs, launches, and then fails every signed request, which is easy to report as a server problem and is not one."
},
{
- "capability": "blocked-unity-il2cpp-react-native-ios",
+ "capability": "blocked-react-native-ios",
"group": "boundary",
"status": "blocked",
"route_measured": false,
@@ -670,7 +670,38 @@
"evidence_not_shipped": [
"docs/tool-verification/README.md (repository root, not shipped)"
],
- "limits": "Unity / IL2CPP logic recovery, React Native / Hermes bytecode and Cordova internals, and every iOS or .ipa workflow are outside this skill. references/framework-runtimes.md identifies the runtime and establishes that the dex is not the battlefield; there is no verified recipe here for locating a method inside libil2cpp.so plus global-metadata.dat. Say so instead of improvising."
+ "limits": "React Native / Hermes bytecode and Cordova internals, and every iOS or .ipa workflow, are outside this skill. references/framework-runtimes.md identifies the runtime and establishes that the dex is not the battlefield; it carries no Hermes-bytecode recipe and no iOS tooling. Say so instead of improvising. Unity is no longer in this row: its documented route is the unity-il2cpp-logic-recovery row, which is a route and not a measurement."
+ },
+ {
+ "capability": "unity-il2cpp-logic-recovery",
+ "group": "runtime",
+ "status": "partial",
+ "route_measured": false,
+ "strength": "unverified",
+ "benchmark_rows": [],
+ "evidence": [
+ "references/unity-runtime.md",
+ "references/framework-runtimes.md"
+ ],
+ "evidence_not_shipped": [
+ "ref/unity1.md, ref/unity2.md, ref/unity3.md -- public Unity reverse-engineering write-ups (2026-02, 2026-06), methodology absorbed, no text copied"
+ ],
+ "limits": "Absorbed, never run here: no Unity package was dumped and no patched Mono runtime was built in this record. What is documented: Mono versus IL2CPP identification by shipped files, Il2CppDumper over libil2cpp.so + global-metadata.dat with the metadata magic (0xFAB11BAF) as the encryption check, the four-step IDA import order (ida_py3.py, script.json, stringliteral.json, ida_with_struct_py3.py), a frida-il2cpp-bridge lookup for a run-time or encrypted case, and the dnSpy-Unity-mono patch set that makes a retail Mono player accept a debugger. Unverified details: the exact dnSpy patch hunks, the Android libmono.so equivalent (inferred from a desktop-only source), whether the loader prefers an on-disk .so on a hardened build, and every tool's behaviour on a stripped or protected package. No opcode or offset claim is made here."
+ },
+ {
+ "capability": "native-so-remote-debugger-attach",
+ "group": "native",
+ "status": "partial",
+ "route_measured": false,
+ "strength": "unverified",
+ "benchmark_rows": [],
+ "evidence": [
+ "references/ida-so-debugging.md"
+ ],
+ "evidence_not_shipped": [
+ "ref/idaso1.md, ref/idaso2.md -- public IDA-on-Android walkthroughs (2024-07), commands absorbed, no text copied"
+ ],
+ "limits": "This is a documented route, not a measured one: no debugger was attached in this record and neither source targeted a hardened app. The mechanics covered are the two manifest preconditions, matching dbgsrv to the loaded ABI, running the server as uid 0 (else the attach list hides the target), am start -D plus the JDWP forward and jdb connect that actually releases the suspended process, and the extractNativeLibs=false failure in which the library is mapped from base.apk so no breakpoint can bind. Unverified: dbgsrv filenames per IDA release, chmod mode, menu labels across versions, and whether pushing an on-disk .so overrides the in-APK copy on a protected build. The route cannot hide TracerPid, the server binary on disk, or software breakpoints in mapped pages."
},
{
"capability": "blocked-server-side-authority",
diff --git a/skills/apk-reverse/evidence/known-limitations.md b/skills/apk-reverse/evidence/known-limitations.md
index dc087c9..0b55fe8 100644
--- a/skills/apk-reverse/evidence/known-limitations.md
+++ b/skills/apk-reverse/evidence/known-limitations.md
@@ -71,9 +71,15 @@ built on them has nothing to check itself against.
`aarch64` cross-compiler, `make` or `ndk-build`, and there is no kernel source. One structural
correction survives without a measurement and is worth more than the templates: **an ordinary
KernelSU module is a userspace module and cannot change a syscall return value.**
-- **Unity / IL2CPP logic recovery, React Native / Hermes bytecode and Cordova internals, and every
- iOS or `.ipa` workflow.** The runtime can be identified; the logic recovery is not covered, and
- there is no verified recipe for locating a method inside `libil2cpp.so` plus `global-metadata.dat`.
+- **React Native / Hermes bytecode and Cordova internals, and every iOS or `.ipa` workflow.** The
+ runtime can be identified; the internals are not covered.
+- **A measured Unity / IL2CPP route.** `unity-runtime.md` documents backend identification,
+ Il2CppDumper, the IDA import order, the Frida bridge and the dnSpy-Unity-mono patch — but nothing
+ in it was executed here, there is no byte-level reading of generated native code, and no package
+ was tested protected. Do not report a successful dump as a located method.
+- **A measured debugger attach.** `ida-so-debugging.md` documents the `dbgsrv` route and the
+ `am start -D` + JDWP handshake, never run here; the `extractNativeLibs="false"` chapter is the
+ recorded failure mode, not a fix verified on a hardened build.
- **Defeating a server-side authority.** This skill determines *who owns a gate*, not how to break an
authorization the server performs. Report it as a residual instead of patching harder.
diff --git a/skills/apk-reverse/references/coverage-and-limits.md b/skills/apk-reverse/references/coverage-and-limits.md
index 2bbec1c..a031080 100644
--- a/skills/apk-reverse/references/coverage-and-limits.md
+++ b/skills/apk-reverse/references/coverage-and-limits.md
@@ -166,6 +166,17 @@ its own strength note at the top.
for browser targets and never run on an Android long-connection app here — `unverified`
end to end, with Android capture points `inferred` from documented APIs
(`protocol-reverse.md` §6).
+- **Unity logic recovery (Mono and IL2CPP)** — telling the backends apart by what they ship,
+ dumping `libil2cpp.so` + `global-metadata.dat` with Il2CppDumper, the metadata magic that decides
+ whether the dump is even possible, the four-step IDA import order, a run-time class/method lookup
+ over frida-il2cpp-bridge, and the dnSpy-Unity-mono patch that makes a retail Mono player accept a
+ debugger. Absorbed from three public write-ups; no package was dumped here and no patched runtime
+ was built, so every step is `unverified` (`unity-runtime.md`).
+- **Attaching IDA's remote debug server to a `.so`** — ABI-matched `dbgsrv`, the root server, the
+ wait-for-debugger start plus the JDWP handshake that releases the process, and the
+ `extractNativeLibs="false"` failure where the library is mapped from `base.apk` and no breakpoint
+ can bind. Absorbed from two walkthroughs; never run here, and neither source targeted a hardened
+ app (`ida-so-debugging.md`).
- **Kernel-side event injection** — invisible watchpoints and register rewrites from a driver, as
generated templates with their gates; nothing kernel-side was ever compiled or loaded here
(`kernel-event-injection.md`, `scripts/kernel_instrument_scaffold.py`).
@@ -189,10 +200,12 @@ its own strength note at the top.
## Not covered — say so rather than improvise
-- **Unity / IL2CPP logic recovery.** `framework-runtimes.md` identifies the runtime and establishes
- that the dex is not the battlefield; it does not carry the IL2CPP equivalent of `dart-aot.md`.
- There is no verified recipe here for locating a method inside `libil2cpp.so` plus
- `global-metadata.dat`.
+- **A *verified* Unity / IL2CPP route.** `unity-runtime.md` carries the documented route —
+ backend identification, Il2CppDumper, the IDA import order, the Frida bridge, the dnSpy-Unity-mono
+ patch — but nothing in it was run in this record, and it is not the IL2CPP equivalent of
+ `dart-aot.md`: there is no byte-level reading of generated native code here, no opcode map, and no
+ measurement on a stripped or protected package. Do not present a dump as proof that a method was
+ located.
- **React Native / Hermes bytecode** and Cordova/hybrid internals, beyond runtime identification and
the generic "find the string, then find what references it" approach. One carve-out: if the JS
layer's problem is **VMP-protected JavaScript** (a jsvmp opcode literal plus interpreter), that is
diff --git a/skills/apk-reverse/references/coverage/route-inventory.md b/skills/apk-reverse/references/coverage/route-inventory.md
index e3bdcc4..d945bb8 100644
--- a/skills/apk-reverse/references/coverage/route-inventory.md
+++ b/skills/apk-reverse/references/coverage/route-inventory.md
@@ -52,6 +52,8 @@ route was not measured on this repository's evidence**. Treat them as leads with
| JS AST pre-deobfuscation | family detection + per-family pass order + safe-rewrite rules; detector shipped and fixture-tested, rewriting needs node+babel (not shipped) — `js-ast-deobfuscation.md`, `js_obf_detect.py` |
| Kotlin original-name recovery | obfuscated→original class map from `@DebugMetadata`/`@Metadata` in any jadx export; parsing fixture-tested, recovery rates upstream-claimed — `kotlin-name-recovery.md`, `kotlin_name_recovery.py` |
| Long-connection (WebSocket) analysis | state chains, message families, envelope/payload split — absorbed methodology, never run on an Android target here — `protocol-reverse.md` |
+| Unity (Mono and IL2CPP) | backend identification, Il2CppDumper over `libil2cpp.so` + `global-metadata.dat`, the IDA import order, a run-time lookup over frida-il2cpp-bridge, the dnSpy-Unity-mono patch — absorbed, nothing run here — `unity-runtime.md` |
+| Attaching a real debugger to a `.so` | IDA `dbgsrv` with the ABI match, wait-for-debugger plus the JDWP handshake, and the `extractNativeLibs="false"` failure where no breakpoint binds — `ida-so-debugging.md` |
| Kernel-side event injection | watchpoints and register rewrites from a driver, as gated templates — `kernel-event-injection.md` |
| Working from the phone itself | MT Manager edit/repack/sign and its APK MCP, LSPosed Manager, Termux — `on-device-tooling.md` |
| Publishing without publishing the target | the leak scanner, the do-not-anonymize list, and the graded precedent library — `desensitization-and-leak-scans.md`, `scripts/scan_leaks.py`, `precedents/` |
@@ -76,9 +78,10 @@ dependency yourself.
## Not covered — say so rather than improvise
-Unity / IL2CPP logic recovery · React Native, Hermes bytecode and Cordova internals · iOS of any kind ·
+React Native, Hermes bytecode and Cordova internals · iOS of any kind ·
defeating a server-side authority · an off-the-shelf unpacker · an anti-detection arms race · building
-and shipping a kernel module · a Stalker trace guaranteed on every device.
+and shipping a kernel module · a Stalker trace guaranteed on every device · **a measured** Unity /
+IL2CPP route (the documented one is `unity-runtime.md`, and it carries no measurement).
Each has a reason and, where one exists, an evidence boundary — in `references/coverage-and-limits.md`.
**Silence in that record is not support**, and a route that is only "documented" is not a route that
diff --git a/skills/apk-reverse/references/dynamic-frida.md b/skills/apk-reverse/references/dynamic-frida.md
index f4206d4..9054f24 100644
--- a/skills/apk-reverse/references/dynamic-frida.md
+++ b/skills/apk-reverse/references/dynamic-frida.md
@@ -495,6 +495,7 @@ runtime-grade information, and they are often enough to finish the job:
| **Static instrumentation** — load a gadget/small agent by modifying the APK (e.g. an injected `Application` wrapper, a gadget `.so` added to `lib//` and wired from the manifest) | Hooks run from process start with no external attach, no `ptrace`, no server process on the device to be found | Must repack and re-sign; the build you observe is no longer byte-identical to the original, so **it cannot serve as the unmodified control** (`verification.md` §the control build rule) |
| **Static read of the answer** — the check's inputs are usually visible: the field it reads, the string it compares, the response field it trusts | Often enough to plan a patch without ever observing | You do not learn the live values; combine with `runtime-data.md` for local state |
| **Self-recorded evidence** — the app's own cached responses, logs, or on-disk state | Real runtime data with no injection at all (a HTTP cache, a pref, a JSON snapshot left behind by a normal launch) | Only what the app happened to persist; needs one clean launch and then a filesystem read |
+| **A real debugger** — IDA's `dbgsrv` on the device, `am start -D`, and the JDWP handshake that releases the process | single-step, register and stack reads at an exact instruction, and one-shot breakpoints without writing a hook | needs a debuggable build, leaves `TracerPid` non-zero, and its software breakpoints are visible in mapped pages — `ida-so-debugging.md` |
| **`LD_PRELOAD`** — the classic libc-interposition route | function-level interception with no on-device agent | effectively closed on Android: zygote-forked apps do not inherit your environment, restricted processes drop it, and linker namespaces limit which `.so` paths load at all (namespaces: Android 7.0+, fully in force from 8.0/Treble — older targets are less constrained); the debuggable-build `wrap.sh` escape hatch is the one place it still works. *(inferred — absorbed from milksu.org notes, retrieved 2026-10-19)* |
The last one is worth remembering because it is the cheapest and it is easy to
diff --git a/skills/apk-reverse/references/evidence-summary.md b/skills/apk-reverse/references/evidence-summary.md
index add71d2..ef79914 100644
--- a/skills/apk-reverse/references/evidence-summary.md
+++ b/skills/apk-reverse/references/evidence-summary.md
@@ -73,7 +73,9 @@ next** — and points at the one section that says "the full record is elsewhere
| Update and forced-upgrade neutralisation | Documented, no measurement; the failure shape is a build that installs, runs and fails every signed request | partial | inferred | `references/updates-and-forced-upgrade.md`, `references/signature-derived-keys.md` |
| Signature-derived keys | Documented only: neither the offline candidate path nor the live read has a public-target measurement here | partial | unverified | `references/signature-derived-keys.md`, `scripts/sig_probe.py` |
| Publishing sanitisation and leak scanning | Every rule fired on a planted corpus with zero false positives on the do-not-anonymize list, and the scan found 26 strong hits in its own evidence file | ok | observed | `references/desensitization-and-leak-scans.md`, `scripts/scan_leaks.py` |
-| Unity / IL2CPP, React Native / Hermes, iOS | Outside this skill: the runtime can be identified, the logic recovery is not covered | blocked | unverified | `references/framework-runtimes.md`, `references/coverage-and-limits.md` |
+| Unity / IL2CPP logic recovery | Documented route only: backend identification, Il2CppDumper over `libil2cpp.so` + `global-metadata.dat`, the IDA import order, the Frida bridge, and the dnSpy-Unity-mono patch. No Unity package was dumped here and no patched Mono runtime was built | partial | unverified | `references/unity-runtime.md`, `references/framework-runtimes.md` |
+| Attaching IDA's debug server to a `.so` | Documented route only: ABI-matched `dbgsrv`, root server, wait-for-debugger plus the JDWP handshake that releases the process, and the `extractNativeLibs="false"` failure where no breakpoint binds. Never run here; neither source targeted a hardened app | partial | unverified | `references/ida-so-debugging.md` |
+| React Native / Hermes, iOS | Outside this skill: the runtime can be identified, the logic recovery is not covered | blocked | unverified | `references/framework-runtimes.md`, `references/coverage-and-limits.md` |
| Defeating a server-side authority | Out of scope by design; report it as a residual rather than patching harder | blocked | unverified | `references/server-api.md`, `references/handoff-boundaries.md` |
## Where the full record lives — repository root, not shipped
diff --git a/skills/apk-reverse/references/framework-runtimes.md b/skills/apk-reverse/references/framework-runtimes.md
index 32226ea..4c41e86 100644
--- a/skills/apk-reverse/references/framework-runtimes.md
+++ b/skills/apk-reverse/references/framework-runtimes.md
@@ -14,7 +14,7 @@ Signals in the APK:
|---|---|
| Flutter | `libflutter.so`, `libapp.so`, `assets/flutter_assets/`, Dart snapshot |
| React Native | `libhermes.so` or `libjsc.so`, `index.android.bundle`, `assets/index.android.bundle` |
-| Unity | `libunity.so`, `libil2cpp.so`, `assets/bin/Data/`, `globalgamemanagers` |
+| Unity | `libunity.so`, `libil2cpp.so`, `assets/bin/Data/`, `globalgamemanagers` → `unity-runtime.md` for Mono versus IL2CPP |
| Cordova / Ionic | `assets/www/`, `cordova.js` |
| Xamarin / .NET | `libmonodroid.so`, `assemblies/` in the APK |
@@ -74,6 +74,23 @@ building a decompiler for exactly that version, the object-pool model and how to
and boolean conventions, the three assembly signatures that identify most business logic, the locating
workflow, and how to patch this layer safely.
+## Unity specifics
+
+- **Two backends, and they are not the same problem.** A Mono build ships readable CIL
+ (`assets/bin/Data/Managed/Assembly-CSharp.dll`); an IL2CPP build ships
+ `lib//libil2cpp.so` **plus** `assets/bin/Data/Managed/Metadata/global-metadata.dat`, and the
+ metadata is what carries the names. `references/unity-runtime.md` covers identifying which one you
+ have, dumping the pair with Il2CppDumper, importing the result into IDA in the order that works, and
+ the Frida bridge for a run-time lookup.
+- **A Unity UI is drawn by the engine, so Java hooks are glue.** The Android layer is SDKs, permissions
+ and the launch Activity; the gate you are looking at is a C# method. Patching the dex to remove it is
+ the layer error this file opens with.
+- **`Debugger.IsAttached` is a real anti-debug here.** A retail Mono runtime refuses a debugger
+ connection at the VM level, which reads as "the game is protected" — `unity-runtime.md` §Debugging
+ Mono at run time owns that path, and `ida-so-debugging.md` owns the native attach underneath it.
+- **The metadata can be encrypted while the `.so` is not, or the reverse.** Check each file's magic
+ before concluding the dump failed; the two failures have different routes.
+
## Locating logic without symbols
- **Strings first.** User-visible text (dialog bodies, feature labels) is the cheapest anchor in any
diff --git a/skills/apk-reverse/references/ida-so-debugging.md b/skills/apk-reverse/references/ida-so-debugging.md
new file mode 100644
index 0000000..2990a51
--- /dev/null
+++ b/skills/apk-reverse/references/ida-so-debugging.md
@@ -0,0 +1,137 @@
+# Attaching a real debugger: IDA's remote server, and the handshake that releases the app
+
+Load this when Frida is the wrong instrument — you need to single-step, read registers at a fault, or
+set a breakpoint inside a `.so` and inspect the stack — and the target is debuggable or you can make
+it so. `dynamic-frida.md` owns hooking; `hardware-watchpoints.md` owns observation with no debugger
+attached at all; this file owns the debug-server route and the two preconditions that decide whether
+it works.
+
+**Claim strength.** Absorbed from two public walkthroughs (2024-07, plus a MuMu-emulator note), both
+against CTF apps that were already debuggable, and neither reproduced here. Commands on which the two
+agree are stated plainly; the single-source details are marked *unverified*; anything this file adds
+as judgement is marked *inferred*.
+
+## 1. Two manifest facts decide everything
+
+- **`android:debuggable="true"`** — without it the app cannot be started in wait-for-debugger mode and
+ `run-as` does not exist. A retail build needs a repack (`repack-and-sign.md`) or a
+ `ro.debuggable` override on a rooted ROM (`magisk resetprop ro.debuggable 1`, then `stop; start`).
+ The override is **global and persistent** — every app on the device becomes debuggable — so it is a
+ fallback for your own analysis device, never part of a deliverable.
+- **`android:extractNativeLibs="true"`** — this one is not about permissions. It decides whether the
+ `.so` exists as a file on disk, which is what a debugger needs; §5 below is the failure it causes.
+
+Read them from `apktool d` → `AndroidManifest.xml`, or `aapt dump xmltree app.apk AndroidManifest.xml`.
+On a split package check the base member, not the last file you opened (`split-apk.md`).
+
+## 2. Match the server to the ABI that is actually loaded
+
+IDA ships one debug server per ABI under `dbgsrv/`. The library's directory decides which: a target in
+`lib/arm64-v8a/` wants the arm64 server, one in `lib/x86_64/` wants the x86-64 server. A mismatch is
+reported **when the server starts** ("expected X but Y"), not when you attach — so read the server's
+first line instead of interpreting a silent attach later.
+
+```bash
+adb push /dbgsrv/android_x64_server /data/local/tmp/android_x64_server
+adb shell
+su
+chmod 755 /data/local/tmp/android_x64_server
+```
+
+The write-up uses `777`; `755` is sufficient when the file is owned by the shell user and the server runs
+as root (*inferred*). Two mechanical traps it records: `\` and `/` are not interchangeable in the push
+path, and the file keeps the name you gave it on the destination side — the later
+`./` must match that, not the source filename. `/data/local/tmp` is the only location an
+unprivileged `adb push` can write and a root shell can execute without SELinux complaints on most
+builds.
+
+## 3. Run it as root, forward the port, and leave it running
+
+```bash
+adb forward tcp:23946 tcp:23946 # IDA's default debug-server port
+/data/local/tmp/android_x64_server # in the root shell from §2
+```
+
+Start the server **as uid 0**. A server running as the shell user can only see its own processes, so
+the target is simply absent from IDA's attach list — the recorded symptom is "the list is empty or has
+one entry that is not the app". Keep that terminal open: killing it detaches every session.
+
+## 4. Start paused, then release the app
+
+```bash
+adb shell am start -D -n /.MainActivity
+```
+
+`-D` starts the process **suspended, waiting for a debugger**. Attaching from IDA alone does not
+resolve that wait, because what the process is waiting for is the *JDWP* handshake, not the remote
+debug server. That is why the app appears frozen and why breakpoints "never fire": no code is
+executing yet. Forward a second port and connect the JDWP client:
+
+```bash
+adb shell ps | grep # or: adb jdwp / frida-ps -U
+adb forward tcp:8700 jdwp:
+jdb -connect com.sun.jdi.SocketAttach:hostname=127.0.0.1,port=8700
+```
+
+Use a high port (the write-ups recommend 5000–10000) so it does not collide with 23946, and use the
+pid of the **app** process, not of the debug server. IDA → Debugger → Attach to process now, with the
+remote host set to `127.0.0.1` and port `23946`. Suspending on process entry, on thread start/exit and
+on library load/unload is worth enabling before the first run: without it, an early breakpoint in a
+library constructor is already behind you when the process stops.
+
+## 5. "The breakpoint will not bind": the `.so` was never extracted
+
+**Symptom.** The process runs, IDA lists and attaches to it, the module list has no `libtarget.so`, and
+every breakpoint set in it is refused or never hits. Nothing in logcat. The library is plainly loaded —
+the feature it implements works.
+
+**Root cause.** `android:extractNativeLibs="false"` means installation leaves the library inside
+`base.apk` and the loader maps it from there. There is no
+`/data/app/-/lib//libtarget.so` for the debugger to name, so breakpoint resolution has
+nothing to bind to. `pm path ` names the APK's directory; `ls` beside it finds no `lib/` at all.
+The mapped region is backed by the APK, which is the same shape as the "an `r--s` view of `base.apk`
+looks like a dex" case in `advanced-unpacking.md` — the file on disk is not the thing in memory.
+
+**Two fixes.**
+
+1. Repack with `android:extractNativeLibs="true"`. The install then lays the libraries out under
+ `lib//`, and every debugger names them normally. It changes the signing certificate, so a
+ signature check turns this into the `signature-derived-keys.md` problem.
+2. Keep the original package and give the loader the on-disk copy it prefers. `pm path ` to find
+ the install directory, then push the matching `.so` into
+ `/data/app/-/lib//` as root (create the directory if absent). The recorded result is
+ that the app then loads the file instead of the in-APK copy. Whether a *hardened* build still
+ prefers it, and whether its own integrity check notices the extra file, is *unverified* — verify
+ both before building a plan on it.
+
+**What not to do.** Pointing IDA at the `.so` extracted from the APK and calling that "the module" is
+the trap that wastes the session: the addresses in that file are the ones the process never mapped, so
+a breakpoint that *does* set lands on a copy of the code rather than the code.
+
+## 6. Prove the channel before blaming the target
+
+Set one breakpoint in a library that certainly executes early — `libc`'s `open`, the ART loader — and
+resume. If it hits, the channel is good and a non-hitting breakpoint in the target is about the target
+(never loaded, different copy, inlined, or anti-debug). If it does not hit, the fault is in setup:
+server uid, ABI mismatch, or a JDWP wait that was never released. This is the same control-run
+discipline as `long-task-discipline.md` §single-variable discipline, applied to the debugger itself.
+
+## 7. What this route cannot hide
+
+- `TracerPid` in `/proc/self/status` is non-zero for as long as you are attached, so any check that
+ reads it sees you (`detection-and-anti-analysis.md`).
+- The server binary is a file with a known name in `/data/local/tmp`, and a process whose parent is a
+ root shell is visible in `ps`.
+- Software breakpoints are written into executable pages; a checksum over a mapped library catches them
+ (`byte-level-patching.md`). For observation with no writes, use `hardware-watchpoints.md`.
+- `am start -D` is visible to the platform and puts the process in a debuggable state that the app can
+ query, so "the app behaves differently only while I debug it" is a real observation, not a mistake.
+
+## 8. Boundary
+
+Both sources are emulator or AVD walkthroughs on CTF apps with `debuggable` already set; neither
+exercised a hardened target, and this record did not run the route at all. `unverified`: the exact
+`dbgsrv` filenames per IDA release, the `chmod` mode, the debugger menu labels across 7.x and 8.x, and
+whether an on-disk `.so` outranks the in-APK copy on a hardened build. What would make the route
+`observed`: one breakpoint hit and one register read at a known function, on a package whose only
+modification is the manifest.
diff --git a/skills/apk-reverse/references/pitfalls.md b/skills/apk-reverse/references/pitfalls.md
index 88871d5..5a39650 100644
--- a/skills/apk-reverse/references/pitfalls.md
+++ b/skills/apk-reverse/references/pitfalls.md
@@ -1437,3 +1437,37 @@ is recorded. Two traps recur: regex metacharacters that are legal characters in
descriptors, `[` in array types, `.` in package names), and representation mismatches that hide
behind a matching line count. The encoding cousins of this failure are in `framework-runtimes.md`
and the line-ending trap in `patch-audit.md`.
+
+## P48. Every breakpoint in the target `.so` refuses to bind, and the app is running fine
+
+**Symptom**
+A debugger attaches to the process, the process keeps running normally, the feature implemented by
+the library works — and the library is not in the module list. Breakpoints set in it are refused, or
+are accepted and never hit. There is no error, no log line, and no crash, so the first hypothesis is
+an anti-debug check, then "the library is packed", and the session is spent on both.
+
+**Root cause**
+`android:extractNativeLibs="false"` — the default for modern packages — leaves the `.so` inside
+`base.apk`, and the loader maps it from there. There is no
+`/data/app/-/lib//libtarget.so` on disk, and a debugger binds breakpoints to a
+module *file*. The library is present in memory and absent from the filesystem, which is a
+contradiction only if you assume those are the same thing. `pm path ` names the APK's directory,
+and `ls` beside it finds no `lib/` at all; the mapped region is backed by the APK, the same shape as an
+`r--s` mapping of `base.apk` that looks like a dex (`advanced-unpacking.md`).
+
+**Why it is hard to see**
+Everything that would normally discriminate a setup fault from a target fault reports success.
+Attach works, the process is alive, PID and ABI match, the server logged nothing. The one fact that
+explains it — the missing file — is a *negative* the tool never prints: a debugger lists the modules
+it can see, not the modules it cannot. And the target's own behaviour is the strongest false signal
+available: the feature works, so the library is obviously loaded, so the debugger must be blocked.
+
+**The rule**
+**When a breakpoint will not bind and the module list is short, look for the file before you look for
+a check.** `adb shell pm path ` and `ls` the sibling `lib//`; if it is absent, this is
+`extractNativeLibs`, not anti-debug. Fix it by repacking with the flag at `true` (and then handle the
+signature consequences — `signature-derived-keys.md`), or by giving the loader an on-disk copy in
+`lib//` on a rooted device and verifying that the app loads it. Never point the debugger at the
+`.so` extracted from the APK and call it the module: those addresses belong to a copy the process
+never mapped, so a breakpoint that does land is worse than one that does not. The full attach sequence
+is in `ida-so-debugging.md`.
diff --git a/skills/apk-reverse/references/routing.md b/skills/apk-reverse/references/routing.md
index 39cb521..e64bebc 100644
--- a/skills/apk-reverse/references/routing.md
+++ b/skills/apk-reverse/references/routing.md
@@ -83,8 +83,10 @@ reference file is not named here, or when a script is not named here.
| The value you need (sign, token, risk parameter) is computed in **JavaScript** — a WebView or embedded JS engine — by an entry call fed a huge hex/base64 opcode literal: a JS virtual machine | `jsvmp-recovery.md` — de-obfuscate and **call it** first, then the stack/register-VM procedures and the four JS traps; `emulation-and-rpc.md` owns the call-don't-read route |
| You are reading a decompiled **Kotlin** app and every class name is a meaningless short token (`nq.e`, `C0123x`) | `kotlin-name-recovery.md` — the original FQNs survive R8 inside `@DebugMetadata`/`@Metadata`; `scripts/kotlin_name_recovery.py` rebuilds the mapping from any jadx export |
| A store build arrives as `base.apk` + `split_config.*.apk`, or a rebuilt build is refused **as a set** although every file verifies on its own | `split-apk.md` — one keystore across every member for `pm install-multiple`, and check that a merge is legal before trusting a merged single APK |
+| A debugger lists and attaches to the process, but the target `.so` is absent from the module list and every breakpoint in it refuses or never hits, while its feature works | `ida-so-debugging.md` §the `.so` was never extracted — `android:extractNativeLibs="false"` maps the library from `base.apk`, so there is no `/data/app//lib//` file to bind to. Not an anti-debug finding |
+| A Unity package has no `Assembly-CSharp.dll` — only `libil2cpp.so` and `global-metadata.dat` — and every Java-layer hook turns out to be glue | `unity-runtime.md` — that is IL2CPP, the normal shipping configuration, not a stripped build: dump the pair with Il2CppDumper and import `script.json` before `stringliteral.json`, or the database is silently half-named |
-_56 row(s) below the header._
+_65 row(s) below the header._
## Reference index (no longer inline in `SKILL.md`)
@@ -147,8 +149,10 @@ _56 row(s) below the header._
| `references/kernel-and-environment-hardening.md` | Userspace hooking provably cannot reach the check — raw `svc` syscalls, `init_array`-early detection: what each layer can still do, the kernel-route map with its version gate, and when escalating is wrong |
| `references/on-device-tooling.md` | Working **from the phone itself**: MT Manager edit/repack/sign and its built-in APK MCP, LSPosed Manager, Termux+frida, and on-device data inspection |
| `references/split-apk.md` | The target is a **split APK / App Bundle set** (`base.apk` + `split_config.*.apk`), or `pm path ` returned several files: reading a set, merge versus unified re-signing, and the install refusal each mistake produces |
+| `references/unity-runtime.md` | The package is **Unity**: telling Mono from IL2CPP, dumping `libil2cpp.so` + `global-metadata.dat` with Il2CppDumper and importing it into IDA in the order that works, a run-time lookup over frida-il2cpp-bridge, the dnSpy-Unity-mono patch for a Mono player, and the audit probes when the deliverable is a report |
+| `references/ida-so-debugging.md` | You need to **single-step or breakpoint a `.so`** rather than hook it: IDA's `dbgsrv` server with the ABI match, the wait-for-debugger start plus the JDWP handshake that actually releases the app, and the `extractNativeLibs="false"` failure where no breakpoint can bind |
-_46 row(s) below the header._
+_58 row(s) below the header._
## Script index (no longer inline in `SKILL.md`)
@@ -220,4 +224,4 @@ _46 row(s) below the header._
| `scripts/svc_scan.py` | Name the syscall behind an inline `svc` and the segment it sits in — which decides whether a libc-level hook can observe the call at all. `--context` shows neighbours, because a byte scan also matches data |
| `scripts/anti_detect_probe.js` | Observer-only probe (patches nothing): path/loader/thread/kill hooks with **caller module + offset**, an environment self-report (`TracerPid`, frida-named mappings), and live streaming so a sub-second target still yields evidence |
-_56 row(s) below the header._
+_64 row(s) below the header._
diff --git a/skills/apk-reverse/references/unity-runtime.md b/skills/apk-reverse/references/unity-runtime.md
new file mode 100644
index 0000000..5178b15
--- /dev/null
+++ b/skills/apk-reverse/references/unity-runtime.md
@@ -0,0 +1,187 @@
+# Unity: Mono and IL2CPP, and where the logic actually lives
+
+Load this when the package is Unity — `libunity.so` under `lib//`, `assets/bin/Data/` — and the
+question is where the game logic is, how to read it, or how to attach a debugger to it.
+`framework-runtimes.md` owns *is this even a runtime* and the layer trap; this file owns the two
+backends and the tools that turn each one back into readable code. Patching that layer is
+`native-and-so.md`; the Android shell around it is `recon.md`.
+
+**Claim strength.** Absorbed from three public write-ups (2026-02 and 2026-06, plus a
+dnSpy-Unity-mono build note), not measured here: no Unity package was dumped and no patched Mono
+runtime was built in this record. Tool names, paths and the two magic numbers are `unverified`
+until you reproduce them; the statements about *which layer owns what* are `inferred` from the same
+sources. Steps this file adds as judgement rather than quotation are marked inline.
+
+## 1. Two backends, decided by two files
+
+| What the package ships | Backend | Where the logic is |
+|---|---|---|
+| `assets/bin/Data/Managed/*.dll` — `Assembly-CSharp.dll` first | **Mono** | CIL in the managed assemblies, JIT-compiled by the Mono VM at run time |
+| `lib//libil2cpp.so` **and** `assets/bin/Data/Managed/Metadata/global-metadata.dat` | **IL2CPP** | C# compiled to C++ then to native code in the `.so`; names, types and literals survive in the metadata |
+
+`libunity.so` is the engine and is present either way. A Mono build still ships native engine code,
+so "there is a big `.so`" proves nothing. On a desktop build the same split appears as
+`*_Data/Managed/Assembly-CSharp.dll` versus `GameAssembly.dll` + `*_Data/il2cpp_data/Metadata/global-metadata.dat`.
+
+```bash
+unzip -l app.apk | grep -Ei 'Assembly-CSharp|libil2cpp|libunity|global-metadata'
+```
+
+Two mis-readings to kill early:
+
+- **"No `Assembly-CSharp.dll`, so the code is stripped or encrypted."** No — that is IL2CPP, the
+ normal shipping configuration. The logic is in `libil2cpp.so`; nothing is missing.
+- **"The metadata is not at that path, so this build has no metadata."** Older and split packages move
+ it; look for `global-metadata.dat` anywhere under `assets/` before concluding anything, and if the
+ package is split, check every member (`split-apk.md`).
+
+## 2. Mono: the logic is IL, and IL is readable
+
+`Assembly-CSharp.dll` is the developer's own code. `UnityEngine*.dll`, `mscorlib`, `netstandard` and
+the `System.*` family are the framework — worth opening once to learn an API's shape, never the
+target of a search. dnSpyEx or ILSpy opens the assembly and decompiles it to C#; search for the
+user-visible string first, then `Analyze` on the method that references it for callers and callees.
+
+The only real obstacles at this layer are name obfuscation (locate by string, field shape and
+call graph instead) and an `Assembly-CSharp.dll` that will not parse at all (then it is encrypted,
+which is a packer problem — `packers.md`).
+
+## 3. Debugging Mono at run time: the stock runtime will not accept the connection
+
+dnSpy's *Unity (connect)* expects to drive the Mono debugger agent through
+`--debugger-agent=transport=dt_socket,...` handed to `mono_jit_parse_options`, and it expects
+`Debugger.IsAttached` to be truthful. A retail Unity build ships a Mono runtime that carries neither
+the dnSpy hook nor a launcher that passes those options, and `defer=y` versus `suspend=n` differs by
+Unity version — so the connection is either never accepted or the player sits suspended at startup.
+The published route is to build the Unity-flavoured Mono with the dnSpy-Unity-mono changes and swap
+`MonoBleedingEdge/EmbedRuntime/mono-2.0-bdwgc.dll` (back the original up first, then start the game
+and use *File → Debug → Unity (connect)*).
+
+The patch is small and each hunk has a purpose worth knowing, because the same hooks are what a
+hardened game would detect:
+
+| Change | Effect |
+|---|---|
+| `ves_icall_System_Diagnostics_Debugger_IsAttached_internal` returns 0 while `dnSpy_hideDebugger` is set | the game's own `Debugger.IsAttached` anti-debug reads "not attached" **while you are attached** — the flag defaults to 1, i.e. hiding is the default |
+| `dnSpy_debugger_init()` called from `mini_init`, reading `DNSPY_UNITY_DBG` / `DNSPY_UNITY_DBG2`, else a built-in `--debugger-agent=transport=dt_socket,server=y,address=127.0.0.1:55555,defer=y` | the agent is configured before the VM starts, with no launcher cooperation |
+| `dnSpy_debugger_agent_parse_options` accepts a `no-hide-debugger` argument | the opt-out from the lie above, for the case where you *want* the game to see you |
+| `mono_debug_init` returns instead of `g_assert(!mono_debug_initialized)` | a second initialisation no longer aborts; the agent and the engine both call it |
+| `insert_breakpoint` prefers a sequence point without `MONO_SEQ_POINT_FLAG_NONEMPTY_STACK` | a breakpoint at an IL offset lands somewhere legal instead of being deferred or rejected |
+| bdwgc built with `GC_DISABLE_INCREMENTAL` | the incremental collector no longer moves objects under the debugger's feet |
+| a dummy `mono_profiler_install` after `debugger_agent_init` | the agent's profiler events are not overwritten by the engine's installer |
+
+Build `libmono-dynamic` Release/x64 from `msvc/mono.sln`. The write-up's own trap: linking fails
+until the new `dnSpy.c` is compiled by hand first, because the project file has not picked it up yet.
+
+Two boundaries. A prebuilt `mono-2.0-bdwgc.dll` for one Unity version circulates with that write-up —
+that is a third-party binary, so audit it before use (`third-party-builds.md`). And on Android the
+equivalent artifact is `lib//libmono.so` (*inferred* — the source is desktop-only); replacing it
+changes the APK, so it is a repack with all of a repack's obligations (`repack-and-sign.md`,
+`signature-derived-keys.md`).
+
+## 4. IL2CPP: native code plus a metadata table
+
+`libil2cpp.so` is the whole of the C# code, ahead-of-time compiled, with no IL and no names. The
+names, namespaces, field and method signatures, type relationships and string literals live in
+`global-metadata.dat`. The pair is the unit of work: the `.so` is unreadable without the metadata, and
+the metadata alone gives you a class list with no addresses.
+
+Both files also tell you whether they are plain, in one line each — and getting this wrong is the most
+expensive mis-diagnosis at this layer, because "no symbols" and "encrypted symbols" look identical in
+a decompiler:
+
+```bash
+xxd -l 16 lib/arm64-v8a/libil2cpp.so # 7f 45 4c 46 (ELF)
+xxd -l 16 assets/bin/Data/Managed/Metadata/global-metadata.dat # af 1b b1 fa (0xFAB11BAF)
+```
+
+### Il2CppDumper, and the order of the IDA import
+
+```bash
+Il2CppDumper libil2cpp.so global-metadata.dat out/
+```
+
+On a desktop build pass `GameAssembly.dll` in place of `libil2cpp.so`. The outputs that matter:
+
+| Output | Use |
+|---|---|
+| `DummyDll/` (and `dump.cs`) | the recovered class / method / field *outline* — open in ILSpy or dnSpy to navigate; bodies are absent by construction |
+| `il2cpp.h` | structs for the decompiler, so IL2CPP objects stop looking like `void*` |
+| `script.json` | method → address, the table that names functions in IDA or Ghidra |
+| `stringliteral.json` | literal → address, the table that names string data |
+| `ida_py3.py` / `ida_with_struct_py3.py` (or `ghidra.py`) | the importers |
+
+The import order is not cosmetic: each script decorates what the previous one produced, so running
+them out of order **succeeds** and leaves a half-named database. In IDA, File → Script file, in this
+order:
+
+1. `ida_py3.py`
+2. `script.json`
+3. `stringliteral.json`
+4. `ida_with_struct_py3.py`, answering `script.json` then `il2cpp.h`
+
+Then analyse from a named function, not from the string blob. Cpp2IL is the alternative front end
+(`Cpp2IL --help`, then `--game-path --output-to `); when two dumpers disagree about the
+same binary, the disagreement is a claim about your toolchain until a reproducible run settles it —
+the same rule as `pitfalls.md` P47.
+
+### When the metadata does not parse
+
+- **Encrypted metadata** (`xxd` shows the wrong magic): dump at run time with Zygisk-Il2CppDumper, or
+ recover the loader's decryption (`packers.md`). A static dumper will never succeed here, and retrying
+ with another one is the wrong move.
+- **Version newer than the dumper**: pin the dumper to the Unity version. A dump from the wrong version
+ produces plausible names with wrong struct offsets, so treat every field offset as suspect until one
+ is confirmed at run time.
+- **Names still missing after a successful dump**: check you are looking at the module the process
+ actually loads — a split or stubbed `libil2cpp.so` inside the APK is not necessarily the one mapped
+ (`native-and-so.md`).
+
+## 5. Driving IL2CPP at run time: frida-il2cpp-bridge
+
+When the dump is encrypted or the question is behavioural, the bridge resolves classes by name at run
+time through the exported il2cpp API. Trace a class:
+
+```ts
+import "frida-il2cpp-bridge";
+Il2Cpp.perform(() => {
+ const image = Il2Cpp.domain.assembly("Assembly-CSharp").image;
+ const cls = image.class("Assets.Scripts.AndroidKit");
+ Il2Cpp.trace(true).classes(cls).and().attach();
+});
+```
+
+or one method, with `Il2Cpp.trace(true).methods(cls.method("SetShield")).and().attach()`. Run it with
+`frida -U -f -l frida.js`, and `Il2Cpp.dump()` writes a metadata dump from the live process —
+the usual answer to an encrypted `global-metadata.dat`. Il2CppHookScripts (`il2cpp-hooker`) is a
+scripted alternative with the same dependency.
+
+The dependency is the boundary: a build that hides or strips the il2cpp exports breaks the bridge, and
+the failure presents as *class not found* — indistinguishable from a wrong class name. Confirm the
+exports exist before believing a negative lookup.
+
+## 6. When the deliverable is an audit rather than a patch
+
+The same artifacts answer "what does this package leak", which is often the actual request. One probe
+per question:
+
+| Question | Probe |
+|---|---|
+| Is this a development build? | `android:debuggable` in the manifest; `Development Build` / script-debugging strings; the `Unity` logcat tag |
+| Do test endpoints or secrets ship in the code? | `strings -a libil2cpp.so \| rg -i 'secret\|token\|password\|gm\|cheat\|test-api'`, the same over `global-metadata.dat`, and `rg` over `dump.cs` / `DummyDll` |
+| Are unshipped assets in the package? | AssetStudio or AssetRipper over `assets/bin/Data`: `TextAsset` configs, `Texture2D`, `AudioClip`, `AssetBundle` names |
+| Does the Android shell leak? | jadx for hardcoded SDK keys, permissions, exported components; `apktool d` for the manifest |
+| Is the client authoritative for currency, drops or purchases? | proxy and replay (`protocol-reverse.md`): a client-supplied value the server never recomputes is the finding, and no client-side change fixes it (`server-api.md`) |
+
+Report one finding per row with the probe, the file and the offset or line, and a boundary sentence
+saying which versions it was observed on. "The package contains X" and "the server trusts X" are
+different findings with different owners.
+
+## 7. Boundary
+
+Nothing here was exercised in this record. The steps that would make it `observed`: dump a known
+Unity package with Il2CppDumper, re-import into IDA in the order above, and set a breakpoint that
+hits at a named method; separately, build the Mono patch and hit a breakpoint in dnSpy. Until then,
+treat the tool output as a map that still needs one verified landmark. For a target that fights the
+debugger, `detection-and-anti-analysis.md` owns the triage — and `ida-so-debugging.md` owns the
+attach mechanics for the underlying `.so`.