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).
This commit is contained in:
root
2026-10-27 00:33:49 +08:00
parent 068280a180
commit 90d9bd3e57
14 changed files with 745 additions and 15 deletions
@@ -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<int, delegate>`, 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.
@@ -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('<i', buf, i)[0]
if count:
return struct.unpack_from('<i', buf, i - 4)[0] + count
i -= 8
return None
def header_walk(pairs=None, start=0x10C):
"""Claim: the backward walk from 0x10C sizes a header whose magic and tail are zeroed.
Ground truth is computed twice: from the pair table that built the fixture, and by an
independent forward scan (max of offset+count). The negative control is a walk on an off-grid
start -- it must disagree, otherwise "steps of 8" is not what the check is testing.
"""
name = 'metadata header size by backward walk (il2cpp-protected-metadata section 2.1)'
if pairs is None:
pairs = [(0x1100, 0x40), (0x10C0, 0x20), (0x10A0, 0x10),
(0x1090, 0x08), (0x1088, 0x08), (0x1080, 0x08)]
end = 0x10C
header = bytearray(0x200)
for idx, (offset, count) in enumerate(pairs):
struct.pack_into('<i', header, end - 8 * idx, count)
struct.pack_into('<i', header, end - 8 * idx - 4, offset)
declared = pairs[0][0] + pairs[0][1]
forward = max(offset + count for offset, count in pairs)
walked = _walk(header, start)
off_grid = _walk(header, start + 4)
ok = walked == declared == forward and off_grid != declared
return report(name, ok,
['declared %#x, forward scan %#x, walk from %#x -> %#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())
@@ -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<int, delegate> 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."
}
]
}
@@ -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:
@@ -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<int, delegate>`), 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
@@ -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<int, delegate>` 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` |
@@ -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<int, delegate>` 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` |
@@ -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.
@@ -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
@@ -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
@@ -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
@@ -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 `"<COAddr>"` 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
@@ -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 <obf> {
int[] aa1; // the guest program, built from a literal
int aa5; // the cursor, i.e. the PC / vEIP
Dictionary<int, Action> 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.
+93
View File
@@ -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