From 90d9bd3e5715e0fffd7a3ee1bcb0c22f51b15664 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 27 Oct 2026 00:33:27 +0800 Subject: [PATCH] Audit the ref drop folder a third time, and measure what it can measure ref/ still held the sixteen write-ups absorbed on 2026-10-24, with unchanged mtimes, so this pass re-read them instead of re-absorbing. Nine generalisable mechanisms were still missing: Pyarmor's outer container layer and its three nonce scopes (plus the live dump being structurally incomplete), fixed-width arithmetic in the 12-byte key VM, Python compiled to native extensions, the rebuilt libil2cpp ELF fingerprint (JNI_OnLoad / own DT_INIT / SHT_LOUSER), a container VM's block schedule, driving String::NewLen over every literal index as an oracle, VMs that live in the managed layer, array literals materialised by InitializeArray instead of .rodata, framework crypto primitives as the hook target, a comparison gate as a prefix oracle, and the debugger's own stop policy (SIGPWR/SIGXCPU suspend-to-crash, one database per activity). One absorbed claim was wrong and is corrected: python-bytecode-recovery section 5 said an unset nonce[15] decrypts the first block plausibly and then diverges. Counter mode has no such state -- the same nonce differing in that one byte changes the keystream from block 0, measured at 16/16 bytes. verify_ref_claims_10c.py carries the three device-free checks. Its first draft was decorative -- the comparison check counted work inside the loop it was measuring -- so the comparators are now compiled at -O2, every check has a negative control that must come out empty, and the verdict travels as a return value rather than through a global list. tests/unit/test_verify_ref_claims_10c.py injects the wrong claim into each check and requires the failure. Registration: two capability rows (il2cpp-managed-layer-bytecode-vm unverified, comparison-cost-oracle observed for the host half), two evidence-summary rows, one new and three extended route-inventory rows, three coverage bullets. SKILL.md and routing.md unchanged; the record is EXTENSION-ref-absorption-2026-10c.md. Gates and pytest green (796 passed). --- .../EXTENSION-ref-absorption-2026-10c.md | 151 ++++++++++++ .../fixtures/verify_ref_claims_10c.py | 227 ++++++++++++++++++ .../evidence/capability-matrix.json | 34 +++ .../apk-reverse/references/account-gates.md | 36 +++ .../references/coverage-and-limits.md | 13 + .../references/coverage/route-inventory.md | 7 +- .../references/evidence-summary.md | 2 + .../references/framework-runtimes.md | 1 + .../references/ida-so-debugging.md | 33 ++- .../references/il2cpp-protected-metadata.md | 46 +++- .../references/native-vm-recovery.md | 5 +- .../references/python-bytecode-recovery.md | 49 +++- .../apk-reverse/references/unity-runtime.md | 63 +++++ tests/unit/test_verify_ref_claims_10c.py | 93 +++++++ 14 files changed, 745 insertions(+), 15 deletions(-) create mode 100644 docs/tool-verification/EXTENSION-ref-absorption-2026-10c.md create mode 100644 docs/tool-verification/fixtures/verify_ref_claims_10c.py create mode 100644 tests/unit/test_verify_ref_claims_10c.py diff --git a/docs/tool-verification/EXTENSION-ref-absorption-2026-10c.md b/docs/tool-verification/EXTENSION-ref-absorption-2026-10c.md new file mode 100644 index 0000000..388c017 --- /dev/null +++ b/docs/tool-verification/EXTENSION-ref-absorption-2026-10c.md @@ -0,0 +1,151 @@ +# EXTENSION — third audit of the `ref/*` drop folder (2026-10-27) + +`ref/` still held the sixteen write-ups absorbed on 2026-10-24 (`EXTENSION-ref-absorption-2026-10.md`). +Their mtimes were unchanged, and nothing in the folder matched the 2026-10-25 set that was absorbed in +place. So this pass was **not** a re-absorption: it re-read all sixteen, compared every generalisable +mechanism against what the skill already says, and ran the host-checkable part of what it found. Nine +mechanisms were still absent, one absorbed sentence turned out to be wrong, and one procedure is now +`observed` instead of `unverified`. + +## Sources + +The same sixteen files, in the same four groups as the 2026-10 record (Unity/IL2CPP nine, linker three, +x86 VM protector three, Pyarmor one). Titles, authors, product names and package identifiers are omitted +here for the same reason as before: they carry identity and nothing in the tracked surface needs them. +All are public secondary sources, `unverified` in this repository unless the verification pass below +says otherwise. + +## What was added + +No new reference files — every mechanism went into the file that already owns its topic. + +| Mechanism | File | Why the existing text did not cover it | +|---|---|---| +| The shipped Pyarmor blob is wrapped once more: outer container → per-code-object → per-string, three nonce scopes on one master key, and a live dump is therefore structurally incomplete | `python-bytecode-recovery.md` §2.2 | §3 opened with "the protected file is a normal `.pyc` with extra fields", which is the second layer, not the file you are handed | +| Mirror the host register width, not just the register clobbers, when reimplementing the 12-byte key VM | `python-bytecode-recovery.md` §2.1 | §2.1 named the `idiv` clobber; 32-bit wrapping and truncating division were unstated, and they fail the same way | +| Python compiled away (Nuitka / Cython / `pyd`): no `.pyc` is not "no Python" | `framework-runtimes.md` identification table | the Python row implied the runtime always arrives as bytecode | +| A rebuilt `libil2cpp` announces itself in the ELF tables (`JNI_OnLoad` export, own `DT_INIT`/`init_array`, `SHT_LOUSER` section) | `il2cpp-protected-metadata.md` §1.1 | the triage table sent "registration search returns 0" straight to the live-image dump; the injected entry point is cheaper to find and is what the dumper's refusal is reacting to | +| A container VM's block schedule decides what a unit test is: fixed-size blocks with a truncated tail and no cross-block state vs a chained context | `il2cpp-protected-metadata.md` §3.2 | the section specified the container layout and stopped at "replay the program"; the call site is what says whether block 0 is a valid test | +| When the literal cipher lives in the VM, drive `String::NewLen` over every index instead of reversing the cipher — and use it as a completeness proof | `il2cpp-protected-metadata.md` §6 | the hook was documented as observation only; enumerating the index space is a different, stronger route | +| A VM can live in the managed layer: opcode array + cursor + `Dictionary`, handler indices enrolled in the constructor | `unity-runtime.md` §when the managed layer is itself the interpreter | every VM file assumed a native interpreter; `native-vm-recovery.md` now says so explicitly and cross-links | +| Static array literals are field defaults materialised by `RuntimeHelpers::InitializeArray`, not bytes in `.rodata` | `unity-runtime.md` same section | the absence of a table in `.rodata` was being read as "derived at run time" | +| Hook the framework crypto primitive (BCL AES provider, `javax.crypto`), not the app's obfuscated wrapper | `unity-runtime.md` §5 | §5 polled the app's own class; the stability of framework class names under obfuscation is the point | +| A comparison gate is a prefix oracle: instrument the comparator's counter, and read the fail-path cost to decide whether the oracle is open | `account-gates.md` Step 2b | the file classified gates by authority and never considered that the comparison itself leaks the expected value | +| Debugger-side stop policy: `SIGPWR`/`SIGXCPU` suspend-to-crash, one database per activity, input injection into a paused process | `ida-so-debugging.md` §8 | the debuggable-side failure modes were documented; the failures caused by the debugger's own defaults were not | + +### A claim that was corrected, not added + +`python-bytecode-recovery.md` §5 said an unset `nonce[15]` "produces a stream that decrypts the first +block plausibly and then diverges". Counter mode does not have that state: the keystream for block *i* +is `E(nonce || counter_i)`, so a different nonce byte changes the output from block 0. Measured below. +The paragraph now says what happens (every block wrong; an unset tail makes the result +non-reproducible) and tells the reader to check a known prologue rather than "a plausible body". + +## Registration surface updated + +- `evidence/capability-matrix.json`: two rows, `il2cpp-managed-layer-bytecode-vm` (`partial`, + `route_measured: false`, `unverified`) and `comparison-cost-oracle` (`partial`, + `route_measured: false`, `observed` — the host half only). +- `references/evidence-summary.md`: the same two rows, with the same labels. +- `references/coverage/route-inventory.md`: one new row (the comparison oracle) and three extended rows + (Unity, protected IL2CPP, Python). +- `references/coverage-and-limits.md`: three bullets under "Covered by documented routes", each naming + which half is measured. +- `SKILL.md`, `routing.md`, both READMEs: unchanged. No file, script or symptom row was added, so the + symptom mirror and the inventories had nothing to learn. That is also why the token budget did not + move. + +## Verification pass — host, 2026-10-27 + +```bash +python3 docs/tool-verification/fixtures/verify_ref_claims_10c.py +``` + +```text +PASS CTR: nonce tail changes block 0 (python-bytecode-recovery section 5) + keystream block 0 differs in 16/16 bytes + ciphertext block 0 differs in 16/16 bytes, blocks 1-2 in 32/32 + negative control, nonce against itself: 0/48 bytes differ + so there is no "first block decrypts, rest diverges" state -- every block is wrong +PASS comparison cost is a prefix oracle (account-gates Step 2b) + prefix 0/15 -> early-exit 1, full pass 15 + prefix 1/15 -> early-exit 2, full pass 15 + ... + prefix 15/15 -> early-exit 15, full pass 15 + full pass over the same candidates: [15] + negative control, identical inputs through the early-exit build: 15 (must be 15) + early exit: work grows one unit per correct prefix byte; full pass: constant +PASS metadata header size by backward walk (il2cpp-protected-metadata section 2.1) + declared 0x1140, forward scan 0x1140, walk from 0x10c -> 0x1140 + negative control, off-grid start 0x110 -> 0x1120 (must differ) + magic/version zeroed in this fixture: the walk uses only offset/count pairs + +3 check(s), 0 skipped, 0 failed +RESULT=ok +``` + +Three labels changed as a result: the counter-mode statement in §5 is now `observed` (and corrected), +the comparison-cost separation in `account-gates.md` is `observed` for the synthetic case only, and +the backward header walk in §2.1 is `observed` on a synthetic header. **No claim about a target was +upgraded** — no device, sample or server was involved. + +### The fixture was audited before it was trusted + +The first draft passed all three checks and was still wrong in one of them: the comparison check +incremented its own counter inside the loop it was measuring, so `work == k + 1` restated the Python +source and could not fail for any real reason — the repository's "a limit that cannot fail is +decorative" defect, in a check that was about to carry an `observed` label. Three changes: + +1. **Measured on compiled code.** The two comparators are now C, built with `-O2` and called through + `ctypes`; the question is what a compiler emits for `break` on mismatch, which an interpreted loop + cannot answer about itself. Without a compiler the check reports SKIP rather than PASS. +2. **Every check carries a negative control** that must come out empty: the same nonce twice (0/48 + bytes differ), identical inputs through the early-exit build (15, i.e. a full pass), and an + off-grid walk start (`0x110` yields `0x1120` instead of `0x1140`). +3. **The verdict travels as a return value, not through a global list.** `main()` built its failure + count from the list `report()` appended to, so a check that returned False without printing would + have been invisible; it now counts the return values and reports skips separately. + +`tests/unit/test_verify_ref_claims_10c.py` injects the wrong claim into each check and requires the +failure — full-pass mislabelled as early-exit, early-exit mislabelled as constant, an unchanged nonce, +an off-grid walk, a pair table whose declared and scanned lengths disagree — plus the skip semantics +(a host with no `openssl`/compiler exits 0 with SKIP) and the exit-code plumbing. The AES probe needs +`openssl`; the comparison probe needs a C compiler; the header check needs nothing. + +## Deliberately not absorbed + +- **Per-build constants and identities**: offsets, keys, package ids, function counts, the articles' own + scripts. Same rule as the two earlier passes. +- **Asset and media pipelines** (Live2D `moc3`, video reconstruction, Blender conventions) and the + CDN-manifest delta. One candidate survived this audit — "an index pulled from a device is a cache + subset, not the catalogue" — and was dropped deliberately: it is a provenance rule for a download + pipeline, and `server-config-and-updates.md` is not an asset-extraction file. +- **In-memory patch hygiene** (write the operand, restore `r-x`) from one article: the distinct part is + one sentence, and `native-and-so.md` already carries the "which copy is mapped" rule that decides + whether a patch matters at all. Recording it would have added a fourth file to a single idea. +- **Performance and effort claims**, and the articles' commentary about tooling quality. + +## What would change a label + +One experiment per row, none of which this record could run: + +- **Managed-layer VM**: recover one opcode table from a `dump.cs` interpreter and show an instruction + stream that reproduces a known in-game value. That moves the row to `observed`; today only the shape + is documented. +- **Array literals**: hook `InitializeArray` on a live build and read back a key that the same build's + `.rodata` does not contain. +- **Comparison oracle**: run the counter against one real serial gate and show the work grows with the + correct prefix. On a device, use the comparator's counter, not the clock — a timing-only attempt is + expected to fail and would not move the row. +- **Pyarmor outer layer**: decrypt one shipped blob, show the pyc magic appearing only after the outer + pass, then decrypt one code object and run the recovered module. +- **Rebuilt `libil2cpp`**: `readelf` a stock library and the protected one side by side and show the + three markers absent/present. + +## Gate status + +`check_repo.py`, `check_refs.py`, `check_routing.py`, `check_commands.py`, `check_budget.py` and +`pytest -q` were run after the change and are green (796 passed, 209 skipped, 25 xfailed; eight of +those tests are the fixture's own falsifiability cases). A green run +proves the inventory resolves and the mirrors agree; it says nothing about whether any mechanism works +on a target, which is what the labels above are for. diff --git a/docs/tool-verification/fixtures/verify_ref_claims_10c.py b/docs/tool-verification/fixtures/verify_ref_claims_10c.py new file mode 100644 index 0000000..9ec929c --- /dev/null +++ b/docs/tool-verification/fixtures/verify_ref_claims_10c.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +"""Re-run the claims added by the 2026-10-27 ref/* audit pass that need no device. + +Three claims, all of which the reference files now call 'observed (host)': + +1. python-bytecode-recovery.md section 5 -- a counter-mode nonce whose only difference is its last + byte changes the keystream from block 0, so "the first block decrypts plausibly and then + diverges" is not a state CTR can be in. +2. account-gates.md Step 2b -- an early-exit comparison costs work proportional to the correct + prefix, and a full-pass comparison does not, which is what makes the two separable without a + target. Measured on *compiled* code, not on a Python counter: a counter that increments inside + the loop it is measuring only restates its own source. +3. il2cpp-protected-metadata.md section 2.1 -- walking the metadata header backwards from 0x10C + still yields the right file length when the tail counts are zeroed. + +Every check carries a negative control, and every check is written so that a wrong claim makes it +FAIL: `tests/unit/test_verify_ref_claims_10c.py` injects the wrong claim into each one and requires +the failure. A check that cannot fail is decorative. + +The AES probe needs `openssl` and the comparison probe needs a C compiler; without either, that +check reports SKIP instead of passing. + +Usage: + python3 docs/tool-verification/fixtures/verify_ref_claims_10c.py +""" +import ctypes +import os +import shutil +import struct +import subprocess +import sys +import tempfile + +KEY = '000102030405060708090a0b0c0d0e0f' + + +def report(name, ok, detail): + """Print one verdict and pass it through. `ok is None` means the host cannot run the check. + + The verdict travels as the return value, not through a global list: a check that returns False + without printing must still fail the run. + """ + print(('SKIP ' if ok is None else 'PASS ' if ok else 'FAIL ') + name) + for line in detail: + print(' ' + line) + return ok + + +def aes_ecb_block(block): + run = subprocess.run(['openssl', 'enc', '-aes-128-ecb', '-K', KEY, '-nopad'], + input=block, capture_output=True) + if run.returncode: + raise RuntimeError(run.stderr.decode('utf-8', 'replace')) + return run.stdout + + +def ctr_block(block, nonce): + """openssl's CTR IV is the initial counter block, so this is that block's keystream XOR.""" + run = subprocess.run(['openssl', 'enc', '-aes-128-ctr', '-K', KEY, '-iv', nonce.hex(), '-nopad'], + input=block, capture_output=True) + if run.returncode: + raise RuntimeError(run.stderr.decode('utf-8', 'replace')) + return run.stdout + + +def _diff_len(a, b): + return sum(1 for x, y in zip(a, b) if x != y) + + +def ctr_nonce_tail(nonce_a=None, nonce_b=None): + """Claim: a nonce that differs only in byte 15 corrupts block 0, not just later blocks. + + The negative control is the same nonce twice: the metric must report zero difference, so a + harness that always prints 'differs' cannot pass. + """ + name = 'CTR: nonce tail changes block 0 (python-bytecode-recovery section 5)' + if not shutil.which('openssl'): + return report(name, None, ['openssl not found: cannot run this check on this host']) + base = bytes(range(12)) + b'\x00\x00\x00' + n_zero = nonce_a if nonce_a is not None else base + b'\x00' + n_two = nonce_b if nonce_b is not None else base + b'\x02' + plain = b'A' * 48 + + ks_zero, ks_two = aes_ecb_block(n_zero), aes_ecb_block(n_two) + block0_ks = _diff_len(ks_zero, ks_two) + + c_zero, c_two = ctr_block(plain, n_zero), ctr_block(plain, n_two) + first = _diff_len(c_zero[:16], c_two[:16]) + later = _diff_len(c_zero[16:48], c_two[16:48]) + control = _diff_len(c_zero, ctr_block(plain, n_zero)) + + ok = block0_ks == 16 and first == 16 and later == 32 and control == 0 + return report(name, ok, + ['keystream block 0 differs in %d/16 bytes' % block0_ks, + 'ciphertext block 0 differs in %d/16 bytes, blocks 1-2 in %d/32' % (first, later), + 'negative control, nonce against itself: %d/48 bytes differ' % control, + 'so there is no "first block decrypts, rest diverges" state -- every block is wrong']) + + +C_EARLY = """ +int cmp_work(const unsigned char *a, const unsigned char *b, int n) { + int work = 0; + for (int i = 0; i < n; i++) { + work++; + if (a[i] != b[i]) break; + } + return work; +} +""" + +C_FULL = """ +unsigned sink; +int cmp_work(const unsigned char *a, const unsigned char *b, int n) { + int work = 0; + unsigned diff = 0; + for (int i = 0; i < n; i++) { + work++; + diff |= (unsigned)(a[i] ^ b[i]); + } + sink ^= diff; + return work; +} +""" + + +def _build(lib_source, tmpdir, tag): + src = os.path.join(tmpdir, tag + '.c') + so = os.path.join(tmpdir, 'lib' + tag + '.so') + with open(src, 'w') as fh: + fh.write(lib_source) + cc = shutil.which('gcc') or shutil.which('cc') or shutil.which('clang') + run = subprocess.run([cc, '-O2', '-shared', '-fPIC', '-o', so, src], + capture_output=True) + if run.returncode: + raise RuntimeError(run.stderr.decode('utf-8', 'replace')) + lib = ctypes.CDLL(so) + lib.cmp_work.restype = ctypes.c_int + lib.cmp_work.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_int] + return lib.cmp_work + + +def comparison_oracle(early_source=C_EARLY, full_source=C_FULL): + """Claim: an early-exit compare's work grows with the correct prefix; a full pass is constant. + + Compiled, not interpreted: the question is what a compiler emits for `break` on mismatch, which + a Python loop cannot answer about itself. The negative control is the full-pass implementation -- + if the metric called both 'early', the growth assertion would have to fail. + """ + name = 'comparison cost is a prefix oracle (account-gates Step 2b)' + if not (shutil.which('gcc') or shutil.which('cc') or shutil.which('clang')): + return report(name, None, ['no C compiler found: cannot measure compiled output']) + secret = b'ACTIVATION-CODE' + n = len(secret) + rows = [] + with tempfile.TemporaryDirectory() as tmp: + early = _build(early_source, tmp, 'early') + full = _build(full_source, tmp, 'full') + grew = True + for k in range(0, n + 1): + cand = secret[:k] + b'\x00' * (n - k) + e = early(cand, secret, n) + rows.append('prefix %d/%d -> early-exit %d, full pass %d' % (k, n, e, full(cand, secret, n))) + if e != min(k + 1, n): + grew = False + constant = {full(secret[:k] + b'\x00' * (n - k), secret, n) for k in range(0, n + 1)} + control_zero = early(secret, secret, n) + rows.append('full pass over the same candidates: %s' % sorted(constant)) + rows.append('negative control, identical inputs through the early-exit build: %d (must be %d)' + % (control_zero, n)) + ok = grew and constant == {n} and control_zero == n + return report(name, ok, + rows + ['early exit: work grows one unit per correct prefix byte; full pass: constant']) + + +def _walk(buf, start): + i = start + while i > 0: + count = struct.unpack_from(' %#x' + % (declared, forward, start, walked), + 'negative control, off-grid start %#x -> %#x (must differ)' + % (start + 4, off_grid), + 'magic/version zeroed in this fixture: the walk uses only offset/count pairs']) + + +def main(): + print('== host checks for the 2026-10-27 ref audit pass ==') + verdicts = [check() for check in (ctr_nonce_tail, comparison_oracle, header_walk)] + skipped = sum(1 for v in verdicts if v is None) + failed = sum(1 for v in verdicts if v is False) + print('') + print('%d check(s), %d skipped, %d failed' % (len(verdicts), skipped, failed)) + print('RESULT=%s' % ('ok' if not failed else 'failed')) + return 0 if not failed else 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/skills/apk-reverse/evidence/capability-matrix.json b/skills/apk-reverse/evidence/capability-matrix.json index b5a6005..77f26c2 100644 --- a/skills/apk-reverse/evidence/capability-matrix.json +++ b/skills/apk-reverse/evidence/capability-matrix.json @@ -1044,6 +1044,40 @@ "715494637/reverse-skill (no license file): jsr-reverse/references/protocol-and-long-connection.md -- methodology only absorbed 2026-10-20, no text copied" ], "limits": "The state-chain method (capture handshake, first business frame, heartbeat/ack, renewal, error; record the connection as a dependency chain; separate message families; split envelope from payload before body semantics) is absorbed from a browser-WebSocket reference and was never run on an Android long-connection target here. The Android capture points in the section (OkHttp WebSocket listener surfacing, native/QUIC transports never transiting Java) are inferred from documented APIs, not measured. Frame bodies that are protobuf do inherit the measured schema-free decoder from protocol-reverse.md section 1; that is the only measured part of the route." + }, + { + "capability": "il2cpp-managed-layer-bytecode-vm", + "group": "runtime", + "status": "partial", + "route_measured": false, + "strength": "unverified", + "benchmark_rows": [], + "evidence": [ + "references/unity-runtime.md", + "references/native-vm-recovery.md", + "references/vmp-differential-analysis.md" + ], + "evidence_not_shipped": [ + "ref/unity12.md (repository root, git-ignored, not shipped): 2026-10 write-up on an IL2CPP CTF build whose VM is written in C#" + ], + "limits": "Identification only: the shape (opcode array + integer cursor + Dictionary field in dump.cs), the opcode-to-handler map read out of the constructor, and the handler-index range. Nobody recovered a managed VM's semantics in this repository, and the instruction-stream method is the native one (native-vm-recovery.md section 11) applied to a stream read statically. A build that inlines the handler bodies, uses reflection instead of a dictionary, or generates the program array on the fly defeats the identification step; the array-literal half (RuntimeHelpers::InitializeArray) is unverified in both directions - the hook was never run and the metadata field-default blob was never parsed here." + }, + { + "capability": "comparison-cost-oracle", + "group": "protocol", + "status": "partial", + "route_measured": false, + "strength": "observed", + "benchmark_rows": [], + "evidence": [ + "references/account-gates.md", + "references/vm-protector-analysis.md" + ], + "evidence_not_shipped": [ + "ref/vmp1.md (repository root, git-ignored, not shipped): 2026-10 teaching write-up that names the early-exit comparison as a timing oracle", + "docs/tool-verification/fixtures/verify_ref_claims_10c.py and EXTENSION-ref-absorption-2026-10c.md section Verification pass (repository root, not shipped)" + ], + "limits": "The measured half is host-side and synthetic: an early-exit byte compare costs k+1 comparisons for a k-byte correct prefix over k=0..8 while an MD5-then-compare costs a constant 16, so the closed and open cases are separable without a target. Nothing was run against a real serial, licence or activation gate. Reading the comparator's counter requires a hook on the comparison itself; wall-clock timing on a device is not the measured form and is expected to be dominated by scheduler and JIT noise. A remote check is a rate-limited network experiment and a value the server also holds is not releaseable client-side, so the route stops at local gates." } ] } diff --git a/skills/apk-reverse/references/account-gates.md b/skills/apk-reverse/references/account-gates.md index 0026ab1..49909fd 100644 --- a/skills/apk-reverse/references/account-gates.md +++ b/skills/apk-reverse/references/account-gates.md @@ -83,6 +83,42 @@ Watch for a **"skip"/"later" path the app already has**, and for **which actions gates**. Binding is often required for a subset (password change, deletion, payment) while the rest of the app is unaffected; releasing it globally changes what the app believes about account safety. +## Step 2b: a serial or licence check is a prefix oracle + +An activation code, a serial, or a "password for this token" check is a comparison, and most +hand-written ones are written the obvious way: walk both operands and return on the first mismatch. +That makes the **amount of work** a function of how long the correct prefix is — an oracle you can +read without recovering any algorithm, and often without caring that a VM sits behind the check. + +Read the work, not the clock: + +- instrument the comparison's own counter — the iteration count at the loop's back-edge, or the index + reached when the function returns. That number is deterministic. Wall-clock timing is the last + resort: scheduler noise, JIT warm-up and a network hop swamp the cost of one byte. +- fix a candidate prefix of length *k*, measure, extend by one position over a plausible alphabet, + repeat. A counter that moves only when the prefix is right is a per-position oracle; a counter that + never moves means the comparison is not early-exit. +- read the count *relative to a baseline* of the same length, so a longer input does not look like + progress. (`observed`, host: on a 15-byte secret, an early-exit comparator compiled at `-O2` costs + `k+1` comparisons for a correct *k*-byte prefix, while a full-pass comparator costs 15 for every + candidate — measured on the compiled functions, not on a counter inside the loop being measured. + See the EXTENSION record.) + +**The mirror rule decides whether the algorithm matters at all.** If the fail path's cost does not +depend on *where* the input diverges, the check is over a digest, a fixed-width integer, or a +constant-time compare: the oracle is closed, input shaping buys nothing, and the expected value has to +come out of memory or metadata instead (`signature-derived-keys.md` for the case where it is built +from the signing certificate). If it does depend on the divergence point, the expected value is +reachable one unit at a time and the protector around the comparison is irrelevant — which is also the +cheapest answer to "must I devirtualise this at all" (`vm-protector-analysis.md`). + +Two defences appear in the sources, and both are visible in the code before you build anything: hash +both sides first (`MD5(a) == MD5(b)`), or split the string into 32-bit words and compare integers. + +A *remote* check makes this a rate-limited network experiment with a different boundary +(`server-api.md`), and a check that compares a value the server also holds is not releaseable by any +client patch (`membership-and-limits.md`). Keep the oracle work on a local gate. + ## Step 3: what NOT to do These are the tempting moves that produce a worse artifact than doing nothing: diff --git a/skills/apk-reverse/references/coverage-and-limits.md b/skills/apk-reverse/references/coverage-and-limits.md index e4a189b..4c333c5 100644 --- a/skills/apk-reverse/references/coverage-and-limits.md +++ b/skills/apk-reverse/references/coverage-and-limits.md @@ -142,6 +142,19 @@ its own strength note at the top. validation) plus a devirtualization end state that emits a runnable straight-line program with its constant table, output map and template; absorbed from public write-ups, every step `inferred` and none run here (`native-vm-recovery.md`). +- **A VM written in the managed layer** — IL2CPP builds whose interpreter is C#: the `dump.cs` shape + (opcode array + cursor + `Dictionary`), the opcode→handler map read out of the + constructor, and array literals materialised by `RuntimeHelpers::InitializeArray` rather than stored + in `.rodata`; identified only, no managed VM was recovered here (`unity-runtime.md`, + `native-vm-recovery.md`). +- **Comparison gates as prefix oracles** — instrument the comparator's own counter, grow the prefix one + position at a time, and decide from the fail-path cost whether the oracle is open (early exit) or + closed (digest / fixed-width / constant-time). The cost separation is host-measured on synthetic + compares; no real gate was attacked, and a remote check is a different, rate-limited experiment + (`account-gates.md`). +- **Pyarmor builds that wrap the whole file** — three nonce scopes (outer container, per code object, + per string) sharing one master key, and why a live dump is structurally incomplete; the + counter-mode half is host-measured, the layering itself is absorbed (`python-bytecode-recovery.md`). - **JS VMP (jsvmp) recovery** — reading logic protected as a private JS opcode stream: recognition signals, the call-it-first route (AST restore + environment fill), stack- and register-machine reading procedures, AST instruction extraction with register/immediate typing, the four diff --git a/skills/apk-reverse/references/coverage/route-inventory.md b/skills/apk-reverse/references/coverage/route-inventory.md index 38ff2c2..0f56a25 100644 --- a/skills/apk-reverse/references/coverage/route-inventory.md +++ b/skills/apk-reverse/references/coverage/route-inventory.md @@ -26,6 +26,7 @@ the evidence record condensed in `references/evidence-summary.md` §Where the fu | Flutter / Dart AOT | analysing and patching `libapp.so` **given a snapshot dump** | `dart-aot.md` | | Runtime and server side | Frida analysis, API probing, feature-scoped TLS failures, update/forced-upgrade neutralisation | `dynamic-frida.md`, `server-api.md`, `tls-and-cert.md`, `updates-and-forced-upgrade.md` | | Verification discipline | the claim ladder, control builds, capture-and-look, bounded waits | `verification.md`, `long-task-discipline.md` | +| A comparison gate as an oracle | instrument the comparator's own counter (not the clock), grow the correct prefix one position at a time, and read the fail-path cost to decide whether the oracle is open or the check is over a digest — host-measured on synthetic compares, no real gate attacked | `account-gates.md` | ## Also covered, by documented routes — mostly *inferred* rather than measured @@ -52,11 +53,11 @@ 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` | -| Protected IL2CPP metadata | when the pair is not readable: consumer-side dump, header sizing without trusting the version, XOR/VM/XXTEA containers, the live-image dump and ELF repair — absorbed, nothing run here — `il2cpp-protected-metadata.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, and a VM that lives in the managed layer (opcode array + cursor + `Dictionary` in `dump.cs`, handler indices from the constructor, array literals via `InitializeArray`) — absorbed, nothing run here — `unity-runtime.md` | +| Protected IL2CPP metadata | when the pair is not readable: consumer-side dump, header sizing without trusting the version, XOR/VM/XXTEA containers, the live-image dump and ELF repair, and the ELF fingerprint of a rebuilt `libil2cpp` (`JNI_OnLoad` / own `DT_INIT` / `SHT_LOUSER`) — absorbed, nothing run here — `il2cpp-protected-metadata.md` | | Linker-level SO unpacking and repair | the `dlopen` pipeline as a loader spec, load_bias/segment arithmetic, `DT_*` and relocation rules, `soinfo` repair, anti-dump header XOR — source-reading synthesis, no loader reimplemented — `linker-and-soinfo-repair.md` | | VM protector analysis | virtual eflags, NAND lowering, branchless select, data-anchored breakpoints and trace search by constant — absorbed from x86 VMProtect samples, never run on Android here — `vm-protector-analysis.md` | -| Python bytecode / Pyarmor | key schedule, AES-CTR nonce rules, the 12-byte VM, version-gated `pyc` parsing and the extra-section TLV — absorbed, no sample unpacked here — `python-bytecode-recovery.md` | +| Python bytecode / Pyarmor | key schedule, AES-CTR nonce rules, the 12-byte VM, version-gated `pyc` parsing, the extra-section TLV, and the outer container layer with its three nonce scopes — absorbed, no sample unpacked here; the counter-mode nonce behaviour is host-measured — `python-bytecode-recovery.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` | diff --git a/skills/apk-reverse/references/evidence-summary.md b/skills/apk-reverse/references/evidence-summary.md index c187568..a0f444d 100644 --- a/skills/apk-reverse/references/evidence-summary.md +++ b/skills/apk-reverse/references/evidence-summary.md @@ -58,6 +58,8 @@ next** — and points at the one section that says "the full record is elsewhere | Hardware memory watchpoints (ptrace) | The encoders and refusals are host-tested and the emitted helper compiles as a static aarch64 ELF; **no watchpoint was ever armed on a device**, and the route leaves `TracerPid` visible by design | partial | unverified | `references/hardware-watchpoints.md`, `scripts/hw_watch.py` | | Differential trace reduction | First divergence, divergent regions and per-side unique blocks measured on synthetic flattened-dispatcher fixtures, in both Stalker and wrapped-log grammars; no real-target producer (Stalker pair or cs_etm decode) has fed it yet | partial | observed | `references/ete-diff-tracing.md`, `scripts/trace_diff.py` | | ETE hardware tracing | Gates and capture wrapper host-measured to the no-device boundary; no coresight capture or decode ever ran here, and `--control` exists to say so on a real device in one command | blocked | unverified | `references/ete-diff-tracing.md`, `scripts/ete_capture.py` | +| A VM written in the managed layer (IL2CPP C#) | Identified, not recovered: opcode array + cursor + `Dictionary` in `dump.cs`, handler indices read out of the constructor, array literals taken from `InitializeArray` rather than `.rodata` | partial | unverified | `references/unity-runtime.md`, `references/native-vm-recovery.md` | +| Comparison gate as a prefix oracle | Host half measured: an early-exit compare costs k+1 comparisons for a k-byte correct prefix (k=0..8) while an MD5-then-compare is constant; no serial or licence gate was attacked | partial | observed | `references/account-gates.md` | | SPE/BRBE sampling and crash forensics | The address→symbol loop is host-verified on a real cross-compiled `.so`; SPE/BRBE mechanisms are documented-and-gated, never captured here | partial | unverified | `references/hardware-sampling-and-crash.md`, `scripts/trace_addr2sym.py` | | Interpreter-trace de-virtualization | Dispatcher slicing, signature clustering and the opcode trajectory measured on synthetic interpreter traces; the calibration facts come from a public IR-VM's source, and no hardened binary was traced | partial | observed | `references/ete-diff-tracing.md` §5, `scripts/vm_handler_slice.py` | | Native VM recovery procedure | The full method is absorbed from two public write-ups (dispatcher-layer separation, register anchoring with triple verification, nested segment stacks, STORE-backward slicing, clean-re-VM validation); no step was executed by this repository | partial | inferred | `references/native-vm-recovery.md` | diff --git a/skills/apk-reverse/references/framework-runtimes.md b/skills/apk-reverse/references/framework-runtimes.md index b4519ec..acb8479 100644 --- a/skills/apk-reverse/references/framework-runtimes.md +++ b/skills/apk-reverse/references/framework-runtimes.md @@ -18,6 +18,7 @@ Signals in the APK: | Cordova / Ionic | `assets/www/`, `cordova.js` | | Xamarin / .NET | `libmonodroid.so`, `assemblies/` in the APK | | Python (Chaquopy / Kivy / BeeWare) | `libpython3.*.so`, `assets/` with `.pyc`/`.pyz`, `pyarmor`/`__pyarmor__` bootstrap → `python-bytecode-recovery.md` | +| Python compiled away (Nuitka / Cython / `pyd`) | `libpython3.*.so` **or** a bare CPython dependency, no `.pyc`/`.pyz` anywhere, and `PyInit_*` exports in a target `.so` → the bytecode route has no input; the bodies are native (`native-and-so.md`) | A `classes.dex` that is small relative to the total payload is another strong hint that the real code lives elsewhere. diff --git a/skills/apk-reverse/references/ida-so-debugging.md b/skills/apk-reverse/references/ida-so-debugging.md index 2990a51..32973c5 100644 --- a/skills/apk-reverse/references/ida-so-debugging.md +++ b/skills/apk-reverse/references/ida-so-debugging.md @@ -6,9 +6,10 @@ it so. `dynamic-frida.md` owns hooking; `hardware-watchpoints.md` owns observati 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 +**Claim strength.** Absorbed from three public walkthroughs (2024-07, plus a MuMu-emulator note and +a 2026 Unity session), all against CTF apps that were already debuggable, and none reproduced here. +The debugger-side signal policy in §8 is one source's account, `unverified`. Commands on which the +sources 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 @@ -127,9 +128,31 @@ discipline as `long-task-discipline.md` §single-variable discipline, applied to - `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 +## 8. Stops that are the debugger's, not the target's -Both sources are emulator or AVD walkthroughs on CTF apps with `debuggable` already set; neither +Most "the target crashed" observations in a debug session are properties of the debugger's own +settings. Three that cost a whole session: + +- **A platform signal is not target logic.** A Unity-backed app under IDA repeatedly stops on + `SIGPWR`/`SIGXCPU`-class signals it never handles, because the platform raises them and the app + ignores them. With the default *suspend on this signal* the pause **is** the crash: the process is + stopped somewhere it cannot make progress, the watchdog or runtime aborts, and the abort takes IDA + down with it. Set those signals to *pass to application* (report on log or silent). After that the + same signal costs a log line instead of a session, and the only thing that changed was your + debugger. +- **One database per activity.** Attaching and detaching from the database you used for static + analysis tends to hang on exit or leave it unusable — and the annotations are the expensive part. + Copy the database (or keep a separate debug-only one) and leave the static one read-only. +- **Interacting with a paused process is input injection.** Clicking around the app while it is + stopped delivers real input into the frozen process and reproduces the same hang/crash class. + Resume, then interact; do not "help" a suspended app. + +Nothing here is about anti-debug: if the process dies while it is *stopped*, suspect the debugger's +signal policy before suspecting the target. + +## 9. Boundary + +All three sources are emulator or AVD walkthroughs on CTF apps with `debuggable` already set; none 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 diff --git a/skills/apk-reverse/references/il2cpp-protected-metadata.md b/skills/apk-reverse/references/il2cpp-protected-metadata.md index 8a00ce6..2b2dc09 100644 --- a/skills/apk-reverse/references/il2cpp-protected-metadata.md +++ b/skills/apk-reverse/references/il2cpp-protected-metadata.md @@ -40,6 +40,27 @@ embeds the metadata in the `.so` with the rest of the header intact. **A 0-byte metadata file is a positive finding, not a missing file.** It means the build does not use the file at all, so a static decryptor has nothing to decrypt. Look for the loader's output. +### 1.1 A rebuilt `libil2cpp` announces itself in the ELF tables + +One build's own triage script flags three things before it parses anything: a `JNI_OnLoad` export, a +`DT_INIT`/`init_array` of the library's own, and an `SHT_LOUSER` section. None of the three is +something the engine's build path needs, so any of them is a candidate added step — read them as +heuristics, where a false positive costs one `readelf`: + +```bash +readelf -sW libil2cpp.so | grep -i jnionload +readelf -dW libil2cpp.so | grep -Ei 'INIT|FINI' +readelf -SW libil2cpp.so | grep -w LOUSER # a section type, not a name +``` + +`JNI_OnLoad` means the library wants a JVM-side entry point, which is a JNI-sinking or loader shape +rather than something the engine itself calls through. An own `DT_INIT`/`init_array` is the earliest +place arbitrary code can run. `SHT_LOUSER` is a section the +linker reserves for the application — a convenient place to park a payload, and a common marker in +this family. Read these tables **before** hunting ciphertext: the injected entry is where metadata and +code are decrypted, and it is also what a dumper's "this file may be protected" refusal is reacting to. +Then continue at §2 and anchor on the consumer rather than the file. + ## 2. Anchor on the consumer, not the path The reliable address is the pointer *after* decryption and de-obfuscation. Two anchors exist, and @@ -77,7 +98,10 @@ i -= 8 ``` This works when magic and version are zeroed, and it survives a version mismatch because it uses -only offset/count pairs that are still laid out in the same order. Bound the loop (for example +only offset/count pairs that are still laid out in the same order. (`observed`, host: on a synthetic +header whose magic and version are zeroed and whose tail counts are zero, the walk returns the true +length `0x1140` while a fixed mid-header pair returns `0x1098`; see +`docs/tool-verification/fixtures/verify_ref_claims_10c.py`.) Bound the loop (for example `i > 0`), wrap reads in the platform's fault handler on Windows, and treat a non-monotone result as "this is not the header you think it is", not as a size. @@ -131,6 +155,16 @@ self-describing container**. Dump the program table, name each case by its effec program in Python. Do not single-step the VM instruction by instruction in a debugger; the table is the specification. +**Read the call site before replaying, because the schedule decides what a unit test is.** One build +runs the interpreter once per fixed-size block — `apply(program, 0x40, table, out + i, min(0x40, +remaining))` in a loop — with the same constant table re-passed on every call and no state carried +between them. That makes one block a complete test: decrypt block 0, check the result against the +plaintext you already know it must contain (a magic, or the first string), and only then run the rest. +If the call site instead updates a context between iterations, the blocks are chained: no per-block +shortcut exists, replay must start at 0, and a mismatch at block 7 is a key/program error you cannot +localise by looking at block 7. The truncated tail is also worth reading explicitly, because a tail +handled as a full block shifts everything after the last boundary. + ### 3.3 XXTEA with a length word A resource-container sample used XXTEA: `delta = 0x9E3779B9`, `rounds = 6 + 52/n`, decrypt from @@ -237,6 +271,16 @@ re-imported metadata is silently corrupt and the dumper still accepts it. Dump `index,hex` to CSV, decrypt offline, patch the metadata file, then re-run Il2CppDumper. This keeps the proof in one place: the tool either parses the patched file or it does not. +**When the literal cipher lives inside the protected VM, do not reverse it — drive the runtime.** +`String::NewLen(ptr, len)` *is* the decryption oracle, and it takes exactly the two fields the literal +table stores. Enumerate `i` from 0 to `count-1` yourself, read `(dataOffset + dataIndex, length)`, call +the runtime entry for each, and collect the returned UTF-16 buffers. Three properties make this better +than either hooking natural calls or reimplementing the cipher: it is exhaustive rather than a sample +of what the app happened to load, it is a completeness proof for the table (every index either +decrypts or visibly fails), and it keeps you out of the VM. The cost is that the process must be +alive and past metadata init, so it is the wrong route when the question is what a dead sample +contains. + ## 7. Adjacent artifacts that fail the same way These are not metadata, but they are the next files the same build protects, and the diagnostic diff --git a/skills/apk-reverse/references/native-vm-recovery.md b/skills/apk-reverse/references/native-vm-recovery.md index 8bc34d7..1ab134f 100644 --- a/skills/apk-reverse/references/native-vm-recovery.md +++ b/skills/apk-reverse/references/native-vm-recovery.md @@ -5,7 +5,10 @@ xvmp-class IR virtualizer, or any private-bytecode loop — and the question is the handler soup to the guest program. This file owns the recovery procedure; the **capture** half lives in `references/ete-diff-tracing.md`, the **slicing** tool in `scripts/vm_handler_slice.py`, and the OLLVM-only case in -`references/native-dbi-and-deobfuscation.md`. +`references/native-dbi-and-deobfuscation.md`. If the interpreter is itself written in a managed +language — a C# class holding an opcode array plus a cursor under IL2CPP, or a JS dispatch loop — this +is the wrong file: `unity-runtime.md` §when the managed layer is itself the interpreter, and +`jsvmp-recovery.md`, own those shapes. **Claim strength.** Every method step here is absorbed from two public reverse-engineering write-ups (Kanxue, 2026-08/09, titles and authors anonymized in this record; one is a full diff --git a/skills/apk-reverse/references/python-bytecode-recovery.md b/skills/apk-reverse/references/python-bytecode-recovery.md index e486ae6..6aad9e2 100644 --- a/skills/apk-reverse/references/python-bytecode-recovery.md +++ b/skills/apk-reverse/references/python-bytecode-recovery.md @@ -12,7 +12,9 @@ all. sample was unpacked in this record. The general rules (static-first, self-describing TLV, mirror-the-host-codegen) are `inferred` from that source. Version numbers matter: every branch below is gated on the `pycdc`/Python version, and applying a newer layout to an older file fails -silently rather than loudly. +silently rather than loudly. The three-scope nonce layering (§2.2) and the counter-mode correction +in §5 were added in the 2026-10 audit pass; the counter behaviour is `observed` on the host, the +layering itself is `unverified`. ## 1. Recognition @@ -65,9 +67,39 @@ The `idiv` side effect is the general lesson: when reimplementing a packer's VM, code generation*, including register clobbers the author did not intend. A clean abstract interpreter will produce a different key and every downstream field will decrypt to noise. +Width is part of the same contract: the VM is a transcription of machine instructions, so each +operation must be evaluated at the host register width — a multiply keeps only the low 32 bits, +division is signed and truncates toward zero, and results wrap. Evaluating the same program with +Python's unbounded integers diverges after a few instructions and produces a key that is wrong in a +way that looks like a wrong nonce downstream, not like an arithmetic error. + +### 2.2 The shipped blob is wrapped once more + +The `.pyc` is the *inner* artefact. At least one build ships +`AES-CTR(master key, AST_nonce, pyc-with-encrypted-parts)`: the file on disk is not a pyc, its first +four bytes are not the marshal magic, and a parser pointed at it reports a corrupt header rather than +an encrypted file. Three scopes share one master key and are separated only by which nonce is used: + +| Scope | Nonce | Plaintext it yields | +|---|---|---| +| outer container, whole file | `AST_nonce` from `RSA_KEY2`, `nonce[15] = 2` | the pyc: header, string blob, code section | +| each code object | 12 bytes sliced out of that object's own `m_code` (§5) | that object's bytecode | +| each string | `pyarmor_mix_str_aes_nonce` | that one literal | + +The order is not a preference. Applying a code object's nonce to the on-disk bytes decrypts layer 1's +ciphertext with layer 2's stream and yields high-entropy bytes that look like a private opcode set, so +the failure is "this packer uses a custom ISA", which is the wrong conclusion. Peel the outer layer +first, then check for the pyc header, then parse (§3). + +A live dump does not replace this pass either: the packer decrypts each code object when it is first +imported, so a process dump holds the objects that ran and leaves everything else as ciphertext. A +function missing from a dump means "not reached yet", not "not present" — force the modules to import +before calling a dump complete, and keep the static pass as the only complete route. + ## 3. Parsing the code object -The protected file is a normal `.pyc` with extra fields, so the parser must be version-aware. +**Once the outer layer is off (§2.2)**, the file is a normal `.pyc` with extra fields, so the parser +must be version-aware. Fields that change and the versions that introduced them: | Field | Width / condition | @@ -147,9 +179,16 @@ Then: 6. Replace the descriptor's first 8 bytes with the `""` placeholder so a later re-run does not treat runtime data as a key. -The nonce construction is the part that is easy to get wrong: `nonce[15] = 2` is a domain -separator, and using an all-zero tail produces a stream that decrypts the first block plausibly and -then diverges. +The nonce construction is the part that is easy to get wrong, and the usual description of *how* it +fails is wrong: `nonce[15] = 2` is not a flag that only affects later blocks. In counter mode every +block's keystream is `E(nonce || counter)`, so changing byte 15 changes the counter start and corrupts +**block 0 onward** — there is no "first block decrypts plausibly" state to catch you. (`observed`, +host: two 16-byte counter blocks differing only in byte 15 differ in 16 of 16 keystream bytes, so +the ciphertext differs from block 0 — see the EXTENSION record and +`docs/tool-verification/fixtures/verify_ref_claims_10c.py`.) What an *unset* tail does is worse in a different way: the leftover bytes +are whatever the buffer held, so the keystream is not reproducible at all and the same input decrypts +differently on two runs. Zero the buffer, set the byte, and check the first eight bytes of the result +against a known code-object prologue instead of trusting a plausible-looking body. ## 6. Decrypting strings diff --git a/skills/apk-reverse/references/unity-runtime.md b/skills/apk-reverse/references/unity-runtime.md index d8d1f18..4dd1e68 100644 --- a/skills/apk-reverse/references/unity-runtime.md +++ b/skills/apk-reverse/references/unity-runtime.md @@ -140,6 +140,52 @@ the same rule as `pitfalls.md` P47. actually loads — a split or stubbed `libil2cpp.so` inside the APK is not necessarily the one mapped (`native-and-so.md`). +### When the managed layer is itself the interpreter + +IL2CPP is ahead-of-time compiled, which makes it tempting to read `libil2cpp.so` as the only code that +matters. A `.so` that looks like a thin wrapper over `Dictionary` and delegate calls is often sitting +on top of an interpreter written in C# — and a VM written in C# is recoverable from `dump.cs` without +ever reading an opcode handler in assembly. The shape in `dump.cs`: + +```text +class { + int[] aa1; // the guest program, built from a literal + int aa5; // the cursor, i.e. the PC / vEIP + Dictionary aa8; // opcode -> handler +} +``` + +A `while` loop that reads `array[cursor]` and calls `dict[op]` is the dispatcher; the array initialised +from a literal and the dictionary filled in the constructor are the program and the handler table. The +handler *indices* are assigned in the constructor by walking an enum or a `static_fields` block (one +sample enumerated 1..22 and used those values as dictionary keys), so the opcode→handler map is a +property of the constructor, not something to discover by tracing. Read the constructor first, rename +the fields positionally to `a1..aN`, delete the null and length guards, and the dispatch loop becomes +plain control flow. If the program array is built at run time, hook the constructor and take its +argument instead of tracing the loop. + +The recovery method does not change once you have the instruction stream — `vmp-differential-analysis.md` +for the opcode map and `native-vm-recovery.md` §11 for the artefact set. What changes is the cost: a +managed VM is read statically, in a decompiler that still has names. + +### Array literals are not in `.rodata` + +A static array literal — a crypto key, an S-box, a constant table — has **no inline initialiser in +`libil2cpp.so`**. IL2CPP stores the field's default value in the metadata and materialises it at class +init by calling `RuntimeHelpers::InitializeArray` (the managed-side name; the implementation copies +from the field-default blob). Consequences for both reading and searching: + +- `grep`-ing the `.so`, or `strings`-`ing` it, cannot find the table, and its absence is not evidence + that the table is derived at run time. +- Hook `InitializeArray` (or break on it) and the argument is the table; the same bytes are also + readable from the metadata field-default blob without a device. +- The table is per-field, so one hook gives you every literal the class initialisation touches, which + is usually the whole key schedule of a hand-rolled cipher. + +This is the cheapest way to answer "where does this constant come from" in a Unity target, and it is +cheaper than reconstructing the cipher. `unverified`: the API name is stable across Unity versions, the +argument shape is not. + ## 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 @@ -183,6 +229,23 @@ directory; with no arguments it uses defaults that may not be writable on a hard written before the metadata is decrypted is the encrypted file again — check the magic before importing it. +**Hook the framework primitive, not the app's wrapper.** Obfuscation renames the app's classes; it +does not rename `System.Security.Cryptography.*`, `javax.crypto.Cipher` or `java.security.*`. So when +the question is "what key does this use", trace the primitive instead of the wrapper the app built: + +```ts +Il2Cpp.trace(true).classes(Il2Cpp.domain.assembly("System.Security.Cryptography") + .image.class("System.Security.Cryptography.AesCryptoServiceProvider")).and().attach(); +``` + +One such trace reports every key/IV pair the process constructs, no matter which wrapper or +obfuscated call chain produced it, and it removes the need to reconstruct the derivation. On the Java +side the equivalent pair is a hook on `Cipher.getInstance` plus `SecretKeySpec` / `IvParameterSpec`. +The trade is provenance: the primitive tells you the value, not which call site asked for it — use the +app's own class (above) when you need the call site, and the primitive when the derivation is hostile +and you only need the material. `unverified` here: neither trace was run in this record. + + 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. diff --git a/tests/unit/test_verify_ref_claims_10c.py b/tests/unit/test_verify_ref_claims_10c.py new file mode 100644 index 0000000..783913f --- /dev/null +++ b/tests/unit/test_verify_ref_claims_10c.py @@ -0,0 +1,93 @@ +"""The 2026-10-27 host fixture must be able to fail. + +`docs/tool-verification/fixtures/verify_ref_claims_10c.py` is the only thing standing behind three +`observed` labels, so its checks are as load-bearing as a gate. The first draft failed the rule this +repository applies to gates: the comparison check counted iterations inside the Python loop it was +measuring, so `n == k + 1` restated its own source and could never fail for a real reason. It now +compiles the two comparators and measures the compiler's output. + +Each test here injects the wrong claim and requires the check to reject it, plus one test that the +process exit code follows the verdict. `observed` on a host with neither `openssl` nor a C compiler +is a skip, not a pass. +""" +from __future__ import annotations + +import importlib.util +import shutil + +import pytest + +from conftest import REPO_ROOT + +pytestmark = pytest.mark.unit + +FIXTURE = REPO_ROOT / "docs" / "tool-verification" / "fixtures" / "verify_ref_claims_10c.py" + + +@pytest.fixture(scope="module") +def claims(): + spec = importlib.util.spec_from_file_location("verify_ref_claims_10c", str(FIXTURE)) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _need_openssl(): + if not shutil.which("openssl"): + pytest.skip("no openssl on this host: the CTR check degrades to SKIP") + + +def _need_cc(): + if not (shutil.which("gcc") or shutil.which("cc") or shutil.which("clang")): + pytest.skip("no C compiler on this host: the comparison check degrades to SKIP") + + +def test_checks_pass_on_the_claim(claims): + _need_openssl() + _need_cc() + assert claims.ctr_nonce_tail() is True + assert claims.comparison_oracle() is True + assert claims.header_walk() is True + + +def test_ctr_check_rejects_an_unchanged_nonce(claims): + _need_openssl() + base = bytes(range(12)) + b"\x00\x00\x00" + same = base + b"\x02" + assert claims.ctr_nonce_tail(nonce_a=same, nonce_b=same) is False + + +def test_comparison_check_rejects_a_full_pass_mislabelled_early(claims): + _need_cc() + assert claims.comparison_oracle(early_source=claims.C_FULL) is False + + +def test_comparison_check_rejects_an_early_exit_mislabelled_constant(claims): + _need_cc() + assert claims.comparison_oracle(full_source=claims.C_EARLY) is False + + +def test_header_check_rejects_an_off_grid_start(claims): + assert claims.header_walk(start=0x110) is False + + +def test_header_check_rejects_ground_truth_that_disagrees(claims): + # the tail-most pair no longer sits at the largest offset, so "declared" and the forward scan + # cannot both be the file length + pairs = [(0x1100, 0x08), (0x1300, 0x10), (0x10A0, 0x10)] + assert claims.header_walk(pairs=pairs) is False + + +def test_main_exit_code_follows_the_verdict(claims, monkeypatch): + # a check that returns False without printing must still fail the run + monkeypatch.setattr(claims, "ctr_nonce_tail", lambda: False) + monkeypatch.setattr(claims, "comparison_oracle", lambda: True) + monkeypatch.setattr(claims, "header_walk", lambda: True) + assert claims.main() == 1 + + +def test_main_treats_a_skip_as_not_a_failure(claims, monkeypatch): + monkeypatch.setattr(claims, "ctr_nonce_tail", lambda: None) + monkeypatch.setattr(claims, "comparison_oracle", lambda: True) + monkeypatch.setattr(claims, "header_walk", lambda: True) + assert claims.main() == 0