reverse-skills-max: initial public release (restored, redacted, hardened)

This commit is contained in:
baradok609hecl1pq6qi
2026-10-20 02:17:15 +03:00
commit da24c2bd20
2227 changed files with 443931 additions and 0 deletions

View File

@@ -0,0 +1,34 @@
# flake8 configuration for this repository.
#
# Three settings, each with the measurement behind it rather than a preference.
#
# max-line-length = 160
# 120 was tried first and two lines still exceeded it: the secret-scanner rule table in
# scan_leaks.py holds raw regex source of 148 and 156 characters. Splitting a regex inside a raw
# string either changes the pattern or forces concatenation that is harder to read than the long
# line, so the limit is set from what a real string in this repository needs (156) with a little
# room, and a line over 160 is a genuine defect rather than a style disagreement.
#
# max-complexity is not set, and C901 is excluded below with the measurement: this repository's
# parsers and dispatchers reach 21-41 (check_commands.command_findings is 41, a flat if/elif chain
# over finding kinds). Setting a threshold above the maximum would be a number chosen to pass rather
# than a standard; splitting them into one function per case would move the shape of the format away
# from the code that reads it, and looking like the specification is this repository's argument.
# The numbers are recorded here so a future pass can decide to refactor with them in hand.
#
# extend-ignore:
# C901 see above -- reported here as a measurement, not enforced
# E203 slice whitespace (`data[start : end]`); flake8 and every autoformatter disagree, and the
# repository has no autoformatter
#
# Run from the repository root:
# flake8 *.py skills/apk-reverse/scripts/*.py
#
# Enforced in CI: the `lint` job in .github/workflows/ci.yml runs this exact command alongside mypy,
# on a bare interpreter, so the configuration is exercised in the poorest environment it must keep
# working in.
[flake8]
max-line-length = 160
extend-ignore = C901,E203
exclude = .git,__pycache__,tools,build,dist

21
vendor-new/apk-reverse/.gitattributes vendored Normal file
View File

@@ -0,0 +1,21 @@
# Normalize line endings so a Windows checkout does not rewrite committed files.
# Scripts stay LF (they are consumed by python/node and by shell on POSIX hosts).
* text=auto eol=lf
*.py text eol=lf
*.js text eol=lf
*.sh text eol=lf
*.md text eol=lf
*.java text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
# PowerShell is the one thing that is happier with CRLF on Windows.
*.ps1 text eol=crlf
# Never normalize binary-ish artifacts.
*.jar binary
*.png binary
*.apk binary
*.dex binary
*.so binary

View File

@@ -0,0 +1,132 @@
# CI for the apk-reverse skill repository.
#
# WHAT THIS RUNS
# * the repository's own structural gates: check_repo.py, check_refs.py,
# check_budget.py, check_routing.py, and check_commands.py when it is present
# * the pytest suite: tests/unit, tests/cli, tests/integration
#
# WHAT THIS DOES NOT RUN, ON PURPOSE
# * anything needing a device, adb, frida, a rooted phone, a network peer or a
# real sample APK. Those routes are recorded experiments in tests/benchmark.md
# and their status is documented in
# docs/tool-verification/EXTENSION-test-harness.md.
# * anything about agent behaviour. Whether a model routes, classifies or
# refuses correctly is not observable from a unit test; skills/apk-reverse/
# evals/evals.json is the skeleton for that, and it has never been executed.
# * the dex_patch_bytes regression hash. Its input lives in git-ignored
# tools/_work/bench/, so that test self-skips here with a printed reason
# rather than failing. Run it locally after downloading the sample.
#
# Coverage is therefore: static structure, documentation anchors, budget, command
# references, routing coherence, pure parsers, CLI contracts, typing/style lint and
# no-device flows. Nothing more is claimed.
name: ci
on:
push:
pull_request:
#
# NOTE ON TRIGGERS: no `paths:` filter, deliberately. A filter that watches only
# `*.py` and the lint configuration would skip the structural gates on exactly the
# changes they exist to catch -- a reference index or a documented command edited
# without touching a script. check_refs.py and check_routing.py are documentation
# gates, so they have to run on documentation commits.
permissions:
contents: read
jobs:
test:
name: python ${{ matrix.python-version }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12", "3.13", "3.14"]
env:
PYTHONIOENCODING: utf-8
PYTHONDONTWRITEBYTECODE: "1"
# The gates spawn sibling scripts and decode their output. On a Windows
# runner the default codec is the console's (GBK here), and one script's
# --help text is not GBK-decodable, which is reported as a crash in that
# script rather than as a harness decoding problem. Pinning UTF-8 removes
# the whole class of failure on every platform.
PYTHONUTF8: "1"
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install test dependencies
# `tiktoken` is optional for the suite but not for the budget gate's *measurement*: without
# it, `check_budget.py` falls back to characters/constant, and the two paths must agree.
# Installing it here means CI exercises the real tokenizer path, and the unit test comparing
# both paths runs instead of skipping.
# Not upgrading pip itself: it is not a goal here, and a pip self-upgrade failing would be
# reported as this repository's regression.
run: python -m pip install --upgrade pytest tiktoken
- name: Structural gate - repository layout
run: python check_repo.py
- name: Structural gate - documentation anchors
run: python check_refs.py
- name: Structural gate - context budget
run: python check_budget.py
- name: Structural gate - documented commands (if present)
run: |
if [ -f check_commands.py ]; then
python check_commands.py
else
echo "check_commands.py is not present in this revision; skipping."
fi
- name: Structural gate - routing inventory matches the entry point
run: python check_routing.py
- name: Regression suite
run: python -m pytest -q
- name: Summarise skipped and expected-failure coverage
if: always()
run: python -m pytest -q -ra --no-header -p no:cacheprovider 2>&1 | tail -n 40
lint:
# Static checks over the same tracked surface the local commands cover, so what passes locally
# cannot drift from what CI enforces. Both tools read their configuration (mypy.ini, .flake8)
# from the repository root automatically.
#
# The environment is deliberately bare -- only mypy and flake8, none of PyYAML, capstone, tiktoken
# or frida. That is the poorest environment the configuration must keep working in, which is what
# mypy.ini's ignore_missing_imports overrides exist for: CI exercises the fallbacks instead of a
# machine-local install hiding a regression.
#
# One interpreter, not the matrix: the checks are static, and the matrix's job is runtime
# behaviour, which lint does not observe. `check_routing.py` -- a gate the test job also runs --
# is deliberately not repeated here.
#
# Verified locally before being added: on a venv with only mypy and flake8, `flake8` reports 0 and
# `mypy` reports "Success: no issues found in 55 source files".
name: lint (mypy + flake8)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install lint dependencies
run: python -m pip install --upgrade mypy flake8
- name: mypy
run: mypy *.py skills/apk-reverse/scripts/*.py
- name: flake8
run: flake8 *.py skills/apk-reverse/scripts/*.py

54
vendor-new/apk-reverse/.gitignore vendored Normal file
View File

@@ -0,0 +1,54 @@
# build artifacts
*.class
*.jar
!skills/apk-reverse/scripts/dexpatch/*.java
# working data -- never commit samples or outputs
*.apk
*.aab
*.dex
*.odex
*.vdex
*.oat
*.so
*.jks
*.keystore
*.pk8
*.pem
*.p12
# local scratch
_tmp/
out/
work/
audit_out/
# local extension-pass workspace: toolchains, device archives, dumped images, scratch notes.
# Nothing under tools/ belongs to the repository -- the skill's own evidence record lives in
# docs/tool-verification/ and carries no target identity or vendored binaries.
tools/
# local input material (the reporting sample and the analysis notes it came with)
存在问题和例子/
# sandbox installs made while testing `npx skills add .` against this repo
.claude/
.agents/
*.log
*.jsonl
*.pcap
*.png
*.jpg
# python / editors
__pycache__/
*.pyc
.venv/
.vscode/
.idea/
.DS_Store
# local input material -- the same rule as 存在问题和例子/: the reports these passes are based on
# are the user's material, not content of this repository
新的建议和思路/
朋友全面审查和建议/

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 apk-reverse contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,612 @@
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="assets/banner-dark.svg">
<img src="assets/banner-light.svg" alt="apk-reverse" width="100%">
</picture>
</p>
<p align="center">
<b>English</b> · <a href="README.zh-CN.md">简体中文</a>
</p>
<p align="center">
<a href="https://github.com/newliver666/apk-reverse/stargazers"><img src="https://img.shields.io/github/stars/newliver666/apk-reverse?style=flat-square&label=stars&color=49454F" alt="stars"></a>
<a href="https://github.com/newliver666/apk-reverse/network/members"><img src="https://img.shields.io/github/forks/newliver666/apk-reverse?style=flat-square&label=forks&color=49454F" alt="forks"></a>
<a href="LICENSE"><img src="https://img.shields.io/github/license/newliver666/apk-reverse?style=flat-square&color=49454F" alt="license"></a>
<img src="https://img.shields.io/badge/python-3.9%2B-49454F?style=flat-square&logo=python&logoColor=white" alt="python">
<img src="https://img.shields.io/badge/platform-android-49454F?style=flat-square&logo=android&logoColor=white" alt="android">
<a href="https://github.com/newliver666/apk-reverse/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/newliver666/apk-reverse/ci.yml?style=flat-square&label=ci&color=49454F" alt="ci"></a>
</p>
<p align="center">
<a href="#what-it-is-good-at">Capabilities</a> · <a href="#structure">Structure</a> · <a href="#install">Install</a> · <a href="#requirements">Requirements</a> · <a href="#read-this-first">Failure catalogue</a> · <a href="#scope">Scope</a> · <a href="#repository-maintenance">Maintenance</a> · <a href="#disclaimer">Disclaimer</a>
</p>
# apk-reverse
An Agent Skill for Android APK reverse engineering, debloating, ad removal, surgical
dex patching, repacking, and runtime/server analysis.
It is a **skill**, not a tutorial: it is written to be loaded by an agent (Claude Code,
Codex, or any harness that supports the Agent Skills format) while it works, so it is
organized for progressive disclosure — a short decision-oriented `SKILL.md`, detailed
references loaded only when a step needs them, and parameterized scripts you can run
directly.
## How an agent is expected to consume this
`SKILL.md` is deliberately written as a **procedure with gates** rather than as advice, because the
observed failure mode is not ignorance — it is a model reading the whole thing, agreeing with it, and
then reasoning from first principles anyway.
So there are four things in the body that are meant to be *acted on*, not read:
- **Four override rules (R1–R4).** Where they conflict with the current plan, they win until evidence
overrides them.
- **A symptom index.** Each row is a failure that has already been paid for. **A matching row is a
stop signal**: load that file before running another command, rather than after a few more attempts.
Reasoning past a known symptom is how the same hours get spent twice.
- **Four gates (G1–G4),** each an action with a pass criterion. "I understand the idea" does not clear
a gate. They exist so that classification, environment truth and a control build happen *before*
the first patch, not after the third failure.
- **A two-strike rule and stop conditions.** Two failures of the same shape mean the model is wrong,
not the parameters. The third variant of a hypothesis that already failed twice is where rounds go
to die.
And one thing at the end that is meant to be *withheld*: **"done"** has a definition (six items). A
clean log is not one of them. Anything short of all six is a checkpoint, and should be reported as a
checkpoint with what remains.
If you are an agent reading this: the cheapest possible first command is
`python skills/apk-reverse/scripts/doctor.py`. It tells you which of these tools exist here, which
scripts can actually run, and whether something in the environment is already poisoning your
measurements.
## What it is good at
- Deciding **fast** whether a request is even achievable client-side, instead of
burning hours on a paywall that is enforced by a server.
- Deciding **what form the deliverable must take** before any work starts — an
unrooted, self-contained artifact is a different problem from "make it work on this
machine", and confusing the two is the most expensive drift in this domain.
- Choosing the **safest patch layer** for a given change, and avoiding the layers that
break the app.
- Catching the repack failure that looks like success: an app that installs, launches and
renders perfectly while **every signed request is rejected**, because the client derives its
request-signing key from its own signing certificate.
- Separating **your own mistakes from the app's or the server's problems** — a
feature-scoped failure (login, registration, payment) is often a TLS/certificate issue on
one code path, not a consequence of the patch you just built. Device state, a dead device
server and clock drift masquerade the same way.
- Establishing **which architecture and which library are actually executing**, rather
than trusting what the manifest ships or what the device claims.
- Working through **packed/hardened targets**: identifying the packer, unpacking, and turning a
memory dump back into a patched, installable APK.
- Handling a **hardened library that terminates the process on purpose** — including the
deliberate-crash shape (`fault addr 0x4`) that looks exactly like an ordinary null-dereference
bug, and the "neutralise it, but never by making it *not return*" rule that decides whether the
fix works or freezes the whole app in a way that looks nothing like the cause.
- Knowing **which tools to reach for and where each one lies** — including the ones that only
exist as a GUI, so you ask for a human instead of silently substituting a weaker method.
- Making a patched build **stay** patched: neutralising version checks, forced-upgrade dialogs and
self-update installers so the work cannot be switched off remotely — and recognising the
hot-update/remote-config channel that can quietly undo it without any version change.
- Separating a **client-side sign-in gate** (patchable) from an **account-scoped resource** (empty
because the server has nothing to answer with), and knowing that forging a session produces a state
worse than being signed out.
- Keeping a **long task honest**: a live record, graded conclusions, calibrated timeouts, and
bounded waits, so progress is not lost and the same mistake is not made twice.
- Avoiding the specific mistakes that produce an APK that builds perfectly and dies at
runtime.
- Deciding **what the deliverable should be when an APK is not an option** — a repack refused by
several independent checks is *blocked*, not expensive, and the fallback ladder is a system-level
module, a local RPC service, or an honest report with a stated boundary.
- Telling a **real memory dump from an extraction-shell skeleton**, and knowing which recovery route
applies — including the root-side dump for when `frida` itself is refused. What the measurement can
and cannot see is in `skills/apk-reverse/references/advanced-unpacking.md`.
- **Calling a routine instead of reversing it** when reversing costs more than invoking: emulated
execution on the host, or a live function service-ified over Frida RPC.
- Reading **instruction-level execution evidence** when a native function has been flattened into a
state machine by OLLVM — including the two ways Stalker was measured to bite back on a real device.
- Recognising when **userspace hooking cannot reach the check at all** (raw `svc` syscalls,
`init_array`-early detection), what the next layer up and down can actually do, and when escalating
is the wrong answer.
- Working **protocols that are not REST** — protobuf without a schema, gRPC, QUIC/HTTP3 — and
native-side certificate pinning that ignores the system trust store.
- Working **from the phone itself**: MT Manager's edit/repack/sign flow and its APK MCP surface,
LSPosed Manager, and on-device data inspection, alongside the PC toolchain rather than instead of it.
- Telling **Java2C apart from an extraction shell** before spending hours hunting a decrypted DEX that
does not exist at any point in the process lifetime — the code was compiled into a `.so`.
- Handling a build that arrives as a **split APK / App Bundle set**: reading the set off a device,
signing every member with one keystore for `pm install-multiple`, or merging code/native members
into a standalone APK when that is legal.
- Working a **real Dex VMP** with the known-plaintext differential — which links can be automated and
which cannot, what a compiled fixture can and cannot reach, and how to *prove* a derived
private-opcode table instead of asserting one.
- **Publishing what it learns without publishing the target** — a scanner that reports identity
shapes with their context, an explicit list of what must *not* be redacted (tools, libraries,
protocol fields, CVEs, hardening products, public crackmes) because redacting those destroys the
reusable part, and exit codes that gate a commit.
- Reading a **precedent** before repeating work this repository already converged on: the positive
half of the record, with the route including its dead ends, a grade on every assertion, and the
files the case says to write back to.
## Structure
`SKILL.md`, `references/` and `scripts/` are all inside the skill directory, `skills/apk-reverse/`.
Everything at the repository root is maintenance tooling shared across skills, not part of an
installed skill.
```
SKILL.md a procedure with gates, not background reading:
how-to-use -> four override rules (R1-R4)
symptom index (a matching row is a stop signal)
four gates (G1-G4, actions with pass criteria)
thirteen classification questions
the workflow, with a per-step skip condition and a two-strike rule
what "done" means -> stop conditions -> constraints -> indexes
references/ loaded on demand, one topic each
recon.md identify packer, SDKs, code location, tamper checks; unpacking
server-config-and-updates.md
the most common shape of "ad" and the one usually mis-diagnosed:
the server supplies UI the client renders (launch screen, popup,
announcement, tab set). The two-layer fetch that proves it, how to
find the config DTOs by the field names data classes keep, why you
patch the decision and not the data, deciding the scope of "remove",
and remote re-enable / cached config durability
byte-level-patching.md equal-length byte edits: why they beat method rebuilding (measured),
locating an instruction's exact offset without scraping listings,
the instruction width traps that desynchronise a decode, neutralise
a branch vs redirect it, dex header integrity field order, and the
verifier's move-result rule
packers.md hardened targets: rejection signals, measuring the validation
boundary with single-variable tests, choosing a native host
code-virtualization-and-custom-linkers.md
the layer between "packed" and "clean": whole classes turned into
`native` declarations, a private loader whose SONAME does not match
its filename, an embedded self-decrypting payload, a Java-layer
"signature killer" that logs success while a native check kills you.
The keep-it/drop-it deadlock, how to separate the *checker* from the
*implementation*, and the string-redirect technique that ends it
without neutralizing anything
framework-runtimes.md Flutter / React Native / Unity: which layer owns the UI, and how to
find logic when there are no symbols (string encoding traps)
dart-aot.md Dart AOT in depth: version pinning and building a matching decompiler,
the object pool and reference indexes, register/boolean conventions,
the three signatures that identify business logic, locating, patching.
Begins with the snapshot-decoding front end it depends on (aotopsy or
blutter) because the pool listing is an input, not something this skill
produces itself
native-and-so.md .so hosts, DT_NEEDED vs JNI_OnLoad, relocation limits,
relocation-free bootstrapping, replacing Java methods natively,
and which ABI/library is *actually loaded and executing*
native-tamper-and-suicide.md how a hardened library kills its own process: the visible
mechanisms, how to tell which one actually fires, how to find the
site, forged section headers, function boundaries from
PT_GNU_EH_FRAME, scanner traps, and neutralising safely
detection-and-anti-analysis.md when the app fights back or the tool cannot run here: telling
detection apart from a broken environment, deciding by cost instead
of escalating, recognising an environment where dynamic analysis
simply does not work, and keeping the "blocks my analysis" question
separate from "blocks the deliverable"
toolchain.md what to install, how to invoke it non-interactively, which tools
are GUI-only, version-alignment traps, working offline,
**"not on PATH" is not "not installed"**, and which signer to use
long-task-discipline.md live record, conclusion grading, drift control, timeout and
wait calibration, deliverable-form drift, captures-you-never-looked-at,
long-context decay, handover
ad-removal.md ad taxonomy, wrapper mapping, callback trap, global gates, verification
updates-and-forced-upgrade.md keeping a patched build alive: locating the version check, the
two-layer patch (no-op the routine, neutralise the comparison), what not
to touch (manifest version, installer permission, host blocking),
self-update and hot-update/remote-config channels, verifying that no
version request is issued at all
account-gates.md sign-in walls, forced phone binding, guest mode: telling a client-side
gate (patchable) apart from an account-scoped resource (not), why
fabricating a session is worse than staying signed out, and the
unavoidable session loss after a reinstall
signature-derived-keys.md when the app's own signing certificate is used as key material:
detection greps, why offline extraction is unreliable, the
hardcode-then-verify procedure
membership-and-limits.md server vs client authority; what is and is not patchable
server-api.md probe an app's API; prove who owns the gate
tls-and-cert.md feature-scoped network failures: expired certs, dual trust chains
third-party-builds.md auditing a "cracked"/"modded" APK before trusting it
dex-patching.md patch-layer table + dexlib2 technique in depth
patch-audit.md proving a patch *landed* and is *legal*: length-vs-bytes
comparison, the equal-length-replacement blind spot, verifier-level
legality (move-result adjacency) checked statically, text-matching
patch traps, and reporting a missing patch
repack-and-sign.md repack rules, unpack-and-repack, signing, post-install hazards
runtime-data.md DataStore / SharedPreferences / SQLite / protobuf; when the app
rewrites your edit, and decoding a value that looks encrypted
dynamic-frida.md Frida setup, version pinning, the four-layer probe, hook strategy
environment.md device/emulator setup, root, ADB, offline devices, log signals,
emulator console control and recovery, preflight, look-at-the-screen
verification.md the claim ladder; what "done" means
desensitization-and-leak-scans.md
publishing discipline: what must be desensitized and what must stay,
the do-not-anonymize list, the leak scanner and its exit states, and
the entry-point file as a prompt surface
precedents/ the positive case library: route including dead ends, a grade per
assertion, measured pit-falls, and the write-back checklist
routing.md the on-demand inventory: every reference with when to load it, every
script with what it does, and a mirror of the symptom index
rasc-and-droidsaw.md the Rust re-implementation of the ASC indexer: measured speedup and
identical class sets, the enum shape where it silently drops bodies,
and how to build and verify it
evidence-summary.md the condensation that ships with the skill: capability, one-line
conclusion, strength, and the evidence you can actually open in an
installed copy
../evals/ NOT a spec directory either, but the location the Agent
Skills guidance recommends: `evals.json` holds the
with-skill / without-skill cases this skill has **not** run,
with the method for running them written into the file
../evidence/ NOT a spec directory: the machine-readable companions to the evidence
summary reference above -- `capability-matrix.json` (the same rows with
more fields), `tested-tool-versions.json` (versions and the probe behind
each), `known-limitations.md` (the installer-facing limit list). Shipped
inside the skill so an installed copy can answer "was this verified, and
how strongly" without the repository
pitfalls.md the failure catalogue -- read before building
advanced-unpacking.md the dump landed but the bodies are empty: extraction-shell diagnosis by
trivial-body ratio, FART-style active invocation and why its classic hooks
died on Android 12-16, code_item splicing, the root-side dump for when
frida itself is refused, and the honest VMP boundary
lsposed-and-modules.md the repack is refused, so deliver a system-level hook module instead:
module anatomy, a gradle-free build chain, scope configuration and how to
verify injection, and the layer a Java module cannot reach
emulation-and-rpc.md call the routine instead of reading it: Unidbg/Unicorn emulation and its
environment-filling cost, versus service-ifying a live function over Frida RPC
native-dbi-and-deobfuscation.md
OLLVM shapes, Frida-Stalker traces, the trace-to-CFG route, the
Stalker/QBDI/emulation decision, and two measured boundaries (a follow that
delivers no events, and a crash from following a hot libc export)
protocol-reverse.md protobuf without a schema, schema recovery from decompiled code, gRPC frame
capture, the QUIC/HTTP3 limit, and native-side certificate pinning
kernel-and-environment-hardening.md
userspace hooking provably cannot reach the check: raw `svc`, init_array-early
detection, what each root scheme hides, the kernel-route map with its version
gate, and when to stop escalating
on-device-tooling.md working from the phone itself: MT Manager edit/repack/sign and its APK MCP,
LSPosed Manager, Termux+frida, on-device data inspection
java2c-and-jni-sinking.md Java2C and JNI sinking, the two hardening shapes most easily confused
with an extraction shell: the table that separates landing shell /
extraction shell / VMP / Java2C / JNI sinking, why the code is in the
`.so` and *never* in a dumped dex, and why a `Java_*` symbol search
comes back empty (dynamic registration, `-fvisibility=hidden`)
split-apk.md App Bundle / split APK sets: what the set is, pulling it off a device,
merging into one APK vs signing the set as a unit, the install refusals
and what each means, and making an installable fixture from a pulled set
vmp-differential-analysis.md
the known-plaintext differential for a real Dex VMP: which links can be
automated and which cannot (the upload is the bottleneck), the coverage a
compiled fixture can reach, how to *prove* a derived private-opcode
table, smali generation, and when the route is closed
coverage-and-limits.md the claim ladder applied to the skill itself: the evidence behind each
covered item, the dependencies this skill does not ship, and what was
never exercised
handoff-boundaries.md where this skill ends and another discipline begins: the JNI form
table, the packer-versus-loader split, and what "verified" means for
each of the four deliverable forms
scripts/ parameterized, path-agnostic
doctor.py run this first: capability report + per-script runnability, finds
tools installed off-PATH or as runnable jars, and surfaces the
environment facts that poison experiments (clock skew, leftover
adb forward / proxy, a device-side frida process already running)
dexutil.py dependency-free dex reader: structural walk + exact instruction
decode, dex header recompute/verify (correct checksum/signature
order), branch-target and operand helpers. Library shared by the
dex scripts, also runs standalone to dump one method with offsets
dex_find_insn.py locate an instruction by decoded semantics and print its exact byte
offset with context and both sides of any branch -- how you find a
patch site instead of guessing offsets
dex_patch_bytes.py equal-length byte patches from a JSON spec: semantic match, polarity
pin via expect_next, equal-length enforcement, verifier check, dex
header recompute, re-decode to prove it landed (--dry-run first)
dex_check_verifier.py tier-3 check: does any conditional branch target a move-result
(bypassing its producer)? Compares two builds and separates
pre-existing findings from regressions your patch introduced
coldstart.py cold-launch capture: timed screenshot burst + logcat signals +
installed-build facts + launch timing, and warns when the foreground
activity is not your app
so_constpatch.py same-length in-place rewrite of an isolated string constant, for
redirecting a library load instead of defeating a check
smtool.py baksmali/smali wrapper with a configurable classpath
dexpatch/ dexlib2 method-level rewriter (for changes that need new instructions)
patch_smali.py method-body replacement in a smali tree
dex_strpatch.py byte-level string patch with a string_ids ordering guard
dex_classdiff.py prove a dex edit was surgical
dex_strings.py strings/URLs/SDK markers without a decompiler
dart_pool_strings.py recover literals from a Dart AOT snapshot (framed entries, the
one-byte vs UTF-16 split, file offsets, run-length noise filter)
dart_pprefs.py build/query the object-pool -> code-site index for a Dart snapshot
dart_disasm.py annotated windowed disassembly of Dart AOT code + B/BL caller index
find_refs.py count callers of a method before patching it
repack.py rebuild APK, strip only signatures, keep META-INF/services/, write a
4-byte-aligned archive (resources.arsc STORED+aligned), sign, verify;
also split APK / App Bundle sets: inventory, sign every member with one
keystore, or merge code/native members into a standalone APK
devsh.py quoting-safe ADB shell helper
usb_net_proxy.py give an offline device network over USB
datastore_inject.py encode/inject AndroidX DataStore preferences safely
probe_api.py probe an HTTP API with the right headers
grab_crash.py recover stacks hidden by a crash-reporter SDK
install_test.py install + launch health check with logcat signal scan
frida_probe.js four-layer runtime probe (app net layer + OkHttp + java.net + exceptions)
run_probe.py inject the probe, stream it to a log file, stay resident
tls_check.py strict certificate check for one or more hosts
preflight.py environment check before every experiment block (device, root,
ABI/translation, clock skew, leftover proxy/forwards, dead server)
lib_map.py what is *actually mapped* into a live process: per-library path,
base, architecture, and whether it came from the APK or was
materialized at runtime
elf_plt.py resolve a PLT stub to its imported symbol (x86_64 + aarch64) from
the relocation table; list a symbol's callers; byte-diff two
libraries and name the symbol each changed stub belongs to
apk_diff.py entry-level diff of two builds: changed / added / removed, by
content hash so same-size replacements are caught
native_crash.py locate a native death from a log or tombstone: signal, fault
address, registers, frames split app vs system, the faulting
instruction, and a flag when the fault looks *arranged*
blob_decode.py search, don't guess, the framing of a stored value
(base64/hex x rotation x deflate); re-encode the edited payload
snap.py bounded burst screenshots + control-tree capture with a stall
detector, and a verdict on whether the tree is usable at all
sig_probe.py find the exact signatures[0].toCharsString() value — offline
candidates from an APK, or the authoritative read from a device
spawn_patch_detach.py spawn under a Frida probe, detach, then launch and capture: under
spawn mode the Activity stack often never comes up, and memory
writes survive detach while hooks do not
hook_patch_only.js the minimal probe for spawn_patch_detach.py — neutralise one native
death site by offset and report PATCHED
dex_dump_validate.py dedupe, validate and rank a directory of dumped dex images: sha256
grouping, header integrity, the trivial-body ratio that separates a real
dump from an extraction-shell skeleton, and a most-likely-original ranking
(--trim for page-aligned /proc/<pid>/mem captures)
dex_mem_scan.py search memory captures for embedded dex images and extract each at the
size its own header declares -- for a decrypted dex sitting in an
anonymous mapping no maps entry names
lsposed_scaffold.py generate a minimal LSPosed/Xposed module project (manifest with the
xposed meta-data, assets/xposed_init, hook class, gradle-free build notes)
frida_rpc_serve.py bridge a Frida script's rpc.exports to a local caller with reconnect
handling, so a live native function can be called rather than reversed
rpc_template.js the editable companion to frida_rpc_serve.py
stalker_trace.js instruction-level tracing with Frida Stalker: configurable targets,
trigger selection, the event stream, and output-size rules
stalker_report.py reduce a stalker_trace.js log to block histograms and call sequences,
with an explicit diagnostic for the measured zero-event case
mt_mcp_probe.py probe MT Manager's on-device APK MCP (Streamable HTTP, port 8787):
JSON-RPC handshake plus the grouped tool inventory
java2c_probe.py collect the evidence that separates Java2C from an extraction shell, a
VMP and ordinary JNI sinking: native density and stub ratio from the dex,
JNI_OnLoad / dynamic registration / toolchain strings from the `.so`,
each item labelled strong/medium/weak
protobuf_decode_raw.py schema-free protobuf decode: hex / file / stdin to a JSON tree, every
length-delimited field kept as a candidate set with ties labelled rather
than guessed, plus a byte-exact re-encode to check a round trip
vmp_diff_harness.py build a labelled opcode-coverage fixture, derive a candidate private-
opcode map from an original/hardened dex pair, verify the comparison in a
closed loop, and render a restored stream as a smali skeleton
kernelsu_syscall_mask.py generate a KernelSU/APatch syscall-masking scaffold: an installable
userspace module skeleton plus KPM/LKM/eBPF kernel-side templates, each
with its version gate and an explicit unverified label
rasc_build.py build and verify rasc, the Rust ASC re-implementation:
--check what is present, --build clone plus cargo, --verify an APK
against droidasc and fail on any class-set difference
scan_leaks.py scan a repository for target identity before publishing it: bundle ids
in manifest / `pm` / `ps` contexts, serial-shaped tokens, PATs, inline
appkey assignments, literal endpoints, host user paths. Exemptions for
everything that must stay (tools, libraries, CVEs, hardening products,
public crackmes, placeholders), findings carry their context,
`--show-exempt` prints why a hit was suppressed, exit 0/1/2
svc_scan.py name the syscall behind an inline `svc` and the segment it sits in,
which decides whether a libc-level hook can observe the call at all;
`--context` shows neighbours because a byte scan also matches data
anti_detect_probe.js observer-only Frida probe (patches nothing): path/loader/thread/kill
hooks with caller module + offset, an environment self-report
(`TracerPid`, frida-named mappings), and live streaming so a sub-second
self-destructing target still yields evidence
```
The repository also carries an **executable** test layer, which is a different thing from the
evidence record: `tests/` asserts what the scripts do (unit, CLI contract, no-device
integration) and `tests/benchmark.md` records what a route did on a real target. `tests/README.md`
states the split, and `.github/workflows/ci.yml` runs the gates plus the suite.
## Install
This repository is a **skills repository**: the skill lives at `skills/apk-reverse/`, which is the
layout the `skills` CLI resolves, and it is installed by name rather than by copying a directory:
```
npx skills add newliver666/apk-reverse # install every skill in the repo
npx skills add newliver666/apk-reverse --list # list what is here, install nothing
npx skills add newliver666/apk-reverse --skill apk-reverse -y
npx skills use newliver666/apk-reverse@apk-reverse # use it once, without installing
```
The CLI symlinks the skill into your agent's skills directory by default (`--copy` makes independent
copies instead), and `-g` installs for every project rather than the current one. With one skill in
the repository, `--skill apk-reverse` is redundant today; it is written out here because it is what
selects a single skill once a second one exists.
Once installed, the agent loads `SKILL.md` when a task matches its description, and pulls in
`references/*` only as needed. No global state, no machine-specific paths, and no build step.
## Requirements
Nothing is mandatory; each script checks what it needs. `skills/apk-reverse/scripts/doctor.py` reports
which of these are present here, which scripts can therefore run, and — usefully — which tools exist
somewhere other than PATH.
If your toolchain lives outside PATH (a project-local `tools/` directory, a versioned SDK folder, a
runnable `.jar` instead of a command), set `APKREV_TOOLS` to one or more directories and `doctor.py`
will find them:
```
set APKREV_TOOLS=<dir>;<dir> # Windows, e.g. an SDK or project-local tools dir
export APKREV_TOOLS=<dir>:<dir> # POSIX
```
The scripts themselves are plain `python3` and are intended to work identically on Windows, macOS and
Linux; where a snippet is POSIX-only it is labelled. Nothing here assumes a Unix shell.
| Tool | Used for |
|---|---|
| Python 3.9+ | all scripts |
| **`droidasc`** (ASC) (**optional but strongly recommended — install this first**) | whole-APK cross-reference index: `findrefs` / `listclass` / `getclass` / `getmanifest`. One `pip install droidasc`, no JVM, no SDK, no index build. Turns "which of N thousand classes mentions this string" into a sub-second query, and it is the only route to a class whose name R8 mangled. **This is the tool an agent should reach for before any full decompile** — see `skills/apk-reverse/references/toolchain.md` §droidasc (ASC) — ask an APK "who references this?", in one query |
| `ddc` (optional but strongly recommended) | single-binary dex→Java decompiler with query subcommands (`info`, `findrefs`, `strings --with-locations`, per-class decompile). No JVM. **Reads** what ASC **locates**; also reports package identity reliably — see `skills/apk-reverse/references/toolchain.md` §ddc — dex-to-Java with query subcommands (worth adopting) |
| `baksmali` / `smali` + `dexlib2` jars | disassembly, assembly, surgical patching |
| JDK (`javac`, `java`) | building/running the dexlib2 patcher; also provides `keytool`/`jarsigner` |
| Android SDK build-tools (`aapt`, `zipalign`, `apksigner`) | manifest info, alignment, signing. **`apksigner` is the signer to use** — `jarsigner` rewrites the archive and breaks the alignment Android R+ requires |
| `uber-apk-signer` (optional) | one-step align + sign |
| ADB | device work |
| Frida (host package + matching on-device server) | dynamic analysis |
| a rooted device or emulator | anything beyond static analysis |
None of these need to be on `PATH`: every script accepts an explicit path for the
tools it shells out to, and `skills/apk-reverse/references/toolchain.md` covers finding
an install that `PATH` does not know about (the common case for `apksigner` and
`keytool`).
## Read this first
**This project is published for learning, research and authorized security testing only.** It ships
no exploit payloads, no target data and no third-party binaries — it is a method, a set of scripts
and an evidence record. You are responsible for having the right to analyze whatever you point it at;
see **Disclaimer** at the end of this file.
`skills/apk-reverse/references/pitfalls.md`. It is the most valuable file here — every entry is a
failure that produced a broken artifact while looking completely healthy.
The four that hurt most:
1. Stripping the whole `META-INF/` during a repack deletes ServiceLoader registrations
and the app dies at startup with an error that names an unrelated library.
2. Patching a byte-level string without preserving `string_ids` ordering gets the whole
dex rejected, while checksums and signatures verify perfectly.
3. Rebuilding a dex with a whole-tree smali round-trip damages R8 output invisibly —
class tables compare clean, and it only blows up at runtime.
4. Neutralising a native terminate path by making it **not return**. A spinning stub does not
suppress the check; it freezes the caller and every thread behind it. The app hangs with *no
crash record at all*, and the eventual death gets blamed on whatever killed the frozen process.
## Scope
Built for working on your own applications, on samples you are authorized to analyze,
and in CTF/competition sandboxes. It contains no vendored third-party binaries and no
target-specific data.
What it covers, and what it deliberately does not, is stated at the top of `SKILL.md`
under **Coverage**. The short version: Android only (no iOS), and deep on the layers
that have been worked through for real — dex patching, repacking, packers and custom
loaders, native tamper response, and Flutter/Dart AOT. An extension pass added a second
tier of documented routes: **module-side delivery** when a repack is blocked,
**extraction-shell recovery** and its VMP boundary, **emulation and live RPC** for
calling rather than reading, **instruction-level tracing** against OLLVM, **protocol
reversing** beyond REST, the **kernel-side route** map for when userspace hooking is
provably out of reach, and **on-device tooling**. A **benchmark pass** then put public
targets under those routes (`tests/benchmark.md`): it added **Java2C discrimination** (the
misdiagnosis that sends an agent hunting a decrypted DEX that never exists), **split APK /
App Bundle handling**, **schema-free protobuf decoding**, a **Dex-VMP differential** harness,
and **kernel-module templates with their version gates** — and it corrected two earlier claims
whose measurements disagreed with them. Unity/IL2CPP logic recovery,
React Native/Hermes bytecode internals, and defeating a server-side authority are **not**
covered, and the skill is written to say so and stop rather than apply the nearest
documented procedure to a target it was not written for.
Four qualifications that the Coverage section states in full and that belong here too:
- **Flutter/Dart AOT analysis has a dependency.** The workflow begins at a pool listing
(`pp.txt`-class output). Producing that needs a snapshot-decoding decompiler — aotopsy (a static
binary, no toolchain) or blutter (built from source, ~80 s) — and this repository does not contain
one. It is named as a prerequisite rather than left implicit.
- **Not every claim in this repository has a run behind it.** `docs/tool-verification/` records
what was actually measured, on which target, and with which independent cross-check; anything not
covered there is documented from experience and should be read as *inferred*, per this skill's own
claim ladder.
- **The extension pass is recorded separately and is mostly *inferred*.** Its evidence lives in
`docs/tool-verification/EXTENSION-*.md`, one file per topic, with its own strength note. The
common shape there is *the tool was measured, the route was not* — so read those files before
treating any of the newer documents as a verified path.
- **The benchmark pass is recorded per row, with that row's own strength.** `tests/benchmark.md`
names each public target, the scripts the row exercises, what actually happened (including the
rows that failed and the rows nobody ran), and how strong the evidence is. Rows marked
`unverified` are statements about the evidence in this repository, not about the mechanism.
## Repository maintenance
Four tools live at the root and are not part of the installed skill:
```
check_repo.py every skill discovered, frontmatter valid, scripts runnable,
documented paths resolve, README paths explicit and existing,
and -- on the tracked surface only -- no target identity
(delegates the rules to skills/apk-reverse/scripts/scan_leaks.py
so there is one place to argue with the exemption list)
check_refs.py every cross-reference that names a section of another
document reaches a real heading in that document
check_routing.py the on-demand inventory still matches the entry point: the
symptom mirror agrees with SKILL.md, every reference file is
named in skills/apk-reverse/references/routing.md, and every
script is too
check_commands.py every command a document tells you to run is checked against
the script's own argparse table -- a documented flag that does
not exist is a drift the anchor checks cannot see
check_budget.py keep the always-loaded part from creeping: SKILL.md's whole
body (index lines included, because they load too) measured in
lines and tokens, index-row length, long files with no
navigable head, and hedged rules reported as a trend
build_scripts.py audit for machine-specific leftovers (absolute paths, credentials)
```
Consistency has a natural counter-pressure -- a broken path fails loudly, and someone fixes it.
Bloat has none, which is why the third tool exists: every pass adds a reference, an index row and
a coverage claim, and without a measurement nothing in the repository notices.
`tests/benchmark.md` holds the **regression matrix**: dimension -> public target -> the scripts the
row exercises -> measured result -> strength label. It is the checklist to re-run before trusting
any claim under `docs/tool-verification/`. Samples are downloaded into `tools/_work/` and are never
committed, so each row names its public source and records the hash it was run against.
`docs/tool-verification/` is not part of the installed skill either. It is the evidence record
for one measurement pass against a real target: what each script actually did, which independent
method confirmed it, which defects were found, and which scenarios the target could not exercise.
It exists so the **Coverage** claims in `SKILL.md` can be checked against runs instead of trusted,
and so the gaps are written down where the next person will find them.
---
Proudly supported by the [LINUX DO](https://linux.do) community.
## Disclaimer
**For learning, research and authorized security testing only.** Every script, reference and
recorded result in this repository exists to explain *how* Android application analysis works, so
that practitioners can reason about the tools they already own. Nothing here is a service, a
product, or an endorsement of any particular use.
- **Authorized targets only.** Use this on applications you own or have been explicitly permitted to
analyze, on public CTF/challenge material, or in a sandbox you control. Analyzing software you have
no right to analyze may be unlawful where you live, and that determination is yours to make, not
this repository's.
- **No warranty, no fitness for any purpose.** The material is provided *as is*, without warranty of
any kind. Results are recorded as they were measured on one machine at one time; nothing here
promises that a route will work on your target, your device, your toolchain or today's app version.
- **Verify before you trust; back up before you act.** Several scripts modify artifacts (dex, APK,
`.so`, stored app data) and some operate on a rooted device. Keep your own copies, work on
duplicates, and read `SKILL.md`'s gates before running anything against something you care about.
- **Your use is your responsibility.** The authors and contributors accept no liability for any loss,
damage, legal consequence or service interruption arising from the use or misuse of this
repository, and are not affiliated with, endorsed by, or acting on behalf of any application,
vendor or platform it may be used to examine.
- **Test data is not distributed here.** Samples, dumps and device artifacts are deliberately absent
from the tree (`.gitignore` excludes them) and live only in a local, ignored workspace. Anything you
obtain to follow along is yours to keep safe and to delete when you are done with it — follow your
local rules and the terms that came with the sample. What this repository *does* publish is the
method and the evidence, with all target identity removed.
- **No affiliation.** Names of tools, libraries, hardening products and public challenge targets
appear only to make the material reusable; they belong to their respective owners and this project
is not connected to them.

View File

@@ -0,0 +1,457 @@
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="assets/banner-dark.svg">
<img src="assets/banner-light.svg" alt="apk-reverse" width="100%">
</picture>
</p>
<p align="center">
<a href="README.md">English</a> · <b>简体中文</b>
</p>
<p align="center">
<a href="https://github.com/newliver666/apk-reverse/stargazers"><img src="https://img.shields.io/github/stars/newliver666/apk-reverse?style=flat-square&label=stars&color=49454F" alt="stars"></a>
<a href="https://github.com/newliver666/apk-reverse/network/members"><img src="https://img.shields.io/github/forks/newliver666/apk-reverse?style=flat-square&label=forks&color=49454F" alt="forks"></a>
<a href="LICENSE"><img src="https://img.shields.io/github/license/newliver666/apk-reverse?style=flat-square&color=49454F" alt="license"></a>
<img src="https://img.shields.io/badge/python-3.9%2B-49454F?style=flat-square&logo=python&logoColor=white" alt="python">
<img src="https://img.shields.io/badge/platform-android-49454F?style=flat-square&logo=android&logoColor=white" alt="android">
<a href="https://github.com/newliver666/apk-reverse/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/newliver666/apk-reverse/ci.yml?style=flat-square&label=ci&color=49454F" alt="ci"></a>
</p>
<p align="center">
<a href="#它擅长什么">能力</a> · <a href="#结构">结构</a> · <a href="#安装">安装</a> · <a href="#环境要求">环境要求</a> · <a href="#先读这个">失败目录</a> · <a href="#范围">范围</a> · <a href="#仓库维护">维护</a> · <a href="#免责声明">免责声明</a>
</p>
# apk-reverse
面向 Android APK 逆向工程、去臃肿、去广告、外科式 dex 补丁、重打包,以及运行时/服务端分析的 Agent Skill。
它是一个 **skill**,不是教程:它是写给 agent(Claude Code、Codex,或任何支持 Agent Skills 格式的 harness)在工作过程中加载的,因此按 progressive disclosure(渐进披露)来组织——一份简短、面向决策的 `SKILL.md`,只在某一步真正需要时才加载的详细 references,以及可直接运行的参数化脚本。
## Agent 应当如何使用它
`SKILL.md` 刻意写成一份**带 gate 的流程**,而不是建议,因为观察到的失败模式并不是无知——而是模型把整份文档读完、表示认同,然后照样从第一性原理重新推理一遍。
所以正文里有四样东西是用来**执行**的,不是用来读的:
- **四条覆盖规则(R1–R4)。** 当它们与当前计划冲突时,在证据推翻它们之前,以它们为准。
- **一份症状索引。** 每一行都是已经付过代价的失败。**命中一行就是停止信号**:在你跑下一条命令之前加载那个文件,而不是在又试了几次之后。绕过已经记录在案的已知症状继续推理,就是同一批小时被花掉两次的方式。
- **四个 gate(G1–G4)**,每个都是一个动作加一条通过标准。「我理解这个思路」并不能通过 gate。它们存在的意义是让分类、环境真相和对照构建发生在第一次打补丁**之前**,而不是在第三次失败之后。
- **一条两击规则和若干停止条件。** 同一形态的失败出现两次,说明错的是模型,不是参数。一个已经失败过两次的假设再出第三个变体,就是回合葬身之处。
另外还有一样在结尾、应当被**扣住**的东西:**「done」**有定义(六项)。一份干净的日志不在其中。少于六项全部达成,就只是一个检查点,应当作为检查点汇报,并说明还剩什么。
如果你是正在读这段的 agent:成本最低的第一条命令是 `python skills/apk-reverse/scripts/doctor.py`。它会告诉你这里存在哪些工具、哪些脚本真的能跑,以及环境里是否已经有什么东西在污染你的测量结果。
## 它擅长什么
- **快速**判断一个需求在客户端是否本来就做不到,而不是把几小时烧在一个由服务端强制执行的付费墙(paywall)上。
- 在任何工作开始之前先确定**交付物必须是什么形态**——一个免 root、自包含的产物与「让它在你这台机器上能跑」是两个不同的问题,混淆这两者是这个领域里最昂贵的漂移。
- 为给定改动选择**最安全的补丁层**,并避开那些会把 app 弄坏的层。
- 抓住那种「看起来成功」的重打包失败:app 安装、启动、渲染都完美,但**每一个签名请求都被拒绝**,因为客户端是从自己的签名证书推导请求签名密钥的。
- 把**你自己的错误与 app 或服务端的问题区分开**——限定在某个功能上的失败(登录、注册、支付)常常是某一条代码路径上的 TLS/证书问题,而不是你刚做出来的补丁的后果。设备状态、失联的 device server 和时钟漂移会以同样的方式伪装成同类问题。
- 确认**实际执行的是哪个架构、哪个库**,而不是相信 manifest 里带了什么或设备自称什么。
- 处理**加壳/加固目标**:识别壳、脱壳,并把内存 dump 还原成一个打过补丁、可安装的 APK。
- 处理一个**故意终止进程的加固库**——包括那种刻意崩溃的形态(`fault addr 0x4`),它看起来和普通的空指针解引用 bug 一模一样;以及「把它失效化,但绝不要让它*不返回*」这条规则,它决定修法是生效,还是以完全看不出与原因有关的方式冻住整个 app。
- 知道**该伸手去拿哪些工具,以及每个工具在哪里说谎**——包括那些只以 GUI 形式存在的工具,此时应当请求人类介入,而不是悄悄换用一个更弱的方法。
- 让打过补丁的构建**保持**打过补丁:失效化版本检查、强制升级弹窗和自更新安装器,使这份成果无法被远端一键关掉——同时认出那个能在版本号毫无变化的情况下悄悄把它撤销的热更新/远程配置通道。
- 把**客户端侧的登录闸门**(可打补丁)与**账号维度的资源**(为空,因为服务端没有东西可答)区分开;并且知道伪造一个 session 所产生的状态比保持未登录更糟。
- 让**长时间任务保持诚实**:一份实时记录、分级结论、校准过的超时和有界等待,使进展不会丢失、同一个错误不会被犯两次。
- 避开那些会产出「构建完美、运行即死」的 APK 的具体错误。
- 当 APK 不是可选项时,决定**交付物应该是什么**——被多个独立检查拒绝的重打包是*受阻*,不是*昂贵*;回落阶梯是系统级模块、本地 RPC 服务,或一份明确写出边界的诚实报告。
- 分辨真实内存 dump 与**抽取壳骨架**,并知道哪条恢复路线适用——包括当 `frida` 本身被拒绝时走 root 侧 dump。这次测量能看见什么、看不见什么,写在 `skills/apk-reverse/references/advanced-unpacking.md`。
- 当逆向的成本高于调用时,**调用例程而不是逆向它**:在宿主上做模拟执行,或把一个活函数通过 Frida RPC 服务化。
- 当一个 native 函数被 OLLVM 碾平成状态机时,去读**指令级执行证据**——包括在真实设备上实测到的 Stalker 反咬的两种方式。
- 认出**用户态 hooking 根本够不到那个检查**的情况(裸 `svc` 系统调用、`init_array` 早期检测)、上下相邻层实际能做什么,以及什么时候继续升级是错误答案。
- 处理**非 REST 的协议**——无 schema 的 protobuf、gRPC、QUIC/HTTP3——以及无视系统信任库的 native 侧证书固定(pinning)。
- **直接在手机上工作**:MT Manager 的编辑/重打包/签名流程及其 APK MCP 接口、LSPosed Manager、设备上的数据检查,与 PC 工具链并用,而不是用它来替代 PC 工具链。
- 在花几小时去追一个在进程生命周期内任何时刻都不存在的「解密 DEX」之前,先把 **Java2C 与抽取壳区分开**——代码已经被编译进 `.so`。
- 处理以 **split APK / App Bundle 集合**形式到来的构建:从设备上取出该集合、用一个 keystore 为每个成员签名以便 `pm install-multiple`,或在合法时把 code/native 成员合并成一个独立 APK。
- 用已知明文差分处理一个**真实的 Dex VMP**——哪些环节可以自动化、哪些不能,一个编译出来的 fixture 能触达什么、不能触达什么,以及如何*证明*一张推导出的私有 opcode 表,而不是断言它。
- **在发表所学的同时不发表目标**——一个带上下文报告目标身份形态的扫描器、一份明确列出*不得*脱敏内容(工具、库、协议字段、CVE、加固产品、公开 crackme)的清单,因为把这些脱敏会毁掉可复用的那部分;以及能 gate 住一次提交的退出码。
- 在重复本仓库已经收敛过的劳动之前先读**先例**:记录中正向的那一半,含路线及其死路、对每条断言的分级,以及该案例指明要回写的文件。
## 结构
`SKILL.md`、`references/` 与 `scripts/` 都在 skill 目录 `skills/apk-reverse/` 内。仓库根目录下的一切是跨 skill 共享的维护工具,不属于已安装的 skill。
```
SKILL.md 带 gate 的流程,不是背景阅读材料:
how-to-use -> 四条覆盖规则(R1-R4)
症状索引(命中一行即为停止信号)
四个 gate(G1-G4,带通过标准的动作)
十三个分类问题
工作流,带每一步的跳过条件和一条两击规则
「done」的含义 -> 停止条件 -> 约束 -> 索引
references/ 按需加载,每个文件一个主题
recon.md 识别壳、SDK、代码位置、篡改检查;脱壳
server-config-and-updates.md
「广告」最常见的那种形态,也是通常被误诊的那种:
服务端提供 UI,客户端负责渲染(启动屏、弹窗、
公告、tab 集合)。证明它的两层抓取、如何按
data class 保留的字段名找到配置 DTO、为什么
你要补的是决策而不是数据、「移除」的范围如何界定,
以及远端重新启用 / 配置缓存持久性
byte-level-patching.md 等长字节编辑:为什么它优于方法重建(实测)、
如何在不刮 listing 的情况下定位一条指令的精确偏移、
会让解码失步的指令宽度陷阱、把分支失效化还是重定向它、
dex 头完整性字段的顺序,以及校验器的 move-result 规则
packers.md 加固目标:拒绝信号、用单变量测试测量校验边界、
选择 native 宿主
code-virtualization-and-custom-linkers.md
「已加壳」与「干净」之间的那一层:整个类被变成
`native` 声明、SONAME 与文件名不匹配的私有加载器、
内嵌的自解密 payload、一个 Java 层「签名杀手」在日志里
报告成功而 native 检查却在杀你。留还是删的死锁、
如何把*检查器*与*实现*分开,以及那条不做任何
失效化就终结它的字符串重定向技术
framework-runtimes.md Flutter / React Native / Unity:UI 归哪一层所有,
以及在没有符号时怎么找逻辑(字符串编码陷阱)
dart-aot.md Dart AOT 深入:版本钉定与构建一个匹配的反编译器、
对象池与引用索引、寄存器/布尔约定、
识别业务逻辑的三种签名、定位、打补丁。
开篇先讲它所依赖的 snapshot 解码前端(aotopsy 或
blutter),因为 pool 列表是输入,不是本 skill 自己产出的东西
native-and-so.md .so 宿主、DT_NEEDED vs JNI_OnLoad、重定位限制、
免重定位的 bootstrap、用 native 替换 Java 方法,
以及*实际加载并执行*的是哪个 ABI/库
native-tamper-and-suicide.md 加固库如何杀掉自己的进程:可见的机制、
如何判断实际触发的是哪一个、如何定位现场、
伪造的 section header、由 PT_GNU_EH_FRAME 推出的
函数边界、扫描器陷阱,以及安全地失效化
detection-and-anti-analysis.md 当 app 反击,或工具在这里跑不起来时:把检测与
环境损坏区分开、按成本而不是按升级来决策、认出
动态分析根本无效的环境,并把「挡住我的分析」这个问题
与「挡住交付物」分开
toolchain.md 该装什么、如何非交互地调用它、哪些工具只有 GUI、
版本对齐陷阱、离线工作、**「不在 PATH 上」不等于「没装」**,
以及该用哪个签名器
long-task-discipline.md 实时记录、结论分级、漂移控制、超时与等待校准、
交付物形态漂移、你从未看过的截图、
长上下文衰减、交接
ad-removal.md 广告分类学、包装层映射、回调陷阱、全局闸门、验证
updates-and-forced-upgrade.md 让打过补丁的构建活下去:定位版本检查、
两层补丁(把例程 no-op、把比较失效化)、不能碰的东西
(manifest 版本、安装器权限、host 阻断)、
自更新与热更新/远程配置通道、验证根本没有发出任何版本请求
account-gates.md 登录墙、强制手机绑定、游客模式:把客户端侧闸门
(可补)与账号维度资源(不可补)区分开、为什么
伪造 session 比保持未登录更糟,以及重装后
无法避免的登录态丢失
signature-derived-keys.md 当 app 自己的签名证书被当作密钥材料时:
检测用 grep、为什么离线提取不可靠、
先硬编码再验证的流程
membership-and-limits.md 服务端 vs 客户端权威;什么可补、什么不可补
server-api.md 探测 app 的 API;证明闸门归谁所有
tls-and-cert.md 功能维度的网络失败:过期证书、双信任链
third-party-builds.md 在信任一个「破解版」/「魔改版」APK 之前审计它
dex-patching.md 补丁层对照表 + dexlib2 技术深入
patch-audit.md 证明一个补丁*落地*了并且*合法*:长度 vs 字节
比较、等长替换的盲区、校验器层面的合法性
(move-result 相邻性)静态检查、文本匹配式
补丁陷阱,以及如何报告一个缺失的补丁
repack-and-sign.md 重打包规则、解包与重打包、签名、安装后隐患
runtime-data.md DataStore / SharedPreferences / SQLite / protobuf;app 何时
会改写你的编辑,以及解码一个看起来像加密的值
dynamic-frida.md Frida 配置、版本钉定、四层探测、hook 策略
environment.md 设备/模拟器配置、root、ADB、离线设备、日志信号、
模拟器 console 控制与恢复、preflight、看一眼屏幕
verification.md 主张阶梯;「done」的含义
desensitization-and-leak-scans.md
发表纪律:什么必须脱敏、什么必须保留、
不得匿名化的清单、泄漏扫描器及其退出状态,以及
入口文件本身就是一处提示词暴露面
precedents/ 正向案例库:含死路的路线、每条断言的分级、
实测到的坑,以及回写清单
routing.md 按需清单:每个 reference 及其加载时机、每个
script 及其用途,以及症状索引的镜像
rasc-and-droidsaw.md ASC 索引器的 Rust 重实现:实测加速比与
完全一致的类集合、它会静默丢掉方法体的 enum 形态,
以及如何构建与验证它
evidence-summary.md 随 skill 一起发布的浓缩版:能力、一行结论、
强度,以及你在已安装副本里实际能打开的证据
../evals/ 也不是 spec 目录,而是 Agent Skills 指南推荐的
位置:`evals.json` 存放本 skill **尚未**运行过的
带 skill / 不带 skill 用例,运行方法写在文件里
../evidence/ 不是 spec 目录:上面 evidence summary reference 的
机器可读配套文件 —— `capability-matrix.json`(同样的行、
更多字段)、`tested-tool-versions.json`(版本及每个版本背后的
探测)、`known-limitations.md`(面向安装者的限制清单)。随 skill
一起发布,使已安装的副本不需要仓库就能回答「这一条验证过吗、
强度如何」
pitfalls.md 失败目录 —— 构建之前先读
advanced-unpacking.md dump 拿到了但方法体是空的:按平凡方法体比例做
抽取壳诊断、FART 式主动调用以及为什么它的经典 hook
在 Android 12-16 上失效、code_item 拼接、当 frida
本身被拒绝时的 root 侧 dump,以及诚实的 VMP 边界
lsposed-and-modules.md 重打包被拒绝,那就交付一个系统级 hook 模块:模块解剖、
无 gradle 的构建链、作用域配置与如何验证注入,
以及 Java 模块够不到的那一层
emulation-and-rpc.md 调用例程而不是读它:Unidbg/Unicorn 模拟及其
环境填充成本,对比把活函数通过 Frida RPC 服务化
native-dbi-and-deobfuscation.md
OLLVM 形态、Frida-Stalker trace、trace 到 CFG 的路线、
Stalker/QBDI/模拟的取舍,以及两条实测边界(一次
不产生任何事件的 follow,和一次因 follow 热 libc 导出而崩溃)
protocol-reverse.md 无 schema 的 protobuf、从反编译代码恢复 schema、gRPC 帧
抓取、QUIC/HTTP3 的限制,以及 native 侧证书固定
kernel-and-environment-hardening.md
用户态 hooking 可证明够不到检查:裸 `svc`、init_array 早期
检测、每种 root 方案隐藏了什么、带版本闸门的
内核路线图,以及什么时候停止升级
on-device-tooling.md 直接在手机上工作:MT Manager 的编辑/重打包/签名及其 APK MCP、
LSPosed Manager、Termux+frida、设备上数据检查
java2c-and-jni-sinking.md Java2C 与 JNI sinking,最容易与抽取壳混淆的两种加固
形态:区分落地壳 / 抽取壳 / VMP / Java2C / JNI sinking
的那张表、为什么代码在 `.so` 里而*永远*不在 dump 出的 dex 里,
以及为什么 `Java_*` 符号搜索会一无所获(动态注册、
`-fvisibility=hidden`)
split-apk.md App Bundle / split APK 集合:集合是什么、如何从设备拉取、
合并成一个 APK 还是把整个集合作为一个单元签名、安装拒绝
及其各自含义,以及如何从拉取的集合做出一个可安装的 fixture
vmp-differential-analysis.md
针对真实 Dex VMP 的已知明文差分:哪些环节可以
自动化、哪些不能(上传是瓶颈)、一个编译出的
fixture 能覆盖到什么程度、如何*证明*一张推导出的私有
opcode 表、smali 生成,以及这条路线何时关闭
coverage-and-limits.md 把主张阶梯用在 skill 自己身上:每个已覆盖条目背后的
证据、本 skill 不随附的依赖,以及从未实际演练过的东西
handoff-boundaries.md 本 skill 在哪里结束、另一门学科在哪里开始:JNI 形态
对照表、壳与加载器的分界,以及「已核实」对四种
交付物形态各自意味着什么
scripts/ 参数化、与路径无关
doctor.py 先跑这个:能力报告 + 每个脚本的可运行性,发现
安装在 PATH 之外或作为可运行 jar 存在的工具,并暴露
会污染实验的环境事实(时钟偏移、残留的
adb forward / proxy、设备侧已在运行的 frida 进程)
dexutil.py 无依赖的 dex 读取器:结构遍历 + 精确指令
解码、dex 头重算/校验(正确的 checksum/signature
顺序)、分支目标与操作数辅助函数。被
dex 脚本共享的库,也可独立运行以带偏移 dump 单个方法
dex_find_insn.py 按解码语义定位一条指令,并打印其精确字节
偏移,附上下文与任一分支的两侧 —— 你靠这个找到
补丁点,而不是猜偏移
dex_patch_bytes.py 按 JSON 规格做等长字节补丁:语义匹配、通过
expect_next 钉定极性、等长强制、校验器检查、dex
头重算、重新解码以证明它已落地(先 --dry-run)
dex_check_verifier.py 三级检查:是否有任何条件分支以 move-result 为目标
(绕过它的生产者)?比较两个构建,并把
既存发现与你补丁引入的回归分开
coldstart.py 冷启动捕获:定时截图连拍 + logcat 信号 +
已安装构建事实 + 启动计时,并在前台
activity 不是你的 app 时发出警告
so_constpatch.py 对一个孤立的字符串常量做等长原地改写,用于
重定向一次库加载,而不是击败一个检查
smtool.py 带可配置 classpath 的 baksmali/smali 包装器
dexpatch/ dexlib2 方法级重写器(用于需要新指令的改动)
patch_smali.py smali 树中的方法体替换
dex_strpatch.py 带 string_ids 顺序守卫的字节级字符串补丁
dex_classdiff.py 证明一次 dex 编辑是外科式的
dex_strings.py 不需要反编译器的 strings/URL/SDK 标记提取
dart_pool_strings.py 从 Dart AOT snapshot 恢复字面量(带帧条目、
单字节 vs UTF-16 的分岔、文件偏移、游程噪声过滤)
dart_pprefs.py 为 Dart snapshot 构建/查询 对象池 -> 代码位置 索引
dart_disasm.py Dart AOT 代码的带注释窗口式反汇编 + B/BL 调用者索引
find_refs.py 在给一个方法打补丁之前统计它的调用者
repack.py 重建 APK、只剥离签名、保留 META-INF/services/、写出
4 字节对齐的归档(resources.arsc STORED+对齐)、签名、校验;
也处理 split APK / App Bundle 集合:清点、用一个
keystore 为每个成员签名,或把 code/native 成员合并成独立 APK
devsh.py 引号安全的 ADB shell 辅助工具
usb_net_proxy.py 通过 USB 给离线设备提供网络
datastore_inject.py 安全地编码/注入 AndroidX DataStore preferences
probe_api.py 带正确请求头探测一个 HTTP API
grab_crash.py 恢复被崩溃上报 SDK 藏起来的栈
install_test.py 安装 + 启动健康检查,带 logcat 信号扫描
frida_probe.js 四层运行时探测(app 网络层 + OkHttp + java.net + 异常)
run_probe.py 注入探测、把它流式写入日志文件、保持常驻
tls_check.py 对一个或多个 host 做严格证书检查
preflight.py 每个实验块之前的环境检查(设备、root、
ABI/翻译层、时钟偏移、残留 proxy/forward、失联的 server)
lib_map.py 一个活进程里*实际映射*了什么:每库路径、
基址、架构,以及它来自 APK 还是
运行时物化出来的
elf_plt.py 从重定位表把 PLT stub 解析到它导入的符号(x86_64 + aarch64);
列出某个符号的调用者;对两个
库做字节级 diff,并指出每个变更 stub 属于哪个符号
apk_diff.py 两个构建的条目级 diff:变更 / 新增 / 移除,按
内容哈希,使等长替换也能被抓住
native_crash.py 从日志或 tombstone 定位一次 native 死亡:signal、故障
地址、寄存器、把帧拆成 app 与 system、故障
指令本身,以及当故障看起来是*被安排好的*时给出标记
blob_decode.py 搜索、而不是猜测一个存储值的分帧
(base64/hex x 旋转 x deflate);把编辑后的 payload 重新编码
snap.py 有界连拍截图 + 带停滞检测的控件树捕获,
并对这棵控件树是否可用给出结论
sig_probe.py 找到确切的 signatures[0].toCharsString() 值 —— 从 APK 得到的
离线候选,或从设备读取的权威值
spawn_patch_detach.py 在 Frida 探测下 spawn、detach,然后启动并捕获:在
spawn 模式下 Activity 栈常常起不来,而内存
写入能在 detach 后存活,hook 不能
hook_patch_only.js spawn_patch_detach.py 的最小探测 —— 按偏移把一个 native
死亡点失效化并报告 PATCHED
dex_dump_validate.py 对一目录的 dump dex 镜像去重、校验并排序:sha256
分组、头完整性、区分真实 dump 与抽取壳骨架的
平凡方法体比例,以及一个最可能原件的排序
(对页对齐的 /proc/<pid>/mem 捕获用 --trim)
dex_mem_scan.py 在内存捕获中搜索内嵌的 dex 镜像,并按每个镜像自己头部
声明的尺寸抽取出来 —— 用于躺在匿名映射里、
没有任何 maps 条目为它命名的解密 dex
lsposed_scaffold.py 生成一个最小 LSPosed/Xposed 模块工程(带
xposed meta-data 的 manifest、assets/xposed_init、hook 类、无 gradle 的构建说明)
frida_rpc_serve.py 把 Frida 脚本的 rpc.exports 桥接到本地调用方,带断线重连
处理,从而可以调用一个活的 native 函数,而不是逆向它
rpc_template.js frida_rpc_serve.py 的可编辑配套脚本
stalker_trace.js 用 Frida Stalker 做指令级追踪:可配置目标、
触发选择、事件流,以及输出体积规则
stalker_report.py 把 stalker_trace.js 的日志归约为基本块直方图与调用序列,
并对实测到的零事件情形给出明确诊断
mt_mcp_probe.py 探测 MT Manager 的设备端 APK MCP(Streamable HTTP,端口 8787):
JSON-RPC 握手加上分组后的工具清单
java2c_probe.py 收集能区分 Java2C 与抽取壳、VMP 及普通 JNI sinking 的
证据:从 dex 得到 native 密度与 stub 比例、
从 `.so` 得到 JNI_OnLoad / 动态注册 / 工具链字符串,
每一项都标注为 强/中/弱
protobuf_decode_raw.py 无 schema 的 protobuf 解码:hex / 文件 / stdin 到 JSON 树,每个
长度定界字段都保留为一个候选集合、并列项予以标注而
不是猜测,另含字节级精确重编码以检查一次往返
vmp_diff_harness.py 构建带标注的 opcode 覆盖 fixture、从原始/加固 dex 对
推导候选私有 opcode 映射、在闭环中验证该比较,
并把还原出的指令流渲染成 smali 骨架
kernelsu_syscall_mask.py 生成 KernelSU/APatch 系统调用遮蔽脚手架:一个可安装的
用户态模块骨架加上 KPM/LKM/eBPF 内核侧模板,每个
都带自己的版本闸门和明确的未核实标注
rasc_build.py 构建并验证 rasc,即 ASC 索引器的 Rust 重实现:
--check 检查已有什么、--build 克隆并 cargo、--verify 用
droidasc 校验一个 APK,并在任何类集合差异上失败
scan_leaks.py 在发布一个仓库之前扫描其中的目标身份:manifest / `pm` / `ps`
上下文里的 bundle id、序列号形态的 token、PAT、内联
appkey 赋值、字面 endpoint、宿主用户路径。对
必须保留的一切给予豁免(工具、库、CVE、加固产品、
公开 crackme、占位符),发现项带上下文,
`--show-exempt` 打印某个命中项为什么被抑制,退出码 0/1/2
svc_scan.py 指出某条内联 `svc` 背后的系统调用以及它所在的段,
这决定 libc 层面的 hook 究竟能否观察到该调用;
`--context` 展示邻近字节,因为字节扫描也会匹配到数据
anti_detect_probe.js 仅观察的 Frida 探测(不打任何补丁):path/loader/thread/kill
hook 附调用者模块 + 偏移、一份环境自述
(`TracerPid`、以 frida 命名的映射),以及实时流式输出,使一个
亚秒级自毁目标仍能产出证据
```
仓库另外带有一个**可执行**的测试层,它与证据记录是两回事:`tests/` 断言脚本的行为(单元、CLI 契约、无设备集成),`tests/benchmark.md` 记录某条路线在真实目标上的表现。`tests/README.md` 说明了这个划分,`.github/workflows/ci.yml` 运行各闸门加测试套件。
## 安装
这个仓库是一个 **skills 仓库**:skill 位于 `skills/apk-reverse/`,这正是 `skills` CLI 解析的布局;安装是按名字进行的,而不是复制一个目录:
```
npx skills add newliver666/apk-reverse # install every skill in the repo
npx skills add newliver666/apk-reverse --list # list what is here, install nothing
npx skills add newliver666/apk-reverse --skill apk-reverse -y
npx skills use newliver666/apk-reverse@apk-reverse # use it once, without installing
```
CLI 默认把 skill 软链进你的 agent 的 skills 目录(`--copy` 则生成独立副本),`-g` 为所有项目安装而不是仅当前项目。仓库里只有一个 skill 时,`--skill apk-reverse` 今天是多余的;这里把它写出来,是因为等第二个 skill 存在时,正是它用来选中单个 skill。
安装后,agent 在任务匹配其描述时加载 `SKILL.md`,并且只在需要时拉入 `references/*`。没有全局状态,没有与机器绑定的路径,也没有构建步骤。
## 环境要求
没有什么是强制的;每个脚本都自己检查所需。`skills/apk-reverse/scripts/doctor.py` 报告这里哪些工具存在、因此哪些脚本能跑,以及——这很有用——哪些工具存在于 PATH 之外。
如果你的工具链不在 PATH 上(项目本地的 `tools/` 目录、带版本号的 SDK 目录、可运行的 `.jar` 而不是一条命令),把 `APKREV_TOOLS` 设为一个或多个目录,`doctor.py` 就会找到它们:
```
set APKREV_TOOLS=<dir>;<dir> # Windows, e.g. an SDK or project-local tools dir
export APKREV_TOOLS=<dir>:<dir> # POSIX
```
脚本本身是纯 `python3`,并且意图在 Windows、macOS 和 Linux 上行为一致;凡有片段仅限 POSIX,都会标注出来。这里没有任何东西假定你用的是 Unix shell。
| 工具 | 用途 |
|---|---|
| Python 3.9+ | 所有脚本 |
| **`droidasc`**(ASC)(**可选,但强烈建议——先装这个**) | 整 APK 交叉引用索引:`findrefs` / `listclass` / `getclass` / `getmanifest`。一句 `pip install droidasc`,无 JVM、无 SDK、无需建索引。把「这几千个类里哪一个提到过这个字符串」变成亚秒级查询,也是找到名字被 R8 混淆过的类的唯一路线。**这是 agent 在做任何完整反编译之前应当伸手去拿的工具** —— 见 `skills/apk-reverse/references/toolchain.md` §droidasc (ASC) —— 一次查询就能问一个 APK「谁引用了这个?」 |
| `ddc`(可选,但强烈建议) | 单文件 dex→Java 反编译器,带查询子命令(`info`、`findrefs`、`strings --with-locations`、按类反编译)。无 JVM。**读** ASC 所**定位**的东西;也能可靠地报告包身份 —— 见 `skills/apk-reverse/references/toolchain.md` §ddc —— 带查询子命令的 dex 转 Java(值得采用) |
| `baksmali` / `smali` + `dexlib2` jars | 反汇编、汇编、外科式打补丁 |
| JDK(`javac`、`java`) | 构建/运行 dexlib2 补丁器;同时提供 `keytool`/`jarsigner` |
| Android SDK build-tools(`aapt`、`zipalign`、`apksigner`) | manifest 信息、对齐、签名。**`apksigner` 才是该用的签名器** —— `jarsigner` 会重写归档并破坏 Android R+ 所要求的对齐 |
| `uber-apk-signer`(可选) | 一步完成对齐 + 签名 |
| ADB | 设备工作 |
| Frida(宿主包 + 与之匹配的设备端 server) | 动态分析 |
| 一台已 root 的设备或模拟器 | 静态分析之外的任何事 |
这些都不需要在 `PATH` 上:每个脚本都为它外壳调用的工具接受显式路径,`skills/apk-reverse/references/toolchain.md` 覆盖了如何找到一个 `PATH` 不认识的安装(对 `apksigner` 和 `keytool` 来说这是常见情况)。
## 先读这个
**本项目仅为学习、研究和经授权的安全测试而发布。** 它不附带任何 exploit payload、任何目标数据、任何第三方二进制 —— 它是一套方法、一组脚本和一份证据记录。你有责任确保自己对所分析的对象拥有相应权利;见文末**免责声明**。
`skills/apk-reverse/references/pitfalls.md`。这是这里最有价值的文件 —— 每一条都是在看起来完全健康的情况下产出了损坏产物的失败。
最痛的四个:
1. 重打包时把整个 `META-INF/` 剥离,会删掉 ServiceLoader 注册,app 在启动时死掉,而报错信息指向一个毫不相干的库。
2. 对一个字节级字符串打补丁却不保持 `string_ids` 顺序,整个 dex 会被拒绝,而 checksum 和签名却完美通过校验。
3. 用整棵 smali 树往返来重建 dex,会不可见地损坏 R8 的输出 —— 类表对比是干净的,它只在运行时炸开。
4. 通过让它**不返回**来失效化一条 native 终止路径。一个自旋 stub 并不能抑制检查;它冻住调用者和它身后的每一个线程。app 挂死,且*完全没有崩溃记录*,最终的死亡会被归咎于那个杀掉这个冻结进程的任意东西。
## 范围
为处理你自己的应用、你被授权分析的样本,以及 CTF/竞赛沙箱而构建。它不含任何内置的第三方二进制,也不含任何目标特定数据。
它覆盖什么、刻意不覆盖什么,写在 `SKILL.md` 顶部 **Coverage** 一节。简版:仅 Android(不含 iOS),并且在经过真实打磨的层上深入 —— dex 补丁、重打包、壳与自定义加载器、native 篡改响应,以及 Flutter/Dart AOT。一次扩展迭代加入了第二梯队的已记录路线:重打包受阻时的**模块侧交付**、**抽取壳恢复**及其 VMP 边界、用于「调用而不是阅读」的**模拟执行与实时 RPC**、针对 OLLVM 的**指令级追踪**、REST 之外的**协议逆向**、当用户态 hooking 可证明够不到时用的**内核路线**图,以及**设备上工具**。随后一次**基准迭代**把公开目标放到这些路线之下(`tests/benchmark.md`):它加入了 **Java2C 判别**(那种会让 agent 去追一个从不存在的解密 DEX 的误诊)、**split APK / App Bundle 处理**、**无 schema 的 protobuf 解码**、一个 **Dex-VMP 差分**测试台,以及**带版本闸门的内核模块模板** —— 并纠正了先前两条与自身测量结果不一致的说法。Unity/IL2CPP 逻辑恢复、React Native/Hermes 字节码内部,以及击败服务端权威**不**在覆盖范围内,skill 的写法就是明说这一点并停下,而不是把最近的一条已记录流程套用到一个它并非为之编写的目标上。
Coverage 一节完整陈述、同时也应当放在这里的四条限定:
- **Flutter/Dart AOT 分析有一个依赖。** 工作流始于一份 pool 列表(`pp.txt` 一类输出)。产出它需要一个 snapshot 解码反编译器 —— aotopsy(静态二进制,不需要工具链)或 blutter(从源码构建,约 80 秒)—— 而本仓库不含其中之一。它被点名为前置条件,而不是留在那里让人自己推。
- **本仓库里并非每条主张背后都有一次运行。** `docs/tool-verification/` 记录了实际测量了什么、在哪个目标上、用哪个独立交叉检查确认;那里未覆盖的内容都是凭经验记录的,按本 skill 自己的主张阶梯,应当读作*推断*。
- **扩展迭代被单独记录,并且大部分是*推断*。** 其证据在 `docs/tool-verification/EXTENSION-*.md`,一个主题一个文件,各自带强度说明。那里常见的形态是*工具被测量过,路线没有* —— 所以在把任何较新的文档当作已验证路径之前,先读那些文件。
- **基准迭代按行记录,并带有该行自己的强度。** `tests/benchmark.md` 列出每个公开目标、该行演练的脚本、实际发生了什么(包括失败的行和没人跑的行),以及证据有多强。标为 `unverified` 的行是在陈述本仓库证据的状况,不是在陈述机制本身。
## 仓库维护
根目录下住着四个工具,它们不属于已安装的 skill:
```
check_repo.py 发现的每个 skill、frontmatter 有效、脚本可运行、
文档中的路径可解析、README 路径显式且存在,
并且 —— 仅在受跟踪的表面上 —— 没有目标身份
(把规则委派给 skills/apk-reverse/scripts/scan_leaks.py,
这样要争论豁免清单时就只有一个地方)
check_refs.py 每一处指向另一文档某节的交叉引用,
都能到达该文档里真实存在的标题
check_routing.py 按需清单仍与入口点一致:症状镜像与 SKILL.md 一致、
每个 reference 文件都在 skills/apk-reverse/references/routing.md 中
被点名,每个脚本也是
check_commands.py 文档让你运行的每条命令都会与脚本自己的 argparse 表
核对 —— 一个不存在却被写进文档的 flag,是锚点检查
看不见的漂移
check_budget.py 阻止常驻加载的部分悄悄膨胀:按行和 token 测量 SKILL.md
的整个主体(含索引行,因为它们也会被加载)、索引行长度、
没有可导航头部的长文件,并把含糊其辞的规则作为趋势报告
build_scripts.py 审计机器特定残留物(绝对路径、凭据)
```
一致性有一种天然的反向压力 —— 一条断掉的路径会大声失败,然后有人修它。膨胀没有,这正是第三个工具存在的原因:每次迭代都会加一个 reference、一行索引和一条覆盖主张,而没有测量的话,仓库里没有任何东西会注意到。
`tests/benchmark.md` 保存**回归矩阵**:维度 -> 公开目标 -> 该行演练的脚本 -> 实测结果 -> 强度标注。它是你在信任 `docs/tool-verification/` 下任何主张之前要重跑的检查表。样本会下载进 `tools/_work/` 且从不提交,因此每一行都点名它的公开来源并记录它所针对的哈希。
`docs/tool-verification/` 也不属于已安装的 skill。它是针对一个真实目标、一次测量迭代的证据记录:每个脚本实际做了什么、哪个独立方法确认了它、发现了哪些缺陷,以及该目标未能演练哪些场景。它存在的意义,是让 `SKILL.md` 里的 **Coverage** 主张可以被对照运行来检查,而不是被信任;也让缺口被写在下一个人能找到它们的地方。
---
Proudly supported by the [LINUX DO](https://linux.do) community.
## 免责声明
**仅为学习、研究和经授权的安全测试。** 本仓库中的每个脚本、reference 和已记录结果,都是为了解释 Android 应用分析**如何**工作,使从业者能对自己已经拥有的工具进行推理。这里没有任何东西是一项服务、一个产品,也不构成对任何特定用途的背书。
- **仅限经授权的目标。** 请把它用在你自己拥有的应用、你被明确许可分析的应用、公开的 CTF/挑战材料,或你掌控的沙箱中。分析你无权分析的软件,在你所在的地区可能违法,而这个判断要由你作出,不是由本仓库作出。
- **不提供担保,不保证适用于任何目的。** 材料按*现状*提供,不附带任何形式的担保。结果是某台机器在某个时刻的测量记录;这里没有任何东西承诺某条路线会在你的目标、你的设备、你的工具链或今天的 app 版本上生效。
- **先验证再信任;先备份再行动。** 多个脚本会修改产物(dex、APK、`.so`、已存储的 app 数据),有些会在已 root 的设备上操作。保留你自己的副本、在副本上作业,并在对你在意的东西跑任何东西之前先读 `SKILL.md` 的 gate。
- **你的使用由你自己负责。** 作者与贡献者不对因使用或误用本仓库而产生的任何损失、损害、法律后果或服务中断承担任何责任,也不与任何可能被用于检查的应用、厂商或平台存在隶属、背书或代理关系。
- **测试数据不在此分发。** 样本、dump 和设备产物被刻意排除在这棵树之外(`.gitignore` 排除了它们),只存在于本地被忽略的工作区中。你为了跟做而取得的任何东西,都由你自己负责保管,并在用完后删除 —— 遵守你本地的规则以及样本自带的条款。本仓库*确实*发表的是方法与证据,且已移除全部目标身份。
- **无隶属关系。** 工具、库、加固产品和公开挑战目标的名字出现,只是为了让材料可复用;它们属于各自的拥有者,本项目与它们没有关联。

View File

@@ -0,0 +1,46 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 260" width="1200" height="260" role="img" aria-label="apk-reverse">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#141218"/>
<stop offset="0.55" stop-color="#1C1B1F"/>
<stop offset="1" stop-color="#232026"/>
</linearGradient>
<radialGradient id="glow" cx="0.5" cy="0.3" r="0.7">
<stop offset="0" stop-color="#CAC4D0" stop-opacity="0.10"/>
<stop offset="1" stop-color="#CAC4D0" stop-opacity="0"/>
</radialGradient>
<linearGradient id="rule" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#49454F"/>
<stop offset="0.5" stop-color="#9A93A3"/>
<stop offset="1" stop-color="#49454F"/>
</linearGradient>
<pattern id="grid" width="24" height="24" patternUnits="userSpaceOnUse">
<path d="M24 0H0V24" fill="none" stroke="#E6E0E9" stroke-opacity="0.04" stroke-width="1"/>
</pattern>
</defs>
<rect width="1200" height="260" fill="url(#bg)"/>
<rect width="1200" height="260" fill="url(#grid)"/>
<rect width="1200" height="260" fill="url(#glow)"/>
<g transform="translate(600 52)" stroke-linejoin="round">
<path d="M0 -26 L24 -13.5 L24 13.5 L0 27 L-24 13.5 L-24 -13.5 Z"
fill="#E6E0E9" fill-opacity="0.06" stroke="#CAC4D0" stroke-width="2.6"/>
<g stroke="#938F99" stroke-width="2" stroke-linecap="round">
<path d="M0 -11 L-11 8"/>
<path d="M0 -11 L11 8"/>
<path d="M-11 8 L11 8"/>
</g>
<circle cx="0" cy="-11" r="4.2" fill="#E6E0E9"/>
<circle cx="-11" cy="8" r="3.4" fill="#938F99"/>
<circle cx="11" cy="8" r="3.4" fill="#938F99"/>
</g>
<g font-family="system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif" text-anchor="middle">
<text x="600" y="152" font-size="60" font-weight="700" letter-spacing="-1.5" fill="#E6E0E9">apk-reverse</text>
<text x="600" y="192" font-size="20" fill="#CAC4D0">Agent Skill for Android APK Reverse Engineering</text>
<text x="600" y="224" font-size="16" fill="#938F99">dex patching &#183; repacking &amp; signing &#183; packers &amp; loaders &#183; native tamper &#183; Dart AOT</text>
</g>
<rect x="0" y="257" width="1200" height="3" fill="url(#rule)"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1,47 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 260" width="1200" height="260" role="img" aria-label="apk-reverse">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#FFFFFF"/>
<stop offset="0.55" stop-color="#F5F4F7"/>
<stop offset="1" stop-color="#EDECF1"/>
</linearGradient>
<radialGradient id="glow" cx="0.5" cy="0.3" r="0.7">
<stop offset="0" stop-color="#79747E" stop-opacity="0.09"/>
<stop offset="1" stop-color="#79747E" stop-opacity="0"/>
</radialGradient>
<linearGradient id="rule" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#CAC4D0"/>
<stop offset="0.5" stop-color="#79747E"/>
<stop offset="1" stop-color="#CAC4D0"/>
</linearGradient>
<pattern id="grid" width="24" height="24" patternUnits="userSpaceOnUse">
<path d="M24 0H0V24" fill="none" stroke="#1D1B20" stroke-opacity="0.035" stroke-width="1"/>
</pattern>
</defs>
<rect width="1200" height="260" fill="url(#bg)"/>
<rect width="1200" height="260" fill="url(#grid)"/>
<rect width="1200" height="260" fill="url(#glow)"/>
<rect x="0.75" y="0.75" width="1198.5" height="258.5" fill="none" stroke="#CAC4D0" stroke-width="1.5"/>
<g transform="translate(600 52)" stroke-linejoin="round">
<path d="M0 -26 L24 -13.5 L24 13.5 L0 27 L-24 13.5 L-24 -13.5 Z"
fill="#49454F" fill-opacity="0.05" stroke="#49454F" stroke-width="2.6"/>
<g stroke="#79747E" stroke-width="2" stroke-linecap="round">
<path d="M0 -11 L-11 8"/>
<path d="M0 -11 L11 8"/>
<path d="M-11 8 L11 8"/>
</g>
<circle cx="0" cy="-11" r="4.2" fill="#1D1B20"/>
<circle cx="-11" cy="8" r="3.4" fill="#79747E"/>
<circle cx="11" cy="8" r="3.4" fill="#79747E"/>
</g>
<g font-family="system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif" text-anchor="middle">
<text x="600" y="152" font-size="60" font-weight="700" letter-spacing="-1.5" fill="#1D1B20">apk-reverse</text>
<text x="600" y="192" font-size="20" fill="#49454F">Agent Skill for Android APK Reverse Engineering</text>
<text x="600" y="224" font-size="16" fill="#79747E">dex patching &#183; repacking &amp; signing &#183; packers &amp; loaders &#183; native tamper &#183; Dart AOT</text>
</g>
<rect x="0" y="257" width="1200" height="3" fill="url(#rule)"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -0,0 +1,286 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Sync a script directory to a publish location, and audit it for machine-specific leftovers.
WHY THIS EXISTS
---------------
An Agent Skill gets copied across machines and across runtimes (Claude Code, Codex,
DeepSeek Harness). Anything machine-specific that survives the copy becomes a
landmine: a baked-in JDK path, a keystore password, a home directory that only
exists on the author's box. This tool does two things:
1. AUDIT (default, read-only): report every absolute-path literal that names
*this* machine, and every credential-looking constant, with file and line
number. Generic install locations (`Program Files` on Windows, `/usr/bin`
on POSIX) and already-redacted paths (carrying a `<placeholder>` or an
ellipsis) are exempt -- see `GENERIC_PATH_PREFIXES` and `_leaks_nothing`,
which exist because a gate whose output is mostly noise stops being read.
2. SYNC (--apply): copy matching files to the destination, optionally after
neutralising the absolute-path literals it found (--scrub).
The audit is the default on purpose: a silent copy is exactly how the leftovers
travel from the author's machine into someone else's first run.
USAGE
-----
python build_scripts.py scripts2 out # audit only, nothing written
python build_scripts.py scripts out --apply # copy
python build_scripts.py scripts out --apply --scrub # copy, neutralising abs paths
python build_scripts.py scripts out --pattern '*.py' --pattern '*.js'
Exit codes: 0 = clean, 2 = findings reported (audit mode), 1 = usage or IO error.
NOTES
-----
* A finding is a heuristic, not a verdict: `/data/local/tmp/...` is a legitimate
Android device path and is deliberately NOT reported; a drive-letter path is.
* --scrub rewrites `'<abs path>'` literals into `''`, which keeps the file
syntactically valid but leaves the decision (and the fix) to a human. It never
runs unless --apply is also given.
"""
import argparse
import fnmatch
import os
import re
import shutil
import sys
DEFAULT_PATTERNS = ('*.py', '*.js', '*.java', '*.md', '*.txt')
# Absolute-path literals that belong to one particular machine.
# Deliberately excluded: /data/... (an Android device path, not a host path).
WINDOWS_LITERAL = re.compile(r"(?P<prefix>(?:r|u|b)?r?)(?P<quote>['\"])"
r"(?P<path>[A-Za-z]:[\\/][^'\"]*)(?P=quote)")
POSIX_LITERAL = re.compile(r"(?P<prefix>(?:r|u|b)?r?)(?P<quote>['\"])"
r"(?P<path>/(?:home|Users|mnt)/[^'\"]*)(?P=quote)")
# Any drive-letter or foreign-home path outside a quoted literal as well.
# Tuned to avoid the two false-positive families that made an earlier version of
# this audit useless: a URL scheme ("https://" contains "s:" + "/") and a Python
# escape sequence ("is:\n" contains "s:" + "\"). Hence: the previous character must
# not be alphanumeric, a drive letter must be followed by a real backslash (not an
# escape letter, not a second separator), and the POSIX form must start a path.
BARE_ABS = re.compile(
r"(?<![A-Za-z0-9])(?:"
r"[A-Za-z]:\\(?![ntr0bxu'\"\\])\S{2,}"
r"|/(?:home|Users|mnt)/[A-Za-z0-9_.\-]{2,}"
r")")
CREDENTIAL_HINT = re.compile(
r"(?i)(BEGIN [A-Z ]*PRIVATE KEY|storepass\s*=\s*['\"][^'\"]|"
r"keypass\s*=\s*['\"][^'\"]|password\s*=\s*['\"][^'\"]|"
r"api[_-]?key\s*=\s*['\"][^'\"]|secret\s*=\s*['\"][^'\"]{6,})")
# ---------------------------------------------------------------------------
# What is NOT a machine-specific leftover.
#
# This audit exists to catch paths that leak *this* machine's layout. Two
# families look like absolute paths but leak nothing, and reporting them is
# what made an earlier version of this gate unreadable: 37 findings on the pass
# that introduced the evidence record, of which the large majority were these
# two families. A gate whose output is mostly noise is a gate people stop
# reading, so the exemptions are explicit and auditable rather than silent.
#
# 1. Generic install locations. `C:\Program Files\...` exists on every Windows
# host and `/usr/bin/...` on every POSIX host; naming one names the
# platform, not the machine. Same for a distribution's own paths.
# `/data/...` is already excluded above for the same reason -- it is a
# device path, not a host path.
#
# 2. Already-redacted paths. A path carrying a `<placeholder>` or an ellipsis
# has been through a normalisation pass deliberately; re-reporting it
# punishes the correct behaviour. `scan_leaks.py` reads the same signal as
# PLACEHOLDER_TOKENS for target identity.
#
# What remains is a drive or home path with no placeholder: a real workbench
# location. That is the thing worth failing on, and the thing to fix in the
# document rather than in this list.
GENERIC_PATH_PREFIXES = (
# Windows, generic
'c:\\program files', 'c:\\program files (x86)', 'c:\\windows',
'c:\\python', 'c:\\mingw', 'c:\\msys', 'c:\\cygwin', 'c:\\strawberry',
'c:\\android', 'c:\\android-sdk', 'c:\\users\\public', 'c:\\users\\default',
# POSIX, generic
'/usr/', '/opt/', '/etc/', '/var/', '/tmp/', '/bin/', '/sbin/',
)
# `<user>`, `<name>`, `<path>`, `<serial>` -- a placeholder is the mark of a path
# that has already been made portable on purpose.
PLACEHOLDER_RE = re.compile(r'<[A-Za-z_][A-Za-z0-9_.\-]*>')
# A literal ellipsis in a path means the middle was deliberately elided.
ELLIPSIS_RE = re.compile(r'\.\.\.|\u2026')
def _leaks_nothing(path):
"""True when an absolute path says nothing about this particular machine.
Kept separate from the matching rules so the exemption can be tested and
argued with on its own, rather than being folded into the regex.
The comparison is **bidirectional on purpose**. `BARE_ABS` stops at
whitespace (it cannot know where a path ends), so a real
`C:\\Program Files\\...` arrives here as the truncated `C:\\Program`. A
one-directional `startswith` then misses the exemption and reports a path
that exists on every Windows host. Requiring a minimum length keeps the
reverse direction from turning a short drive prefix into a blanket pass.
"""
low = path.lower().replace('/', '\\')
for prefix in GENERIC_PATH_PREFIXES:
p = prefix.replace('/', '\\')
if low.startswith(p):
return True
if len(low) >= 8 and p.startswith(low):
return True
# A bare user-directory root names no one -- only the segment after it does.
# `C:\Users\` appears legitimately when a document describes a rule rather
# than quoting a location, so it is exempt, while a personal name in that
# position still reports. Phrased without a placeholder on purpose:
# `scan_leaks.py` recognises a fixed token list, and an invented one reads
# to it as the very name this rule is trying to catch.
#
# The lookahead must exclude a following separator as well as alphanumerics.
# With only `[a-z0-9]` the optional `\\?` backtracks: it matches the empty
# string, the next character is the separator, that passes the lookahead, and
# a real personal path is swallowed. Excluding both is what separates a
# harmless root from a leak. `BARE_ABS` can also append punctuation (a
# trailing backtick), which is why the rule is a lookahead and not `$`.
if re.match(r'^c:\\users\\?(?![\\a-z0-9])', low):
return True
if PLACEHOLDER_RE.search(path) or ELLIPSIS_RE.search(path):
return True
return False
def collect(src, patterns):
"""Return [(absolute_path, relative_path)] for every matching file."""
out = []
for root, _dirs, files in os.walk(src):
for name in sorted(files):
if any(fnmatch.fnmatch(name, p) for p in patterns):
full = os.path.join(root, name)
out.append((full, os.path.relpath(full, src)))
return out
def audit(path, rel):
"""Return [(kind, lineno, detail)] for one file."""
findings = []
try:
with open(path, encoding='utf-8', errors='replace') as fh:
lines = fh.readlines()
except OSError as exc:
return [('io-error', 0, str(exc))]
for lineno, line in enumerate(lines, 1):
for match in BARE_ABS.finditer(line):
hit = match.group(0)
# Report only paths that actually name this machine; a generic
# install location or an already-redacted path is not a leftover.
if _leaks_nothing(hit):
continue
findings.append(('machine-path', lineno, hit[:90]))
if CREDENTIAL_HINT.search(line):
findings.append(('credential', lineno, line.strip()[:110]))
return findings
def scrub(text):
"""Neutralise absolute-path literals; returns (new_text, count)."""
total = 0
def repl(_match):
return "''"
for pattern in (WINDOWS_LITERAL, POSIX_LITERAL):
text, count = pattern.subn(repl, text)
total += count
return text, total
def main():
# A finding can quote a byte or a path the console's code page cannot
# represent -- a Windows console defaults to a legacy encoding such as
# cp936 or cp1252. Without this, the audit dies with UnicodeEncodeError
# *while reporting its own result*, which reads as "the tool is broken"
# instead of "here is what it found".
try:
sys.stdout.reconfigure(errors='replace')
except (AttributeError, ValueError):
pass
ap = argparse.ArgumentParser(
description='Sync a script directory to a publish location and audit it for '
'machine-specific leftovers (absolute paths, hardcoded credentials).')
ap.add_argument('src', help='source directory to publish from')
ap.add_argument('dst', help='destination directory to publish into')
ap.add_argument('--pattern', action='append', default=list(DEFAULT_PATTERNS),
help='glob to include, repeatable (default: %s)'
% ' '.join(DEFAULT_PATTERNS))
ap.add_argument('--apply', action='store_true',
help='actually copy files (default is audit only)')
ap.add_argument('--scrub', action='store_true',
help='with --apply, neutralise absolute-path literals in the copies')
args = ap.parse_args()
if not os.path.isdir(args.src):
print('error: source directory not found: %s' % args.src)
return 1
files = collect(args.src, args.pattern)
if not files:
print('error: no files matched %s in %s'
% (', '.join(args.pattern), args.src))
return 1
total_findings = 0
scrubbed = 0
copied = 0
for full, rel in files:
findings = audit(full, rel)
path_findings = [f for f in findings if f[0] == 'machine-path']
cred_findings = [f for f in findings if f[0] == 'credential']
other = [f for f in findings if f[0] not in ('machine-path', 'credential')]
if findings:
print('-- %s' % rel)
for kind, lineno, detail in path_findings + cred_findings + other:
print(' [%s] line %d: %s' % (kind, lineno, detail))
total_findings += len(findings)
if not args.apply:
continue
target = os.path.join(args.dst, rel)
os.makedirs(os.path.dirname(target) or '.', exist_ok=True)
if args.scrub and (path_findings or cred_findings):
with open(full, encoding='utf-8', errors='replace') as fh:
text = fh.read()
new_text, count = scrub(text)
if count:
with open(target, 'w', encoding='utf-8', newline='\n') as fh:
fh.write(new_text)
scrubbed += count
print(' -> copied with %d absolute-path literal(s) neutralised' % count)
copied += 1
continue
shutil.copy2(full, target)
copied += 1
print('')
if not args.apply:
print('== audit: %d file(s) checked, %d finding(s). Nothing was written.'
% (len(files), total_findings))
print(' re-run with --apply to copy, and with --scrub to neutralise '
'absolute-path literals.')
return 2 if total_findings else 0
print('== sync: %d file(s) copied to %s (%d literal(s) scrubbed), %d finding(s) reported.'
% (copied, args.dst, scrubbed, total_findings))
return 0
if __name__ == '__main__':
sys.exit(main())

View File

@@ -0,0 +1,372 @@
#!/usr/bin/env python3
"""Budget and shape gate for the apk-reverse skill: keep the always-loaded part from creeping,
and keep the long parts navigable.
`check_repo.py` proves the repository is *consistent* (paths resolve, indexes are complete).
This proves it is not *bloating*, and that a reader can find their way into a long file. Those are
different failures, and the second has no natural counter-pressure: every pass adds a reference, an
index row and a coverage claim, and nothing in the repository notices.
What is measured, and why each measure is shaped the way it is:
1. **The always-loaded part, in tokens rather than lines.** A skill's whole `SKILL.md` body loads
into context when the skill activates (agentskills.io/specification, "Progressive disclosure"),
so every line of it competes for attention -- **including the index tables**. An earlier version
of this gate subtracted the index lines from the budget, which made the number look better than
the context actually is: 150 index lines are still 150 lines the model reads. The budget is now
measured on the whole file, reported in lines *and* estimated tokens, with the index share
reported for context rather than credited against the budget.
Line counts are kept because they are the number a human can check by hand; the token estimate
is `characters / 4`, which is crude and stated as crude -- it is a trend indicator, not a
measurement of a specific tokenizer.
2. **Index row length.** The index tables are the largest part of `SKILL.md` and the easiest to
grow by accident: each new entry is written by someone who knows the detail, and the detail
lands in the row. An index row says *when to load the file*; the file says what is in it.
3. **Restated conclusions** (reported as notes, never as failures). A conclusion legitimately
appears in the benchmark matrix, in the evidence record, and in the coverage statement --
those three exist to state results. The check flags a concept that has spread *beyond* those
homes, because that is when a future correction has to hunt for every copy.
4. **Reference discoverability.** Every reference must be reachable from SKILL.md or nothing will
ever load it. (`check_repo.py` also enforces this; the duplication is deliberate, because a
dead reference is invisible in a way a broken path is not.)
5. **Long-file navigability.** A file over `NAV_LINES` that a reader cannot skim is a file they
will not load. Each one must open with a navigable head -- a "what this answers" line and
either a table of contents or a "when to load" line -- so the decision to read further can be
made from the top. This is the check that keeps "file-level progressive disclosure" from
becoming a claim the repository makes but does not implement.
6. **Instruction strength** (a note, never a failure). The repository's rules are meant to be
followed, not weighed: a rule phrased as "consider X" reads as optional and is skipped under
load. This reports the ratio of hedging phrases to imperative ones as a *trend*, and lists the
hedged lines it found, so a human can decide -- the gate cannot judge whether a given sentence
should be optional.
7. **Corpus size** (a note, never a failure). The checks above govern what every task *loads*; this
reports what the repository *carries*. The first cannot see the second: a new reference costs
exactly one index line, so the corpus can double while the always-loaded count stays green. A
large corpus is a choice; a growth nobody measured is the defect.
Usage:
python check_budget.py # report; exit 1 if a hard limit is exceeded
python check_budget.py --strict # treat every warning as a failure
python check_budget.py --json
"""
import argparse
import glob
import json
import os
import re
import sys
ROOT = os.path.dirname(os.path.abspath(__file__))
SKILL_DIR = os.path.join(ROOT, 'skills', 'apk-reverse')
SKILL = os.path.join(SKILL_DIR, 'SKILL.md')
REFS = os.path.join(SKILL_DIR, 'references')
CONTENT_LINES_OK = 460 # always-loaded lines: soft ceiling, warns above this
CONTENT_LINES_HARD = 560 # past this, something is structurally wrong
# Token budget for the always-loaded body. Calibrated here rather than copied: the Agent Skills
# spec recommends roughly 5,000 tokens of *instructions*, and this skill deliberately exceeds it,
# because the two things it will not move out of the always-loaded body are the symptom index
# (~54 rows; "symptom -> file" is the one lookup that must stay one hop) and the four gates with
# their pass criteria. The budget therefore tracks whether the body is *growing past what the
# design requires*, and the number is reviewed against a real tokenizer (see token_estimate).
TOKEN_SOFT = 13000
TOKEN_HARD = 16000
INDEX_ROW_SOFT = 250 # chars of description per row
INDEX_ROW_HARD = 400 # a row longer than this is a paragraph, not an index entry
INDEXES = ('Symptom index', 'Reference index', 'Script index')
NAV_LINES = 100 # above this, a reference must open navigably
CORPUS_FILES_NOTE = 110 # references + scripts, reported as a note past this
CORPUS_LINES_NOTE = 26000 # total lines across the same corpus, same treatment
# Characters per token, for the no-tiktoken fallback. **Calibrated on SKILL.md against
# tiktoken/cl100k_base** (47,629 chars -> 11,114 tokens = 4.285), so both measurement paths reach
# the same verdict. Getting this wrong made the gate answer differently on CI than locally.
TOKENS_PER_CHAR = 4.285
# A navigable head: one of these must appear in the first NAV_HEAD_LINES lines.
NAV_HEAD_LINES = 40
HEDGE_RE = re.compile(
r'\b(consider|considering|you may|you might|might want|optionally|prefer(?:ably)?|'
r'it is worth|feel free|if you like|tends to be better)\b', re.I)
# Concepts that have been restated once already. Reported, not enforced -- see the docstring.
RESTATED = {
'trivial-body is bimodal': [r'bimodal'],
'Java2C native density (2000x)': [r'82\.76', r'2,?000x'],
'Stalker exclusion keeps the process alive': [r'keeps? the (?:target|process) alive'],
'Dex-VMP coverage fixture (218/224)': [r'218 of 224', r'218/224'],
'ezAndroid is JNI sinking, not a VMP': [r'JNI sinking'],
'KernelSU userspace cannot change a syscall': [r'userspace module.{0,60}cannot change'],
'protobuf proto3 explicit zero': [r'proto3.{0,40}(?:explicit )?zero'],
'armv7 AOT strings are UTF-8, not UTF-16': [r'armv7.{0,80}UTF-8', r'UTF-8.{0,80}armv7'],
}
# Documents whose job is to state results; a concept appearing here is not drift.
LEGITIMATE_HOMES = ('tests/benchmark.md', 'docs/tool-verification/')
def read_lines(path):
with open(path, encoding='utf-8') as fh:
return fh.read().splitlines()
def skill_lines():
return read_lines(SKILL)
def index_rows(lines):
"""(index line count, [(section, first-cell, desc-length)] for over-long rows)."""
n = 0
long_rows = []
cur = None
for ln in lines:
if ln.startswith('## '):
cur = ln[3:].strip()
continue
if not (cur and any(cur.startswith(i) for i in INDEXES) and ln.startswith('|')):
continue
if set(ln) <= set('|-: '):
continue
cells = [c.strip() for c in ln.strip().strip('|').split('|')]
if len(cells) < 2:
continue
n += 1
if cells[0].lower() in ('file', 'script', 'what you observe', 'status'):
continue
desc = cells[-1]
if len(desc) > INDEX_ROW_SOFT:
long_rows.append((cur, cells[0][:44], len(desc)))
return n, long_rows
def token_estimate(text):
"""Token count for the always-loaded body -- deterministic, not installation-dependent.
Uses `tiktoken` (cl100k_base) when it is available, because a real measurement beats an estimate
everywhere in this repository, including in this file. When it is not, it falls back to
`characters / TOKENS_PER_CHAR`.
The fallback constant is **calibrated against that measurement** rather than guessed: a
markdown-heavy document tokenizes worse per character than the usual /4 rule of thumb, and this
file measured 0.30 characters-to-tokens on SKILL.md (67,011 chars -> 19,929 tokens).
Why the value matters more than the method: the first version used 3.6 and the gate therefore
reached *different verdicts on different machines* -- `WARN over budget` where tiktoken was
absent (CI's 3.11 job, and any contributor's box) and `ok` where it was installed. A budget gate
whose answer depends on what the runner happens to have installed is not a gate. Calibrating the
fallback to the same ballpark makes the verdict agree, and the printed `how` field still says
which number a reader is looking at.
"""
try:
import tiktoken
return len(tiktoken.get_encoding('cl100k_base').encode(text)), 'tiktoken/cl100k_base'
except Exception:
return int(len(text) / TOKENS_PER_CHAR), 'estimate chars/%.1f' % TOKENS_PER_CHAR
def check_size(lines):
n_idx, _ = index_rows(lines)
content = len(lines)
text = '\n'.join(lines)
toks, how = token_estimate(text)
pct_idx = (100.0 * n_idx / content) if content else 0.0
findings = []
if content <= CONTENT_LINES_OK and toks <= TOKEN_SOFT:
msg = ('SKILL.md %d lines (%d index = %.0f%% of the file, and it is still loaded: the budget '
'is measured on the whole body), ~%d tokens (%s; soft %d)'
% (content, n_idx, pct_idx, toks, how, TOKEN_SOFT))
findings.append(('ok', msg))
elif content <= CONTENT_LINES_HARD and toks <= TOKEN_HARD:
findings.append((
'warn',
'SKILL.md is %d lines / ~%d tokens (%s), over the soft budget (%d lines / %d tokens) -- '
'the always-loaded body is what the model reads on activation. Move detail into '
'references/ and leave a pointer; %d of those lines are the index tables, which are '
'loaded too. Total references: %d files.'
% (content, toks, how, CONTENT_LINES_OK, TOKEN_SOFT, n_idx,
len(glob.glob(os.path.join(REFS, '*.md'))))))
else:
findings.append((
'fail',
'SKILL.md is %d lines / ~%d tokens (%s), past the hard limit (%d lines / %d tokens)'
% (content, toks, how, CONTENT_LINES_HARD, TOKEN_HARD)))
return findings, n_idx, content, toks
def check_index_rows(lines):
_, long_rows = index_rows(lines)
out = []
for section, key, size in long_rows:
hard = size > INDEX_ROW_HARD
out.append(('warn' if hard else 'warn',
'%s: row for %s is %d chars (soft limit %d)%s' %
(section, key, size, INDEX_ROW_SOFT,
' -- that is a paragraph, not an index entry' if hard else '')))
return out
def check_navigable():
"""A long reference must be skimmable from its first screen.
The head is what lets a reader decide *not* to read the rest, which is the only thing that makes
a 400-line reference cheaper than a 40-line one. Two shapes are accepted, because both are in
use here: an explicit table of contents, or an explicit "when to load / what this answers" line.
"""
out = []
for path in sorted(glob.glob(os.path.join(REFS, '*.md'))):
lines = read_lines(path)
if len(lines) < NAV_LINES:
continue
head = '\n'.join(lines[:NAV_HEAD_LINES]).lower()
has_toc = bool(re.search(r'(?m)^#+ *(table of contents|contents)\s*$', head))
has_when = ('when to load' in head or 'what this answers' in head
or 'load this when' in head or 'when this applies' in head)
if not (has_toc or has_when):
out.append(('warn', 'references/%s is %d lines with neither a table of contents nor a '
'"when to load"/"what this answers" line in its first %d lines -- '
'a reader cannot decide from the top whether to load it'
% (os.path.basename(path), len(lines), NAV_HEAD_LINES)))
return out
def check_hedging():
"""Report hedged instruction lines; never fail on them.
A rule that reads as optional gets skipped when the context is under load. This cannot be
decided by regex -- some sentences *should* be optional -- so it reports the lines and a ratio,
and a human decides. The ratio is the trend: if it climbs, the rules are getting softer.
"""
lines = skill_lines()
body = [ln for ln in lines
if ln.strip() and not ln.startswith('|') and not ln.startswith('#')
and not ln.startswith('---')]
hedged = [(i + 1, ln.strip()) for i, ln in enumerate(lines) if HEDGE_RE.search(ln)]
ratio = (len(hedged) / len(body)) if body else 0.0
notes = []
if hedged:
notes.append('hedged instruction lines: %d of %d prose lines (%.1f%%; report-only -- a '
'hedged rule reads as optional). First few: %s'
% (len(hedged), len(body), 100.0 * ratio,
'; '.join('L%d %s' % (n, t[:52]) for n, t in hedged[:3])))
return notes
def check_restated():
notes = []
files = (glob.glob(os.path.join(REFS, '*.md')) +
glob.glob(os.path.join(REFS, '*', '*.md')) +
glob.glob(os.path.join(ROOT, 'docs', 'tool-verification', '*.md')) +
[SKILL, os.path.join(ROOT, 'README.md'), os.path.join(ROOT, 'tests', 'benchmark.md')])
corpus = {}
for f in files:
if os.path.isfile(f):
try:
rel = os.path.relpath(f, ROOT).replace('\\', '/')
corpus[rel] = open(f, encoding='utf-8').read()
except OSError:
pass
for label, pats in RESTATED.items():
hits = [p for p, t in corpus.items() if any(re.search(x, t, re.I) for x in pats)]
# files whose job is to state results do not count as drift
drift = [h for h in hits if not any(h.startswith(p) for p in LEGITIMATE_HOMES)]
if len(drift) > 3:
notes.append('restated in %d non-record files: %s -- %s' %
(len(drift), label, ', '.join(sorted(d.split('/')[-1] for d in drift))))
return notes
def check_discoverable():
"""Every reference must be reachable from the entry point or from the routing file it points to.
`SKILL.md` is the entry point; since this pass it carries the symptom index and delegates the
inventory to `references/routing.md` (which `check_routing.py` in turn proves names every
reference and every script). Counting the routing file here is what keeps the delegation honest
instead of letting a file become unreachable the moment its index row moves.
"""
body = open(SKILL, encoding='utf-8').read()
routing = os.path.join(REFS, 'routing.md')
if os.path.isfile(routing):
body += open(routing, encoding='utf-8').read()
out = []
for f in sorted(glob.glob(os.path.join(REFS, '*.md'))):
name = os.path.basename(f)
if name not in body:
out.append(('fail', 'references/%s is named neither in SKILL.md nor in '
'references/routing.md -- nothing will load it' % name))
return out
def check_corpus():
"""Report corpus size so growth is visible instead of discovered late.
Contributed on this repository while the checks above were being rewritten. Kept as its own
function and its own note: it measures what the repository *carries*, which is a different
quantity from what a task loads, and collapsing the two would let a growing corpus hide behind a
green always-loaded count.
"""
refs = sorted(glob.glob(os.path.join(REFS, '**', '*.md'), recursive=True))
scripts = sorted(p for p in glob.glob(os.path.join(SKILL_DIR, 'scripts', '**', '*'), recursive=True)
if os.path.isfile(p))
def count(paths):
total = 0
for p in paths:
try:
with open(p, encoding='utf-8', errors='replace') as fh:
total += sum(1 for _ in fh)
except OSError:
pass
return total
ref_lines, script_lines = count(refs), count(scripts)
n_files = len(refs) + len(scripts)
lines_total = ref_lines + script_lines
notes = ['corpus: %d references + %d scripts = %d files, %d lines total'
% (len(refs), len(scripts), n_files, lines_total)]
if n_files > CORPUS_FILES_NOTE:
notes.append('corpus is %d files, past the %d-file note threshold -- every entry costs an '
'index line and a share of the maintenance surface, so confirm the new ones are '
'load-bearing rather than merely adjacent' % (n_files, CORPUS_FILES_NOTE))
if lines_total > CORPUS_LINES_NOTE:
notes.append('corpus is %d lines, past the %d-line note threshold'
% (lines_total, CORPUS_LINES_NOTE))
return notes
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('--strict', action='store_true', help='treat warnings as failures')
ap.add_argument('--json', action='store_true')
args = ap.parse_args()
lines = skill_lines()
findings, n_idx, content, toks = check_size(lines)
findings += check_index_rows(lines)
findings += check_discoverable()
findings += check_navigable()
notes = check_restated() + check_hedging() + check_corpus()
if args.json:
print(json.dumps({
'findings': [{'level': ln, 'message': m} for ln, m in findings],
'notes': notes,
'index_lines': n_idx,
'total_lines': content,
'token_estimate': toks,
}, indent=2))
else:
for ln, m in findings:
print('%s %s' % ({'ok': ' ok ', 'warn': ' WARN ', 'fail': ' FAIL '}[ln], m))
for m in notes:
print(' note %s' % m)
n_fail = sum(1 for ln, _ in findings if ln == 'fail')
n_warn = sum(1 for ln, _ in findings if ln == 'warn')
print()
print('== budget: %d failure(s), %d warning(s), %d note(s) ==' % (n_fail, n_warn, len(notes)))
n_bad = sum(1 for ln, _ in findings if ln == 'fail' or (args.strict and ln == 'warn'))
return 1 if n_bad else 0
if __name__ == '__main__':
sys.exit(main())

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,252 @@
#!/usr/bin/env python3
"""Audit cross-references of the form `file.md` §section, and `file.md` P12.
A reference like `references/toolchain.md` §"not on PATH" is a promise that the
target file has a section by that name. When a section is renamed or removed the
promise silently breaks: the reader arrives at a file that does not contain the
thing they were sent for, and the most likely outcome is that they conclude the
material is missing rather than that the link is stale. Prose links do not fail
loudly the way a missing file does, which is why this script exists separately
from `check_repo.py` (that one only proves the *file* resolves).
A section reference is reported as:
* OK -- normalised, it matches a heading (or bold numbered rule) in the target
* WARN -- its words appear in the target body, but no heading carries them;
usually a stale or paraphrased link, worth a human look
* FAIL -- nothing in the target file corresponds; the link is dangling
Layout is discovered the same way as in `check_repo.py`, so a second skill under
skills/ is audited with no change here.
"""
import os
import re
import sys
ROOT = os.path.dirname(os.path.abspath(__file__))
SKILLS_DIR = os.path.join(ROOT, 'skills')
# A section reference. The optional leading file may be separated from the
# section mark by the closing backtick and a line break, since references are
# often wrapped mid-sentence.
REF_RE = re.compile(
r'(?:(?P<file>[A-Za-z0-9_\-]+\.md)`?[\s`]{0,40})?'
r'§\s*(?P<sec>[^\n|`();,.\u2014\u3002]{2,90})')
# A failure-catalogue reference, e.g. `pitfalls.md` P31, or a bare (P18).
PITFALL_RE = re.compile(
r'(?:(?P<file>[A-Za-z0-9_\-]+\.md)`?[\s`]{0,40})?'
r'\(?\bP(?P<num>\d{1,2})\b')
HEADING_RE = re.compile(r'^#{1,6}\s+(?P<title>.+?)\s*$')
BOLD_RE = re.compile(r'^\*\*(?P<title>.+?)\*\*\s*$')
def norm(text):
"""Fold a heading or a reference into comparable tokens.
Hyphens and slashes become spaces so that `finding-the-call-site` matches
"Finding the call site", and quotes/emphasis are dropped so that
`§"not on PATH"` matches a heading containing the same words quoted.
"""
text = text.lower()
text = text.replace('\u2014', ' ').replace('\u2013', ' ')
text = re.sub(r'[`*_"\'()\[\]]', '', text)
text = re.sub(r'[^a-z0-9]+', ' ', text)
return text.strip()
def discover_skills():
"""Return [(name, skill_dir)] for each skill root, shallow shadowing deep."""
found = []
if not os.path.isdir(SKILLS_DIR):
return found
for dirpath, dirnames, filenames in os.walk(SKILLS_DIR):
dirnames[:] = sorted(d for d in dirnames if not d.startswith('.'))
rel = os.path.relpath(dirpath, SKILLS_DIR)
depth = 0 if rel == '.' else rel.count(os.sep) + 1
if depth > 3:
dirnames[:] = []
continue
if 'SKILL.md' in filenames:
found.append((rel.replace(os.sep, '/'), dirpath))
dirnames[:] = []
return sorted(found)
def discover_docs():
"""Return [(path, skill_name_or_None, text)] for every markdown document."""
docs = []
readme = os.path.join(ROOT, 'README.md')
if os.path.exists(readme):
docs.append((readme, None, open(readme, encoding='utf-8').read()))
for skill, skill_dir in discover_skills():
for dirpath, dirnames, filenames in os.walk(skill_dir):
dirnames[:] = sorted(d for d in dirnames if not d.startswith('.'))
for fname in sorted(filenames):
if not fname.endswith('.md'):
continue
path = os.path.join(dirpath, fname)
docs.append((path, skill, open(path, encoding='utf-8').read()))
return docs
def anchors(text):
"""Headings, and bold stand-alone lines (`**2a. ...**`), as match targets."""
out = []
for line in text.splitlines():
line = line.strip()
m = HEADING_RE.match(line) or BOLD_RE.match(line)
if m:
out.append(m.group('title'))
return out
def matches(sec, anchor_list):
want = norm(sec)
if not want:
return True
want_tokens = want.split()
for raw in anchor_list:
have_tokens = norm(raw).split()
if not have_tokens:
continue
# A numbered heading (`## 3. Reading AOT code`) carries the number as a
# token, but a reference may name the section with or without it, so
# both readings are candidates.
candidates = [have_tokens]
if have_tokens[0].isdigit() and len(have_tokens) > 1:
candidates.append(have_tokens[1:])
for have in candidates:
text = ' '.join(have)
if text == want or text.startswith(want + ' ') \
or want.startswith(text + ' '):
return True
if all(tok in have for tok in want_tokens):
return True
# A reference frequently runs on into the sentence that follows it
# (`§Gates are cleared here or not at all`), leaving prose attached
# to the heading name. Sharing the leading word is the strongest
# signal available in that shape.
if have[0] == want_tokens[0]:
return True
return False
def first_token_match(sec, anchor_list):
"""True when some heading starts with the same word as the reference.
A reference is often a heading plus the rest of the sentence
(`§Gates are cleared here or not at all`), so an exact match is too strict
to be the only accepted shape. This is deliberately weaker than matches()
and is reported as a warning, not as a pass.
"""
want = norm(sec).split()
if not want:
return False
for raw in anchor_list:
have = norm(raw).split()
if have and have[0] == want[0]:
return True
return False
def in_body(sec, text):
"""Weaker fallback: every token of the section name occurs in the body."""
want_tokens = norm(sec).split()
if not want_tokens:
return True
body = norm(text)
return all(tok in body for tok in want_tokens)
def main():
docs = discover_docs()
by_name = {}
for path, skill, text in docs:
by_name.setdefault(os.path.basename(path), []).append((path, skill, text))
ok = warn = 0
problems = []
warnings = []
for path, skill, text in docs:
where = os.path.relpath(path, ROOT).replace(os.sep, '/')
for m in REF_RE.finditer(text):
raw_sec = m.group('sec').strip().strip('.,;:!?\'")*').lower()
# `§7 gives the exact framing` -- keep only the number.
num = re.match(r'(\d{1,2}[a-z]?)\b', raw_sec)
sec = num.group(1) if num else raw_sec
if not sec:
continue
# `§8C` names sub-item C of section 8, so the bare section number is
# an acceptable fallback target.
variants = [sec]
sub = re.fullmatch(r'(\d{1,2})[a-z]', sec)
if sub:
variants.append(sub.group(1))
target_name = m.group('file')
if target_name is None:
# `target_list` is kept as part of the pair this branch assigns so both arms
# of the branch below produce the same two names; only `target_text` is read.
target_text, target_list = text, [path] # noqa: F841
target_label = where
else:
candidates = [c for c in by_name.get(target_name, [])
if skill is None or c[1] == skill] \
or by_name.get(target_name, [])
if not candidates:
problems.append('%s: §%s -> %s (no such file)'
% (where, sec, target_name))
continue
target_text = candidates[0][2]
target_label = os.path.relpath(candidates[0][0], ROOT).replace(os.sep, '/')
if any(matches(v, anchors(target_text)) for v in variants):
ok += 1
elif len(norm(sec).split()) >= 2 and first_token_match(
sec, anchors(target_text)):
warn += 1
warnings.append('%s: §%s -> %s (heading starts with this word '
'but the wording differs)'
% (where, sec, target_label))
elif in_body(sec, target_text):
warn += 1
warnings.append('%s: §%s -> %s (words present, no matching heading)'
% (where, sec, target_label))
else:
problems.append('%s: §%s -> %s (section not found)'
% (where, sec, target_label))
# Failure-catalogue references: `pitfalls.md` P31, or a bare (P18).
pit_anchors = set()
for name, entries in by_name.items():
for path, _skill, text in entries:
for raw in anchors(text):
m = re.match(r'P(\d{1,2})[.\s]', raw)
if m:
pit_anchors.add(m.group(1))
for path, _skill, text in docs:
where = os.path.relpath(path, ROOT).replace(os.sep, '/')
for m in PITFALL_RE.finditer(text):
if m.group('num') not in pit_anchors:
problems.append('%s: P%s (no such pitfall)' % (where, m.group('num')))
else:
ok += 1
print('== markdown documents ==')
for path, _skill, _text in docs:
print(' %s' % os.path.relpath(path, ROOT).replace(os.sep, '/'))
print('\n== section references: %d reachable ==' % ok)
for w in sorted(set(warnings)):
print(' WARN %s' % w)
for p in sorted(set(problems)):
print(' FAIL %s' % p)
print('\n== result: %d dangling, %d warning(s) =='
% (len(set(problems)), len(set(warnings))))
return 1 if problems else 0
if __name__ == '__main__':
sys.exit(main())

View File

@@ -0,0 +1,356 @@
#!/usr/bin/env python3
"""Pre-commit checks for this repository.
Assumed layout -- the one the `skills` CLI resolves:
README.md repository-level docs (root)
skills/<skill-name>/SKILL.md the skill itself
skills/<skill-name>/references/*.md loaded on demand
skills/<skill-name>/scripts/* run, not read
Every skill under `skills/` is discovered automatically (up to three levels;
a SKILL.md at a shallower level shadows anything nested below it, which is the
CLI's own rule), so adding a second skill requires no change to this script.
Per skill:
1. frontmatter exists, `name` matches the directory name, `description` <= 1024
2. every scripts/*.py parses and answers --help without a traceback
3. every `references/X.md` / `scripts/X.py` reference resolves inside that skill
4. every reference file is reachable from that skill's SKILL.md or the README
5. every script is mentioned somewhere in the docs
On the root README:
6. every path it names is an explicit `skills/<name>/...` path that exists
"""
import ast
import os
import re
import subprocess
import sys
try:
import yaml
except ImportError: # the skills CLI is the real authority; this is a local gate
yaml = None
ROOT = os.path.dirname(os.path.abspath(__file__))
SKILLS_DIR = os.path.join(ROOT, 'skills')
README = os.path.join(ROOT, 'README.md')
REF_RE = re.compile(r'`?(references/[A-Za-z0-9_\-]+\.md)')
SCRIPT_RE = re.compile(r'`?(scripts/[A-Za-z0-9_\-]+\.(?:py|js))')
NAMED_PATH_RE = re.compile(
r'skills/(?P<skill>[a-z0-9\-]+)/(?P<sub>references|scripts)/'
r'(?P<file>[A-Za-z0-9_\-]+\.(?:md|py|js))')
BARE_PATH_RE = re.compile(r'(?<![\w/])(?P<sub>references|scripts)/'
r'(?P<file>[A-Za-z0-9_\-]+\.(?:md|py|js))')
fail: list[str] = []
def read(path):
with open(path, encoding='utf-8') as fh:
return fh.read()
def discover_skills():
"""Return [(name, skill_dir)] for every skills/<...>/SKILL.md."""
found = []
if not os.path.isdir(SKILLS_DIR):
return found
for dirpath, dirnames, filenames in os.walk(SKILLS_DIR):
dirnames[:] = sorted(d for d in dirnames if not d.startswith('.'))
rel = os.path.relpath(dirpath, SKILLS_DIR)
depth = 0 if rel == '.' else rel.count(os.sep) + 1
if depth > 3:
dirnames[:] = []
continue
if 'SKILL.md' in filenames:
found.append((rel.replace(os.sep, '/'), dirpath))
dirnames[:] = [] # shallower SKILL.md shadows what is below
return sorted(found)
NAME_RE = re.compile(r'^[a-z0-9]+(?:-[a-z0-9]+)*$')
def check_frontmatter(name, skill_dir):
"""Validate the frontmatter the way the CLI will, not merely its presence.
This has to be a real parse. Frontmatter that satisfies a regex can still be
rejected by a YAML parser -- an unquoted `description` containing ": " does
it, because the colon starts a nested mapping -- and the `skills` CLI then
reports "No skills found" and installs nothing at all.
"""
path = os.path.join(skill_dir, 'SKILL.md')
text = read(path)
m = re.match(r'^---\r?\n(.*?)\r?\n---\r?\n', text, re.S)
if not m:
fail.append('%s: no YAML frontmatter block' % path)
return
raw = m.group(1)
if yaml is None:
print(' WARN PyYAML is not installed, so the frontmatter was not parsed')
line = re.search(r'^description:(.*)$', raw, re.M)
if line:
value = line.group(1)
if value[:1] not in ('"', "'", '>', '|') and ': ' in value:
fail.append('%s: unquoted description contains ": ", which a YAML '
'parser rejects; quote the value' % path)
return
try:
data = yaml.safe_load(raw)
except yaml.YAMLError as exc:
detail = str(exc).splitlines()[0]
fail.append('%s: frontmatter is not valid YAML (%s); the skills CLI '
'skips a skill whose frontmatter will not parse'
% (path, detail))
return
if not isinstance(data, dict):
fail.append('%s: frontmatter is not a YAML mapping' % path)
return
got = data.get('name')
if not got:
fail.append('%s: frontmatter has no `name`' % path)
elif str(got) != name:
fail.append('%s: frontmatter name %r does not match directory %r'
% (path, got, name))
elif not NAME_RE.match(str(got)) or len(str(got)) > 64:
fail.append('%s: name %r violates the spec (lowercase alphanumerics and '
'single hyphens, max 64 chars)' % (path, got))
desc = data.get('description')
if not desc:
fail.append('%s: frontmatter has no `description`' % path)
else:
desc = str(desc)
if len(desc) > 1024:
fail.append('%s: description is %d chars (spec max 1024)'
% (path, len(desc)))
elif len(desc) < 40:
fail.append('%s: description is only %d chars, too short to tell an '
'agent when this skill applies' % (path, len(desc)))
def check_scripts(skill_dir):
scripts = os.path.join(skill_dir, 'scripts')
if not os.path.isdir(scripts):
return
for name in sorted(os.listdir(scripts)):
if not name.endswith('.py'):
continue
path = os.path.join(scripts, name)
try:
ast.parse(read(path))
except SyntaxError as exc:
fail.append('%s: syntax error %s' % (path, exc))
print(' FAIL %s' % name)
continue
run = subprocess.run([sys.executable, '-B', path, '--help'],
capture_output=True, timeout=60, cwd=ROOT,
# Decode explicitly. `text=True` alone uses the *locale* encoding, which
# on a Chinese Windows console is GBK: a script printing an em dash in a
# usage line then fails to decode, and this gate reports it as a crash.
# That is a defect in the gate, not in the script -- measured on this
# repository, where `dex_strpatch.py --help` was reported as crashed.
encoding='utf-8', errors='replace')
# A script may exit non-zero on --help under the "print usage, exit 2"
# convention. That is acceptable; a traceback is not.
crashed = 'Traceback (most recent call last)' in (run.stderr or '')
helped = bool((run.stdout or '').strip()) or bool(run.stderr)
ok = (not crashed) and helped
verdict = 'ok' if run.returncode == 0 and not crashed else \
('usage-ok rc=%d' % run.returncode if ok else
'CRASH rc=%d' % run.returncode)
print(' %-32s %s' % (name, verdict))
if not ok:
fail.append('%s --help crashed: %s' % (path, (run.stderr or '')[:300]))
def check_references(name, skill_dir, root_docs):
"""Resolve in-skill references, and confirm every file is reachable."""
ref_dir = os.path.join(skill_dir, 'references')
script_dir = os.path.join(skill_dir, 'scripts')
refs = set(os.listdir(ref_dir)) if os.path.isdir(ref_dir) else set()
scripts = set(os.listdir(script_dir)) if os.path.isdir(script_dir) else set()
# Documents whose relative paths are rooted at the skill directory.
own = [os.path.join(skill_dir, 'SKILL.md')]
own += [os.path.join(ref_dir, f) for f in sorted(refs) if f.endswith('.md')]
missing = []
for doc in own:
if not os.path.exists(doc):
continue
text = read(doc)
for m in REF_RE.finditer(text):
if m.group(1).split('/')[1] not in refs:
missing.append('%s -> %s' % (os.path.relpath(doc, ROOT), m.group(1)))
for m in SCRIPT_RE.finditer(text):
if m.group(1).split('/')[1] not in scripts:
missing.append('%s -> %s' % (os.path.relpath(doc, ROOT), m.group(1)))
if missing:
for x in sorted(set(missing)):
print(' MISSING %s' % x)
fail.extend(missing)
# Reachability: nothing may be orphaned from the entry point.
#
# The registration surface is SKILL.md **plus** `references/routing.md`, because this repository
# splits the two: SKILL.md keeps the symptom index (symptom -> file must stay one hop) and the
# reference/script inventory lives in the routing file, which `check_routing.py` in turn proves
# names every reference and every script. Reading only SKILL.md here would report a correctly
# registered file as orphaned -- and did, for every file added after that split.
routing = os.path.join(ref_dir, 'routing.md')
bodies = read(os.path.join(skill_dir, 'SKILL.md')) + root_docs
if os.path.isfile(routing):
bodies += read(routing)
for f in sorted(refs):
if f.endswith('.md') and f not in bodies:
fail.append('%s: references/%s is named neither in SKILL.md, README.md nor '
'references/routing.md' % (name, f))
print(' UNLISTED references/%s' % f)
all_docs = bodies
for f in sorted(refs):
if f.endswith('.md'):
all_docs += read(os.path.join(ref_dir, f))
for sub in ('', 'coverage', 'precedents'):
d = os.path.join(ref_dir, sub) if sub else ref_dir
if os.path.isdir(d):
for f in sorted(os.listdir(d)):
if f.endswith('.md') and os.path.isfile(os.path.join(d, f)):
all_docs += read(os.path.join(d, f))
for s in sorted(scripts):
if s.endswith(('.py', '.js')) and s not in all_docs:
fail.append('%s: scripts/%s is undocumented' % (name, s))
print(' UNDOCUMENTED scripts/%s' % s)
def check_readme():
"""README paths must be explicit and must exist."""
text = read(README)
if not os.path.isdir(SKILLS_DIR):
fail.append('no skills/ directory at the repository root')
return
skills = {d for d, _ in discover_skills()}
named = set()
for m in NAMED_PATH_RE.finditer(text):
named.add(m.group(0))
skill, sub, fname = m.group('skill'), m.group('sub'), m.group('file')
if skill not in skills:
fail.append('README.md: names unknown skill %r' % skill)
print(' UNKNOWN SKILL skills/%s' % skill)
elif not os.path.exists(os.path.join(SKILLS_DIR, skill, sub, fname)):
fail.append('README.md: %s does not exist' % m.group(0))
print(' MISSING %s' % m.group(0))
# Bare relative paths are ambiguous once there is more than one skill, so the
# README must not use them at all.
stripped = NAMED_PATH_RE.sub('', text)
for m in BARE_PATH_RE.finditer(stripped):
fail.append('README.md: bare path %r should be skills/<name>/%s'
% (m.group(0), m.group(0)))
print(' BARE PATH %s' % m.group(0))
def check_leaks():
"""No target identity on the committed surface.
Path resolution, anchors and layout are all *structural* checks: they pass on a file that names
a live app and a real phone. This one reads content, and it is deliberately not reimplemented
here -- `scripts/scan_leaks.py` owns the rules and their exemption list, so there is one place
to argue with. The tracked-file list is passed in so a git-ignored work area (which may
legitimately hold real identifiers) can never produce a finding.
A scanner error is reported as a note rather than a failure: an unreadable rule table is a
broken tool, not a leak, and conflating the two teaches people to ignore this line.
"""
scanner = os.path.join(SKILLS_DIR, 'apk-reverse', 'scripts', 'scan_leaks.py')
if not os.path.isfile(scanner):
print(' SKIP no scan_leaks.py (the leak gate is not installed)')
return
import subprocess
import tempfile
proc = subprocess.run(['git', 'ls-files'], cwd=ROOT, capture_output=True,
encoding='utf-8', errors='replace')
if proc.returncode != 0 or not proc.stdout.strip():
print(' SKIP git ls-files unavailable (not a checkout?)')
return
with tempfile.NamedTemporaryFile('w', suffix='.txt', delete=False, encoding='utf-8') as fh:
fh.write(proc.stdout)
listing = fh.name
try:
run = subprocess.run([sys.executable, scanner, '--root', ROOT,
'--files-from', listing, '--max', '10'],
capture_output=True, encoding='utf-8', errors='replace')
finally:
os.unlink(listing)
token = (run.stdout or '').strip().splitlines()
token = token[-1] if token else ''
if run.returncode == 2 or 'RESULT=error' in token:
print(' NOTE scan_leaks.py could not complete: %s' % (run.stderr or '').strip()[:160])
return
if run.returncode == 0:
print(' no strong target identity on the tracked surface (%s)' % (token or 'RESULT=clean'))
return
# Non-zero: report the findings, which carry their own context. The scanner prints each hit as
# "<file>:<line>:<col> [<category>/<strength>] <rule>", followed by its indented match/context
# lines. The list below keeps only the strong hits -- a weak hit is the scanner's business, not
# this gate's -- but a weak hit sitting between two strong ones may contribute its context pair,
# because attributing an indented line to the hit above it is heuristic. The exit code is the
# verdict; this block is for the reader.
print(' LEAK scan_leaks.py reported strong findings on the tracked surface:')
keep_detail = False
for line in (run.stdout or '').splitlines():
stripped = line.strip()
if re.search(r'\[(package|device|token|appkey|path)/(strong|certain)\]', stripped):
print(' %s' % stripped)
keep_detail = True
elif stripped.startswith('[') and stripped.count(']') >= 2:
keep_detail = False
elif keep_detail and (stripped.startswith('match:') or stripped.startswith('context:')):
print(' %s' % stripped)
fail.append('leak scan: strong target identity on the tracked surface '
'(run: python skills/apk-reverse/scripts/scan_leaks.py)')
def main():
skills = discover_skills()
print('== skills discovered ==')
if not skills:
print(' none (expected at least skills/<name>/SKILL.md)')
for name, _ in skills:
print(' skills/%s' % name)
print('\n== frontmatter ==')
for name, skill_dir in skills:
check_frontmatter(name, skill_dir)
print(' %d checked' % len(skills))
root_docs = read(README)
for name, skill_dir in skills:
print('\n== %s: python syntax + --help ==' % name)
check_scripts(skill_dir)
print('\n== %s: reference and script paths resolve ==' % name)
before = len(fail)
check_references(name, skill_dir, root_docs)
if len(fail) == before:
print(' all referenced reference/script paths exist')
print('\n== README paths are explicit and exist ==')
check_readme()
print('\n== tracked surface carries no target identity ==')
check_leaks()
print('\n== result: %d problem(s) ==' % len(fail))
for f in fail:
print(' - %s' % f)
return 1 if fail else 0
if __name__ == '__main__':
sys.exit(main())

View File

@@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""Keep `skills/apk-reverse/references/routing.md` coherent with `SKILL.md` and with the tree.
Why this exists: `SKILL.md` loads in full whenever this skill activates, so it carries only what has
to be one hop there -- the symptom index (recognising a failure must not become a two-hop lookup) and
the four gates with their pass criteria. The *inventory* lookups -- every reference file with when to
load it, every script with what it does -- live in `references/routing.md` and load on demand.
That split creates one risk: two tables that used to be one. This script removes it by **comparing**
the tables instead of generating one from the other:
* the **symptom index** has a single home, `SKILL.md`; `routing.md` keeps a mirror so a reader gets
all three tables in one load, and `--check` fails when the mirror and the live table differ;
* the **reference and script indexes** have a single home, `routing.md`;
* every file under `references/` must be named in the routing reference table, and every script in
`scripts/` must be named in the routing script table -- an inventory that has drifted from the
directory is worse than no inventory, because it reads as complete.
Usage:
python check_routing.py # report coherence; exit 1 on any problem
python check_routing.py --json
"""
import argparse
import glob
import json
import os
import sys
ROOT = os.path.dirname(os.path.abspath(__file__))
SKILL_DIR = os.path.join(ROOT, 'skills', 'apk-reverse')
SKILL = os.path.join(SKILL_DIR, 'SKILL.md')
REFS = os.path.join(SKILL_DIR, 'references')
SCRIPTS = os.path.join(SKILL_DIR, 'scripts')
ROUTE = os.path.join(REFS, 'routing.md')
# A markdown table separator row: pipes, dashes, colons and spaces, and at least one dash. Tested on
# the row's *cells*, not the whole line -- every data row also starts and ends with `|`, so any
# whole-line pattern loose enough to accept a separator also accepts every data row, and the reverse
# mistake drops every data row and reports an intact inventory as broken.
def is_separator(row):
cells = [c for c in row.strip().strip('|').split('|')]
return bool(cells) and all(c.strip() and set(c.strip()) <= set('-:') for c in cells)
# Headings are matched by prefix: they contain an em dash, and comparing against an ASCII hyphen
# silently reports "table not found" instead of failing.
PREFIXES = {
'symptom': 'Symptom index',
'reference': 'Reference index',
'script': 'Script index',
}
def read(path):
with open(path, encoding='utf-8') as fh:
return fh.read()
def tables(text):
"""{heading: [data rows]} -- data rows exclude the header row.
The separator row is identified by its characters (`-`, `|`, `:`, spaces only). Do **not** use
`set(row) > set('|-: ')` to decide this: set comparison is not a character test, and every row
whose content happens to be a superset of those characters gets dropped -- which once classified
43 of 53 symptom rows as "not a table row" and made the checker report the inventory as broken
while it was intact. This file's own failure mode, found by a row count that did not add up.
"""
out = {}
cur = None
for ln in text.split('\n'):
if ln.startswith('## '):
cur = ln[3:].strip()
out.setdefault(cur, [])
elif cur is not None and ln.startswith('|'):
out[cur].append(ln)
result = {}
for key, rows in out.items():
data = [r for r in rows if not is_separator(r)]
result[key] = data[1:] if data else [] # drop the header row
return result
def pick(sections, prefix):
for key, rows in sections.items():
if key.startswith(prefix):
return rows
return None
def cell0(row):
return row.strip().strip('|').split('|')[0].strip().strip('`* ').lower()
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('--json', action='store_true')
args = ap.parse_args()
problems = []
if not os.path.isfile(ROUTE):
print('FAIL references/routing.md is missing -- the inventory has no home')
print('RESULT=missing')
return 1
sk = tables(read(SKILL))
rt = tables(read(ROUTE))
live_sym = pick(sk, PREFIXES['symptom']) or []
mirror_sym = pick(rt, PREFIXES['symptom']) or []
ref_rows = pick(rt, PREFIXES['reference'])
scr_rows = pick(rt, PREFIXES['script'])
for name, rows in (('symptom (routing.md)', mirror_sym), ('reference', ref_rows),
('script', scr_rows)):
if rows is None:
problems.append('routing.md has no %s table' % name)
live_set = {cell0(r) for r in live_sym}
mirror_set = {cell0(r) for r in mirror_sym}
if live_set - mirror_set:
problems.append('routing.md symptom mirror is missing %d row(s) present in SKILL.md: %s'
% (len(live_set - mirror_set), '; '.join(sorted(live_set - mirror_set))[:180]))
if mirror_set - live_set:
problems.append('routing.md symptom mirror has %d row(s) SKILL.md does not: %s'
% (len(mirror_set - live_set), '; '.join(sorted(mirror_set - live_set))[:180]))
ref_text = ' '.join(r for r in (ref_rows or []))
ref_files = sorted(os.path.basename(p) for p in glob.glob(os.path.join(REFS, '*.md')))
unlisted = [f for f in ref_files if f not in ref_text]
if unlisted:
problems.append('routing.md reference table does not name %d reference file(s): %s'
% (len(unlisted), ', '.join(unlisted)))
scr_text = ' '.join(r for r in (scr_rows or []))
scr_files = sorted(f for f in os.listdir(SCRIPTS) if f.endswith(('.py', '.js')))
unlisted_s = [f for f in scr_files if f not in scr_text]
if unlisted_s:
problems.append('routing.md script table does not name %d script(s): %s'
% (len(unlisted_s), ', '.join(unlisted_s)))
report = {
'skill_symptom_rows': len(live_sym),
'routing_symptom_rows': len(mirror_sym),
'routing_reference_rows': len(ref_rows or []),
'routing_script_rows': len(scr_rows or []),
'reference_files_in_tree': len(ref_files),
'scripts_in_tree': len(scr_files),
'problems': problems,
'result': 'ok' if not problems else 'incoherent',
}
if args.json:
print(json.dumps(report, indent=2, ensure_ascii=False))
print('RESULT=%s' % report['result'])
return 1 if problems else 0
print('routing coherence')
print(' symptom index : SKILL.md %d rows / routing mirror %d rows'
% (len(live_sym), len(mirror_sym)))
print(' reference table: %d rows, covering %d reference files' % (len(ref_rows or []), len(ref_files)))
print(' script table : %d rows, covering %d scripts' % (len(scr_rows or []), len(scr_files)))
for p in problems:
print(' FAIL %s' % p)
print()
if problems:
print('== routing: %d problem(s) ==' % len(problems))
print('RESULT=incoherent')
return 1
print('== routing: coherent ==')
print('RESULT=ok')
return 0
if __name__ == '__main__':
sys.exit(main())

View File

@@ -0,0 +1,638 @@
# Extension verification — benchmark B1/B2 against OWASP MASTG Crackmes
Covers the pass that turned the `tests/benchmark.md` rows **B1** (equal-length dex patch + repack +
re-sign installability, gates G2/G4) and **B2** (anti-debug / self-destruct race) from `pending`
into measured results.
Strength labels follow `docs/tool-verification/README.md`: **observed** (exact command and its
output reproduced here), **inferred** (follows from observed facts, the step itself not executed),
**unverified** (assumed or reported and not independently confirmed).
Reference environment: Windows 11 host, Python 3.14.0, host frida 16.7.19 (frida-tools 13.7.1),
JDK 17.0.4.1 at `C:\Program Files\Java\jdk-17.0.4.1` (not on `PATH`), `apksigner.bat` /
`zipalign.exe` at `E:\tools\bin`. Device: **`<DEVICE>`**, Android 11 / API 30, arm64-v8a, root via
`su`, Magisk denylist `enforced` with an **empty** list (no app is root-hidden). Package under test:
**`<PKG>`**.
All device operations ran under the shared device lock (`tools/_work/devlock.py`).
---
## 0. Headline
- **B1 is `observed` and positive.** An equal-length dex edit (2 bytes of a conditional branch
replaced by `nop`s), through `dex_patch_bytes.py` → `repack.py --no-sign` → `zipalign` →
`apksigner` → root `pm install`, installs and **demonstrably changes the app's behaviour on
screen**: the original and a zero-change control both show a blocking `Root detected!` dialog, the
patched build reaches `MainActivity` with the dialog gone.
- **`repack.py`'s default signing path did not work when this pass started, and the blocker was not
`apksigner`.** `apksigner`/`zipalign` are both present and both work; at that moment `repack.py`
signed only through an `uber-apk-signer` jar, which is not installed. **The repository changed
mid-pass** — another writer landed a `sign_with_apksigner()` route — and re-running the same
command then produced a fully signed, `[result] OK` APK in one invocation with byte-identical
output to the manual route (`sha256 ad6a51ce…7e56`). Both states are recorded below, because the
stale state is what `tools/_work/ENV.md` predicted and the new state is what a reader will meet.
This is also a reminder that a "the tool cannot do X" finding on a shared worktree has an expiry
date.
- **B2 is a split result.** The two scripts run and the probe writes its bytes (0.04–0.05 s after
resume, gated on `PATCHED`), so the *tooling* is verified. But the **patch does not prevent the
target's termination**: `libfoo.so` aborts through `goodbye()+12` at **3.22 s** after resume, and
three patched sites including `goodbyev` itself do not change that. Neutralising the obvious death
site is **not sufficient** on this target.
- **A finding that changes the whole B2 setup:** on this device the **unmodified, original APK
already reports `Rooting or tampering detected.`** and its tamper path runs before anything is
attached. L3's native CRC self-check fails against the pristine community APK on Android 11 /
arm64-v8a, so the "pristine baseline" the row assumed does not exist here.
---
## 1. Gate G2 and G4 — environment and control (`observed`)
`scripts/doctor.py`:
```
platform : Windows 11 / AMD64
python : 3.14.0
[OK ] static (dex/zip/strings) [OK ] repack + sign
[OK ] native ELF patching [-- ] jadx decompile
[-- ] apktool unpack [OK ] device work
[OK ] root on device [OK ] frida dynamic
[OK ] java 17.0.4.1 [OK ] adb 1.0.41 [OK ] frida 16.7.19
[-- ] apktool not on PATH
[-- ] unzip not on PATH
java jars found near this skill : (none)
device : <DEVICE> arm64-v8a Android 11 (sdk 30) root: yes
clock skew: 3.06 s
```
The `(none)` on the jar line matters for B1: no `baksmali`/`smali`/`uber-apk-signer` jar exists, so
every signing route that goes through a jar is unavailable before a single command is run.
**Keystore and tool locations** (recorded because `repack.py` resolves both off-`PATH`):
```
keytool : C:\Program Files\Java\jdk-17.0.4.1\bin\keytool.exe
jarsigner : C:\Program Files\Java\jdk-17.0.4.1\bin\jarsigner.exe
apksigner : E:\tools\bin\apksigner.bat
zipalign : E:\tools\bin\zipalign.exe
```
`repack.py`'s `sibling_tool()` cannot find `keytool` from `C:\Program Files\Common Files\Oracle\Java\javapath\java.exe`,
because that directory holds only forwarders. Passing `--keytool` explicitly is required.
---
## 2. B1 — L1 equal-length dex patch, full chain (`observed`)
### 2.1 Source facts
```
$ python -c "... load_dex('UnCrackable-Level1.apk') ..."
classes.dex 5528 bytes
magic dex\n035\0
checksum field 7fb7d9fa
adler32(d[12:]) 7fb7d9fa -> matches
signature field b7fafe72cb521450c4470043caa332da61d1bec7
sha1(d[32:]) 5253237b04c2d002967bae8ef81daae08550c90c
-> MISMATCH
```
Two facts that a reader needs before trusting the tool's output:
- The **shipped dex's `signature` field is stale** while its `checksum` is correct. `dex_patch_bytes.py`
prints `header before: checksum_ok=True signature_ok=False` and explains it. If the tool had
written the two fields in the wrong order, that starting state would have hidden the bug.
- The class that owns the behaviour is
`Lsg/vantagepoint/uncrackable1/MainActivity;.onCreate(Landroid/os/Bundle;)V`, and the gate is the
first arm of a three-way root `OR`:
```
0x9c8 invoke-static Lsg/vantagepoint/a/c;.a()Z
0x9ce move-result
0x9d0 39000e00 if-nez v0 -> 0x9ec <-- the edit site
0x9d4 invoke-static Lsg/vantagepoint/a/c;.b()Z
```
`0x9ec` is `const-string "Root detected!"` followed by the private `a(String)` that builds the
blocking dialog. **The branch structure, not the method name, was read before editing** — the edit
target is the arm whose *fall-through* continues into the next check.
### 2.2 The patch
```json
[{
"name": "l1-first-root-check-fallthrough",
"class": "Lsg/vantagepoint/uncrackable1/MainActivity;",
"method": "onCreate", "desc": "(Landroid/os/Bundle;)V",
"match": {"kind": "if-testz", "bytes": "39000e00"},
"expect_next": {"kind": "invoke", "target_class": "Lsg/vantagepoint/a/c;",
"target_method": "b"},
"replace": {"kind": "nops"}
}]
```
```
$ python skills/apk-reverse/scripts/dex_patch_bytes.py tools/_work/bench/l1l3/dex/classes.dex \
--spec tools/_work/bench/l1l3/l1_root.spec.json --dry-run
-- [l1-first-root-check-fallthrough] Lsg/vantagepoint/uncrackable1/MainActivity;.onCreate(...)V
site 0x9d0 0x9d0: 39000e00 if-nez v0 -> 0x9ec
(before) 0x9c8: 710027000000 invoke-static Lsg/vantagepoint/a/c;.a()Z
(before) 0x9ce: 0a00 move-result
-> 0x9d0: 00000000 if-nez
(after) 0x9d4: 710028000000 invoke-static Lsg/vantagepoint/a/c;.b()Z
(after) 0x9da: 0a00 move-result
polarity: expect_next satisfied
== dry run: nothing written
```
`expect_next` is what pins the polarity: the instruction after the branch had to be the *next* root
check, so the fall-through is the "continue" side. Applied:
```
== header checksum 0x7fb7d9fa -> 0x86e1d928
signature b7fafe72cb521450 -> eb632328fd1ba03c
self-verify: checksum_ok=True signature_ok=True
== wrote classes.patched.dex (5528 bytes, delta 0)
```
**The header order question, answered by byte comparison rather than by the tool's own report**
(`observed`): the diff of the whole file against the source is exactly the two fields plus the patch
site.
```
sizes 5528 5528
diff offsets raw: ['0x8','0xa','0xb','0xc'...'0x1f', '0x9d0','0x9d2']
site 0x9d0 orig 39000e00 patched 00000000
8..12 fad9b77f -> 28d9e186 (checksum, bytes 8..12)
12..32 b7fafe72cb521450c4470043caa332da61d1bec7
-> eb632328fd1ba03c7ebc6df181e0708a761aa2e3 (signature, bytes 12..32)
selfverify checksum True signature True
```
Recomputing `zlib.adler32(d[12:])` and `hashlib.sha1(d[32:])` **on the written file** reproduces both
header fields, which is only possible if the signature was computed before the checksum. The patch
owns 4 bytes and the header owns 24; nothing else moved.
### 2.3 Repack — where the documented pipeline actually fails
`repack.py`'s default signing route:
```
$ python skills/apk-reverse/scripts/repack.py --apk UnCrackable-Level1.apk \
--dex classes.dex=classes.patched.dex --out out/l1_patched.apk \
--workdir out --ks out/bench.keystore \
--keytool ".../jdk-17.0.4.1/bin/keytool.exe" \
--jarsigner ".../jdk-17.0.4.1/bin/jarsigner.exe" \
--apksigner E:\tools\bin\apksigner.bat --zipalign E:\tools\bin\zipalign.exe
[zip] - META-INF/CERT.RSA (signature artifact)
[zip] - META-INF/CERT.SF (signature artifact)
[zip] - META-INF/MANIFEST.MF (signature artifact)
[zip] + classes.dex (5528 bytes <- .../classes.patched.dex)
[zip] unsigned written: 60967 bytes
[ks] generated E:\...\out\bench.keystore (alias=apkreverse)
error: signer jar not found: uber-apk-signer.jar
pass --signer-jar <path>, or set APK_SIGNER_JAR.
Any zipalign+apksigner based signer works here.
[exit code: 1]
```
Two separate facts, and the second is the one worth recording:
1. `--signer-jar` defaults to `uber-apk-signer.jar` and no such jar exists here, so the default run
cannot sign. (`ENV.md` predicted this.)
2. **The error message's second line is misleading in this environment.** "Any zipalign+apksigner
based signer works here" is true, but `repack.py` has no code path that *uses* a plain
`zipalign`+`apksigner` pair for signing — `sign_apk()` only ever invokes
`java -jar <signer_jar>`. `--apksigner`/`--zipalign` are used for **verification only**. So the
documented Route B works, but only if the caller performs the sign step outside `repack.py`.
The `--no-sign` flag is the supported seam.
Working route, both builds through the **same** pipeline:
```
$ python skills/apk-reverse/scripts/repack.py --apk UnCrackable-Level1.apk \
--dexdir tools/_work/bench/l1l3/dex_orig \
--out out/l1_control_unsigned.apk --workdir out/ctrl --no-sign
[zip] unsigned written: 60967 bytes
[zip] resources.arsc STORED offset=0xd4d4 aligned
== alignment gate: resources.arsc STORED and 4-byte aligned (OK)
[result] UNSIGNED (--no-sign): good for inspection, not installable as-is
$ (same, with --dex classes.dex=classes.patched.dex) -> out/l1_patched_unsigned.apk
[zip] resources.arsc STORED offset=0xd4d4 aligned
== alignment gate: resources.arsc STORED and 4-byte aligned (OK)
```
`AndroidManifest.xml` is reported `STORED ... NOT ALIGNED`, which is correct and not a defect: the
4-byte alignment requirement is `resources.arsc` (and uncompressed `lib/*.so`), not the manifest.
Signing, explicitly, both builds, same key:
```
$ E:\tools\bin\zipalign.exe -p -f 4 l1_control_unsigned.apk l1_control_aligned.apk ; rc=0
$ E:\tools\bin\apksigner.bat sign --ks bench.keystore --ks-pass pass:<pw> --key-pass pass:<pw> \
--v1-signing-enabled true --v2-signing-enabled true --v3-signing-enabled true \
--out l1_control.apk l1_control_aligned.apk ; rc=0
$ E:\tools\bin\apksigner.bat verify --print-certs --verbose \
--min-sdk-version 21 --max-sdk-version 34 l1_control.apk
Verifies
Verified using v1 scheme (JAR signing): true
Verified using v2 scheme (APK Signature Scheme v2): true
Verified using v3 scheme (APK Signature Scheme v3): true
Signer #1 certificate SHA-256 digest: ad13912308e0b6ded5d8be7da2868356348655033d1dde402d8f70d452fbf6db
```
Identical result for `l1_patched.apk`, with the same certificate digest, so the two builds differ
only in the dex bytes.
### 2.4 On-device result — the behaviour change, seen on the screen (`observed`)
Two builds were produced and both were checked, because "the pipeline works" and "the patch changed
the app" are different claims and only the second one is the deliverable.
**The one-command pipeline, re-run after the repository changed** (this supersedes §2.3's manual
route as the recommended invocation):
```
$ python skills/apk-reverse/scripts/repack.py --apk UnCrackable-Level1.apk \
--dex classes.dex=classes.patched.dex --out out/l1_patched_final.apk \
--workdir out/fin --ks out/bench.keystore \
--keytool "C:\Program Files\Java\jdk-17.0.4.1\bin\keytool.exe" \
--apksigner E:\tools\bin\apksigner.bat --zipalign E:\tools\bin\zipalign.exe
[sign] route: apksigner
[out] ...\out\l1_patched_final.apk (70647 bytes)
== alignment gate: resources.arsc STORED and 4-byte aligned (OK)
Verified using v1 scheme (JAR signing): true
Verified using v2 scheme (APK Signature Scheme v2): true
Verified using v3 scheme (APK Signature Scheme v3): true
[result] OK
local sha256 ad6a51cef4699b73ff052d4e049ca9efd64e9570900d19509f0b7a7c20f87e56
```
The manual `zipalign`+`apksigner` route earlier in this section produced **the same sha256**, so the
two paths agree byte for byte and the result does not depend on which one a reader follows.
Install through the **root** path (`pm install -r -d`, which bypasses this ROM's streamed-install
interception), and the installed `base.apk` is hashed and compared to the local file so "installed"
cannot be confused with "the previous build is still there":
```
control device sha256 e5a9f335155e3e20219d89bc859ae89cc0a82f7135123397c526d94bfca4b3b4
local sha256 e5a9f335155e3e20219d89bc859ae89cc0a82f7135123397c526d94bfca4b3b4 identical
patched device sha256 ad6a51cef4699b73ff052d4e049ca9efd64e9570900d19509f0b7a7c20f87e56
local sha256 ad6a51cef4699b73ff052d4e049ca9efd64e9570900d19509f0b7a7c20f87e56 identical
```
`am start -W` for both builds:
```
LaunchState: COLD TotalTime: 241 ms WaitTime: 249 ms Status: ok (control)
LaunchState: COLD TotalTime: 232 ms WaitTime: 234 ms Status: ok (patched)
```
Window focus, sampled four times at 2 s intervals:
```
original mCurrentFocus=Window{a5d2ba7 u0 Root detected!} (all four samples)
control mCurrentFocus=Window{c4047e2 u0 Root detected!} (all four samples)
patched mCurrentFocus=Window{8c94fc1 u0 <PKG>/...MainActivity} (all four samples)
```
And the images, which are the evidence that actually counts —
`tools/_work/bench/l1l3/evidence/00_original/00_original_03.png` shows the modal
`Root detected! / This is unacceptable. The app is now going to exit. / OK` over a greyed-out input
screen. The same shot on the control build (`evidence/01_control/*.png`, and again on
`evidence/12_control_final/*.png` from the final one-command build) is byte-comparable to the
original's. `evidence/13_patched_final/13_patched_final.png` — the final patched build — shows the
screen **without any dialog**: input field live with the caret in it, `VERIFY` enabled, and
`mCurrentFocus` on `<PKG>/...MainActivity`.
**G4 is therefore cleared in the strong form**: the control is not just "the same APK" — it went
through the identical repack-and-sign pipeline with a byte-identical dex and still shows the old
behaviour. The behaviour change is attributable to the four patched bytes, and it reproduced on a
second, independently built APK from the final pipeline.
### 2.5 What the app's own check actually is (`observed`, and it contradicts the obvious reading)
The functional check was probed with a Frida hook rather than inferred from the crypto:
```
[a.b] input="8d127684cbc37c17616d806cf50473cc" -> 16 bytes: 8d127684cbc37c17616d806cf50473cc
[lib a.a] key=8d127684cbc37c17616d806cf50473cc
data=e5426215cb5b9a06c3a0b5e6a4bd769a49e8f074f82eff1d95ab7c17147618e7
-> 17 bytes 492077616e7420746f2062656c69657665 ascii="I want to believe"
[a.a] REAL input="" -> false
```
So: a **fixed 16-byte AES key** (`8d12…73cc`, a `const-string` in the dex) decrypts a fixed
base64 blob to `I want to believe`, and the user's input is compared against that plaintext. My first
reading — that the hex string was itself the secret — is **wrong**, and it was falsified by running
it: entering `8d127684cbc37c17616d806cf50473cc` produces `Nope...`.
`TextUtils.equals` is **not** the comparison site: hooking it captured only dialog-internal
comparisons (`("Nope...", "<null>")`). The comparison lives in the decrypted-bytes path of
`a.a(String)`.
Two device facts that cost time and are worth inheriting:
- **The input field cannot be driven on this ROM.** `adb shell input text "I%swant%sto%sbelieve"`
and a per-character `input keyevent` loop both landed `iwantto`: 7 of 17 characters survived and
every space was dropped. Three independent quoting routes gave the same result, so the field
itself rejects what the injection path sends. Reading the field back with `uiautomator dump` after
each attempt is what distinguished "the app said Nope" from "my input never arrived" — the first
two attempts looked like a wrong secret and were not.
- `EditText.setText(String)` is not reachable from outside (only
`setText(CharSequence, TextView$BufferType)` is declared, and `TextView.BufferType` is not exposed
as a property on the class object); an **empty** `EditText` also returns a **null** `Editable`, so
`getText().length()` fails with "not a function" and looks like a bridge limitation rather than an
empty field.
The success branch was therefore reached through the app's own method with the boolean forced, which
is honest as long as it is labelled — and it is:
`evidence/09_success_branch/09_success_branch.png` shows the app's `Success! / This is the correct
secret.` dialog. That image proves **the patched build reaches and renders the app's own success
branch**; it does not prove the secret was typed and accepted. The check itself is evidenced by the
libfoo/`a.b`/`a.a` log above.
### 2.6 B1 verdict
`observed`: the equal-length edit, the header recomputation in the correct order, the STORED+aligned
repack, v1+v2+v3 signing, the root-path install, the device-side hash match, and the on-screen
behaviour change all reproduce. G2 and G4 both clear.
---
## 3. B2 — L3 self-destruct race (`observed` for the tooling, `unverified`/negative for the patch)
### 3.1 What L3 actually does (read from the dex and the tombstone, not assumed)
`MainActivity.onCreate` calls `verifyLibs()` **first**, then `init("pizzapizzapizzapizzapizz".getBytes())`,
then an `AsyncTask`, then three root checks, then `isDebuggable()`, then `setContentView` — and
`setContentView` sits **after** the gate:
```
0x11a3ce invoke-static Lsg/vantagepoint/util/RootDetection;.checkRoot1()Z
...
0x11a406 sget Lsg/vantagepoint/uncrackable3/MainActivity;.tampered:I
0x11a40a if-eqz v0 -> 0x11a418 ; tampered == 0 -> skip the dialog
0x11a40e const-string "Rooting or tampering detected."
0x11a412 invoke-direct MainActivity;.showDialog(Ljava/lang/String;)V
0x11a418 ... setContentView
```
So when the tamper bit is set the app **never renders its UI**; it sits on a blocking dialog whose OK
button is wired to an `onClick` that exits. `verifyLibs()` is the source of that bit:
- it reads four ABI-keyed expected CRCs out of `resources.arsc` string resources
(`R.string` ids `0x7f0b002b`, `0x7f0b0029`, `0x7f0b0034`, `0x7f0b0035`),
- compares them to the zip entry CRCs of `lib/<abi>/libfoo.so`, and
- compares `classes.dex`'s zip CRC to the value the native `baz()` returns.
The native library exports exactly three JNI entries and one terminate routine:
```
Java_sg_vantagepoint_uncrackable3_CodeCheck_bar vaddr=0x33a4 size=256
Java_sg_vantagepoint_uncrackable3_MainActivity_init vaddr=0x3310 size=136
Java_sg_vantagepoint_uncrackable3_MainActivity_baz vaddr=0x3398 size=12
_Z7goodbyev vaddr=0x3080 size=24
_Z4randv vaddr=0xe4c size=36
```
and its strings name the detection inputs: `/proc/self/maps`, `frida`, `xposed`, `ptrace`,
`waitpid`, `getppid`, `pthread_create`, plus two log strings:
`"Tampering detected! Terminating..."` and `"Error opening /proc/self/maps! Terminating..."`.
`libfoo.so`'s single RX `PT_LOAD` is `off=0x0 vaddr=0x0 filesz=0x4700`, so **file offset == virtual
address == runtime offset from the module base** for everything in `.text`. That is what makes
`hook_patch_only.js`'s `FILE_OFFSET` meaningful here, and it is not true in general — a library whose
text segment has a non-zero `vaddr` would need `p_vaddr + delta`.
### 3.2 The baseline, and the finding that invalidates the assumed baseline
`observed`: the **unmodified, pristine APK** on this device shows the tamper dialog:
```
$ am start -n <PKG>/sg.vantagepoint.uncrackable3.MainActivity
833 ms pid=5377 5413 mCurrentFocus=Window{1d9ee56 u0 Rooting or tampering detected.}
1638 ms ... mCurrentFocus=Window{1d9ee56 u0 Rooting or tampering detected.}
...
10712 ms ... mCurrentFocus=Window{1d9ee56 u0 Rooting or tampering detected.}
```
and `logcat` shows the CRCs it compared (`V/UnCrackable3`), with `tampered` set. The process stays
alive in this state for at least 25 s: the tamper dialog alone does **not** kill the process, it waits
for the OK button.
`observed` — the **time-to-death baseline with a frida probe attached**, which is the number the row
needs:
```
$ python tools/_work/bench/l1l3/ttd_attach.py --package <PKG> --activity sg.vantagepoint.uncrackable3.MainActivity \
--js l3_read_crc.js --launch-delay 1.5 --seconds 20
[i] pre-attach main pid=7020 at 1.88s
[i] attached at +0.00s (probe loaded)
attach+ 0.75s main_pid=7020
... (alive)
attach+ 4.90s main_pid=7020
attach+ 5.34s main_pid=-
=== logcat ===
F/libc ( 7020): Fatal signal 6 (SIGABRT), code -6 (SI_TKILL) in tid 7052 (tg.uncrackable3)
F/DEBUG ( 7203): #01 pc 000000000000308c .../lib/arm64/libfoo.so (goodbye()+12)
F/DEBUG ( 7203): #02 pc 00000000000031ac .../lib/arm64/libfoo.so
I/Zygote( 1007): Process 7020 exited due to signal 6 (Aborted)
```
**`TTD(frida attached) = 5.34 s`, and the death is a native `SIGABRT` originating in `goodbye()+12`.**
Note which process dies: `7020` is the main `<PKG>` process, `7052`/`tg.uncrackable3` is the failing
*thread*. That distinction matters — see §3.4.
### 3.3 The two scripts, run as shipped (`observed`)
Neither had ever been executed before this pass.
```
$ python skills/apk-reverse/scripts/spawn_patch_detach.py \
--package <PKG> --js skills/apk-reverse/scripts/hook_patch_only.js \
--captures 2 --interval 3 --no-screencap
[i] spawned pid=7442
[!] PATCH_BYTES is empty -- configure this probe before using it
[MSG] PATCHFAIL
[i] patched=False
[i] detached (memory writes persist, hooks are gone)
=== capture 0 ===
7567 00:01 sh -c ps -A -o PID,ETIME,ARGS | grep -i uncrackable3
7570 00:01 grep -i uncrackable3
mCurrentFocus=Window{7c35f28 u0 com.oppo.launcher/com.oppo.launcher.Launcher}
```
Three defects, all confirmed by later runs:
1. **`hook_patch_only.js` is a template, and its guard is correct but the guard is the whole
result.** `PATCH_BYTES = []` correctly refuses and reports `PATCHFAIL`, so an unconfigured run
cannot silently claim success. That is good behaviour; it just means the shipped file tests
nothing.
2. **`spawn_patch_detach.py` resumed the target before waiting for `PATCHED`.** The original order
was `resume(pid)` → then wait. That makes the wait meaningless: by the time the probe's
`Process.findModuleByName` poll sees the library, the target has been running.
3. **`--activity` defaults to `None` and the code then did nothing.** After `am force-stop` and a
spawn, omitting `--activity` left the app un-launched, so the capture set was of the launcher and
read exactly like "the app died". Fixed by resolving the launcher through
`cmd package resolve-activity --brief <pkg>` when `--activity` is absent.
**Correction to my own first fix.** I changed the order so the driver held the target **paused**
until `PATCHED`. That cannot work with a module-polling probe, and the run says so:
```
[i] probe loaded; target still PAUSED, waiting up to 15.0s for the patch
[5.00s][alive] [10.00s][alive] [15.00s][alive]
[!] the probe did not report PATCHED in 15.0s
[i] resumed pid=7666
[15.11s][i] libfoo.so base=0x764c0aa000 bytes@0x3080 = fd 7b bf a9 ...
[15.12s][PATCH] now = 00 00 80 52 d6 03 5f c0 ...
```
**While the process is frozen its libraries are not mapped**, so the poll can never succeed: 15 s
paused produced nothing, and `libfoo.so base=0x764c0aa000` appeared 0.11 s after resume. The shipped
order was right about *that*; what was wrong was detaching before the write landed. The corrected
driver resumes, then waits for `PATCHED` (reported loudly when it never arrives), then detaches.
### 3.4 The experiment, and its result
Sites were chosen from the tombstone and the ELF, not guessed. `goodbyev` at `0x3080`:
```
0x3080 a9bf7bfd stp x29, x30, [sp, #-16]!
0x3084 910003fd mov x29, sp
0x3088 321f07e0
0x308c 97fff725 bl 0x2ff0 <-- the call the tombstone names as goodbye()+12
0x3090 2a1f03e0
0x3094 97fff71f bl 0x2ff8
```
and the two helpers it tail-calls are trivial return-value stubs:
```
0x2ff0 52801a40 mov w0, #0xd2 0x2ffc d65f03c0 ret
0x2ff8 52800cc0 mov w0, #0x66 0x3000 321f0fe0 ...
```
So probe v2 patches the **return values the branch reads** (`0x2ff0`, `0x2ff8`) as well as
`goodbyev`'s entry, each rewritten to `mov w0, #0 ; ret` (+ nops, equal length), with an assertion on
the expected original bytes before each write.
Patched condition:
```
$ python tools/_work/bench/l1l3/b2_measure.py --js l3_hook_patch_v2.js --tag PATCHED --probe-seconds 25
[i] spawned pid=10000 (paused)
[i] resumed pid=10000
[0.04s][i] libfoo.so base=0x764b89d000 size=90112
[0.04s][i] goodbye entry = fd 7b bf a9 fd 03 00 91 e0 07 1f 32 25 f7 ff 97
[0.04s][i] goodbye+12 = 25 f7 ff 97
[0.04s][PATCH] 0x2ff0 (helper A -> return 0) 40 1a 80 52 c0 03 5f d6 -> 00 00 80 52 d6 03 5f c0
[0.05s][PATCH] 0x2ff8 (helper B -> return 0) c0 0c 80 52 c0 03 5f d6 -> 00 00 80 52 d6 03 5f c0
[0.05s][PATCH] 0x3080 (goodbyev entry -> no-op) fd 7b bf a9 ... -> 00 00 80 52 d6 03 5f c0 1f 20 03 d5 1f 20 03 d5
[0.05s][i] sites written=3 skipped=0
[i] patched=True
3.22s pid=10000 alive=False
V/UnCrackable3(10000): Tampering detected! Terminating...
F/libc (10000): Fatal signal 6 (SIGABRT), code -6 (SI_TKILL) in tid 10032 (tg.uncrackable3), pid 10000
F/DEBUG (10036): #01 pc 000000000000308c .../lib/arm64/libfoo.so (goodbye()+12)
I/Zygote( 1007): Process 10000 exited due to signal 6 (Aborted)
[RESULT] tag=PATCHED spawned=10000 ttd_after_resume=3.22 survived=False
```
Control condition (identical reads and writes, original bytes written back):
```
$ python tools/_work/bench/l1l3/b2_measure.py --js l3_hook_control_v2.js --tag control_v2 --probe-seconds 22
[0.04s][CONTROL] 0x2ff0 unchanged (wrote back 40 1a 80 52 c0 03 5f d6)
[0.04s][CONTROL] 0x2ff8 unchanged (wrote back c0 0c 80 52 c0 03 5f d6)
[0.04s][CONTROL] 0x3080 unchanged (wrote back fd 7b bf a9 fd 03 00 91)
[0.04s][i] control sites ok=3
V/UnCrackable3(9312): Tampering detected! Terminating...
F/DEBUG ( 9348): #01 pc 000000000000308c .../lib/arm64/libfoo.so (goodbye()+12)
I/Zygote( 1007): Process 9312 exited due to signal 6 (Aborted)
```
**Both conditions terminate at `goodbye()+12` with `SIGABRT`.** The probes apply the patch
successfully, inside the race window (0.04–0.05 s after resume against a ~3.2 s deadline), and the
target still dies.
### 3.5 What this means, and what to do instead
`observed`:
- `spawn_patch_detach.py` **can** deliver an early memory patch: the write landed 0.05 s after resume
and survived the detach. The mechanism the row tests is real.
- `hook_patch_only.js` is a **template with correct guard rails**, not a working probe.
- The patch as configured is **insufficient**: three sites including the terminate routine's entry
and both of its branch inputs do not stop the abort.
`inferred` (labelled, not executed): the terminate path is not confined to `goodbyev` and its two
stubs. `goodbyev` is 24 bytes and its only two calls are the two stubs; the `bl` at `+12` is the
observed faulting frame, and yet patching both the callee and the caller changed nothing. The most
consistent explanation is that the tampering thread reaches the *same abort* through a second site
that this static pass did not map — the region around `0x2f80–0x3080` is a dense run of
`mov w0, #imm ; ret` pairs (values 0x11, 0x1b, 0x43, 0x5b, 0x67, 0x7b, 0x92, 0xa2, 0xa3, 0xae, 0xcc,
0xd2, 0xe3, 0xe7, 0xe8, 0xed, 0xf6, 0xfd, …), i.e. a jump-table-shaped cluster of tiny accessors
that a proper disassembly would resolve into a call graph, and `rand` (`_Z4randv`, 0xe4c, 36 B) is
part of the same obfuscated scheme. **A real fix needs a full AArch64 disassembly of `.text`
(~18 KB) rather than four hand-picked offsets.**
`observed` — the route that should be tried first next time: the abort is reachable from a **Java**
layer that is already fully mapped. `verifyLibs()` computes `tampered` and `onCreate` branches on it,
and the dialog's OK button is a plain `DialogInterface.OnClickListener` in `MainActivity$1`. Patching
the dex in that class (`if-eqz v0 -> 0x11a418` → nops, forcing the non-tamper side) removes the
dialog and lets `setContentView` run **without touching native code at all** — and unlike the native
patch it is a route this repository already has measured tooling for (see B1). This was not executed
in this pass; it is the recommended next experiment, not a result.
`observed` — a limitation the next pass must budget for: on this device the L3 **original** APK
already fails its own CRC self-check, so the tamper state is on before any instrumentation. Whatever
the follow-up tests must therefore be measured against *that* baseline, not against a clean one.
---
## 4. Task C — L2 (not run)
Not attempted: A and B consumed the pass. `UnCrackable-Level2.apk` is on disk
(`tools/_work/bench/apks/`, 901022 B, sha256 prefix `4c7980ef1f6cc295`) and the `dynamic-frida.md`
spawn route is untested for it. Recorded as **not run**, not as a negative result.
---
## 5. Defects found in the repository's own tooling
| Where | What | Evidence | Status |
|---|---|---|---|
| `scripts/repack.py` (`sign_apk`) | **Stale finding, re-opened and closed.** At the start of this pass it signed **only** via `java -jar <signer_jar>`; `--apksigner`/`--zipalign` were verification-only, so the "any zipalign+apksigner based signer works here" hint had no code path behind it | §2.3 exit 1 with `signer jar not found` | **fixed by another writer mid-pass**; re-measured in §2.4 — `[sign] route: apksigner`, `[result] OK`, byte-identical output |
| `scripts/repack.py` | The `signer jar not found` message is still emitted by the jar-only `sign_apk()` and still claims a zipalign+apksigner route works. When no `--apksigner` was supplied (the first run in §2.3) the message is the *only* guidance a reader gets, and it points at a function that does not exist | §2.3 | documented; the message should name the apksigner route it now has |
| `scripts/spawn_patch_detach.py` | Detached before the probe's write landed, so the write could be missed entirely while the run still reported `patched=False` and looked merely unlucky | §3.3 | **fixed** — resume, wait for `PATCHED`, report loudly, then detach |
| `scripts/spawn_patch_detach.py` | `--activity` absent left the target un-launched after a spawn, so the captures were of the launcher | §3.3 | **fixed** — resolve the launcher via `cmd package resolve-activity` |
| `scripts/spawn_patch_detach.py` | Unbounded `adb`/frida calls; `adb_run()`'s rc is never inspected for the launcher step | §3.3 | partially fixed (`--launch-timeout`, rc surfaced) |
| `scripts/hook_patch_only.js` | Guard rails are correct (`PATCH_BYTES` empty → `PATCHFAIL`); it is a template, and the docs imply it is a runnable probe | §3.3 | documented |
| `tools/_work/ENV.md` | Attributes the B1 signing problem to the apksigner/zipalign route; the blocker was the missing uber-apk-signer jar, and both apksigner and zipalign work | §2.3 | corrected here |
| `tests/benchmark.md` B2 | Describes L3 as a self-destruct race against a pristine baseline; on this device the original APK is already in its tamper state | §3.2 | corrected here |
---
## 6. Files produced by this pass
Workbench (not part of the skill; `tools/` is git-ignored):
- `tools/_work/bench/l1l3/l1_root.spec.json`, `dex/classes.patched.dex`, `dex_orig/classes.dex`
- `tools/_work/bench/l1l3/out/l1_control.apk`, `l1_patched.apk`, `l1_control_now.apk`,
`l1_patched_final.apk` (+ unsigned/aligned intermediates)
- `tools/_work/bench/l1l3/l1_probe.py`, `l1_type.py`, `l1_verify_field.py`, `l1_try_secret.py`,
`l1_drive.js`, `l1_probe_full.js`, `attach_probe.py`
- `tools/_work/bench/l1l3/l3_hook_patch_v2.js`, `l3_hook_control_v2.js`, `l3_read_crc.js`,
`elf_l3.py`, `dis_l3.py`
- `tools/_work/bench/l1l3/b2_measure.py`, `ttd.py`, `ttd_attach.py`
- `tools/_work/bench/l1l3/evidence/**` (PNG captures: `00_original`, `01_control`,
`02_patched_nosecret`, `09_success_branch`, `12_control_final`, `13_patched_final`)
A note on making §2.4 reproducible: `l1_probe.py` reuses the tag as the remote filename, so
`--tag` must be unique per run. Reusing a tag after a failed `adb push` installs the *previous*
remote file, which is exactly how the first attempt at the final patched verification showed
`Root detected!` — the control build's hash, from the control run's leftover remote path. The hash
comparison in the same script is what caught it; a screenshot alone would not have.

View File

@@ -0,0 +1,183 @@
# EXTENSION — Dart AOT string-table formats, measured on a real dual-ABI app
Evidence file for the `dart-aot.md` changes in this pass. Every claim below carries a grade:
- **observed** — a command was run here and its output is quoted in this file.
- **inferred** — follows from an observed fact or from documented mechanism; the step was not executed.
- **unverified** — assumed or reported elsewhere, not reproduced here.
Target identity is replaced with `<PKG>`; the artifact is referred to as "the sample". The technique
(tool, library, function, hardening product) is kept, per
`skills/apk-reverse/references/desensitization-and-leak-scans.md`.
## 1. What was measured
| Claim under test | Grade | Result |
|---|---|---|
| The arm64 Dart AOT string table is `[0x80\|(len<<1)\|two_byte][payload]` and the entries chain | **observed** | confirmed on 4,980 entries |
| The armv7 (32-bit) snapshot uses the *same* format | **refuted** | the same extractor keeps 0 entries; the tag byte is not present before the literal |
| An armv7 string record is `[length u32le][UTF-16LE chars]`, so the length field reads `len*2` | **refuted** | the u32 before the payload reads the **byte** count, and the payload is UTF-8 |
| The engine version banner always yields `x.y.z (stable) (…)` | **refuted** | a real `libflutter.so` carries `0.0.1` with a blank build id and no `(stable)` |
| An equal-length replacement of a data key is legal on both ABIs without recomputing the tag | **observed** (record layout), **unverified** (end-to-end device run) | the record is self-contained on both ABIs |
Two of the five were assumptions that would have been written into the reference as fact. Both were
killed by a single command each, which is the whole argument for measuring before documenting.
## 2. Sample and preparation
The sample is the locally supplied "complex encryption and hardening" APK (360 shell + Flutter).
The two AOT snapshots were extracted from the zip without repacking:
```
$ python -c "import zipfile; z=zipfile.ZipFile(r'存在问题和例子\<sample>.apk'); \
[print(n, len(z.read(n))) for n in ('lib/arm64-v8a/libapp.so','lib/armeabi-v7a/libapp.so')]"
lib/arm64-v8a/libapp.so 16352152
lib/armeabi-v7a/libapp.so 17629776
```
Work copies live under the ignored workspace `tools/_work/dart/`; nothing from this evidence pass is
committed except the reference text.
## 3. The arm64 packed table — confirmed
```
$ python skills/apk-reverse/scripts/dart_pool_strings.py tools/_work/dart/app_arm64.so s64.tsv --min 6
candidates : 17352
kept (run >= 3) : 4980 (one-byte 4980 / utf16le 0)
below threshold : 12372 [--keep-isolated to keep them]
written s64.tsv
```
The tag arithmetic was checked directly against real literals in the image rather than only through
the script. For each literal, the byte immediately before it must equal `0x80|(len<<1)`:
```
$ python -c "... for m in (b'AdmobBanner', b'showSplashAd', b'AdPgl_Splash'): ..."
### app_arm64.so
AdmobBanner @0x4932e len=11 expected_tag=0x96
tag_byte_at_-1=0x96 matches=True
showSplashAd @0x39deb len=12 expected_tag=0x98
tag_byte_at_-1=0x98 matches=True
AdPgl_Splash @0x493ee len=12 expected_tag=0x98
tag_byte_at_-1=0x98 matches=True
```
**observed**: three independent literals of two different lengths, three matching tag bytes. This is
also the assertion a patch script should perform (`dart-aot.md` §10), and it is what rejects a hit
that landed mid-string.
## 4. The armv7 assumption — refuted, and what is actually there
The starting hypothesis (carried over from an external case) was that the 32-bit snapshot stores a
string as `[tag 0x81][len*2 u32le][UTF-16LE chars]`. Two scanners written against that hypothesis
found nothing, which is itself the evidence:
```
$ python tools/_work/dart/probe3.py app_armv7.so raw # [8-byte hdr ending 0x81][u32le len][chars]
hits=0 chained_runs=0 entries_in_runs=0
$ python tools/_work/dart/probe3.py app_armv7.so packed # the arm64 tag scheme
hits=0 chained_runs=0 entries_in_runs=0
```
The same extractor that keeps 4,980 entries on arm64 keeps **zero** on armv7:
```
$ python skills/apk-reverse/scripts/dart_pool_strings.py tools/_work/dart/app_armv7.so s7.tsv --min 6
candidates : 1355
kept (run >= 3) : 0 (one-byte 0 / utf16le 0)
```
A zero here is a **format mismatch, not an empty string table**, and that distinction is now stated in
`dart-aot.md` §7: reading it as "the strings were stripped" is exactly the wrong conclusion the same
section warns about for UTF-16.
What the record actually looks like, from bytes around a known literal:
```
$ python -c "... b.find(b'SystemChrome') ..."
0x1ffb3c b'\x00\x00\x00\x008\x06U\x00F\x00\x00\x00\xce-\x8a@SystemChrome.setEnab...'
0x204ce4 b'\x00\x00\x00\x008\x06U\x00H\x00\x00\x00\xa45jVSystemChrome.setSyst...'
0x2185c4 b' ere8\x07U\x00J\x00\x00\x00\x8c]\xb4GSystemChrome.setPref...'
```
Read as `[header u32][length u32le][payload]`: `0x00550638` then `0x00000046` = **70**, which is the
byte length of `SystemChrome.setEnabledSystemUIMode` (36 chars) immediately followed by
`SystemChrome.setSystemUIOverlayStyle` (34 chars) — **70 bytes, UTF-8, not UTF-16**. The second
literal starts at the exact byte where the first ends, and its own length field reads `0x00000048` =
72, matching its own continuation.
**observed**: `length == payload byte count`, payload is UTF-8, and in-place equal-length replacement
therefore keeps the record valid without recomputing anything. **refuted**: the `len*2` / UTF-16 form.
The header word is a tags value with full 4-byte alignment, not a constant to hard-code:
```
$ python -c "... alignment histogram over the image ..."
alignment mod 4: {0: 13397}
```
```
$ python tools/_work/dart/perf.py app_armv7.so
scanned 17629776 B in 0.94s using 4-byte stride + int.from_bytes
aligned-header string records: 582
stride-1 micro scan over 17629776 B took 2.80s (found 2762)
```
The literal search plus a backwards length assertion is the tool-independent method, and it costs
under a second over a 17.6 MB image — so validating every candidate is affordable and the armv7
limitation does not justify a hand-built table parser. **unverified**: whether a *different* armv7
snapshot uses a different header word; the reference tells the reader to read it from the file.
## 5. The engine version banner — a version-shaped lie
```
$ python -c "... rfind NUL before the ' on \"android_arm64\"' suffix ..."
end-of-version offset 0x110821
version string start 0x1107f1 => total 66
b'0.0.1 on "android_arm64"'
```
The banner exists, is 66 bytes long, and its numeric field is `0.0.1` with a blank build-id field —
no `(stable)`, no `(Mon …)`. A regex that takes `\d+\.\d+\.\d+` out of this returns a value that would
send a reader to build a Dart VM for `0.0.1`.
**observed** (the byte string above), **inferred** (that this is a build-time population failure rather
than a deliberately rewritten banner). The actionable consequence is in `dart-aot.md` §1: assert the
`(stable) (…)` tail before using the number, and fall back to the snapshot hash otherwise. **The
sample's Dart version was not pinned in this pass** — that is an unverified item, not a finding.
## 6. Cross-validation
Every measurement above is a **raw byte search with an independent arithmetic assertion** (find the
literal in the file; check the byte or word immediately before it against the length of what follows).
It does not depend on `dart_pool_strings.py`, and the script's numbers are reported alongside the raw
probe rather than as the source of truth. The two agree on arm64 (4,980 kept entries, tag byte equal to
`0x80|(len<<1)` at every literal tested) and on armv7 (the packed scheme finds nothing, the byte layer
shows why).
**Not obtained**: a second, independently implemented Dart snapshot parser. `blutter` was not built in
this pass, so the reference's `pp.txt`-side claims were not re-derived here, and the armv7 conclusion
rests on the byte layout rather than on a tool's agreement. That is the boundary of this evidence.
## 7. What this pass changed in the repository
- `skills/apk-reverse/references/dart-aot.md` — §7 gained the armv7 record format and the
"the armv7 zero is a format mismatch" warning; §1 gained the banner-shape assertion; §10 gained the
data-key-versus-API-path rule with its measured failure mode, and the longest-first / assert-the-prefix
rule. No new file, no new index row: the finding belongs in the file that already owns this layer.
- The equal-length-replacement failure (a 404 whose body is not JSON, `jsonDecode` throwing, the app
frozen on the launch logo) is documented as the concrete instance of the top-level constraint
"do not make an API fail to suppress a UI element".
## 8. Not done, and known to be outstanding
1. **No end-to-end device run.** The string-table patch was not repacked, signed and installed in this
pass, so "an equal-length key replacement suppresses the feature while the app still starts" is
`unverified` end-to-end even though the record layout that makes it legal is `observed`.
2. **No `blutter` build**, so no `pp.txt` comparison and no confirmation of the arm64 pool space from a
second implementation (§6).
3. **The sample's Dart version is unpinned** (§5); a structural probe or a snapshot hash is the next
experiment, not a guessed number.
4. **The armv7 header word is not characterised.** It is read from the file and used as a validator;
which component assigns it was not determined.

View File

@@ -0,0 +1,454 @@
# Extension verification — desensitization and leak scans
Covers the pass that added `skills/apk-reverse/scripts/scan_leaks.py` and
`skills/apk-reverse/references/desensitization-and-leak-scans.md`, plus the precedent library
under `skills/apk-reverse/references/precedents/`.
Strength labels follow `docs/tool-verification/README.md`: **observed** (the exact command and
its output are reproduced here), **inferred** (follows from observed facts; the step itself was
not executed), **unverified** (assumed or reported elsewhere and not independently confirmed).
Reference environment: Windows 11 host, Python 3.14.0, repository root on branch
`main`. **No device, no network and no target sample were involved** — this pass is entirely
machine-local, because the thing worth measuring is what a scanner reports against a tree whose
contents are already known. Every identifier in the fixture and in every transcript below is
synthetic; the repository's convention of `<PKG>` / `<DEVICE>` / `<serial>` is used wherever a
real one would have appeared.
Date of the recorded runs: 2026-09-21. The numbers in §3b and §4a were measured on that date, on
the tree this pass leaves behind; those two sections' transcripts are the ones this pass produced.
**Headline: the planted-leak corpus is reported in full (30 findings, 10 further shapes admitted
through the exemption path), and the repository scan converges to zero strong/certain findings —
the residue is four `endpoint/weak` hits, all four an RFC 5737 documentation address, which is a
class this scanner reports on purpose. The first version of this file was itself the
counter-example to its own subject: it quoted the planted values to prove the rules fire, and the
scanner found that as a leak (§3b). The corpus keeps its values, because a corpus that cannot be
found proves nothing; the published document keeps none. The gate is not wired into anything yet;
that is the largest gap, and it is a Lead-side action.**
---
## 1. What was built, and what it was measured against
| Artifact | Role | Measured? |
|---|---|---|
| `skills/apk-reverse/scripts/scan_leaks.py` | the scanner: 17 rules in 6 categories, exemption table, four `RESULT=` tokens, `--fail-on strong\|any` | **observed** — every run below |
| `tools/_work/leak-precedent/gen_fixtures.py` | generates the planted corpus and the exemption corpus | **observed** — 7 files, re-runnable |
| `tools/_work/leak-precedent/fixture/` | the corpus itself (git-ignored work area) | **observed** |
| `skills/apk-reverse/references/desensitization-and-leak-scans.md` | the reasoning, the do-not-anonymize table, the failure modes | not executable |
| `skills/apk-reverse/references/precedents/**` | the case library, distilled from this repository's existing evidence | see the per-case strength tables |
The fixture exists so the claim "the scanner reports planted leaks" is reproducible rather than
a transcript pasted once:
```
$ python tools/_work/leak-precedent/gen_fixtures.py
wrote tools/_work/leak-precedent\fixture\planted\package.txt (411 bytes)
wrote tools/_work/leak-precedent\fixture\planted\device.txt (218 bytes)
wrote tools/_work/leak-precedent\fixture\planted\token.txt (261 bytes)
wrote tools/_work/leak-precedent\fixture\planted\appkey.txt (212 bytes)
wrote tools/_work/leak-precedent\fixture\planted\endpoint.txt (146 bytes)
wrote tools/_work/leak-precedent\fixture\planted\path.txt (175 bytes)
wrote tools/_work/leak-precedent\fixture\exempt\benign.txt (1396 bytes)
== 7 fixture file(s) ==
```
## 2. `--help`, and the rule table — observed
```
$ python skills/apk-reverse/scripts/scan_leaks.py --help
usage: scan_leaks.py [-h] [--root ROOT] [--only ONLY] [--exclude EXCLUDE]
[--format {text,json}] [--show-exempt] [--list-rules]
[--max MAX] [--quiet] [--fail-on {strong,any}]
[--no-color]
Scan a skills repository for target identity that should not be published (bundle ids, device serials, tokens, SDK keys, literal endpoints, user paths). Exit 0 clean / 1 leaks_found / 2 error; prints RESULT=<token> last. `--fail-on strong` (default) keeps the gate green when the only hits are documentation-range addresses.
options:
-h, --help show this help message and exit
--root ROOT repository root to scan (default: the repo this script
lives in)
--only ONLY comma-separated categories to restrict to, e.g.
pat,appkey
--exclude EXCLUDE extra directory name to skip (repeatable)
--format {text,json}
--show-exempt also print findings that were suppressed by an
exemption
--list-rules print the rule table and exit
--max MAX stop after N findings (0 = no limit)
--quiet print only the RESULT token
--fail-on {strong,any}
which findings make the exit code non-zero: `strong`
(default) fails only on strong/certain findings, `any`
also fails on `weak` ones such as an address from the
RFC 5737 documentation range
--no-color accepted for compatibility
Categories: package, device, token, appkey, endpoint, path
Exemptions (tool names, library names, CVE ids, hardening products, public
crackme names, <PKG>-style placeholders, loopback/emulator addresses) are built
in and audited with --show-exempt.
```
`--list-rules` prints the table the exemption functions are attached to, which is what makes the
scanner arguable rather than opaque. It exits 0 and prints `RESULT=clean` (no files were read),
and the top of its output is:
```
$ python skills/apk-reverse/scripts/scan_leaks.py --list-rules
== rules ==
bundle_pkg_attr package strong a `package=` bundle id in a manifest or transcript
bundle_pkg_decl package strong a bundle id in a `<manifest>` line
bundle_pm_path package strong a bundle id in a `pm`/`am`/`monkey` invocation
bundle_process_line package strong a bundle id in a `ps`/`pidof`/`top` capture
bundle_component package strong a bundle id in a `component=`/`-n` activity reference
device_serial device strong an `adb devices`-shaped device serial
device_serial_context device strong a serial next to a device/install/`su` context word
device_serial_tabular device strong a bare 16-char token in a device-listing table
token_github_pat token certain a GitHub fine-grained personal access token
token_github_classic token certain a GitHub classic token
token_env_assignment token strong an inline API-key/token/secret assignment
token_authorization_header token strong a literal Authorization header value
appkey_assignment appkey strong an SDK appkey/appsecret assignment with a literal value
plain_addr endpoint weak a non-loopback literal IP:port
plain_addr_ipliteral endpoint weak a non-loopback bare IPv4 literal
user_path_posix path strong an absolute POSIX user-home path
user_path_windows path strong an absolute Windows user-profile path
RESULT=clean (exit 0)
```
## 3. The planted corpus — observed, every rule fires
```
$ python skills/apk-reverse/scripts/scan_leaks.py --root tools/_work/leak-precedent/fixture --format json
{ "root": "...\\tools\\_work\\leak-precedent\\fixture", "files_scanned": 7,
"findings": [ ... 30 ... ], "exempted": [], "errors": [], "result": "leaks_found" }
RESULT=leaks_found (exit 1)
```
aggregated (the `findings` array of the same JSON run, grouped by category):
```
files 7
findings 30 {'appkey': 4, 'token': 5, 'device': 8, 'endpoint': 5, 'package': 5, 'path': 3}
```
and the 30 hits by position, rule and value **shape** — every field of the report except the
literal itself. Rule name, category, strength, position and count are the evidence; the quoted
value is a copy of the leak, and copying it into a published document re-creates what the scan
exists to prevent (§3b is where that rule was paid for):
```
planted/package.txt:2:16 bundle_pkg_decl 4-segment all-lowercase reverse domain
planted/package.txt:2:26 bundle_pkg_attr the same value, second rule on the same line
planted/package.txt:3:1 bundle_pm_path 3-segment all-lowercase reverse domain, after `pm path`
planted/package.txt:5:1 bundle_process_line 4-segment all-lowercase reverse domain, after `pidof`
planted/package.txt:6:15 bundle_component 4-segment all-lowercase reverse domain, after `component=`
planted/device.txt:4:1 device_serial 16×[A-Z0-9], tab-separated from the word `device`
planted/device.txt:4:1 device_serial_tabular the same value, second rule on the same line
planted/device.txt:5:3 device_serial_context 16×[A-Z0-9], after `$ adb -s `
planted/device.txt:5:10 device_serial the same value, second rule on the same line
planted/device.txt:6:2 device_serial_context 16×[A-Z0-9], after `[serial `
planted/device.txt:6:9 device_serial the same value, second rule on the same line
planted/device.txt:7:1 device_serial_context 16×[A-Z0-9], after `device serial: ` (a second value)
planted/device.txt:7:16 device_serial the same value, second rule on the same line
planted/token.txt:2:14 token_github_pat fixed provider prefix + 62×[A-Za-z0-9_]
planted/token.txt:3:15 token_github_classic fixed 4-char provider prefix + 36×[A-Za-z0-9]
planted/token.txt:4:1 token_env_assignment 20-char `AKIA`-prefixed key id after a key assignment
planted/token.txt:5:9 token_authorization_header 32×[A-Za-z0-9] after an `Authorization:` keyword
planted/appkey.txt:5:1 token_env_assignment the same 40-char secret, second rule on the same line
planted/appkey.txt:2:1 appkey_assignment 24×[0-9a-f] after an appkey assignment
planted/appkey.txt:3:1 appkey_assignment 24×[0-9a-f], quoted, after a camelCase app-secret assignment
planted/appkey.txt:4:1 appkey_assignment 7 dash-separated 4-character uppercase groups
planted/appkey.txt:5:1 appkey_assignment 40-char mixed-case secret after a `SECRET_KEY` assignment
planted/endpoint.txt:2:7 plain_addr an RFC 5737 documentation address, with `:port`
planted/endpoint.txt:2:7 plain_addr_ipliteral the bare form of the same address
planted/endpoint.txt:3:16 plain_addr an RFC 1918 private-range address, with `:port`
planted/endpoint.txt:3:16 plain_addr_ipliteral the bare form of the same address
planted/endpoint.txt:4:12 plain_addr_ipliteral an RFC 5737 documentation address, bare
planted/path.txt:2:10 user_path_posix two-segment personal name after `/home/`
planted/path.txt:3:6 user_path_posix one-word personal name after `/Users/`
planted/path.txt:4:6 user_path_windows capitalized personal name after `C:\Users\`
```
Two rules firing on one line (`SECRET_KEY=` matches both an appkey and a token pattern) is kept
deliberately and is visible in the count; identical duplicates (same rule, same value, same
line) are collapsed. Eight of the 30 findings share their line with a second rule — one package
line, all four serial lines, the `SECRET_KEY` line and the two endpoint lines — which is why 30
findings cover 22 distinct values (counted from the `findings` array of the JSON run above, not
by hand).
### 3a. The exemption corpus — observed, and it is the half that is easy to get wrong
The same run with `--show-exempt` shows what was *not* reported from
`fixture/exempt/benign.txt`, and the reason:
```
$ python skills/apk-reverse/scripts/scan_leaks.py --root tools/_work/leak-precedent/fixture --show-exempt
== suppressed by exemption: 10 ==
exempt/benign.txt:13 [user_path_windows] <user> -> placeholder user
exempt/benign.txt:17 [plain_addr] 127.0.0.1:27042 -> loopback/unspecified/emulator address
exempt/benign.txt:17 [plain_addr_ipliteral] 127.0.0.1 -> loopback/unspecified/emulator address
exempt/benign.txt:17 [plain_addr] 0.0.0.0:8080 -> loopback/unspecified/emulator address
exempt/benign.txt:17 [plain_addr_ipliteral] 0.0.0.0 -> loopback/unspecified/emulator address
exempt/benign.txt:17 [plain_addr] 10.0.2.2:8080 -> loopback/unspecified/emulator address
exempt/benign.txt:17 [plain_addr_ipliteral] 10.0.2.2 -> loopback/unspecified/emulator address
exempt/benign.txt:18 [plain_addr] 127.0.0.1:8787 -> loopback/unspecified/emulator address
exempt/benign.txt:18 [plain_addr_ipliteral] 127.0.0.1 -> loopback/unspecified/emulator address
exempt/benign.txt:18 [plain_addr_ipliteral] 169.254.169.254 -> loopback/unspecified/emulator address
```
The earlier pass recorded 6 suppressed hits, all `plain_addr`; the current run reports the paired
`plain_addr_ipliteral` for each exempt address as well, which is what the two same-line rules
look like when nothing is being collapsed. The count moved from 6 to 10 for that reason and not
because the exemption table changed — nothing in it was touched by this pass.
Everything else on that file — tool names, library names, function names, dex/ELF constant
identifiers, protocol field names, CVE ids, hardening product names, the public crackme and its
URL, platform and SDK packages, `com.example.*` / `probe.synthetic.*` and the `<PKG>`/`<DEVICE>`/
`<hash>` placeholders — produced **no finding at all**, which is the intended outcome for the
do-not-anonymize list and is also the check that the two synthetic fixture prefixes named in
`references/desensitization-and-leak-scans.md` really are exempt: `com.example.app` sits on line
83 of that file and is absent from both the findings and the suppressed list, because
`BENIGN_PACKAGE_PREFIXES` contains `com.example`, `org.example`, `net.example`, `io.example` and
`probe.synthetic`. The file was written to hold every category of that list, one line each, so a
regression there shows up as a new finding rather than as a silent behavioural drift.
`169.254.169.254` (the cloud metadata endpoint) is exempted **only** in this documented-endpoint
form. It is a real address that matters in a different context; the exemption is a judgement
recorded in `BENIGN_IP_PREFIXES`, not a pattern match, and it is the entry most worth
re-examining if this list is ever copied to another repository.
### 3b. The leak this file carried — found by the scanner it documents
The first version of this section listed all 30 hits **with their values**, quoted out of the
JSON, because a report with the value in it is the concrete kind. The Lead then ran the tool
against the repository and got `RESULT=leaks_found`, exit 1, with **26 strong/certain findings
and 14 weak ones — all 26 strong ones inside this file**, at lines 113–146 of the version that
existed then: the 5 bundle-id hits, 2 distinct device serials (12 hits), 2 provider-format tokens,
2 inline assignment values, 2 SDK appkeys and 3 host user names it had just finished quoting.
Nothing else in the tracked surface produced a strong hit.
That is the failure mode this whole pass exists to prevent, committed by the pass itself, and it
states the boundary in one line: **the corpus is where identity is allowed; the published surface
is not.** `tools/_work/leak-precedent/` is git-ignored and excluded from the default scan root so
that it *can* hold the values — a corpus that does not contain them cannot demonstrate the rules
firing. This file is not in that category: it is tracked, it is read, and every string in it is
published.
The correction, applied in this pass:
1. The hit list in §3 now carries rule, category, strength, position, count and a **structural
description** of each value, in the repository's own placeholder notation. No evidence was
dropped: all 30 positions, all 17 rule ids, both strength labels and the per-category counts
are still here, and the corpus still produces all 30 when it is scanned directly (§3).
2. The two version-shaped literals in §4's false-positive table are now written in
`<N>.<N>.<N>.<N>` form. They are legitimate values, but leaving them would put two *different*
classes in the weak residue instead of one, and an unexplained second class is how a weak hit
starts getting ignored.
3. `references/desensitization-and-leak-scans.md` records the rule this produced — a report is a
copy of what it found, so the report is scanned like any other published surface — and its
exit table now carries the `leaks_found_strong_only` state.
The scanner's own verdict on the corrected tree is in §4a. The lesson is the one the reference
file already made about entry points, applied to an evidence file: **run the scan on the file you
are about to publish, not on the file you started from** — and then do not paste the thing it
found back into that file.
## 4. The repository scan — observed, and the false positives that produced it
The first run against the real tree reported **28 findings**, of which 27 were wrong. The
sequence is recorded in full because the corrections are the useful part: every one of them was
found by running the scanner, not by reasoning about it.
```
$ python skills/apk-reverse/scripts/scan_leaks.py # first run
== leak scan: 118 file(s) scanned under the repository root ==
findings by category: device=4, endpoint=20, package=4
== result: 28 finding(s) == RESULT=leaks_found (exit 1)
```
| # | Reported | Why it was wrong | Fix |
|---|---|---|---|
| 1 | a `jdk`-version-shaped run (`…\jdk-<N>.<N>.<N>.<N>`) ×19, across eight files in `docs/tool-verification/` | it is the JDK version in the toolchain's own path; the token `jdk` sits immediately before the match | a 30-character context window before the literal must not end in a version word (`jdk\|java\|python\|frida\|…`) |
| 2 | `0000000040001000`, `0000000000714240` | tombstone register/`pc` values — 16 hex digits, all digits | an all-digit 16-character token is suppressed; a real platform serial carries at least one letter |
| 3 | `0807060504030201` ×3 | a protobuf `fixed64` fixture in `EXTENSION-protobuf-raw.md` and in `scripts/protobuf_decode_raw.py` | same rule as #2 |
| 4 | `sg.vantagepoint.uncrackable3` | the published package name of an OWASP MASTG crackme | `PUBLIC_TARGET_PREFIXES` (public crackme/benchmark families are reuse, not identity) |
| 5 | `probe.synthetic.vmpdiff` | the synthetic manifest inside `vmp_diff_harness.py` | added `probe.synthetic` to the fixture prefixes |
| 6 | `args.package.spli` (twice truncated to `spli`) | a property lookup in `scripts/spawn_patch_detach.py`; the exemption window was measured from the *match* start, which an unbounded `[^\n]*?` prefix pushes past the `args.` | the window starts at the **capture group** (`m.start(group)`), and the exemption reason is printed so the path can be audited |
| 7 | `<N>.<N>.<N>.<N>` | an SDK version *directory* (`…/com.kwad.components.tachikoma/<N>.<N>.<N>.<N>/base-1_apk`) inside a private-storage path | a version-directory rule: a `.`-separated digit run enclosed in path separators is not an address |
Two rows in that table are quoted in placeholder form rather than as the literal the first run
printed — row 1's four-part JDK version run and row 7's four-part SDK version run. Neither names
anybody: both are version numbers, and both were rewritten in this pass for the reason given in
§3b, because leaving them would put a second class (version runs, not addresses) into the weak
residue, where an unexplained class is the first thing a reader learns to skip. The shape is what
each row is about, and it survives the rewrite intact.
### 4a. What this pass inherited, and what it leaves — observed
Inherited state, reproduced at the start of this pass (same tree, before the §3b correction):
```
$ python skills/apk-reverse/scripts/scan_leaks.py
== leak scan: 128 file(s) scanned under the repository root ==
findings by category: appkey=2, device=12, endpoint=14, package=5, path=3, token=4
== result: 40 finding(s) -- each one is a line to look at, not a verdict ==
RESULT=leaks_found (exit 1)
```
26 of the 40 are strong/certain (`package` 5, `device` 12, `token` 4, `appkey` 2, `path` 3) and
every one of those 26 is a line of this file's former §3 hit list, at lines 113–146. The other 14
are weak: 5 more lines of that same block (its endpoint rows), 2 version strings in §4's own
table, 3 RFC 5737 range literals in
`skills/apk-reverse/references/desensitization-and-leak-scans.md`, and `scripts/tls_check.py`'s
usage example.
Post-correction, on the tree this pass leaves behind:
```
$ python skills/apk-reverse/scripts/scan_leaks.py --quiet
RESULT=leaks_found_strong_only (exit 0)
$ python skills/apk-reverse/scripts/scan_leaks.py
== leak scan: 129 file(s) scanned under the repository root ==
findings by category: endpoint=4
skills/apk-reverse/references/desensitization-and-leak-scans.md:81:10 [endpoint/weak] plain_addr_ipliteral
match: <RFC 5737 literal>
skills/apk-reverse/references/desensitization-and-leak-scans.md:81:26 [endpoint/weak] plain_addr_ipliteral
match: <RFC 5737 literal>
skills/apk-reverse/references/desensitization-and-leak-scans.md:81:45 [endpoint/weak] plain_addr_ipliteral
match: <RFC 5737 literal>
skills/apk-reverse/scripts/tls_check.py:44:23 [endpoint/weak] plain_addr_ipliteral
match: <RFC 5737 literal>
== result: 4 finding(s) -- each one is a line to look at, not a verdict ==
all are `weak` (the documented address ranges); the gate is green under the default --fail-on strong, and `--fail-on any` is the strict pass
RESULT=leaks_found_strong_only
$ python skills/apk-reverse/scripts/scan_leaks.py --quiet --fail-on any
RESULT=leaks_found (exit 1)
```
The four residual hits, by position, rule and class. Their individual values are deliberately not
reprinted here — that is §3b's correction, and re-printing them is how this file leaked the first
time. All four are the same class:
| position | rule / strength | class |
|---|---|---|
| `skills/apk-reverse/references/desensitization-and-leak-scans.md:81` ×3 | `plain_addr_ipliteral` / weak | the three RFC 5737 documentation blocks, named inside that file's own statement that they are *not* exempt |
| `skills/apk-reverse/scripts/tls_check.py:44` | `plain_addr_ipliteral` / weak | the script's usage example, an RFC 5737 address |
Reading: **zero strong/certain findings and zero unexplained hits on the tracked surface; four
weak ones, one class, both sources named.** The residue is the price of the design decision
recorded in §"Deliberately *not* exempt" of the reference file: the documentation that states the
decision has to name the ranges, and the ranges are exactly what the rule reports. That is why
the gate's pass condition lives in `--fail-on` and not in the exemption table, and why
`--fail-on any` stays red on this repository on purpose.
The file count is 129 here, 128 in the inherited run above and 119 in the first pass: the tracked
surface grows while several writers work on it, and this pass added no file to the scanned
surface, so the count is a property of the run rather than of the leak. The number that is
comparable across the runs is the strong/certain one: **26 inherited, 0 now**. The inherited
figure also reproduces the Lead's own catch (§3b) exactly, which is the point of quoting the
transcript rather than the conclusion.
Two work artifacts of this pass live beside the corpus in the same git-ignored directory, and
neither is part of the scanned surface: `EXTENSION-desensitization.before.md` (the pre-correction
snapshot of this file, kept so that the correction is auditable in both directions) and
`fixture-scan-before.txt` (a captured run of the fixture scan). Both contain the planted values
by construction, exactly like the corpus, and both are excluded from every default run — the work
area is where identity is allowed to exist; that is the whole of §3b's boundary.
## 5. The exit-code contract — observed, all four states
```
$ python skills/apk-reverse/scripts/scan_leaks.py --root LICENSE --quiet
RESULT=clean (exit 0)
$ python skills/apk-reverse/scripts/scan_leaks.py --quiet
RESULT=leaks_found_strong_only (exit 0) # the repository tree, weak hits only
$ python skills/apk-reverse/scripts/scan_leaks.py --quiet --fail-on any
RESULT=leaks_found (exit 1) # the same tree, strict pass
$ python skills/apk-reverse/scripts/scan_leaks.py --root tools/_work/leak-precedent/fixture --quiet
RESULT=leaks_found (exit 1)
$ python skills/apk-reverse/scripts/scan_leaks.py --root tools/_work/nope-not-here
error: root does not exist: tools/_work/nope-not-here [stderr]
RESULT=error (exit 2)
$ python skills/apk-reverse/scripts/scan_leaks.py --only nope
error: unknown category (or categories): nope [stderr]
RESULT=error (exit 2)
```
The token and the exit code are deliberately separate channels: a wrapper that only greps
stdout cannot tell "clean" from "the scanner never ran", and one that only reads `$?` cannot
explain itself in a log.
`--fail-on` is what keeps those two channels from contradicting each other on a tree that is
documentation-shaped. Under the default (`strong`) a tree whose only findings are `weak` reports
`leaks_found_strong_only` and exits 0: the findings are still printed in full, they simply do not
fail the gate. Under `--fail-on any` the same tree reports `leaks_found` and exits 1, which is the
right verdict at publish time and the wrong one for a habit that runs on every change — a gate
that is red on its own design documentation is a gate people disable.
## 6. What was not done
- **The `RESULT=` token convention was undocumented when this pass started, and it is documented
now.** The Lead added `references/long-task-discipline.md` §Every script answers with a token,
not with prose during this pass, so the scanner is no longer the convention's only user. That
section's vocabulary is generic (`clean`, `patched`, `success`, `crash`, `timeout`,
`unavailable`, …) and does not spell out `leaks_found` / `leaks_found_strong_only`; whether
they belong in the enumeration is a Lead call. Verified with
`grep -n "RESULT=" skills/apk-reverse/references/long-task-discipline.md` → one match, line 70.
- **Nothing is wired into a gate.** No pre-commit hook, no CI job, no line in the maintenance
three-command habit. The scanner has never run automatically; every result above is a manual
invocation. Recorded as `unverified` in the sense that matters: whether it *would* be run is
untested.
- **No scan of the git-tracked list specifically.** The scan walks a filesystem root, so its
coverage is "the default surface under this root", not "the files that exist in HEAD". A file
that is present locally but ignored, or tracked but absent, is not distinguished.
- **No check of binary or archive content.** `*.apk`, `*.png`, `*.pcap` and `*.zip` are not
opened. Identity inside a screenshot or a capture file is real and this pass does not address
it.
- **No git-history scan.** A string deleted from the working tree remains in the history; nothing
here inspects commits. This pass did check the one case it created: `git status --short` shows
`docs/tool-verification/EXTENSION-desensitization.md` as `??` (untracked), so the values §3b
removed were never committed and nothing was written into history. That is a check of this
file, not a history scan.
- **No second-language rule set.** Identifiers that are not reverse-domain shaped (a nickname, a
local-part email, a CJK app name) are not covered. The rules are shape-based by construction.
- **No cross-repository validation of the exemption list.** The benign prefixes were derived from
this tree's own contents (a grep over its Markdown for reverse-domain tokens). Whether the same
list is right for another repository is `unverified`, and the reference file says so.
- **The `--max` / `--only` / `--exclude` paths were exercised only in the forms shown above.**
`--max` was not driven at all.
## 7. One-line deliverable statement, and the open items
**Deliverable (verified):** `python skills/apk-reverse/scripts/scan_leaks.py` scans a repository
root for six categories of target identity, reports each hit with a limited context window and a
strength label, exits 0/1/2 with a matching `RESULT=` token, reports all 30 planted leaks in the
reproducible corpus at `tools/_work/leak-precedent/fixture` (7 files, 10 further shapes admitted
through the exemption path), and against this repository's 129-file tracked surface returns zero
strong/certain findings — the residue is four `endpoint/weak` hits, all RFC 5737, both sources
named in §4a — so the default gate is green (`RESULT=leaks_found_strong_only`, exit 0) while
`--fail-on any` stays available as the strict pass (`RESULT=leaks_found`, exit 1).
**Open items for the Lead:**
1. Wire the scan into the maintenance gate (`check_repo.py`, `check_refs.py`, `scan_leaks.py`)
and record in `README.md` that the third one's exit 1 is a *content* finding, not a
mechanical failure like the other two.
2. Decide on a history scan. Nothing in this pass looked at commits, and a leak removed from the
tree is still in them.

View File

@@ -0,0 +1,311 @@
# Extension verification — detection pipeline, layered descent, ptrace-free dumping
Covers the pass that added the §Step 3 locating pipeline and the §Step 2B boundaries to
`references/detection-and-anti-analysis.md`, and the §What this route cannot do /
§Is this dex image real / §Layered descent sections to `references/advanced-unpacking.md`.
It also ships `scripts/svc_scan.py` and `scripts/anti_detect_probe.js`.
Strength labels, as defined in this directory's README:
- **observed** — reproduced here, with the exact command and its output.
- **inferred** — follows from observed facts, but the step itself was not executed.
- **unverified** — assumed, or reported by a tool and not independently confirmed.
**Target identity is deliberately absent.** The hardened sample appears as `<PKG>`; the
public MASTG challenge apps appear by their package names because they are public
published targets, not a user's app. No keys, signatures or user data are recorded.
## 0. Environment and what was decided before measuring
| Item | Value |
|---|---|
| Device | Android 11 (API 30), arm64-v8a, kernel **4.14.186**, Magisk alpha + root, toybox shell |
| Host | frida 16.7.19 / frida-tools 13.7.1; capstone; lief; pyelftools |
| Instrumentation server | **a `frida-server` was already running on the device** (`frida-server -l 0.0.0.0:27099`, pid 31859) with a leftover `adb forward tcp:27099` |
| Decision taken | **Reuse it rather than restart it.** A restart is a device-wide variable and the process had a live abstract socket (`unix:abstract=/frida-...)`; it was never started by this pass, so nothing about it is attributed here. Cost: the server's listening port and its path are part of the environment a detector can see — see §4, where a probe confirms frida artefacts *are* visible in the target's own maps |
| Root hiding | `magisk --denylist status` = *enforced*, list **empty** → no app had root hidden. Not changed in this pass |
`frida-ls-devices` cannot be used as a liveness check in this harness (it requires a
Windows console and dies with `NoConsoleScreenBufferError`); the Python binding is the
reliable route, and that is what every measurement below used:
```python
dev = frida.get_device_manager().add_remote_device('127.0.0.1:27099')
dev.query_system_parameters()
# {'arch': 'arm64', 'os': {'version': '11', 'id': 'android'}, 'api-level': 30, 'access': 'full'}
```
## 1. `scripts/svc_scan.py` — measured, with the count cross-checked
`--help` works; a real call was made on five inputs. T3 cross-check first, because the
whole script is a decode claim:
```
json sites = 214
hand word-scan = 214
set difference = []
```
The hand word-scan is a 6-line script that matches the `svc` encoding
(`(w & 0xFFE0001F) == 0xD4000001`) with no dependency on capstone, so the two readings
are genuinely independent implementations. **observed.**
Device libraries, scanned from copies pulled with `su -c cp`:
| Input | Sites | by_group | Notable |
|---|---|---|---|
| `/apex/…/bin/linker64` (1,451,392 B) | 214 | termination=4, evasion=5, discovery=8, unresolved=6 | the 4 termination sites are `exit`/`exit_group` at `0xda284`/`0xda2a4` |
| `/apex/…/lib64/bionic/libc.so` (1,193,024 B) | 215 | termination=4, evasion=6, discovery=8, unresolved=6 | `kill @0xc5b94`, `tgkill @0xc5bb4`, `ptrace @0xc5bd4`, `mprotect @0xc5f54`, `exit_group @0xc7054`, `exit @0xc7074` |
| `assets/libjiagu_a64.so` from the sample (1,171,896 B) | 21 | **unresolved=21** | every site is inside `scvtf`/`udf`/NEON byte soup — data, not instructions |
| `assets/libjiagu.so` (32-bit, 861,436 B) | 11710 | unresolved=11116 | scanning both PT_LOAD segments mixes code and data; the site count is not usable without inspecting neighbours |
**The interpretation that matters, and it is the reason the script exists:** the four
termination sites in `libc.so` are libc's *own* exported implementations, so a hook on
`exit`/`kill` does see callers that go through libc. The shell library has **no**
termination site and 21 data artefacts — so on this target a libc-level hook is *not*
structurally excluded, and a missing exit event would be a finding about the hook.
**observed.**
Two defects were found by running it and both are fixed in the shipped script:
- `capstone` raises `CS_ERR_DETAIL` unless `detail = True` is set; the first run died on the
first hit (`AttributeError`-equivalent at `insn.operands`).
- `pf_x` is **not** usable as a filter on this platform. The ROM's own `libc.so` and
`linker64` carry `pf_x` set on their code segment but a first version of the probe read
`p_flags` from the wrong struct offset (reading `p_offset`'s low word instead), which
reported `pf_x=0` for everything. The shipped version reports per-segment `pf_x` and does
not filter on it by default: `--code-only` is opt-in precisely because a device that
clears the flag would otherwise return zero hits silently.
## 2. The hardened sample: what it does now, and why nothing was dumped from it
The previous pass recorded this sample as a clean self-exit on a 7-18 s cycle with no
tombstone. **That is no longer the shape.** Measured this pass, twice, 20 samples at 1 Hz
after `monkey` launch:
```
22:28:19.923 D/OppoExSurfaceFlinger: hecate traverse … Splash Screen com.gentle.ppcat#0
22:28:21.684 W/ActivityManager: Process ProcessRecord{… 14905:com.gentle.ppcat/u0a623} failed to attach
22:28:21.686 I/ActivityManager: Killing 14905:com.gentle.ppcat/u0a623 (adj -10000): start timeout
```
- **observed:** the platform kills the process on **`failed to attach` / `start timeout`**
roughly **2 s** after start, i.e. the app never finishes starting.
- **observed:** an attempt to harvest memory inside that lifetime failed on both tries —
`RESULT=NO_PID` and `RESULT=NO_PID` with `t_pid=9.99`, so a 10 s window is already longer
than the process lives. Two strikes, no third variant: **the ptrace-free dump was not
obtained from this sample and this file does not claim one.**
- **observed, and a new signal rather than a repeat:** a tombstone is now produced, and its
fault is not in the packer's own code but in the runtime, reached from JIT output:
```
signal 11 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr 0x75f16ff028
backtrace:
#00 pc 0000000000574028 /apex/com.android.art/lib64/libart.so
(art::Runtime::SetJavaDebuggable(bool))
#01 pc 0000000000022d58 /memfd:jit-cache (deleted)
```
A self-terminating `SIGABRT` would be the expected shape for an anti-analysis kill; a
`SEGV` in `Runtime::SetJavaDebuggable` reached **from JIT-compiled Java** is not, and the
honest reading is limited: **the app now crashes during startup, in the runtime, on a
path a Java method entered.** Whether that is the packer's environment check firing or a
consequence of the kill arriving mid-startup is **unverified** — the sample's own
start/stop behaviour changed between passes and no control run with root hiding
configured was made, because changing root-hiding state is a device-wide variable this
pass deliberately did not touch.
## 3. Clean-target baseline: state, liveness, and a harness artefact avoided
- **observed:** `owasp.mstg.uncrackable1` launched with `monkey` stayed alive at pid 15310
for **more than a minute** of repeated checks — the stable baseline every dynamic
experiment needs, and the reason the arms below were run on it and on L3 rather than on
the hardened sample.
- **observed:** `owasp.mstg.uncrackable3` runs two processes (zygote64 + 32-bit), had its
own integrity log line, and stayed up.
- **observed, and the most expensive-looking failure that turned out to be free:** the
first attach to L1 hung and then failed —
`attach/spawn failed: unexpectedly timed out while waiting for signal from process with PID 15310`
— and a second attach said `process not found`. The process was still in `ps` and
`dev.enumerate_processes()` still listed it, but:
```
$ adb shell 'cat /proc/15310/stat | cut -d" " -f3'
D
```
**`D` (uninterruptible disk sleep)** is why. A userspace attach needs the process to run.
The one-line control that settled it in a single command: attaching to a *different* pid
from the same device succeeded (`attach 15974 OK`). **observed** — the failure was the
environment, not a detector, and this is now Stage 0 of the reference's pipeline.
**Harness self-check applied before interpreting anything** (the discipline that a spawn
which is never resumed produces zero events and reads as a hang): the driver
(`tools/_work/detect/run_anti_detect_probe.py`, a work-file, not shipped) resumes
**explicitly** after the script is loaded, and records whether it did. Both arms below
report `resumed=True`, so "no events" cannot be an un-resumed spawn here.
## 4. The three arms, on one target — and the detection actually found
All arms: `owasp.mstg.uncrackable3`, `--spawn`, script loaded before resume.
| Arm | Script | Result |
|---|---|---|
| **A. baseline** | none (launch, no instrumentation) | process alive and stable across a minute of sampling (§3) |
| **B. control** | attached/spawned, resumed, **no script loaded** | **`alive_at_end=True`**, survived the full window |
| **C. experimental** | `scripts/anti_detect_probe.js` (observer-only) | **`alive_at_end=False`**, process gone before the first heartbeat |
Control vs. experimental differs in exactly one variable — the probe script — so the death
is attributable to instrumentation. Then the observer's own stream named the check:
```
t=0 [armed] script armed
t=207ms [thread_create] pthread_create entry=0x76dc031d78
t=238ms [strcmp_probe] strcmp("Ldalvik/system/ClassExt;")
t=245ms [env_probe] open("/system/fonts/Roboto-Regular.ttf")
t=254ms [loader] android_dlopen_ext("…/oat/arm64/base.odex")
t=303ms [strstr_probe] strstr("frida") <-- the check
t~350ms process gone, heartbeats=0, no tombstone, no crash/ANR record
```
- **observed:** the target **searches for the string `frida`** (`strstr("frida")`) at ~300 ms
of its life. This is the "detection really did happen" datum the task asked for: not an
inference from a death, a recorded call with a timestamp.
- **observed:** an earlier arm of the same shape also captured the kill itself —
`[termination] raise(...) <- …` at **t=352 ms**, i.e. a signal-based self-abort roughly
50 ms after the `strstr`. No tombstone was written and no `has died: crash` record
appeared, which is the clean-self-exit signature.
- **observed, and the reason the arm is reported as unstable:** three attempts at the same
arm produced *one* full sequence, then two runs where the script received **zero**
messages before the process died (`live=0, messages=0, heartbeats=None`). On a target that
kills itself in ~300 ms the observable window is sub-second and **not reproducible arm to
arm**. One successful attribution is therefore not a pipeline, and the reference says so.
What the probe also recorded about the *environment*, before any check ran:
```
tracerpid=0 frida_named_maps=4 (four mappings whose names match /frida/i)
status: Name: tg.uncrackable3 ; State: S (sleeping) ; Seccomp: 2
/proc/net/tcp -> Permission denied /proc/self/task -> Is a directory
```
**observed:** the target's own `/proc/self/maps` contains **four frida-named mappings** while
`TracerPid` reads **0**. So a `TracerPid`-only check would pass while a maps-or-thread-name
check would not — which is exactly the "what can this process actually see" question the
Stage 1 self-report exists to answer, and it is why the probe reports both.
**One defect in the probe, fixed once and then left:** caller attribution printed
`bt-unavailable: cannot read property 'context' of undefined` on every line. Cause: the
`this` binding inside the anonymous `Interceptor` callback was not what the helper assumed;
the fix is to pass `this.context` explicitly. The measured consequence is worth stating
because it is a *plumbing* failure that reads like a hooking failure: the events and their
timings were correct throughout, only the "which module called this" half was missing, and
one of the three arms still showed no events at all. `observed`.
## 5. ptrace-free dumping: the route, its boundary, and a cross-check that passed
### 5.1 What the route yields when the target has a whole dex in memory
Not re-measured here: the earlier pass's 17 ART dex mappings exported from `/proc/<pid>/mem`
without any instrumenter present (`docs/tool-verification/EXTENSION-rootdump.md`). This pass
adds the **negative boundary** the earlier pass could not state, and it needed a clean
target to state it:
```
# both public MASTG targets, ~2,400 map lines each, one adb round-trip
owasp.mstg.uncrackable1: maps_lines=2427 named_dex=0 apk_views=2
owasp.mstg.uncrackable3: maps_lines=2377 named_dex=0 apk_views=2
```
- **observed:** `named_dex=0`. Neither target produces an
`[anon:dalvik-classes.dex extracted in memory from …]` mapping, so the filename-driven
export has **nothing to export** — and this says nothing about those targets being
protected.
- **observed, the explanation:** their `classes.dex` is **deflate-compressed inside the
on-device APK** (`compress_type=8` read from the APK pulled off the device), so the
`dexopt` "map the dex directly" mechanism cannot apply to it.
- **observed:** the `r--s` views that *do* exist point at APK **file offsets**, and the
bytes there are the APK's own bytes — at `0x11000` on L1 that content is the ZIP
**central directory** (`PK\x01\x02`), not a dex.
- **observed, and a trap that produced a false "DIFFERENT":** on L3 the view
`76dc28e000-76dc299000` has file offset `0x15a000` = 1,417,216 B, while the mapping is
44 KiB (45,056 B) long — so the range reaches 1,462,272, i.e. **past the end of the
1,460,555-byte APK**. The file-side read came back short (1,015 B) and the two sha256s
described different byte counts. Compute the length before suspecting the target.
### 5.2 The reproducibility cross-check, which passed
Two independent read paths over the same address range must agree byte-for-byte before a
capture is evidence:
| Range | Read via `/proc/<pid>/mem` | Read from the file | Verdict |
|---|---|---|---|
| L3 `765a89c000-765a8de000` (264 KiB, file offset `0x6000`) | `sha256=2eafd7cca73630c05d0b0c1b4a7709f21ee24540b5f2ba388ad3467af645aa75` | same | **IDENTICAL** |
| L1 `76dfb14000-76dfb15000` (4 KiB, file offset `0x11000`) | `sha256=74846b8cb02a764efef158194745466cf0c8ad2c1c3fecbb2bcf7cf9c58b6465` | same | **IDENTICAL** |
**observed.** The comparison is only meaningful because both sides covered the same byte
count; the one range that differed was the one whose length exceeded its backing file.
### 5.3 The cross-check that was **not** run, and why it is not silently absent
The task's cross-validation requirement is satisfied for `svc_scan.py` by two independent
decoders agreeing on an identical 214-site set (§1). For a **dump**, the equivalent check
needs two producers that emit the *same* artifact. What this pass has is `/proc/<pid>/mem`
(successful, §5.2) and, on the hardened sample, `frida-dexdump` — which was **refused** by
that sample in the previous pass (`script has been destroyed`,
`docs/tool-verification/EXTENSION-device-run.md`). The sample's lifetime then shrank to ~2 s
(§2), so the second producer could not be obtained on any target here:
| Second-producer option | Status |
|---|---|
| `frida-dexdump` on the hardened sample | **unverified** — refused in the earlier pass; not retried here on a 2 s process |
| `frida-dexdump` on L1/L3 | **unverified, and expected to be empty** — `named_dex=0`, so there is no whole image for any dumper to find |
| `process_vm_readv(2)` as a second backend | **not run** — the on-device helper would have needed a second binary on the device, and the byte-identity question it answers was already answered against the file path |
**Statement of the gap, so the next reader does not have to infer it:** the byte-identity
check between two *dumpers* is **unverified** in this repository. What is `observed` is
byte-identity between two *read paths* on the same bytes, and a 214-site decode agreement
between two independent decoders. Those are weaker claims than "two dumpers cross-checked",
and they are the ones this file makes.
**A related statement that is deliberately negative:** two dumpers agreeing is not evidence
of *recovery*. Against an extraction shell, two agreeing dumps are two copies of the same
skeleton. The judge for that is the platform decoder, per
`advanced-unpacking.md` §Establish "the bodies do not decode" with a decoder you did not write.
## 6. What was not established
- **Not** a recovered payload from the hardened sample. Its process lives ~2 s and never
reaches a state where its payload is loaded; the attempt hit the two-strike limit and was
re-routed to the stable targets.
- **Not** a fix for the sample's startup failure. Root hiding was left alone — configuring
`magisk --denylist` is a device-wide change and this pass was the only lock holder, which
is not the same as being free to change every shared variable.
- **Not** a reproducible end-to-end anti-instrumentation pipeline on a fast detector. The
full sequence was captured once out of three arms and the reference labels it that way.
- **Not** a general claim about `strstr("frida")` being the only check, or about MASTG
challenges, or about Android 11. One device, one ROM, one session, with other experiments
sharing the device.
- **Not** a kernel-layer result. Kernel 4.14.186 < 5.10 closes the eBPF route; the layered
descent in the reference stops at the `svc` boundary and says so.
## 7. Reproduction one-liners
```
python skills/apk-reverse/scripts/svc_scan.py tools/_work/detect/libc.so --context 2
python skills/apk-reverse/scripts/svc_scan.py tools/_work/detect/linker64.bin --json
adb shell 'su -c "sh /data/local/tmp/apkmaps.sh owasp.mstg.uncrackable3"' # named_dex / apk_views shape
adb shell 'su -c "sh /data/local/tmp/apkviews_verify.sh owasp.mstg.uncrackable3"'
adb shell 'su -c "sh /data/local/tmp/detlaunch.sh <pkg> /data/local/tmp/dd1"'
python tools/_work/detect/run_anti_detect_probe.py --pid <pid> --watch-ms 8000 --out arm.json
python tools/_work/detect/run_anti_detect_probe.py --pkg owasp.mstg.uncrackable3 --spawn \
--script skills/apk-reverse/scripts/anti_detect_probe.js --out arm_probe.json
```
The on-device helper scripts (`apkmaps.sh`, `apkviews_verify.sh`, `detlaunch.sh`) and the
driver live under `tools/_work/detect/` and are **work files, not shipped deliverables** —
they exist so the commands above are reproducible, and their content is summarised in the
sections that cite them. The two shipped scripts are `scripts/svc_scan.py` and
`scripts/anti_detect_probe.js`.

View File

@@ -0,0 +1,252 @@
# Extension pass — on-device run against a hardened Flutter sample
This file records the **device-side** half of the extension pass: what was actually executed against
a live, hardened sample, what the sample did in response, and which conclusions the evidence supports.
It follows the same three labels the rest of this directory uses:
- **observed** — reproduced here, with the exact command and its output.
- **inferred** — follows from observed facts, but the step itself was not executed.
- **unverified** — assumed, or reported by a tool and not independently confirmed.
**Target identity is deliberately absent.** The sample is referred to as *the sample*, and its package
identifier appears as `<PKG>`. The findings are about the tools and the methods, and stay valid
whichever hardened APK produced them.
## Environment (observed)
| Item | Value |
|---|---|
| Device | one physical arm64 device, Android 11 (API 30), kernel 4.14.186, Magisk root |
| Host | Windows 11, Python 3.14.0, frida 16.7.19, frida-tools 13.7.1, frida-dexdump 2.0.1, java 17, adb 1.0.41 |
| Device server | `frida-server` 16.7.19 (version-matched to the host by `frida-server --version`) |
| Sample | 360-jiagu hardened, `application android:name` = a third-party stub class; Flutter AOT payload |
| Host capability report | `python scripts/doctor.py` → static/repack/smali/native-patch/device/root/frida all OK; `28 / 28` scripts runnable |
| Pre-flight warnings | clock skew 3.32 s; leftover `adb forward` entries (8787, 8651, 65534, 27043); a device-side `frida-server` already running |
Two environment facts from `doctor.py` are worth carrying forward, because each of them silently
invalidates a measurement: the clock skew, and a **frida process already up on the device** — the
second one means the target may already be reacting to instrumentation before your first command.
## The measurement that mattered: the sample fights instrumentation
### (1) Control — the sample starts normally with no instrumentation (observed)
```
$ adb shell 'su -c "am force-stop <PKG>; sleep 1; am start -n <PKG>/.MainActivity"'
$ sleep 12 && adb shell 'su -c "ps -A | grep -i <PKG>"'
u0_a623 22691 17278 14123672 642488 SyS_epoll_wait 0 S <PKG>
```
Resident, 642 MB, healthy. **This is the control build the rest of the section is measured against.**
### (2) The pid drifts — but **not** because of frida (observed, corrected)
During the session the sample's pid changed repeatedly:
```
22691 → 23452 → 24267 → 27274 → 31284 → 32259 → 1368 → 8161
```
The natural reading is "the sample fights instrumentation", and the first write-up of this file said
exactly that. **The follow-up measurement refuted it.** With `frida-server` stopped and no attach of
any kind, the sample was restarted under observation for 90 seconds:
```
20:28:22 pid=[9961] load=36.43 | Mem: 11484 total 11077 used 406 free
20:28:33 pid=[9961] load=36.26 | Mem: 11484 total 10767 used 716 free
20:28:45 pid=[9961] load=35.61 | Mem: 11484 total 10739 used 745 free
20:28:56 pid=[9961] load=34.64 | Mem: 11484 total 10767 used 716 free
20:29:08 pid=[11246] load=34.91 | Mem: 11484 total 11151 used 333 free
20:29:19 pid=[11246] load=35.69 | Mem: 11484 total 11142 used 342 free
20:29:31 pid=[12238] load=35.14 | Mem: 11484 total 11131 used 353 free
20:29:42 pid=[13212] load=37.03 | Mem: 11484 total 11059 used 424 free
```
The pid changes with **no frida present**, roughly every 10-20 seconds, while the device sits at
300-750 MB of free memory out of 11.5 GB. The first correction to this file attributed that to memory
pressure. **That attribution was also wrong, and the device-side dumping run refuted it** with a
better instrument than sampling: `logcat`.
```
$ adb logcat | grep <PKG>
Process <PKG> (pid N) has died: fg TOP
Start proc <pid> for top-activity <PKG>
```
The process **dies as the foreground top activity, with no crash, no tombstone and no ANR record**,
and the platform immediately relaunches it — a clean self-exit in a loop, on a 7-18 second cycle. A
reclaiming device does not do that to a foreground process, and a memory-pressure kill leaves an
`lmkd` line and a low-memory record. What produces exactly this shape is the sample **checking its own
environment and quitting on purpose**.
And the environment it finds is decisive: on this device `magisk --denylist status` reports **enforced
with an empty list**, Zygisk-Assistant is installed but nothing is scoped, and
`/data/adb/shamiko` does not exist — so **no application on this device is hidden from root**
(`EXTENSION-lsposed.md`, observed). A hardened sample that refuses to run on a rooted device will do
exactly this, forever, and no amount of dump-retrying changes it.
**Reading, corrected twice:** the pid drift is an artefact of the **root environment**, not of
instrumentation and not of memory pressure. The practical consequence is the useful part — *before any
dynamic analysis of a hardened sample, configure root hiding and re-run the baseline*, because with
nothing hidden every dynamic result on this device is measured against a process that is trying to die.
The observer's first control run showed a "stable" sample for 12 seconds only because it happened to
sample inside one 17-second lifetime.
This is the skill's own rule applied twice to its own record: a conclusion was reopened each time a
measurement disagreed with it (`SKILL.md` §Stop conditions, last bullet). Two wrong attributions were
cheaper to delete than for a reader to inherit — and the second one only fell because a different
method (`logcat`, not sampling) was pointed at the same question.
The platform's own view of a failed launch was also captured, and is worth keeping as a *shape*:
```
W ActivityManager: Process ProcessRecord{…:<PKG>/u0a623} failed to attach
I ActivityManager: Killing <pid>:<PKG>/u0a623 (adj -10000): start timeout
```
A `start timeout` kill reads the same whether the cause is a slow shell init, a reclaiming device, or a
deliberate delay. It does not discriminate, and it is not evidence either way.
### (3) `frida-dexdump` cannot complete a memory dump — reproduced twice (observed)
Both spawn and attach were tried, with the parameter mistakes fixed between attempts:
```
$ frida-dexdump -U -f <PKG> -d -o <out> # spawn
Failed to spawn: need Gadget to attach on jailed Android; its default location is: …gadget-android-arm64.so
$ frida-dexdump -U -f <PKG> -d 12 -o <out> # 12 was parsed as an argv entry, not a delay
Failed to spawn: the 'argv' option is not supported when spawning Android apps
$ frida-dexdump -U -f <PKG> -d -o <out> # corrected
Failed to spawn: unexpectedly timed out while waiting for app to launch
$ frida-dexdump -U -p <pid> -d -o <out> # attach, twice, second run with the device idle
frida.InvalidOperationError: script has been destroyed: {'addr': '0x721e24c02c', 'size': 409556}
INFO:frida-dexdump:[*] All done... Process terminated
```
Attach was run twice. The **second** run was the clean one: no Stalker traffic anywhere on the device
(see the crash note below), the package enabled and verified running, the pid read immediately before
the attach, and a single variable changed relative to the control. It produced the same
`script has been destroyed` failure, and the sample's pid changed again in the aftermath.
**What the failed runs left behind, checked with the kit's own validator** (`observed`):
```
$ python skills/apk-reverse/scripts/dex_dump_validate.py <dump_dir>
== dex dump validation: 2 file(s), 2 parse, 0 rejected ==
name size sha256[:12] ver cksum sig class noco code stub%
classes.dex 500 9461987e5464 035 BAD BAD 1986754560 0 0 0.0%
walk stopped early: index out of range
classes02.dex 10285489 116d8b84b486 035 BAD BAD 1986754560 0 0 0.0%
walk stopped early: index out of range
```
Both artifacts pass the magic check and then fail everything else: checksum and signature both BAD,
no classes recovered, the walk stopping on an out-of-range index. They are **memory fragments cut
short by the process dying mid-read**, not dex images — and this is exactly the case
`dex_dump_validate.py` exists for. A size-based heuristic would have kept the 10.2 MB file and
discarded the 500-byte one; both are worthless, and only a structural walk says so.
**Reading:** under these conditions the memory-dump route is refused by the target, not by the host
tooling. Two shape-identical failures on a single variable is the skill's own stop signal: the correct
move is to re-classify (`references/advanced-unpacking.md` §the four shapes, then
`references/detection-and-anti-analysis.md`), **not** to run a third variant of the same attach.
Two of the four failures above were the observer's own mistakes, and they are worth keeping because
they are cheap to repeat: `-d` is a switch, not a delay, and any extra positional argument reaches
`spawn(argv)`. `-n <name>` on this version of `frida-dexdump` also still routes through the spawn
path, so "attach by name" does not exist as a way around the spawn refusal — use `-p <pid>`.
### (4) What the sample looks like at the moment it is attacked (observed)
```
$ adb shell 'su -c "top -n 1 -b"'
PID USER PR NI VIRT RES SHR S[%CPU] %MEM TIME+ ARGS
31284 u0_a623 10 -10 13G 561M 268M S 439 4.8 0:05.52 <PKG>
154 root 20 0 0 0 0 S 66.6 0.0 0:02.18 [kswapd0]
17638 system 18 -2 12G 590M 419M S 60.6 5.1 1:15.93 system_server
```
A Flutter application in its startup window saturates several cores (439 % CPU) while the Dart VM
initialises, and the device — 11.7 GB RAM with 357 MB free at that moment — runs its reclaim thread at
two thirds of a core. **The load on this device during the experiment was the sample's, not the
observer's instrumentation.** That matters for attribution: "the device is slow, so the dump failed"
is not available as an explanation here, because the failing command is a *protocol* failure
(`script has been destroyed`) rather than a timeout.
## The device's own framework was already damaged (observed)
Recorded here because it is a **third candidate mechanism** for "things keep restarting", alongside the
sample's self-exit and the memory pressure — and because it invalidates any dynamic result that
depends on the hooking framework:
- `LSPosed Manager` opened to **"LSPosed is not installed"**, with no modules tab, while the `lspd`
daemon was still alive.
- The LSPosed configuration database had not been updated since **19:37** — it still pointed at the
`codePath` of an app that had been uninstalled, and never registered the replacement.
- `logcat` showed a loop: `system server died`, `am is dead`, `no response from bridge`, and
`Magisk: zygote crashed too many times, rolling-back`.
A zygote in a crash-and-rollback cycle restarts application processes by itself, so **"the pid changed"
had at least three independent possible causes on this device** (the sample exiting, reclaim, and a
broken framework). That is exactly why the corrected §2 attributes the drift to *instability of the
observation target* rather than to any single mechanism, and why a dynamic result needs its device's
framework state recorded next to it — `scripts/preflight.py` reads device state for the same reason.
Recovery is a reboot (Magisk re-initialises Zygisk and `lspd` at `late_start`; the LSPosed daemon is
started once and does not return if killed). Whether the framework came back after the reboot, and
whether the sample's self-exit behaviour changed with it, is recorded in
`EXTENSION-lsposed.md` — and any change there must stay **inferred**, because a reboot moves several
variables at once.
## A shared-device hazard, measured (observed)
While the run above was in progress, a second agent on the same device used **Frida Stalker** against
`com.android.settings` and `com.android.systemui`, including a `libc` `malloc` export trigger that
entered and left `follow` hundreds of times. Result:
- `Stalker.follow` on the main thread reported `blocks=0`, `blk=0`, `calls=0` — **the follow installed
and no events arrived**.
- `systemui` then died with `SIGSEGV`, the faulting frame sitting in gum's anonymous code cache and
the backtrace continuing into `libart` at `art::Reference_getReferent`; the process restarted
(`18030 → 26932`).
- Device load average reached **34.11** on an eight-core phone.
**Reading:** two conclusions, kept separate on purpose. First, on this ROM + `frida-server` 16.7.19
combination a Stalker follow produced no events — that is a *boundary* for
`references/native-dbi-and-deobfuscation.md`, not a contradiction of Stalker's documentation. Second,
high-frequency `follow`/`unfollow` on every `malloc` entry crashed a system process — a measured
failure mode for the pattern, and one worth stating before anyone puts it in a shipped script.
The third conclusion is about method rather than about Stalker: **instrumentation on a shared device
is a shared variable.** The concurrent load damaged the *other* experiment's validity — the dumped-run
above could not be attributed until it was repeated with the device idle, and the repeat is what
turned "maybe the load broke it" into a reproducible protocol refusal. R2 (one variable at a time)
applies across agents, not only within one script.
## Conclusions, graded
| Claim | Label |
|---|---|
| The sample runs healthy with no instrumentation on the device | observed |
| The sample's pid drifts repeatedly during an experiment, roughly every 10-20 s | observed — **cause not instrumentation**: reproduced with `frida-server` stopped, under 300-750 MB free memory (§2) |
| `frida-dexdump` spawn refuses (jailed / argv / timeout) and attach fails with `script has been destroyed`, reproduced twice with the parameter mistakes fixed | observed |
| The attach failure is deliberate armour rather than a host-tool bug | **unverified** — memory pressure leaving the process an unstable target is a competing explanation this pass did **not** rule out; the single-variable repeat eliminated *concurrent* load, not *baseline* load |
| The shipped shell `classes.dex` is 8.9 MB and defines only 4 classes / 29 methods with code | observed (recorded in `EXTENSION-unpacking.md`) |
| Recovering the real bodies needs a route other than a `frida` memory dump on this target | inferred |
| Stalker follow delivers no events on this ROM with this frida-server version | observed, single sample, `unverified` as a general rule |
| High-frequency follow/unfollow on `malloc` can crash a system process | observed, once |
| No app on this device is currently hidden from root (`magisk --denylist status` enforced, list empty; Zygisk-Assistant installed but nothing configured) | observed (recorded in `EXTENSION-lsposed.md`) — offered here because a hardened sample's sensitivity to a rooted device has a configuration, not just a cause |
## What this pass did **not** establish
- **Not** a successful end-to-end unpack of the sample. The memory-dump route was refused, and the
pass stopped at the skill's own two-strike rule instead of escalating into an evasion arms race.
- **Not** evidence that the sample is un-unpackable. The routes that do not use `frida` on the device
— root-side `/proc/<pid>/mem` extraction, the module route in `references/lsposed-and-modules.md`,
and the static route already recorded for the Flutter payload — were not exhausted here.
- **Not** a general claim about 360-jiagu, Flutter, or Android 11. Everything above is one device,
one ROM, one server version, one sample.

View File

@@ -0,0 +1,431 @@
# Extension verification — offline emulation (unidbg) and Frida RPC
Covers the 2026-09 extension pass that added `references/emulation-and-rpc.md`,
`scripts/frida_rpc_serve.py` and `scripts/rpc_template.js`. Strength labels as defined in this
directory's README: **observed** (exact command + output recorded here), **inferred** (follows from
observed facts, step not executed), **unverified** (assumed or externally reported, not independently
confirmed here).
Reference environment for this pass: Windows host, Python 3.14.0, host `frida` 16.7.19 +
`frida-python` 16.7.19 (frida-tools 13.7.1), `adb` 37.0.1, JDK 17.0.4.1 (with `JAVA_HOME`
**unset** in the shell — see §5). Device `<DEVICE>`: Android 11 / API 30, arm64-v8a, rooted
(Magisk), on-device frida-server 16.7.19. Workbench artifacts live in `tools/_work/t3-rpc/` (not part
of the skill).
**Device-exclusivity constraint during this pass**: another task was unpacking the hardened Flutter
sample on the same device. This pass never attached to, spawned or force-stopped that sample's
process, and never killed or restarted the device-side frida-server. All RPC work targeted unrelated
processes (system apps and third-party apps). De-identification: the sample's package label, real
package name and md5 are elided in this document as `com.example.app` / `<label>`; command shapes,
counts, class names and timings are verbatim.
---
## 0. Summary of what this pass established
| Claim | Label |
|---|---|
| `droidasc` 0.1.1.post1 and `ddc` 0.1.8 both run on this host; both read the hardened sample's shell dex | observed |
| Both tools see only the 4 shell classes in the APK's dex; business classes are absent (shell dex only) | observed |
| `frida_rpc_serve.py` `--help`, all three modes, reconnect, spawn and attach-by-pid work against a live device | observed |
| Attach **by package name** fails on this ROM while attach **by pid** succeeds for the same process | observed |
| The device-side server may be (re)started on a non-default port; `--remote HOST:PORT` recovers, device-serial lookup does not | observed |
| unidbg 0.9.10-SNAPSHOT builds and boots an Android emulator on this host after a pom fix; one bundled test suite passes | observed |
| Any target `.so` actually emulated under unidbg | **not done** (unverified) |
| frida 17 compatibility of `rpc_template.js` export lookup | unverified (host has 16.7.19 only) |
---
## 1. ASC (`droidasc`) — observed
### 1.1 Install and version
```
$ python -m pip show droidasc
Name: droidasc
Version: 0.1.1.post1
Summary: ASC is a super FAST python Android Decompiler for Agents/Mobile Researchers
License-Expression: Apache-2.0
Location: C:\Python314\Lib\site-packages
Requires: androguard
```
Install line used (recorded after the fact; the package was already present on this host):
`python -m pip install droidasc`. Distribution metadata declares only `androguard` as a dependency —
**no JVM is required** (unlike the `asc` successor tooling in the upstream MG1937/ASC repository,
which is a separate project). The console script is named **`droidasc`**, not `asc`: there is no
`asc.exe` in `C:\Python314\Scripts`, and `asc --version` fails with
`The term 'asc' is not recognized...`. `python -m droidasc` is the equivalent entry point.
### 1.2 Subcommands
```
$ python -m droidasc --help
usage: python.exe -m droidasc [-h]
{getclass,listclass,getmanifest,findrefs} ...
ASC tooling entry.
positional arguments:
{getclass,listclass,getmanifest,findrefs}
getclass Locate the target class in APK, extract one DEX in memory, then decompile.
listclass List classes across all DEX entries; use --prefix to filter by package/class prefix.
getmanifest Decode AndroidManifest.xml from APK and print it as XML.
findrefs Find code references for string/type/method/field across all DEX entries in APK.
examples:
droidasc app.apk --gui
droidasc getclass app.apk Lcom/poc/Main; -o Main.java
droidasc listclass app.apk --prefix com.poc
droidasc getmanifest app.apk -o AndroidManifest.xml
droidasc findrefs app.apk string token -o string_refs.txt
```
`--gui` and the bundled `asc_client` web UI were **not** exercised (unverified, headless pass).
### 1.3 Measured against the sample
```
$ droidasc getmanifest <sample>.apk -o asc-manifest-verify.xml # 0.44 s, 32,345 B
<manifest ... package="com.example.app" platformBuildVersionCode="34" ... android:versionCode="1143"
android:versionName="0.9.0" android:compileSdkVersion="34" ...>
<uses-sdk android:minSdkVersion="19" android:targetSdkVersion="28"/>
<uses-permission android:name="android.permission.INTERNET"/>
... (permissions list follows; manifest decodes cleanly)
$ droidasc findrefs <sample>.apk string jiagu -o asc-findrefs-jiagu.txt # 0.42 s
classes.dex | Lcom/stub/StubApp;-><clinit> | matched=(libjiagu)
classes.dex | Lcom/stub/StubApp;->attachBaseContext | matched=(/.jiagu; jiagu; jiagu_x86)
$ droidasc getclass <sample>.apk com.stub.StubApp -o asc-StubApp-verify.java # 0.33 s, 22,802 B
package com.stub;
public final class StubApp extends android.app.Application { ... }
$ droidasc listclass <sample>.apk --prefix com.stub # 0.23 s
Lcom/stub/StubApp;
$ droidasc listclass <sample>.apk -o asc-listclass-all.txt # 0.18 s, 4 lines
Lcom/stub/StubApp;
Lcom/tianyu/util/Configuration;
Lcom/tianyu/util/DtcLoader;
Lcom/tianyu/util/a;
```
`findrefs` reports the shell's own strings (`libjiagu`, `jiagu_x86`, `/.jiagu`) — i.e. the packer
signature is visible without unpacking, but nothing of the application is.
### 1.4 Limits hit
```
$ droidasc getclass <sample>.apk com.example.app.MainActivity -o asc-mainactivity.java
Error: Class Lcom/example/app/MainActivity; not found in APK.
exit = 1
```
**This is the limit that matters for this sample class:** the full class listing is 4 classes, so on
a packed APK ASC is a manifest + shell-inspector, not an application decompiler. The application code
lives in that shell's payload (`assets/`, the native unpacker and the AOT/Flutter layer), which ASC
does not touch. ASC still earns its place in the flow: `getmanifest` in under half a second is the
fastest manifest pass measured in this repository's tool set, and `findrefs` on shell class names is
how the packer was identified here.
Observed behaviours worth knowing: `droidasc` prints per-command timing nowhere, writes to `-o` only,
and returns a clean `Error: ...` line plus exit 1 when a class is missing.
---
## 2. ddc — observed
### 2.1 Version and acquisition
```
$ bin/ddc.exe -V
ddc 0.1.8 — DEX → Java decompiler
https://github.com/ejfkdev/ddc
```
`ddc` is a Rust binary (upstream: [ejfkdev/ddc](https://github.com/ejfkdev/ddc)); the Windows build
used here is `tools/_work/t3-rpc/bin/ddc.exe`, 1,028,096 B, **not on `PATH`** and with no version
resource in the PE header (`VersionInfo` empty), so `ddc -V` is the only way to learn its version.
No JVM, no Python. Subcommands advertised by `ddc help`: `info`, `listclasses`, `manifest`,
`mainactivity`, `res`, `strings`, `findrefs`, `callers`, `members`, `hierarchy`, `largest`, `disasm`,
`getclass`, `getmethod`, plus a full-decompile mode (`ddc <INPUT>... [OUTPUT]`).
### 2.2 Measured against the sample
```
$ bin/ddc.exe info <sample>.apk # 0.24 s
label <label>
package com.example.app
version 0.9.0 (1143)
application com.stub.StubApp
launcher com.example.app.MainActivity
sdk 19–28
size 36.9 MB (38688120 bytes)
md5 1a037a045205184d4e8272092997334f
image dex classes methods fields strings
<sample>!classes.dex 035 4 266 23 481
total: 1 image(s), 4 classes
$ bin/ddc.exe findrefs <sample>.apk string jiagu # 0.13 s
dex kind class method refs
classes.dex const-string com/stub/StubApp <clinit>()V "libjiagu"
classes.dex const-string com/stub/StubApp attachBaseContext(...)V "jiagu_x86"; "jiagu"; "/.jiagu"
$ bin/ddc.exe strings <sample>.apk -f jiagu --with-locations # 0.14 s
dex string used-by
classes.dex "/.jiagu" com/stub/StubApp attachBaseContext(Landroid/content/Context;)V
classes.dex "jiagu" com/stub/StubApp attachBaseContext(Landroid/content/Context;)V
classes.dex "jiagu_x86" com/stub/StubApp attachBaseContext(Landroid/content/Context;)V
classes.dex "libjiagu" com/stub/StubApp <clinit>()V
```
`ddc info` reaches the same conclusion as ASC by a different route (class/method/string counts per
dex image) and adds the resources-derived label and the launcher verification; `findrefs` output is
tabular and one line per (method, string) pair, which is directly pasteable into a report.
### 2.3 Limits hit
```
$ bin/ddc.exe getclass <sample>.apk com.example.app.MainActivity
ddc: class com.example.app.MainActivity not found in the selected image(s)
(try `ddc listclasses <input> <pattern>`)
exit = 2
```
Exit codes measured on this build: `info` → 0, `-V` → 0, `getclass` with a missing class → **2**.
The missing-class path also reprints the full help text on stdout, so a wrapper that only checks
"did anything print" will misread it as success — key off the exit code. As with ASC, the packed APK
exposes only the shell dex; `ddc` cannot see application classes either, which is a property of the
artifact, not of the tool.
---
## 3. Prior-attempt outputs under test — and the defects found
`skills/apk-reverse/references/emulation-and-rpc.md`, `scripts/frida_rpc_serve.py` and
`scripts/rpc_template.js` were already on disk from an interrupted attempt of this task. This pass
read all three, ran the script, and fixed four defects. `rpc_template.js` needed no change.
| # | Defect (observed) | Fix |
|---|---|---|
| 1 | `RpcBridge._teardown()` never detached the session and used `obj.off(...) if obj is self.session else None` / `obj.unload() if hasattr(obj, "unload")` — a script object has `unload()`, a session has `detach()`, so the expression released only the script. Every `reload()`/reconnect cycle leaked one attached session on the device. | Rewritten to unload the script, remove the `detached` handler and `session.detach()` explicitly. |
| 2 | `list_exports()` called `Script.list_exports()`, which frida 16.7.19 answers with `DeprecationWarning: Script.list_exports will become asynchronous in the future, use the explicit Script.list_exports_sync instead` (printed on every `list` command). | Tries `list_exports_sync` first, then the old name, then local introspection. |
| 3 | `--mode call` raised out of `main()`: an unknown export produced `frida`'s own `unable to find method 'x'` and a raw Python traceback; a bad `--args` value dumped a `JSONDecodeError` traceback. Scripted callers got a stack trace instead of a result. | `run_call_mode()` now returns one JSON object (`{"ok": false, "error": ...}`) and exit code 1 for both cases; `call()` consults the advertised export list first so the message is `no such rpc export: nope (available: add ping ...)`. |
| 4 | `frida.TimedOutError` was absent from the retry whitelist. It is **not** a `TransportError` subclass (`frida.TimedOutError.__mro__` → `(TimedOutError, Exception, BaseException, object)`), so a device that timed out during attach produced a traceback in an otherwise recoverable situation — reproduced live (§4.4). | Retryable types are now collected by name into `RETRYABLE` (TransportError, NotSupportedError, ServerNotRunningError, ProcessNotFoundError, InvalidOperationError, TimedOutError) and used by both `ensure()` and the initial connect in `main()`. |
Post-fix evidence:
```
$ python skills/apk-reverse/scripts/frida_rpc_serve.py --remote 127.0.0.1:27043 --pid 19938 \
--script skills/apk-reverse/scripts/rpc_template.js --mode call --export add --args "[40, 2]"
{"ok": true, "export": "add", "result": 42} exit = 0
$ ... --mode call --export nope
{"ok": false, "export": "nope", "error": "no such rpc export: nope
(available: add ping getpackagename callnative callnativeaddr)"} exit = 1
$ ... --mode call --export add --args "not-json"
{"ok": false, "error": "--args is not valid JSON: Expecting value: line 1 column 1 (char 0)"}
exit = 1
```
Known cosmetic behaviour left as is: in `--mode call` the bridge connects before `--args` is parsed,
so a bad-args invocation still performs a device attach.
## 4. Frida RPC against the live device — observed
### 4.1 Attach path: by name fails, by pid works
Attach **by package name** failed for every package tried, with the process demonstrably alive in
both `frida-ps -Uai` and `ps -A`:
```
$ python ...frida_rpc_serve.py --device-serial <DEVICE> --package com.android.settings ...
[bridge] connect failed (unable to find process with name 'com.android.settings'); retry 1 in 1.0s
... (5 attempts) ...
initial connect failed: could not (re)connect after 5 attempts
$ python ... --package io.github.vvb2060.magisk ...
[bridge] connect failed (unable to find process with name 'io.github.vvb2060.magisk'); retry 1 in 0.5s
initial connect failed: could not (re)connect after 1 attempts
```
The same processes attach normally by pid:
```
$ python ... --pid 19938 --mode call --export getpackagename
{"ok": true, "export": "getpackagename", "result": "com.android.settings"}
$ python ... --pid 21493 --mode call --export getpackagename
{"ok": true, "export": "getpackagename", "result": "com.android.email"}
```
So the gap is in the **name→pid lookup** the server serves, not in injection: `--pid` definitely
does bypass it. `references/emulation-and-rpc.md` had claimed the opposite ("`--pid` does not bypass
a gap that hides the process from the server's own table"); that row was corrected in this pass, and
the corrected wording is what the reference now carries.
### 4.2 The transport itself
| # | Command (pid 19938 = `com.android.settings`, unrelated to the sample) | Result |
|---|---|---|
| 1 | `--mode call --export add --args "[40, 2]"` | `{"ok": true, "result": 42}` |
| 2 | `--mode call --export ping` | `{"ok": true, "result": "pong"}` |
| 3 | `--mode call --export getpackagename` | `{"ok": true, "result": "com.android.settings"}` |
| 4 | `--mode call --export callnative --args '["libc.so","getpid","int",[],[]]'` | `{"ok": true, "result": 19938}` (matches the pid — `NativeFunction` wrapping works) |
| 5 | `--mode repl`, feeding `list` / `reload` / `list` / `add [40, 2]` / `nope` | `add ping getpackagename callnative callnativeaddr`; `reloaded`; same list; `42`; `error: no such rpc export: nope (available: ...)` |
| 6 | `--mode http`, `GET /exports` | `{"ok":true,"exports":[...5 names...]}` |
| 7 | `--mode http`, `POST /call {"export":"add","args":[40,2]}` | `{"ok":true,"result":42}` |
| 8 | `--mode http`, `POST /call {"export":"getpackagename","args":[]}` | `{"ok":true,"result":"com.android.settings"}` |
| 9 | `--mode http`, `POST /call` with an unknown export | HTTP 502 + `{"ok":false,"error":"no such rpc export: nope (available: ...)"}` |
| 10 | `--mode http`, `POST /reload` then `POST /call add [1,2]` | `{"ok":true}` then `{"ok":true,"result":3}` — the re-attach path works, and no `DeprecationWarning` is printed any more |
| 11 | `--spawn --package io.github.vvb2060.magisk --mode call --export getpackagename` | `{"ok": true, "result": "io.github.vvb2060.magisk"}` (spawn → script load → resume → RPC) |
Every script load also delivered the template's own liveness message:
`[script] {"ev": "RPC-READY", "exports": ["add","ping","getpackagename","callnative","callnativeaddr"]}`.
That line is the cheapest health check for a session and is worth grepping for in automation.
### 4.3 The server's port moved under us
Mid-pass, `--device-serial <DEVICE>` began failing on attach while `frida-ps -U` kept working:
```
$ python ... --device-serial <DEVICE> --pid 19938 ...
[bridge] connect failed (unable to connect to remote frida-server: closed); retry 1 in 1.0s
... (3 attempts) ...
$ python -c "import frida;d=frida.get_device('<DEVICE>');d.attach(19938)"
attach failed: ServerNotRunningError unable to connect to remote frida-server: closed
$ adb -s <DEVICE> shell 'su -c "ps -A"' | Select-String 'svc16|frida'
root 1876 1 2227684 55288 do_sys_poll S .svc16 # only this instance alive
$ adb -s <DEVICE> shell 'su -c "netstat -ltnp"' | Select-String 2704
tcp 0 0 0.0.0.0:27043 0.0.0.0:* LISTEN 1876/.svc16 # nothing on 27042
```
The visible `frida-server` process (pid 23405) was gone; a disguised copy (`.svc16`, pid 1876) was
listening on **27043 only**. `frida-ps -U` still worked because frida's USB transport probes a range
of ports, while the scripted device-serial path landed on a dead default port. Recovery, with the
forward already present (`adb forward --list` shows `tcp:27043 tcp:27043`):
```
$ python ... --remote 127.0.0.1:27043 --pid 19938 --mode call --export getpackagename
{"ok": true, "result": "com.android.settings"}
```
This pass neither started nor killed any server instance; it only observed that the instance changed
and switched to the explicit `--remote` form. That form is now the recommended one in the reference's
failure table.
### 4.4 A third-party process that refuses the handshake
```
$ python ... --remote 127.0.0.1:27043 --pid 19984 --mode call --export getpackagename
frida.TimedOutError: unexpectedly timed out while waiting for signal from process with PID 19984
```
An ordinary third-party app process (`com.xunmeng.pinduoduo`, listed by `frida-ps -Uai`) simply did
not answer the agent handshake within frida's timeout. Pre-fix this surfaced as a traceback; post-fix
the bridge treats it as retryable and reports `initial connect failed: ...` with exit 2. This is an
app-side property, not a bridge property — for connectivity probes prefer a system or stable app
process.
### 4.5 Device state after the pass
`frida-ps -U` and `frida-ps -Uai` both still answer; the sample's process was never touched; the
device-side server was never killed or restarted by this pass.
---
## 5. unidbg — observed (build/boot) and unverified (target emulation)
### 5.1 Build
```
$ git clone --depth 1 https://github.com/zhkl0228/unidbg.git # version 0.9.10-SNAPSHOT
$ .\mvnw.cmd -B -pl unidbg-android -am -DskipTests compile
Error: JAVA_HOME not found in your environment. # JAVA_HOME was unset on this host
...
[ERROR] Failed to execute goal ...maven-compiler-plugin:3.3:compile on project unidbg-api:
[ERROR] .../AbstractARMDebugger.java:[79,43] reference to Module is ambiguous
[ERROR] both com.github.unidbg.Module and java.lang.Module match
```
The shipped pom pins `<source>8</source><target>8</target>` with `maven-compiler-plugin` 3.3. On
JDK 9+ that pair does **not** select the JDK 8 API, so `java.lang.Module` stays visible and every
unqualified `Module` in `AbstractARMDebugger` becomes ambiguous — the build dies in `unidbg-api`.
Two local work-copy changes fix it (recorded in `tools/_work/t3-rpc/unidbg/pom.xml`, not part of the
skill and not committed):
```
maven-compiler-plugin 3.3 -> 3.8.1 # 3.13 is rejected: the bundled wrapper is Maven 3.5.4,
# and 3.13 requires Maven >= 3.6.3
<source>8</source><target>8</target> -> <release>8</release> # hides java.lang.Module
```
Result with `JAVA_HOME=C:\Program Files\Java\jdk-17.0.4.1`:
```
$ .\mvnw.cmd -B -pl unidbg-android -am -DskipTests compile
[INFO] unidbg-api ......... SUCCESS [ 40.838 s]
[INFO] unidbg-android ..... SUCCESS [ 4.941 s]
[INFO] BUILD SUCCESS (total 48.4 s, after a one-off dependency download)
```
### 5.2 Boot check
The pom sets `<maven.test.skip>true</maven.test.skip>`, so `-Dmaven.test.skip=false` is required to
run anything:
```
$ .\mvnw.cmd -B -pl unidbg-android -am -Dmaven.test.skip=false \
-Dtest=MemoryTrackerTest -DfailIfNoTests=false test
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
Backend: DynarmicBackend64
=== Memory Leak Report === ... Address: 0x12000000, Size: 4096 ...
[INFO] BUILD SUCCESS
```
This proves the toolchain end to end at the *environment* level: emulator construction, memory
allocator, backend selection (Dynarmic, with Hypervisor and Unicorn2 factories registered) and
Android 11 (`AndroidResolver(23)`-style) library resolution all execute on this host.
### 5.3 What was **not** done — unverified
* Loading and calling **any** target `.so`, including this sample's `libjiagu*.so` or `libapp.so`.
No `createDalvikVM`, `loadLibrary`, `callJNI_OnLoad` or `AbstractJni` stub was exercised.
* Consequently every per-library statement in `references/emulation-and-rpc.md` §Part A — which JNI
callbacks a hardened library demands, how many stub rounds it costs, which `/proc` or signature
checks it runs — remains **inferred** from the library's shape and community practice. The
reference's "Measured here" section was rewritten in this pass to say exactly that.
* Note for anyone porting an older tutorial: `emulator.createCode()` / the `Assembly` helper class
do **not** exist in 0.9.10-SNAPSHOT (no `Assembly*.java` under the tree, no `createCode` symbol in
any module). Recipes that assemble raw ARM machine code through that API need the current
backend/JNI entry points instead.
* `unidbg-android`'s bundled demos (`TTEncrypt`, `SignUtil`, `QDReaderJni`, …) each expect their own
`.so`/APK to be placed first; none were run, so the demo names in the reference are pointers, not
verified commands.
---
## 6. Consolidated list of things that did **not** work (for the pass summary)
1. Attach by package name on this ROM (both `com.android.settings` and `io.github.vvb2060.magisk`
failed while listed by `frida-ps -Uai`). Workaround: pid, from `frida-ps -U` / `pidof`.
2. Device-serial attach once the device's server came back on 27043 — `ServerNotRunningError:
unable to connect to remote frida-server: closed`. Workaround: `adb forward` + `--remote`.
3. Attach by pid to `com.xunmeng.pinduoduo` — `frida.TimedOutError`; the process never answered the
handshake.
4. `asc` as a command name (the console script is `droidasc`), and `mvnw` without `JAVA_HOME`.
5. unidbg out of the box on JDK 17 (`Module` ambiguity) and unidbg tests out of the box
(`maven.test.skip=true`).
6. Application-class decompilation of a packed APK by either ASC or ddc (nothing to fix — the dex
simply is not there; this is the case that justifies the whole emulation/RPC route).
## 7. Artifacts produced by this pass
`tools/_work/t3-rpc/` (not committed): `asc-manifest-verify.xml`, `asc-StubApp-verify.java`,
`asc-listclass-all.txt`, `asc-listclass-stub.log`, `asc-getmanifest.log`, `asc-findrefs.log`,
`asc-findrefs-jiagu.txt`, `asc-getclass.log`, `ddc-info-verify.txt`, `ddc-findrefs-jiagu.txt`,
`ddc-strings-jiagu.txt`, `rpc-*.log` (one per command in §4), `bin/ddc.exe`, `unidbg/` (patched work
copy).
Files changed in the repository by this pass: `references/emulation-and-rpc.md` (corrected failure
table, corrected unidbg build recipe, corrected "Measured here" labelling, and a stale section
reference repointed to `SKILL.md` §Stop conditions) and `scripts/frida_rpc_serve.py` (the four fixes
in §3).

View File

@@ -0,0 +1,539 @@
# Extension verification — extraction shells and the Dex-VMP boundary (bench rows B3 / B4)
Covers benchmark rows **B3** (extraction-shell skeleton detection) and **B4** (Dex-VMP
boundary: refuse-to-repack and downgrade the deliverable). Strength labels are this
directory's own three tiers: **observed** (an exact command and its output are quoted
here), **inferred** (follows from observed facts; the step was not executed),
**unverified** (assumed or externally reported, not reproduced here).
Everything below was produced on this repository's Windows 11 host with the rootless
PC toolchain in `tools/_work/ENV.md`; the device results in §4 name a
`<DEVICE>` and use `<PKG>` placeholders for the target package.
## 0. Headline
| Question | Answer | Strength |
|---|---|---|
| Does `stub%` separate a skeleton copy from a genuine dex? | **Only when the shell writes bare `return-void`.** Three other measured emptying shapes score 0.0 %–2.3 %, at or below the *untouched* control (1.9 %) | observed |
| Was the pre-existing `trivial_ratio` detector correct? | **No — two defects**, one of which inverted the ranking and named a fully-nop-wiped skeleton the "most likely original" | observed |
| Is `repos/CyReverse`'s shipped "extraction shell" asset usable as a shell fixture? | **No.** The pair differs by six bytes and one body; neither is a shell | observed |
| Is `DexPatcher/assets/ezAndroid.apk` a Dex VMP? | **No.** It is a JNI-sinking target: 7 `native` declarations, an exported `Java_*` for each, 34,566 clean dalvik instruction decodes, zero structural errors | observed |
| Can a static dex probe decide VMP vs not? | **It can decide "not dex-VMP"** when the bodies decode cleanly and each native declaration has an export. It **cannot** decide whether the native island performs runtime hardening | observed (negative half) / unverified (positive half) |
| Does `dex_mem_scan.py` find and cut a dex out of a blob? | **Yes** — exact offset, no false hits in 2 MiB of noise, image accepted by the judge | observed |
| Does it reach a dex in a live process's anonymous mapping? | **Not measured here.** The fixture is built and the commands staged; the device was held by another row throughout | unverified |
One claim in this pass was **retracted**: §3.4 records a Dex-VMP verdict drawn from a
desynchronised decoder and the measurement that killed it.
---
## 1. B3 — the shipped CyReverse "extraction shell" assets are a 6-byte difference, not a shell
Source: `repos/CyReverse/app/src/main/assets/{classes3.dex, classes3_extracted.dex}`
(12,536 B each).
```
$ python skills/apk-reverse/scripts/dex_dump_validate.py tools/_work/bench/unpack/b3
== dex dump validation: 2 file(s), 2 parse, 0 rejected ==
name size sha256[:12] ver cksum sig class noco code stub%
cyrev-extracted.dex 12536 c2d7345e955a 037 ok BAD 4 0 20 10.0%
cyrev-skeleton.dex 12536 ba9d5b120a3a 037 ok ok 4 0 20 10.0%
```
A byte diff of the pair (`tools/_work/bench/unpack/b3/analyze_pair.py`) locates every
difference:
```
$ python tools/_work/bench/unpack/b3/analyze_pair.py
len A=12536 len B=12536
first diff offset = 8 (0x8)
diff byte count = 6
diff ranges:
0x8 .. 0x8 (1 B) <- dex checksum (header offset 8)
0xa .. 0xb (2 B) <- dex signature, first half (offset 12 is 4 B wide)
0x121c .. 0x121c (1 B) <- INSNS of PluginClass->getString, units 0 and 2
0x121e .. 0x121e (1 B)
0x1220 .. 0x1220 (1 B)
```
Per-method census of the pair shows the "extracted" copy is the *degraded* one:
```
== cyrev-skeleton.dex == Lcom/cyrus/example/plugin/PluginClass;->getString insns=3 units=001a 0087 0011
== cyrev-extracted.dex == Lcom/cyrus/example/plugin/PluginClass;->getString insns=3 units=0000 0000 0000
```
`const-string v0, "…"; return-object v0` was overwritten with three `0x0000` units in
exactly one of the twenty bodies, and the header was re-fixed. The shipped
`classes3_extracted.dex` is **not** an extraction shell and **not** a recovered dex:
it is a single-body demonstration of body clearing, with 19 of 20 bodies identical to
the skeleton. Neither file can test extraction-shell routing — 10.0 % `stub%` on both
sides is a coincidence of the same body set, not a signal.
**Consequence for the benchmark row:** B3 cannot be run against this asset pair. The
skeleton measurement below had to be built from a real application dex instead.
## 2. B3 — measured `stub%` across every emptying shape, with a zero-change control
Fixture builder: `tools/_work/bench/unpack/b3/make_skeleton.py` derives each variant
from a real 982,480-byte application dex (`src-r2pay.dex`, extracted from
`tools/_work/bench/apks/r2pay-v1.0.apk`, sha256 `c89ff2364879…`). Every variant is
**byte-length preserving**: only the `insns` array of a `code_item` is rewritten and
the tail is nop-filled, so offsets, class table and string table are untouched and the
header is re-fixed with `dexutil.fix_dex_header` (signature first, checksum last).
```
$ python tools/_work/bench/unpack/b3/make_skeleton.py src-r2pay.dex skel
source: src-r2pay.dex
ctrl.dex fraction=0.00 mode=return emptied=0/5061
skel-25.dex fraction=0.25 mode=return emptied=1265/5061
skel-50.dex fraction=0.50 mode=return emptied=2531/5061
skel-75.dex fraction=0.75 mode=return emptied=3796/5061
skel-all.dex fraction=1.00 mode=return emptied=5061/5061
pruned.dex fraction=1.00 mode=pruned emptied=5061/5061
nopfilled.dex fraction=1.00 mode=nop emptied=5061/5061
allstub.dex fraction=1.00 mode=allstub emptied=5061/5061
throwstub.dex fraction=1.00 mode=throw emptied=4134/5061
```
Verdicts from `dex_dump_validate.py` over the whole set — the decisive matrix:
```
$ python skills/apk-reverse/scripts/dex_dump_validate.py tools/_work/bench/unpack/b3/skelall
name size sha256[:12] ver cksum sig class noco code stub% erased% emptied%
allstub.dex 982480 c8d7ca8fb163 035 ok ok 647 250 5061 100.0% 0.0% 100.0%
ctrl.dex 982480 1b3a97896b40 035 ok ok 647 250 5061 1.9% 0.0% 1.9%
nopfilled.dex 982480 de92dcea4eaf 035 ok ok 647 250 5061 0.0% 100.0% 100.0%
pruned.dex 982480 58d7fa3dce48 035 ok ok 647 250 5061 93.2% 0.0% 93.2%
skel-25.dex 982480 63346d9b41f5 035 ok ok 647 250 5061 1.7% 0.0% 1.7%
skel-50.dex 982480 d84601b7aa47 035 ok ok 647 250 5061 1.7% 0.0% 1.7%
skel-75.dex 982480 64114285656d 035 ok ok 647 250 5061 2.1% 0.0% 2.1%
skel-all.dex 982480 6345daef0a3f 035 ok ok 647 250 5061 2.3% 0.0% 2.3%
throwstub.dex 982480 cab8fd51a743 035 ok ok 647 250 5061 1.9% 0.0% 1.9%
```
Read the `mode=` column against `stub%`:
| Emptying shape (what the shell leaves behind) | `stub%` | Separated from `ctrl` (1.9 %)? |
|---|---|---|
| bare `return-void` in every body (`allstub`) | **100.0 %** | yes, by 98 points |
| body truncated to `const/4 v0,#0; return v0` + nop fill (`pruned`) | 93.2 % | yes |
| nop fill, no return at all (`nopfilled`) | **0.0 %** | **no — scores *below* the untouched control** |
| `new RuntimeException; throw` stub (`throwstub`) | 1.9 % | **no — indistinguishable from control** |
| 25 % / 50 % / 75 % of bodies emptied with a legal minimal body | 1.7 / 1.7 / 2.1 % | **no** |
Three conclusions, all observed:
1. **The threshold is not a number, it is a shape.** `stub%` is bimodal — it is either
≈ control (blind) or ≈ 100 % (sighted). There is no intermediate band where a
threshold could sit, so the "tens of percent" calibration the reference file
describes has no measured support.
2. **Partial extraction is invisible.** `skel-75.dex` — three quarters of all bodies
emptied — reports 2.1 % against a 1.9 % control, a 0.2-point difference inside the
sampling noise of what a real app's own trivial bodies contribute.
3. **The two shapes that defeat it are the two that real shells prefer.**
`throw new RuntimeException` stubs are the classic 360/legu family shape, and a
nop-wiped body is what a shell writes when it clears the slot before decrypting.
The false-negative on `nopfilled` was a **defect, not a boundary**, and is fixed in §3.
The `throwstub` blind spot is a **boundary** and is documented as one.
## 3. B3 — defects found in `dex_dump_validate.py`, and the fix
### 3.1 Defect 1: a fully nop-filled body was classified `real`
`classify_body` skipped every `0x0000` unit and then fell through to `return "real"`
when nothing else was found. Measured consequence: `nopfilled.dex` — **all 5,061
bodies wiped** — reported `stub% = 0.0`, and the ranking named it the most likely
original:
```
$ python skills/apk-reverse/scripts/dex_dump_validate.py …/skel/{nopfilled,ctrl}.dex
ranking (most likely original first):
1. nopfilled.dex stub%=0.0 cksum=ok sig=ok classes=647
…
verdict: nopfilled.dex is the most likely original (1 copy(ies) in this set)
```
An agent routing on that line patches a wiped skeleton. That is the failure the file
exists to prevent, so the defect is on the wrong side of "the tool still produces
output".
**Fix.** `classify_body` gained two classes and a wider body scan:
- `erased` — `insns_size > 0` with every unit `0x0000`. A real compiler never emits a
whole body of nops, so this is skeleton evidence.
- `minimal` — a body truncated to `const/4 vR,#0; return vR` (legal, and the shape the
detector is blind to by design). Reported separately so the blind spot is visible
rather than hidden inside `real`.
- The early-exit `meaningful > 1 → real` became `meaningful > 2`, so the two-unit
minimal body reaches its own verdict instead of being mislabelled.
`trivial_ratio` keeps its original definition so previously recorded numbers stay
comparable; `emptied_ratio = (stub + erased) / with_code` is the new skeleton signal,
and `rank_key` sorts on it **low-first** with `bodies_erased` as a tiebreak.
### 3.2 Defect 2 (retracted in-flight): `0xNN0E` is not malformed
An intermediate version of the fix classified a lone `0x020E` unit as `malformed`,
reading a non-zero high byte on a `0x0E` opcode as an illegal `return-void`. The
control dump proved otherwise — 82 of the untouched dex's own bodies are exactly that
shape — and `const/4 v0, #0; return v0` is a legal minimal non-void body. The class was
renamed `minimal`, its comment corrected, and the fixture extended with `pruned.dex`
to measure it deliberately (93.2 %, `stub%` sighted). Recorded here because the
mislabel would have taught the next reader that ordinary short methods are damage.
### 3.3 Post-fix behaviour, and no regression on the recorded fixture
`nopfilled.dex` is now ranked last and flagged:
```
8. nopfilled.dex stub%=0.0 erased%=100.0 cksum=ok sig=ok classes=647 <-- SKELETON: emptied%=100, extraction-shell shape
9. …
verdict: skel-25.dex is the most likely original (1 copy(ies) in this set)
```
The pre-existing acceptance fixture from `EXTENSION-unpacking.md` §2 still produces
its recorded verdict, with only the two new columns appended:
```
$ python skills/apk-reverse/scripts/dex_dump_validate.py tools/_work/t2-unpack --find 'Lcom/stub/'
== dex dump validation: 7 file(s), 4 parse, 3 rejected ==
01_original_shell.dex 8923752 bcd1c0488e86 035 ok BAD 4 160 29 3.4% 0.0% 3.4%
02_duplicate.dex 8923752 bcd1c0488e86 035 ok BAD 4 160 29 3.4% 0.0% 3.4%
03_broken_magic.dex 8923752 33eee53b1317 ? REJECTED: magic=b'XXXX'
04_tampered_body.dex 8923752 b89bc8cc73d9 035 BAD BAD 4 160 29 3.4% 0.0% 3.4%
05_truncated.dex 65536 e6687390a663 035 REJECTED: file_size=8923752 actual=65536
06_extracted_skeleton.dex 8923752 85e56fed1d56 035 ok ok 4 160 29 100.0% 0.0% 100.0%
07_tiny_garbage.dex 16 be45cb2605bf ? REJECTED: too small (16 B) to carry a dex header
verdict: 01_original_shell.dex is the most likely original (2 copy(ies) in this set)
```
Same winner, same three rejections, same dedupe group. The change is additive on that
fixture and corrective on the new one.
### 3.3b The ranking was still pointing at a modified image (found by independent review)
The first fix above added the `emptied_ratio` signal — and then **kept ranking on a
metric this same pass had just proved insensitive to skeletons**. Reproduced by
leading the review on the variant set:
```
$ python skills/apk-reverse/scripts/dex_dump_validate.py tools/_work/bench/unpack/b3/skel
ranking (most likely original first):
1. skel-25.dex stub%=1.7 erased%=0.0 cksum=ok sig=ok classes=647
2. skel-50.dex stub%=1.7 erased%=0.0 cksum=ok sig=ok classes=647
3. ctrl.dex stub%=1.9 erased%=0.0 cksum=ok sig=ok classes=647
…
verdict: skel-25.dex is the most likely original (1 copy(ies) in this set)
```
`skel-25.dex` — a dex with **25 % of its bodies removed** — was named the most likely
original, and the genuine untouched control ranked third. The cause is the bimodality
of §2: inside the low band the ratio is not a signal, and 1.7 % versus 1.9 % is noise
(the extracted bodies are replaced by `const/4 + return`, which *lowers* the
`return-void` count relative to the control). An agent routing on that line adopts a
modified image as its baseline, and every later diff is then built on the wrong
artifact — the same class of failure as the defect in §3.1, reached by a different
path.
**Fix.** `rank_key` became three-level, with an explicit eviction level so that either
signal can disqualify an image instead of the two merely tying:
1. `emptied_ratio >= 0.5` sorts last. Nothing mostly emptied is a candidate however
else it scores. This level exists because the count below cannot see the
`return-void`-in-every-body skeleton: that image has `minimal = erased = 0`, so a
pure-count key ranked it **first** — measured, and it is why this level was added
rather than the count being promoted alone.
2. `bodies_minimal + bodies_erased` low-first. This orders the low band, where the
count is monotone: 82 untouched, then 1299 / 2524 / 3736 / 4946 at 25 / 50 / 75 /
100 %.
3. `emptied_ratio` low-first, then header verdicts, then name.
Result on the same command:
```
ranking (most likely original first):
1. ctrl.dex stub%=1.9 erased%=0.0 min/erased=82 cksum=ok sig=ok
2. only.dex stub%=1.9 erased%=0.0 min/erased=82 cksum=ok sig=ok
3. throwstub.dex stub%=1.9 erased%=0.0 min/erased=82 cksum=ok sig=ok
4. skel-25.dex stub%=1.7 erased%=0.0 min/erased=1299 cksum=ok sig=ok
5. skel-50.dex stub%=1.7 erased%=0.0 min/erased=2524 cksum=ok sig=ok
6. skel-75.dex stub%=2.1 erased%=0.0 min/erased=3736 cksum=ok sig=ok
7. skel-all.dex stub%=2.3 erased%=0.0 min/erased=4946 cksum=ok sig=ok
8. allstub.dex stub%=100.0 erased%=0.0 min/erased=0 cksum=ok sig=ok <-- SKELETON
9. pruned.dex stub%=93.2 erased%=0.0 min/erased=344 cksum=ok sig=ok <-- SKELETON
10. nopfilled.dex stub%=0.0 erased%=100.0 min/erased=5061 cksum=ok sig=ok <-- SKELETON
verdict: ctrl.dex is the best-supported candidate for the original (1 copy(ies) in this set)
```
The control wins and every skeleton sorts behind it. The verdict line now states the
key and its limits instead of declaring a winner outright, and phrases the result as
"best-supported candidate" with an instruction to confirm before patching. `--trim`,
`--json` and `--find` are unchanged (verified: the recorded fixture still returns
`01_original_shell.dex` first, `--json` only gained keys). Note the residual limit the
report prints rather than hides: **`throwstub.dex` still ranks third**, because no body
statistic here sees a `throw`-stub skeleton. That is a boundary, not a bug — it is
listed in §7 with the reproduction.
### 3.4 A retracted Dex-VMP verdict — the measurement that killed it
An intermediate workbench script of mine
(`tools/_work/bench/unpack/b4/vmp_probe.py`, `vmp_methods.py`) decoded instruction
widths from a hand-written table and reported:
```
code_items=22424 desync=1393 (6.21%) bad_op=0xf8 / 0xef / 0xea / …
```
I read that as "bodies present but not decodable → private opcodes → Dex VMP". **The
reading was wrong, and the error was my table's.** The official decoder settles it:
```
$ E:\tools\android-14\dexdump.exe -d tools/_work/bench/unpack/b4/orig/classes.dex
Processing 'orig\classes.dex'...
Opened 'orig\classes.dex', DEX version '035'
… 943,223 lines …
instruction lines (hex:...) : 348566
code items resolved ('insns size :') : 22424
code '(none)' : 1179
```
One of the methods my script called a desync decodes cleanly under `dexdump`:
```
--- androidx.appcompat.app.AlertController$ButtonHandler.<init>:(Landroid/content/DialogInterface;)V
insns size : 11 16-bit code units
1fd564: 7010 9b05 0100 |0000: invoke-direct {v1}, Landroid/os/Handler;.<init>:()V
1fd56a: 2200 040e |0003: new-instance v0, Ljava/lang/ref/WeakReference;
1fd56e: 7020 fb76 2000 |0005: invoke-direct {v0, v2}, Ljava/lang/ref/WeakReference;.<init>:(Ljava/lang/Object;)V
1fd574: 5b10 ed08 |0008: iput-object v0, v1, …$ButtonHandler;.mDialog:…;
1fd578: 0e00 |000a: return-void
```
My script consumed 9 of 11 units and stopped on `0xed`; `0xed` is a legal
`invoke-*/range` opcode. **The lesson is the one the reference file already states and
this pass then failed to apply: a hand-rolled opcode table is not a VMP detector, and
"my decoder desynchronised" is a statement about my decoder.** Any future
"rare-opcode density" metric must be validated against `dexdump` on the same file
before it is allowed to inform a verdict.
The corrected count of bodies that fail to decode as dalvik in this dex is **0**.
Correction confirmed by an independent path: `dexdump` emits 34,566 instruction lines
and reports no structural error for any of the 22,424 code items.
## 4. B4 — the Dex-VMP judgement chain, re-opened
### 4.1 Static findings (`observed`)
Target: `repos/DexPatcher/assets/ezAndroid.apk`, 2,984,841 B, sha256
`76e3fa8119cf8b7c…`, package `<PKG>` (the `ezAndroid` CTF sample shipped in DexPatcher).
```
$ python skills/apk-reverse/scripts/dex_dump_validate.py …/b4/orig/classes.dex
classes=2905 methods_with_code=22424 stub%=1.9 cksum=ok sig=BAD
```
- **Native declarations: 7**, all in the app's own obfuscated classes, each with no
code (`code : (none)`), enumerated from `dexdump`:
`Lcom/example/ezandroid/C;->cf(Ljava/lang/String;I)Z`,
`Lcom/example/ezandroid/MU;->GMS(Ljava/lang/String;)Ljava/lang/String;`,
`Lcom/example/ezandroid/S;->Stringfjni()Ljava/lang/String;`,
`Lcom/example/ezandroid/S;->onCreate`, `Lcom/example/ezandroid/S2;->onCreate`,
`Lcom/example/ezandroid/S$1;->onClick`, `Lcom/example/ezandroid/S2$1;->onClick`.
- **Two JNI exports** in `lib/arm64-v8a/libezandroid.so` (206,904 B, aarch64):
`Java_com_example_ezandroid_S_Stringfjni` @ `0xea6c` (184 B) and
`Java_com_example_ezandroid_MainActivity_stringFromJNI` @ `0xe91c` (184 B).
Both are thin JNI wrappers that construct a `std::string` and dispatch through a
virtual call — the body of the check is behind the vtable, not in the wrapper.
- **No `JNI_OnLoad` and no `RegisterNatives` symbol** anywhere in the library; the
string `JNI_OnLoad` does not occur in the file at all.
- **`assets/` holds six small opaque files** (`0OooOO` 118 B, `O0ooOO` 126 B,
`oo0Ooo` 86 B, `ooO0oo` 132 B, `OOoo0O` 398 B, `OOooO0` 260 B). Each begins with a
three-unit header (`05 00 38 00 02 00 …`) followed by 2-byte-aligned units.
- **No dex string references those six filenames.** Grep over the full 943,223-line
`dexdump` output for `0OooOO|O0ooOO|oo0Ooo|ooO0oo|OOoo0O|OOooO0|assets/` returns
**0 hits**.
### 4.2 Verdict: JNI sinking, not Dex VMP (`observed`)
The reference file's Dex-VMP test is "the dump's method bodies are intact but decode
as private opcodes — rare-opcode density off the charts, unknown opcode slots". On this
target **zero method bodies fail to decode**, and every `native` declaration has a
matching exported `Java_*`. The dex is not carrying an encoded program; the code left
the dex for the `.so`, which is `java2c-and-jni-sinking.md`'s territory, not this
file's. The two must not be conflated: an agent that reads "bodies decode as nonsense"
here will go hunting a private disassembler for code that is ordinary ARM64 in a
206 KB library.
The six `assets/` files are a separate, **unresolved** observation: they are not
referenced by any dex string, so they cannot be load-bearing at the dex layer. Whether
the native island reads them by constructing the path at runtime, and what they
contain, is **unverified** here — it needs a dynamic run (§4.3), not more static
searching.
### 4.3 What static analysis structurally cannot see (the B4 boundary)
`java2c`'s independent pass reached the same classification on this sample and recorded
that the hardening takes effect **at runtime only**. That is the general shape, and it
is the half of B4 that no dex probe can settle:
| Question | Answerable statically? | Why |
|---|---|---|
| Are the bodies real dalvik or private opcodes? | yes | `dexdump` decodes them or does not; measured 0 failures here |
| Did the code leave the dex for native? | yes | `native` access flags + `Java_*` exports / `JNI_OnLoad` |
| Does the native island check the signature / root / debugger? | **no** | the logic is ARM64 behind a vtable; the check may not exist in any dex |
| Do those `assets/` blobs get decrypted and used? | **no** | the path may be assembled at runtime; grep for the literal names finds nothing |
| Does a repack actually fail, and why? | **no** | needs the repack installed and run against a control (G4) |
**Rule this pass proposes for the reference file:** a static dex probe may return
"this is *not* dex-VMP" when the bodies decode cleanly and every native declaration has
an export. It may **not** return "this is not hardened" from the same evidence —
those are different claims, and the second one is the misdiagnosis
`java2c-and-jni-sinking.md` was written to stop.
### 4.4 Repack control — `observed` up to the install, `unverified` beyond
G4 requires a zero-change control through the same pipeline. Built with the
repository's own repacker against an explicit toolchain (this host has no uber-apk-signer;
`keytool` lives in the JDK that `ENV.md` does not list):
```
$ python skills/apk-reverse/scripts/repack.py \
--apk tools/_work/bench/unpack/b4/ezAndroid.apk \
--out tools/_work/bench/unpack/b4/ez-ctrl.apk \
--ks tools/_work/bench/unpack/b4/ctrl.jks --signer apksigner \
--apksigner E:\tools\android-14\apksigner.bat \
--zipalign E:\tools\android-14\zipalign.exe \
--keytool "C:\Program Files\Java\jdk-17.0.4.1\bin\keytool.exe"
[in ] tools/_work/bench\unpack\b4\ezAndroid.apk (2984841 bytes)
[zip] - META-INF/1N0R.SF / 1N0R.RSA / MANIFEST.MF (signature artifact)
[zip] unsigned written: 2828919 bytes
[zip] AndroidManifest.xml STORED offset=0x31
[zip] classes.dex 3960320 bytes method=8 crc=037e9cb3
[zip] resources.arsc STORED offset=0x202f14 aligned
[sign] route: apksigner
[verify:zipalign] rc=0
[verify:v2block] present
[cert] SHA1 C9:89:C5:75:A4:83:AE:E0:A6:4B:F4:F6:0D:E3:D2:73:C2:8B:65:69
[result] OK
```
Zero-change repack, v2 block present, alignment verified, `classes.dex` carried through
at its original CRC (`037e9cb3`). **Install and launch on `<DEVICE>` are `unverified`
in this record**: the shared device was held by a parallel benchmark row (`B1 L1 repack
control + patched install`, `B2 L3 frida`) for the whole window available to this pass.
No claim is made here about whether the control launches; the artifact and the command
above are the reproducible half, and the device half is the open item.
Consequently **"repacking is truly refused on this target" is `inferred`, not
`observed`** — it follows from the native island being the only home of the app's own
logic (`Stringfjni`, `cf`, `GMS` are all native declarations), which makes a
dex-level patch unable to reach the behaviour, but no device run confirms a refusal.
The honest deliverable form for this target is the G1 row **annotated analysis report
with a stated boundary**, with `lsposed_scaffold.py` / `frida_rpc_serve.py` named as
the routes *if* a device run shows the build is refused.
## 5. `dex_mem_scan.py` — the cut half is measured, the in-process half is not
The script's job splits cleanly into *find-and-cut* (locate `dex\n03x` in a blob and
write exactly the declared `file_size`) and *reach the blob at all* (do it against a
live process's anonymous mappings through `/proc/<pid>/mem`). Only the first half was
measurable in this pass, and it was measured with a synthetic high-entropy capture
built to have the shape a heap buffer presents:
```
$ python … # 2 MiB of random bytes with a 4,124-byte dex written at offset 0x9a000
fixture dex: 4124 bytes, magic=b'dex\n039\x00'
capture written: …/memscan/anonregion.bin 2097152 bytes dex at 0x9a000
$ python skills/apk-reverse/scripts/dex_mem_scan.py …/anonregion.bin --dump …/scanout
== dex memory scan: 1 capture(s), 1 hit(s) ==
capture offset ver file_size classes state
anonregion.bin 0x9a000 039 4124 1 ok
wrote …/scanout/anonregion+0x0009a000.dex
$ python skills/apk-reverse/scripts/dex_dump_validate.py …/scanout
name size sha256[:12] ver cksum sig class noco code stub%
anonregion+0x0009a000.dex 4124 d3dedcd2b75e 039 ok ok 1 0 5 0.0%
```
Exact offset, zero false hits in 2 MiB of noise, the extracted image byte-identical
by construction and accepted by the judge including its checksum and signature. That
is the **whole find-and-cut path, observed** — the halves compose correctly.
**What is not measured.** The live-process half: the fixture that would exercise it is
built and staged (§6), but the shared device was held by a parallel benchmark row for
the entire window this pass had, so `dex_mem_scan.py` has still never been run against
a real anonymous mapping here. The one related number that exists is from the earlier
root-dump pass, and it is a **zero**: 142 anonymous regions scanned, no hits, against
17 dex images found in *named* ART mappings with no search at all. A zero-hit scan is
weak evidence for the reasons listed in the script's own header, and the honest
summary is "the finder works on a shape that looks right; its in-process reach is
unverified".
To close it (all commands staged, none run in this pass):
```
adb push tools/_work/bench/unpack/memscan/out2/memfixture.jar /data/local/tmp/
adb push tools/_work/bench/unpack/b3/src-r2pay.dex /data/local/tmp/payload.dex
adb shell 'su -c "CLASSPATH=/data/local/tmp/memfixture.jar app_process /system/bin \
com.revprobe.memfixture.Main /data/local/tmp/payload.dex"' &
# the fixture prints its own pid, truncates the on-disk payload so the heap copy is the
# only remaining source, and writes /data/local/tmp/memfixture.maps
adb shell 'su -c "grep -c . /proc/<pid>/maps"' # confirm cheap maps first
adb shell 'su -c "grep -v \"/\" /proc/<pid>/maps"' # the anonymous set to export
```
Note the fixture deliberately truncates its source file: a scan that finds the dex
after that point cannot have found a file copy, which is what makes the result
attributable to memory rather than to disk.
## 6. Not done — and why
- **No device run of the B4 control build.** The shared device was locked by another
benchmark row for the whole window; `devlock.py acquire unpack` timed out twice
(900 s and 1,800 s waits). Install, launch, time-to-death and refusal/success are
therefore `unverified`.
- **No in-process run of `dex_mem_scan.py`.** The fixture is built and staged
(`tools/_work/bench/unpack/memscan/`, §5), the arm64 `app_process` route is chosen
because this host has **no `android.jar`** and therefore cannot compile a real APK
at all, and the closing commands are listed — but the device was never available, so
the anonymous-mapping hit rate remains unmeasured. The only related measurement is
the earlier pass's **0 of 142** anonymous regions.
- **Whether the `assets/` blobs are live.** Needs a dynamic run (read the files out of
the process after the native island runs, or hook the path construction).
- **No end-to-end FART splice.** The reference file's FART loop needs a live
extraction-shell target; nothing in `repos/` is one (§1), and building an actively
resisting shell is out of scope for this pass.
- **A real APK build on this host.** The build-tools directory ships `d8`, `aapt2`,
`apksigner` and `zipalign` but **no `android.jar`**, and no `android.jar` exists
anywhere on the machine (searched the tool directories, `C:\Program Files`, and
the SDK path). Any
`javac` against `android.*` fails with "package android.os does not exist". APKs can
still be *repacked* here (`repack.py` does not recompile), which is why §4.4 works
and the self-built-APK route in the task brief does not.
## 7. Reproduction
```
python tools/_work/bench/unpack/b3/make_skeleton.py tools/_work/bench/unpack/b3/src-r2pay.dex tools/_work/bench/unpack/b3/skel
python tools/_work/bench/unpack/b3/analyze_pair.py
python tools/_work/bench/unpack/b3/crosscheck.py tools/_work/bench/unpack/b3/skel/*.dex
python skills/apk-reverse/scripts/dex_dump_validate.py tools/_work/bench/unpack/b3/skelall
python skills/apk-reverse/scripts/dex_dump_validate.py tools/_work/t2-unpack --find 'Lcom/stub/'
python tools/_work/bench/unpack/b4/vmp_boundary.py tools/_work/bench/unpack/b4/orig/classes.dex
E:\tools\android-14\dexdump.exe -d tools/_work/bench/unpack/b4/orig/classes.dex
python skills/apk-reverse/scripts/dex_mem_scan.py tools/_work/bench/unpack/memscan/anonregion.bin --dump tools/_work/bench/unpack/memscan/scanout
```
Raw captures live beside the fixtures: `evidence/prefix-validate.txt` (before the fix,
including the inverted ranking) and `evidence/postfix-validate.txt` (after). The
`memscan/` tree carries the staged (unrun) in-process fixture: `plain/com/revprobe/memfixture/Main.java`
(no-`android.jar` form), `build.ps1` (the APK build that cannot complete here, kept as
the record of *why*), and `out2/{classes.dex,memfixture.jar}`.
To re-verify the fix from scratch, the two commands that matter are
`dex_dump_validate.py …/skelall` (expect `nopfilled.dex` flagged, not ranked first) and
`dex_dump_validate.py tools/_work/t2-unpack --find 'Lcom/stub/'` (expect
`01_original_shell.dex` still the winner).

View File

@@ -0,0 +1,316 @@
# Extension verification — Java2C identification and routing
Covers the 2026-09 extension pass that added `references/java2c-and-jni-sinking.md` and
`scripts/java2c_probe.py`. Strength labels as defined in this directory's README:
**observed** (exact command + output here), **inferred** (follows from observed facts,
step not executed), **unverified** (assumed or externally reported, not independently
confirmed).
Reference environment for this pass: Windows 11 host, Python 3.14.0, JDK 17,
build-tools (`d8`/`aapt2`) from a local Android 14 SDK, `gcc` from MinGW only.
**No Android NDK, no host `clang`, no `wsl` (HCS unavailable), and no device-side
`clang`/Termux.** A rooted Android 11 / API 30 / arm64-v8a `<DEVICE>` was attached and
reachable but **was not used at all** in this pass: every result below is static. No
package name, project name or device serial appears in this record.
Samples are identified by hash rather than by name. They are public MASTG training apps
(a no-native baseline, and two carrying a JNI sink), one VMP-labelled app taken from
`tools/_work/bench/repos/`, one real third-party aarch64 ELF already on the host, and one
locally synthesised Java2C-shaped dex.
---
## 1. The compile chain could not be closed — observed
The intended route was to build a real Java2C `.so` with `amimo/dcc` and probe it. `dcc`
is present at `tools/_work/bench/repos/dcc/` and its Python dependencies are satisfied.
The build stops at the C compiler, and every alternative was tried exactly once before
being written off (two-strike rule — no third variant was attempted):
```
$ which gcc clang
gcc.exe C:\MinGW\bin\gcc.exe # MinGW targets PE/COFF, not ELF
gcc/clang for Android: none
$ wsl -l -q ; wsl -e bash -lc "which gcc"
Ubuntu-E
(empty) -> exit 1 with: Wsl/Service/CreateInstance/CreateVm/HCS/HCS_E_SERVICE_NOT_AVAILABLE
$ adb shell "which clang clang++ gcc ; ls /data/data/com.termux"
(no output for which)
ls: /data/data/com.termux: No such file or directory
```
Related **observed** fact: the `dcc` checkout ships **no prebuilt binary at all** — a
recursive search for `*.so`, `*.apk`, `*.a` over the whole repo returns zero files. There
is no ready-made Java2C library to fall back on.
Consequence: **no ELF file anywhere in this pass was produced by a Dex-to-C compiler.**
Every Java2C-specific criterion in the reference document is therefore **inferred**.
## 2. Degenerate route A — `dcc` generates the C without an NDK — observed
`dcc.py` takes `--no-build`, and `dcc_main(..., do_compile=False)` writes the generated
sources and the JNI project *before* `build_project(project_dir)` would be called. With
`--no-build` and no `-o`, the apktool repack path is skipped entirely (it is guarded by
`if is_apk(apkfile) and outapk`). So the translation stage runs to completion on a machine
with no NDK.
```
$ cd tools/_work/bench/repos/dcc
$ python dcc.py <SAMPLE_DIR>/baseline.apk --no-build --dynamic-register \
--source-dir <WORK>/dcc-out
# (emits only Python 3.14 SyntaxWarnings from the bundled androguard)
$ find <WORK>/dcc-out -type f
jni/Android.mk
jni/Application.mk
jni/nc/Dex2C.cpp jni/nc/Dex2C.h
jni/nc/DynamicRegister.cpp jni/nc/DynamicRegister.h
jni/nc/ScopedLocalRef.h jni/nc/ScopedPthreadMutexLock.h
jni/nc/well_known_classes.cpp jni/nc/well_known_classes.h
jni/nc/compiled_methods.txt
jni/nc/Java_<PKG>_<Class>_<method>__<mangled-proto>.cpp
```
This is the mapping evidence the pass needed, and it is exact rather than imagined:
`compiled_methods.txt` is a one-line dex signature, and it produced exactly one C file
whose name is the same identifier in JNI long form:
```
L<PKG>/MainActivity;onCreate(Landroid/os/Bundle;)V
-> Java_<PKG>_MainActivity_onCreate__Landroid_os_Bundle_2.cpp
```
The generated function opens with the parameter shape the reference document cites as the
`.so`-side signature of a translated method:
```c
/* L<PKG>/MainActivity;->onCreate(Landroid/os/Bundle;)V */
void Java_<PKG>_MainActivity_onCreate__Landroid_os_Bundle_2(JNIEnv *env, jobject thiz, jobject p2){
jobject v0 = NULL;
...
jclass cls0 = NULL,cls1 = NULL,cls2 = NULL,cls3 = NULL;
```
And the registration half is generated as real text, which is the strongest single piece
of evidence produced in this pass that the "no `Java_*` symbol to search for" path is not
hypothetical:
```c
// DynamicRegister.cpp
const char *dynamic_register_compile_methods(JNIEnv *env) {
jclass clazz;
clazz = env->FindClass("<PKG>/MainActivity");
if (clazz == nullptr)
return "Class not found: <PKG>/MainActivity";
const JNINativeMethod export_method_0[] = {
{"onCreate", "(Landroid/os/Bundle;)V", (void *)Java_<PKG>_MainActivity_onCreate__Landroid_os_Bundle_2}
};
env->RegisterNatives(clazz, export_method_0, 1);
env->DeleteLocalRef(clazz);
return nullptr;
}
```
Two further facts read out of the generated `jni/Application.mk`, both **observed** and
both load-bearing for the JNI-boundary section of the reference document:
```
APP_STL := c++_static
APP_CPPFLAGS += -fvisibility=hidden
APP_ABI := armeabi-v7a arm64-v8a
```
`-fvisibility=hidden` means the shipped library can present as "exports no `Java_*`
symbol" **even when the translation used static naming** — the second, independent reason a
symbol search returns nothing. `c++_static` is the origin of the `libc++` string criterion,
and is also why that criterion is weak: it is an ordinary NDK setting.
## 3. The probe against real JNI-sinking targets — observed
This is the discriminating measurement the reference document is built on. The tool was
run unmodified:
```
$ python skills/apk-reverse/scripts/java2c_probe.py --apk <SAMPLE_DIR>/jni-a.apk
classes.dex: 720084 B, 625 classes, 5081 methods, native 2 (0.04%),
native-dominated classes 0, fully native 0
libfoo.so: 14176 B, aarch64, dynsym 20 entries, Java_* 2, JNI_OnLoad False
libfoo.so: 13948 B, arm, dynsym 26 entries, Java_* 2, ...
libfoo.so: 13788 B, x86, dynsym 16 entries, Java_* 2, ...
libfoo.so: 14440 B, x86_64, dynsym 16 entries, Java_* 2, ...
verdict : jni-sinking
confidence : medium
```
```
$ python skills/apk-reverse/scripts/java2c_probe.py --apk <SAMPLE_DIR>/jni-b.apk
classes.dex: 1934860 B, 1396 classes, 11182 methods, native 3 (0.03%),
native-dominated classes 0, fully native 0
libfoo.so (arm/arm64: 3 Java_* symbols, x86/x86_64: 3 Java_* symbols)
verdict : jni-sinking
confidence : medium
```
Two independent real targets, and both give the same two results: **native density three
orders of magnitude below the Java2C shape**, and **an exact 1:1 between the dex native
declarations and the exported `Java_*` symbols** (2:2 and 3:3, across four ABIs each).
That 1:1 is the point. It is *not* a Java2C signal on its own — both shapes produce it —
which is why the density measurement and the native layer must be read together. A tool
that reported on either alone would misroute one of these two samples.
Baseline control, same command, no native code at all:
```
$ python skills/apk-reverse/scripts/java2c_probe.py --apk <SAMPLE_DIR>/baseline.apk
classes.dex: 5528 B, 7 classes, 15 methods, native 0 (0.00%), ...
verdict : no-hardening-signal
confidence : medium
```
## 4. The probe against a Java2C-shaped dex — observed
No Java2C library could be built, so the dex half of the shape was synthesised. A Java
source declaring two classes whose methods are almost entirely `native` (plus one mixed
class, standing in for an ordinary JNI boundary) was compiled with `javac --release 8` and
assembled with `d8 --min-api 19`:
```
$ javac --release 8 -d classes src/<PKG>/Calc.java # exit 0
$ d8 --min-api 19 --output out classes/<PKG>/*.class # exit 0
out/classes.dex 1752 B sha256 59e0b1d34f00578e1343ee5f344d0e58829f482b643a7843c8ad280311ce9457
$ python skills/apk-reverse/scripts/java2c_probe.py --dex out/classes.dex
classes.dex: 1752 B, 3 classes, 29 methods, native 24 (82.76%),
native-dominated classes 2, fully native 1
verdict : java2c-suspect-needs-so
confidence : low
```
The low confidence is the correct answer, not a shortcoming: the tool refuses to claim
Java2C from the dex alone, and says so in its route block. That refusal is the guard
against the misdiagnosis this pass exists to prevent.
Combining that dex with a real library that registers dynamically makes the `java2c` branch
reachable, which is what proves the branch is not dead code — the input is composite and is
labelled as such:
```
$ python skills/apk-reverse/scripts/java2c_probe.py --dex out/classes.dex \
--so <WORK>/libandroid.so
verdict : java2c
confidence : medium
evidence: (strong) native layer: dynamic registration in libandroid.so
(JNI_OnLoad exported, zero Java_* symbols)
```
## 5. The JNI boundary — why `RegisterNatives` cannot be found — observed
The reference document claims a symbol search fails silently and that looking for a
`RegisterNatives` symbol is structurally unable to work. Both are measured here.
A real 273,120 B aarch64 system library pulled from one of the samples exports a JNI entry
point and no `Java_*` symbols, and the probe flags it as dynamic registration — the
affirmative check that does work:
```
libandroid.so: 273120 B, aarch64, dynsym 504 entries, Java_* 0,
JNI_OnLoad True, RegisterNatives-import False
(strong) native layer: dynamic registration in libandroid.so
(JNI_OnLoad exported, zero Java_* symbols)
```
Independent confirmation that the absence is real and not a parser gap — the same check run
outside the script:
```
$ python -c "d=open('gadget.so','rb').read(); [print(k, d.count(k.encode())) for k in
['JNI_OnLoad','RegisterNatives','Java_','Dex2C','libc++_shared.so','frida']]"
JNI_OnLoad 0
RegisterNatives 0
Java_ 0
Dex2C 0
libc++_shared.so 0
frida 311
```
A 25.8 MB real third-party aarch64 ELF with 311 occurrences of its own name contains zero
`JNI_OnLoad` and zero `Java_`. The probe reported the same (0 `Java_*`, `JNI_OnLoad False`),
so the two readings agree and the probe is not reporting a parse failure as an absence.
**Why the `RegisterNatives` search cannot fire** `[observed]`: the library above registers
dynamically and contains **zero literal `RegisterNatives` symbols**. The cause is a
documented property of the NDK C++ headers `[inferred]`: `jni.h` declares `RegisterNatives`
as an inline member of `JNIEnv` that dispatches through the function table, so a call site
emits no external symbol. The observed half is the missing symbol; the explanation is
inferred from the mechanism.
## 6. Coverage of the probe's code paths — observed
| Path | Exercised by | Result |
|---|---|---|
| dex header + class_defs + class_data (ULEB128) | 5 real dex images + 1 synthetic | Parsed; header size match reported |
| `direct_methods` / `virtual_methods` decoding | the synthetic fixture (has both) | Correct after the defect in §7 |
| native density, native-dominated classes | all six | 0.03–0.04 % real vs 82.76 % synthetic |
| no-`code_item` and trivial-body ratio | all six | 0 on the fixture, 4–5 % on the real apps |
| ELF header, arch, section table | 10 real ELFs (4 ABI variants of two `libfoo.so`, 2 app libs, 1 gadget) | aarch64/arm/x86/x86_64 all identified |
| `.dynsym` parse, `Java_*` extraction | real `libfoo.so` ×8 | 2 and 3 symbols, matching the dex exactly |
| `JNI_OnLoad` export detection | real system library | True |
| dynamic-registration verdict | same library | Fired |
| string-signature scan | 25.8 MB ELF, all samples | Weak hits only (`dcc` ×6 on the gadget); no false strong hit |
| `--json` | synthetic fixture | Valid JSON, metrics block parses |
| `--dir`, `--apk`, `--dex`, `--so` | all four modes | OK |
| `--help` | direct | OK |
The string scan produced exactly one class of hit across every sample: the weak `dcc`
substring (6 occurrences inside a 25.8 MB binary, i.e. coincidental). **No strong marker was
ever produced by any real sample**, which is why the Java2C verdict rests on structure
rather than on strings.
## 7. Not exercised — and why the Java2C verdict stays inferred
- **A real Java2C `.so`.** Never produced; the compile chain is dead (§1). Everything about
how a *compiled* Java2C library presents — whether the generated functions keep their
names under `-fvisibility=hidden`, whether `Dex2C` runtime strings survive into `.rodata`
— is **inferred** from the generated sources and the build flags, not observed.
- **The runtime claim that no dex bytecode exists in memory.** This is the mechanism the
whole document turns on and it is **inferred from the translation model**. No live Java2C
app was run. It is falsifiable and cheap to test on a live sample; that test is named in
the reference document.
- **VMP identification by static dex metrics.** One VMP-labelled sample (sha256
`76e3fa8119cf8b7c4b4768be557d71863433a8ae4d2ad40901eae6bb829349bc`, a 2,984,841 B APK
from the local bench repos, identified by hash only)
was measured and reported **`jni-sinking`**: its static dex has 23,603 methods of which 7
are native, with a 4 % stub ratio, and the archive contains no additional encrypted
payload. **This is a recorded boundary, not a success**: whatever hardening that sample
carries is applied at runtime, and a static dex probe is structurally unable to see it.
Do not read this row as "the sample is unhardened".
- **Anything on a device.** The `<DEVICE>` was not used; there is no dynamic evidence in
this record.
## 8. Two defects this pass found in its own tool — observed
Both were found by running the tool against the synthetic fixture and checking the numbers
by hand, and both are fixed in the shipped script.
1. **`direct_methods` and `virtual_methods` were decoded as one continuous run.** They are
two independent encoded lists; each one's first `method_idx_diff` is relative to 0.
Walking them as one run mis-attributed every virtual method to the following class. The
fixture exposed it as `Codec: 5 methods` where the source declares 4. Fix: decode them as
two passes. After the fix the per-class counts match the source exactly (Calc 20/21
native, Codec 3/3, Helper 1/2).
2. **The "whole class is native" criterion was unreachable.** `javac` — and, per the
generated output, a translation pass — always leaves `<init>`/`<clinit>` behind, so no
class is ever literally all-native unless a constructor is counted as native. The
fixture showed `fully native 0` while two of its classes were plainly native-shaped. Fix:
constructors are excluded and the test is now ">= 70 % of >= 3 named methods native",
reported alongside the literal count. After the fix: `native-dominated classes 2,
fully native 1`.
Both defects produced *plausible* output — a wrong count and a zero that reads as "nothing
found" — which is the failure shape this whole repository is organised against. Recording
them here because a reader relying on either number would have drawn a wrong conclusion.

View File

@@ -0,0 +1,121 @@
# Extension verification — kernel/environment hardening + on-device tooling
Covers the 2026-09 extension pass that added `references/kernel-and-environment-hardening.md`,
`references/on-device-tooling.md` and `scripts/mt_mcp_probe.py`. Strength labels as defined in
this directory's README: **observed** (exact command + output here), **inferred** (follows from
observed facts, step not executed), **unverified** (assumed or externally reported, not
independently confirmed).
Reference environment for this pass: rooted physical device, Android 11 (API 30), arm64-v8a,
kernel **4.14.186**, Magisk alpha + Zygisk, LSPosed v1.9.2 activated, Zygisk-Assistant v2.1.4
and MoveCertificate v1.6.2 installed (pending reboot merge), MT Manager 2.26.9 installed.
---
## 1. MT APK MCP probe — measured
### 1.1 `scripts/mt_mcp_probe.py` runs and reports correctly
`--help` works (argparse, three options). Observed:
```
$ python skills/apk-reverse/scripts/mt_mcp_probe.py --help
usage: mt_mcp_probe.py [-h] [--url URL] [--timeout TIMEOUT] [--json]
```
Full **observed** output of the probe in the service-down state, with the port forward in place:
```
$ adb forward --list
<serial> tcp:8787 tcp:8787
<serial> tcp:8651 tcp:8651
<serial> tcp:65534 tcp:65534
$ python skills/apk-reverse/scripts/mt_mcp_probe.py
[waiting] MCP server is not answering at http://127.0.0.1:8787/mcp
The MT APK MCP must be started by hand in the MT UI (adb cannot start it):
1. On the phone: MT Manager -> side drawer -> Tools -> APK MCP -> Start
...
exit code 2
```
The failure was localised to the service (not the transport) on the device side — **observed**:
```
$ adb -s <serial> shell "su -c 'netstat -tln | grep 8787 || echo NOT_LISTENING'"
NOT_LISTENING
```
So at pass time: port forward alive, device reachable, MT's MCP service not started in the MT UI
(it requires a manual start that adb cannot perform). The script's designed degraded-mode
behaviour (waiting instructions + exit 2) is therefore **verified by observation**; the online
path (initialize handshake → grouped `mt_apk_*` inventory) is **unverified on this device** — it
was not exercised because the service was never started during the pass. The JSON-RPC shape
(initialize with `protocolVersion 2024-11-05`, `notifications/initialized`, `tools/list`,
`Accept: application/json, text/event-stream`) follows the MCP Streamable HTTP specification and
MT's documented transport; treat the exact response parsing as inferred until a live run.
### 1.2 What was deliberately not done
No attempt was made to start MT's MCP from adb (documented as impossible), and no scripted
tap-driver was built to press the UI button — that is a human step. The probe script doubling as
a "wait until up" check (exit 2 → retry after manual start) is the supported loop.
## 2. Phone-module environment — observed earlier, cited here
These facts were measured in the prior configuration pass and archived in
`tools/_phone-modules/README-手机模块环境.md` and `README-MCP与网络环境.md`; this pass cites them
rather than re-measuring. They count as **observed** (exact commands and outputs exist in those
archives):
- LSPosed v1.9.2 (7024) activation: log lines `ZygiskCompanion: welcome to LSPosed!`, version
banner, `lspd` daemon running as system, manager UI reporting activated.
- Zygisk requires one reboot after enable before `/data/adb/zygisk` exists; modules install to
`/data/adb/modules_update/` and merge into `/data/adb/modules/` only on reboot.
- Shamiko v1.2.5 kept as backup, **not** installed — documented conflict with Zygisk-Assistant
(both do root hiding); Zygisk-Assistant chosen because it does not depend on DenyList
enforcement.
- mcp-termux / stackplz eBPF route closed on this device: requires kernel 5.10+, device has
4.14.186; its bundled kmodules ship only 5.10/5.15/6.1/6.6/6.12 variants.
- MT Manager 2.26.9 installed; MCP port confirmed as 8787 in MT settings; forwards 8787/8651/
65534 established.
## 3. Kernel-level content — unverified / inferred, with sources
No kernel-level mechanism in `kernel-and-environment-hardening.md` was executed, on this device
or any other. Specifically **unverified here**: eBPF kprobe/uprobe tracing, seccomp-BPF
installation into a target, KPM/LKM hooking, self-ptrace anti-anti-debug, and every hiding claim
of Shamiko / Zygisk-Assistant against a real detector (the module archives also record that
hiding was never validated against an actual detection scenario).
The **inferred** claims that carry external sources (fetched 2026-09, URLs in the reference
file):
| Claim | Source |
|---|---|
| KernelSU: kernel-based su, official support GKI 2.0 = kernel 5.10+ (Android 12-era), backport to 4.14 requires self-built kernel; module system conflicts with Magisk magic mount; su-only coexistence possible (kernel vs ramdisk patching) | tiann/KernelSU README + FAQ (github.com) |
| APatch: KernelPatch patches stock boot.img kernel (no kernel source needed — the difference from KernelSU); SuperKey-gated SuperCall syscall; KPM kernel-space modules with inline/syscall-table hooks; SELinux hooked not rewritten; Shamiko officially unsupported | apatch.dev FAQ |
| Shamiko: reads Magisk denylist but requires DenyList enforcement OFF; whitelist mode via `/data/adb/shamiko/whitelist` with documented performance cost | Shamiko README (LSPosed distribution) |
| Zygisk-Assistant: Magisk use = target on denylist + Enforce OFF; KernelSU/APatch use = ZygiskNext + "Umount modules/Exclude modifications" | snake-4/Zygisk-Assistant README |
| GKI 2.0 starts at android12-5.10; eBPF extension docs target GKI kernels | source.android.com (kernel architecture section) |
The version gate consequence (eBPF row closed on 4.14) is **observed** for this device (uname)
and **inferred** as a general gate from the GKI sources above.
## 4. Not run / open items
- Live MCP handshake: blocked on a human UI action (start APK MCP in MT). The probe script is
ready; re-run and record the real `initialize` + `tools/list` output when it happens.
- Zygisk-Assistant + MoveCertificate merge: pending the device reboot recorded in the module
archive; their post-merge behaviour is untested.
- No LSPosed hook module was built or run against a real target in either pass — the framework
is activated (observed), the module workflow remains inferred.
## 5. Reproduction one-liners
```
python skills/apk-reverse/scripts/mt_mcp_probe.py # probe + inventory or waiting help
adb forward --list # confirm 8787 forward exists
adb -s <serial> shell "su -c 'netstat -tln | grep 8787'" # device-side listen check
adb -s <serial> shell uname -r # the 4.14 kernel gate, observed
```

View File

@@ -0,0 +1,236 @@
# Extension verification — kernel-level weapons (templates, gates, and a closed door)
Covers the extension pass that added `scripts/kernelsu_syscall_mask.py` and the `§4a` section of
`references/kernel-and-environment-hardening.md`. Strength labels as defined in this directory's
README: **observed** (exact command + output here), **inferred** (follows from observed facts,
step not executed), **unverified** (assumed or externally reported, not independently confirmed).
Reference environment: Windows 11 host, Python 3.14.0; `<DEVICE>` = rooted Android 11 (API 30)
arm64-v8a, Magisk alpha, no APatch/KernelPatch.
**Headline: the generator, the gate table and the userspace module skeleton are measured. No
kernel-side artifact was compiled or loaded anywhere, and every route is closed on the reference
device. That is the deliverable — a template plus a measured statement of the door.**
---
## 1. Kernel gate, measured on the device — observed
```
$ adb shell 'uname -r; cat /proc/version'
4.14.186+
Linux version 4.14.186+ (nobody@android-build) (Android (6443078 based on r383902) clang version
11.0.1 (https://android.googlesource.com/toolchain/llvm-project b397f81060ce6d701042b782172ed13bee898b79),
LLD 11.0.1 (/buildbot/tmp/tmp6_m7QH ...)) #1 SMP PREEMPT Wed Mar 30 23:32:42 CST 2022
```
4.14.186 is below the 5.10 GKI gate for the eBPF route, and the device carries no
KernelPatch-patched image. Read-only query; no device lock was needed and no state was changed.
## 2. Host toolchain probe — observed
```
$ python skills/apk-reverse/scripts/kernelsu_syscall_mask.py gates
== kernel-side routes and their gates ==
eBPF tracepoint/kprobe GKI kernel 5.10+ (Android 12+)
why: Android's BPF/tracing support as bpftrace-class tooling and
stackplz expect it is a GKI-era feature; BTF is required for
CO-RE style probes.
check: uname -r -> expect 5.10.x or newer; ls /sys/kernel/btf/vmlinux
KernelPatch / APatch KPM KernelPatch-patched boot image + kpm toolchain
why: KPMs load through kpimg injected into the kernel image's
payload segment; building one needs a bare-metal ARM64
compiler (aarch64-none-elf-gcc), not the NDK.
check: ls /data/adb/ | grep -i apatch ; which aarch64-none-elf-gcc
Out-of-tree LKM Kernel source matching the device's exact version + vermagic
why: A module built against a different kernel revision is refused
at load time on a mismatched vermagic, and the device's kernel
source is frequently not published at all.
check: uname -r ; look for a matching kernel source tree for the device
seccomp-BPF (for contrast) any modern kernel, but it is not this route
why: seccomp can make a syscall FAIL (SECCOMP_RET_ERRNO/TRAP); it
cannot rewrite the CONTENT a successful read returns. It closes
doors, it does not paint them -- which is the whole requirement
for a spoofed /proc read.
check: n/a -- this is a capability ceiling, not a version check
== host probe (what can be checked from here) ==
bare-metal ARM64 compiler: NOT FOUND
the KPM route cannot be built from this host as configured
ndk-build: NOT FOUND
make: NOT FOUND
A kernel source tree, a patched boot image and the device's own kernel headers cannot be
checked from the host; they are device facts.
```
The three `NOT FOUND` lines are the measured reason the KPM route cannot be built from here, and
they are also consistent with `ENV.md`'s record that the machine has no Android NDK. The LKM route
additionally needs kernel source that does not exist for this device.
## 3. `generate` — observed
```
$ python skills/apk-reverse/scripts/kernelsu_syscall_mask.py generate \
--out tools/_work/t5-vmp/sysmask --package '<PKG>'
generated module scaffold in tools/_work/t5-vmp/sysmask
module.prop 165 B
customize.sh 166 B
post-fs-data.sh 830 B
service.sh 467 B
uninstall.sh 298 B
config/syscall_mask.json 1079 B
kpm/syscall-mask.c 8096 B
kpm/Makefile 880 B
lkm/syscall-mask.c 2434 B
ebpf/syscall_mask.bpf.c 4338 B
README.md 3752 B
STATUS: the userspace module is installable; every kernel-side file is an UNVERIFIED template.
No kernel code was compiled or loaded.
```
`module.prop`, as generated (the package identity is the `<PKG>` placeholder by design — this file
carries no real package name):
```
id=syscall-mask
name=Syscall Mask
version=0.1.0
versionCode=1
author=apk-reverse
description=config-driven syscall return masking (openat/read/stat on /proc/self/*)
```
The configuration table rendered into the KPM template from `config/syscall_mask.json`, so the
rule set is data rather than hand-edited C:
```
static const struct mask_rule rules[] = {
{ "/proc/self/maps", 0, MASK_DENY, -2, 0 },
{ "/proc/self/maps", 0, MASK_FAKE_SIZE, -2, 0 },
{ "/proc/self/status", "TracerPid", MASK_FILTER_LINE, -2, 0 },
{ "/proc/self/status", "TracerPid", MASK_FILTER_LINE, -2, 0 },
};
```
And the KPM lifecycle surface, which is the part an interface drift would break first:
```
int kfunc_def(strncpy_from_user)(char *dst, const char __user *src, long count);
/* fp_hook_syscalln(__NR_openat, before_openat, 0, 0); */
/* fp_hook_syscalln(__NR_newfstatat, before_newfstatat, 0, 0); */
/* fp_hook_syscalln(__NR_read, before_read, 0, 0); */
KPM_INIT(mask_init);
KPM_EXIT(mask_exit);
```
Those names (`KPM_NAME` / `KPM_INIT` / `KPM_EXIT`, `kfunc_def`, `fp_hook_syscalln`,
`hook_fargs4_t`, `syscall_argn`, `args->skip_origin`, `args->ret`) are taken from a public KPM
development write-up rather than invented — blackr0ck, *APatch KPM 开发:绕过 DirtySepolicy /
Duck Detector 的 SELinux Root 检测*, [bbs.kanxue.com/thread-291665.htm](https://bbs.kanxue.com/thread-291665.htm),
fetched 2026-02. **They are still `unverified` here**: nobody compiled against a `kpmodule.h` in
this pass, and the generated template says so in its own header for exactly that reason.
The registration calls are left **commented out** in the template. A generated file that arms a
kernel hook by default is a file that arms a kernel hook the first time somebody runs `make` in a
hurry.
## 4. `verify` — observed
```
$ python skills/apk-reverse/scripts/kernelsu_syscall_mask.py verify --dir tools/_work/t5-vmp/sysmask
== verifying tools/_work/t5-vmp/sysmask ==
module.prop: author=apk-reverse, description=config-driven syscall return masking
(openat/read/stat on /proc/self/*), id=syscall-mask, name=Syscall Mask,
version=0.1.0, versionCode=1
config: 4 rule(s), actions=['deny', 'fake_size', 'filter_line']
kpm target present: Makefile, syscall-mask.c
status: UNVERIFIED TEMPLATE -- not compiled, not loaded
lkm target present: syscall-mask.c
status: UNVERIFIED TEMPLATE -- not compiled, not loaded
ebpf target present: syscall_mask.bpf.c
status: UNVERIFIED TEMPLATE -- not compiled, not loaded
kpm/syscall-mask.c: KPM_* lifecycle macros present, kfunc_def not extern
== 0 problem(s): structure is consistent ==
(this says nothing about whether the kernel side compiles or loads)
```
`verify` is deliberately read-only and deliberately narrow: it checks `module.prop` fields, the rule
JSON, and the one static trap in the KPM template that has a documented consequence (an `extern`
`kfunc_def` becomes `*UND*` and the loader refuses the module). It states on its last line what it
does **not** establish, because "0 problems" on a template is the easiest sentence in this file to
over-read.
### 4a. A false positive this check produced, and its fix — observed
The first run reported:
```
== 1 problem(s) ==
- syscall-mask.c declares kfunc_def with `extern`, which the loader rejects as an unknown symbol
verify rc=1
```
The template does not declare it with `extern` — its comment *explains* why `extern` is forbidden,
and the check's own regex ran from that prose across to the real declaration's semicolon. A linter
that reads comments as code produces reports nobody can act on. The fix strips `/* */` and `//`
comments before any token check (`_strip_c_comments`). Both the false positive and the fix are
recorded here rather than quietly repaired, because the failure mode — a check that fires on its
own documentation — is one a reader will meet again.
## 5. The userspace module's ceiling — observed by construction, and worth stating plainly
`post-fs-data.sh` and `service.sh` in the generated skeleton do not load anything. That is not an
omission: a KernelSU/Magisk/APatch-userspace module runs as root in the normal world and cannot
change what a syscall returns. The generated `README.md` says this in its first paragraph, and
`post-fs-data.sh` carries the reasoning in a comment:
```
# The kernel side (if you built and loaded one) owns the syscall table; this
# script's whole job is to make the configuration visible to it and to leave a
# record that the module actually ran. Do not put a syscall hook here: this is
# userspace, and nothing here can change what openat/read/stat return.
```
The uninstall hook likewise refuses to unload a kernel module, because a silent unload during
package removal is how a device ends up in a boot loop.
## 6. eBPF route — gate table and the capability ceiling
| Route | Gate | Check on the device | Status here |
|---|---|---|---|
| eBPF tracepoint/kprobe | GKI 5.10+ with tracing/BTF | `uname -r`; `ls /sys/kernel/btf/vmlinux` | **closed** — device is 4.14.186 |
| seccomp-BPF | any modern kernel | n/a | not this route: it can only fail a syscall, not rewrite a successful read |
| KernelPatch / APatch KPM | patched boot image + `aarch64-none-elf-gcc` | `ls /data/adb/ \| grep -i apatch`; `which aarch64-none-elf-gcc` | **closed** — neither present; patch-the-boot-image risk declined |
| Out-of-tree LKM | kernel source matching vermagic | `uname -r` + a matching source tree | **closed** — no source for this device |
The probe template (`ebpf/syscall_mask.bpf.c`) is a `sys_enter_openat` tracepoint observer with a
uid filter map and a ringbuf event, and it documents two dead ends in the file itself:
`bpf_override_return()` only applies to `ALLOW_ERROR_INJECTION` functions, which raw syscall
entries are not, and a tracepoint cannot rewrite a result at all. **A tracepoint observes; it does
not spoof.** The honest eBPF shape for this job is therefore observation — which is still worth
having, because `§5` of the reference file requires you to establish whether the target uses a raw
`svc` or a libc call *before* designing any spoof.
## 7. What was not done
- **No kernel-side code was compiled.** Not the KPM, not the LKM, not the eBPF program. No
`kpmodule.h`, no kernel headers, no `bpftool`, no bare-metal ARM64 compiler on this host.
- **Nothing was loaded, flashed or activated on `<DEVICE>`.** No boot-image patch, no module load,
no kernel modification. The task forbids it for good reason: a kernel module that faults takes
the device down before it can report why.
- **The KPM API surface is `unverified`.** The macro and helper names follow a public write-up; the
argument arity of `fp_hook_syscalln` in particular is marked `TODO(verify-on-device)` in the
generated source rather than guessed, because a wrong arity in a hook registration is a
load-time failure at best.
- **The struct-stat size offset is a TODO, not a value.** The `newfstatat` handler deliberately does
not carry a magic offset: it is ABI-specific and copying one from a different architecture is how
a spoof corrupts an unrelated field.
- **`verify` establishes structure, not capability.** Its own output says so.

View File

@@ -0,0 +1,567 @@
# Extension verification — LSPosed module route
Covers the 2026-09 extension pass that added `skills/apk-reverse/references/lsposed-and-modules.md`
and `skills/apk-reverse/scripts/lsposed_scaffold.py`. Strength labels as defined in this directory's
README: **observed** (exact command + output here), **inferred** (follows from observed facts, step
not executed), **unverified** (assumed or externally reported, not independently confirmed).
Reference environment for this pass: rooted physical device, Android 11 (API 30), arm64-v8a,
Magisk alpha + Zygisk, LSPosed v1.9.2 (7024) activated, Zygisk-Assistant v2.1.4 installed, MT Manager
2.26.9 installed, one hardened Flutter sample installed (referred to below as *the sample*; no target
identity is recorded here). Host: Windows, JDK 17.0.4.1, Android build-tools 34.0.0, API 28
`android.jar`, Xposed `api-82` stub jar.
**Identifier convention:** every occurrence of the sample's package identifier in the transcripts
below is normalized to `<PKG>` (`<PKG>.MainActivity`, `<PKG>.BaseApplication`), and its sub-package
prefixes are shortened the same way. The transcripts are otherwise verbatim, including the class
names a hook reported — those are what make the evidence checkable.
This pass closes an item left open by the previous extension record, which noted that "no LSPosed hook
module was built or run against a real target" (`EXTENSION-kernel-ondevice.md` §4). The build half is
now **observed**; the injection half is **partially observed** and the gap is stated in §6.
---
## 1. Scaffold script — observed
### 1.1 `--help` and generation
```
$ python skills/apk-reverse/scripts/lsposed_scaffold.py --help
usage: lsposed_scaffold.py [-h] --package PACKAGE --name NAME
[--hook-target TARGETS] --out OUT
[--class-name CLASS_NAME] [--tag TAG]
[--description DESCRIPTION] [--min-sdk MIN_SDK]
[--target-sdk TARGET_SDK] [--hook-class HOOK_CLASS]
[--hook-method HOOK_METHOD]
[--hook-params HOOK_PARAMS] [--force]
```
Generation of a throwaway module, **observed**:
```
$ python skills/apk-reverse/scripts/lsposed_scaffold.py \
--package com.example.probe --name "Example probe" \
--hook-target com.example.target \
--hook-class com.example.target.Helper --hook-method check \
--hook-params "java.lang.String" --out <work>/scaffold-gen
[+] <work>/scaffold-gen/AndroidManifest.xml
[+] <work>/scaffold-gen/assets/xposed_init
[+] <work>/scaffold-gen/src/com/example/probe/MainHook.java
[+] <work>/scaffold-gen/README.md
```
`assets/xposed_init` contains exactly one line, `com.example.probe.MainHook` (**observed**). Re-running
without `--force` leaves existing files alone and says so, so the script is safe to re-run.
### 1.2 The generated source compiles as written
The point of the scaffold is that its output builds, so this was checked rather than assumed —
**observed**, exit code 0, four class files (entry class plus three anonymous hook classes):
```
$ javac -encoding UTF-8 -source 8 -target 8 -nowarn -cp "<android.jar>;<api-82.jar>" \
-d <work>/build/classes <work>/scaffold-gen/src/com/example/probe/MainHook.java
$ echo $?
0
MainHook.class MainHook$1.class MainHook$2.class MainHook$3.class
```
## 2. Gradle-free build chain — observed end to end
Built with a parameterised driver that reproduces the chain documented in the reference. Times are
wall-clock on the host above; the module is one class.
| Step | Command shape | Time |
|---|---|---|
| compile | `javac -encoding UTF-8 -source 8 -target 8 -nowarn -cp <android.jar>;<api-82.jar> -d build/classes src/**/*.java` | 850 ms |
| jar | `jar cf build/classes.jar -C build/classes .` | 259 ms |
| dex | `d8.bat --min-api 24 --lib <android.jar> --output build build/classes.jar` | 1143 ms |
| link | `aapt2.exe link -o build/base.apk -I <android.jar> --manifest AndroidManifest.xml --min-sdk-version 24 --target-sdk-version 28 -A assets` | 126 ms |
| add dex | `(cd build && aapt.exe add unsigned.apk classes.dex)` | 97 ms |
| align | `zipalign.exe -f -p 4 build/unsigned.apk build/aligned.apk` | 76 ms |
| sign | `apksigner.bat sign --ks build/test.keystore --ks-pass pass:android --key-pass pass:android --v2-signing-enabled true --out build/module.apk build/aligned.apk` | 1794 ms |
| verify | `apksigner.bat verify --min-sdk-version 24 --print-certs build/module.apk` | 725 ms |
| | **total** | **5.27 s** |
Resulting artifact, **observed**: `module.apk` 12,745 bytes, `classes.dex` 5,024 bytes,
`apksigner verify` reporting v2 signature valid with signer `CN=module, O=local`. APK entry list
(**observed**, read back with zipfile):
```
AndroidManifest.xml 1680
resources.arsc 40
assets/xposed_init 27
classes.dex 5024
META-INF/MANIFEST.MF 375
```
A second, independent build of a hand-written one-class module (different package, different source)
produced the same shape and also verified, so the chain is not specific to the scaffold's output.
### 2.1 Three build-chain traps, all measured rather than assumed
These are the failures that cost time, quoted from the actual runs:
1. **`d8` rejects a directory as program input.** Pointing it at the compiled `classes/` directory
fails with:
```
Error in program input '<work>/build/classes':
com.android.tools.r8.internal.Sb: Unsupported source file type
Compilation failed
```
Jarring the classes first (`jar cf classes.jar -C classes .`) and passing the jar succeeds. This is
the opposite of what several command-line guides imply.
2. **`aapt add` fails with `Unable to add 'classes.dex' to 'unsigned.apk': file not found`** when the
dex is not in the current working directory. It resolves its argument relative to the cwd and stores
the same name, so the dex must be copied out of `dex/` to sit beside the apk.
3. **`jar` and `keytool` are not on `PATH` even where `javac` is.** On this host `javac` resolved to
Oracle's `javapath` shim while `keytool` was absent entirely; both tools had to be called from the
JDK's `bin` directory explicitly. A build script that assumes they are on `PATH` breaks on a
working machine.
Additionally the reference notes that `-bootclasspath` no longer exists on JDK 9+, so the Android API
comes from `-cp` alongside the Xposed stub jar — **observed** (that is the form used above, and it
compiled and dexed cleanly).
## 3. Module state on the device — observed
A previously built probe module was already installed on the device from an earlier pass. Its state
was read, not modified, except where noted:
```
$ adb -s <serial> shell "pm path com.t1.lsphook"
package:/data/app/~~<hash>==/com.t1.lsphook--<hash>==/base.apk
$ adb -s <serial> shell "dumpsys package com.t1.lsphook | grep -E 'User 0|enabled'"
User 0: ceDataInode=35322 installed=true ... stopped=true notLaunched=true enabled=0 ofs=0 ...
```
`enabled=0` here is the **package manager's** component state, not the LSPosed module switch. Enabling
the package was the one state change this pass made on the device:
```
$ adb -s <serial> shell "pm enable com.t1.lsphook"
Package com.t1.lsphook new state: enabled
$ adb -s <serial> shell "dumpsys package com.t1.lsphook | grep 'User 0'"
User 0: ... enabled=1 ...
```
LSPosed itself, **observed**:
```
$ adb -s <serial> shell "su -c 'cat /data/adb/modules/zygisk_lsposed/module.prop'"
id=zygisk_lsposed
name=Zygisk - LSPosed
version=v1.9.2 (7024)
$ adb -s <serial> shell "su -c 'ps -A -o PID,NAME | grep lspd'"
744 lspd
$ adb -s <serial> shell "dumpsys package org.lsposed.manager | grep versionName"
versionName=1.9.2
```
### 3.1 The installed module could never have run — observed
Before reusing that module for an injection attempt, its internals were checked. `assets/xposed_init`
declared one entry class while the dex in the same APK contained a different one:
```
$ python -c "... zipfile.ZipFile('installed-module.apk').read('assets/xposed_init') ..."
com.t1.lsphook.MainHook
$ dexdump -f installed.dex | grep 'Class descriptor'
Class descriptor : 'Lcom/example/t1probe/ProbeHook$1;'
Class descriptor : 'Lcom/example/t1probe/ProbeHook$2;'
Class descriptor : 'Lcom/example/t1probe/ProbeHook$3;'
Class descriptor : 'Lcom/example/t1probe/ProbeHook;'
```
The entry class named in `xposed_init` does not exist in that dex (`MainHook` appears nowhere in the
string table; `ProbeHook` does). LSPosed resolves the class named by `xposed_init`, so this module
could only fail at load — and it would fail **silently** from the outside, which is precisely the
failure mode the reference lists. Recorded because "the package is installed, the `xposedmodule`
meta-data is present and `pm path` returns a path" is still not evidence that the module can run:
the entry class has to exist.
Two consequences measured in the same check:
- A second APK produced by that earlier pass **did** contain the matching class, but was signed with a
different key — signer DN `O=local` against the installed build's `O=tools`, SHA-256
`892c18be…` against `9c86f386…`. Replacing the installed module therefore requires an uninstall
first; `adb install -r` would have failed on a signature mismatch.
- A module rebuilt from the same source with this pass's chain (`build_module.ps1`, the chain in §2)
was verified to contain the entry class, the `T1HOOK` log tag, the `injected: ` literal and the
target package string in its dex, and is the artifact intended for the injection attempt in §6.
Building it took 5.07 s and produced 12,745 bytes.
The **linked** manifest was checked as well, because LSPosed reads the compiled manifest rather than
the source file. `aapt2 dump xmltree <apk> --file AndroidManifest.xml` on the rebuilt APK shows all
three keys intact (`xposedmodule=true`, `xposeddescription="Minimal lifecycle hook probe for route
verification"`, `xposedminversion=82`). The broken APK's manifest was **equally intact** — which is the
point of recording this pair: a correct manifest, an installed package and a valid signature still do
not mean the module can run. Entry-class existence is a separate check, and it is the one that failed
here.
### 3.2 Replacing it, and an install failure that belongs to the ROM — observed
The broken module was removed and the rebuilt one installed. The install path is worth recording
because it failed for a reason that has nothing to do with LSPosed:
```
$ adb -s <serial> uninstall com.t1.lsphook
Success
$ adb -s <serial> install module.apk
[timed out after 240000 ms] # never landed; pm path returned nothing afterwards
$ adb -s <serial> push module.apk /data/local/tmp/<work>/module.apk
$ adb -s <serial> shell "pm install -r /data/local/tmp/<work>/module.apk"
Failure [-99]
$ adb -s <serial> shell "su -c 'pm install -r /data/local/tmp/<work>/module.apk'"
Success
```
Three findings from that sequence:
- `Failure [-99]` from a shell-identity `pm install`, with logcat showing the ROM's own security
centre (`com.coloros.safecenter`) taking the foreground from `com.android.packageinstaller` at the
same moment. The vendor layer is intercepting the install; the framework is not rejecting the APK.
- The same APK installs without complaint as **root**. That is the working path on this ROM, and the
one used here.
- The streaming `adb install` did not merely fail — it **hung for four minutes** and left no package
behind. Retrying as root is cheaper than waiting that out.
Installed state afterwards, **observed**:
```
$ adb -s <serial> shell "pm path com.t1.lsphook"
package:/data/app/~~c7aQQNK-GiP3BKd1g1iEvw==/com.t1.lsphook-<hash>/base.apk
$ adb -s <serial> shell "pm enable com.t1.lsphook"
Package com.t1.lsphook new state: enabled
$ adb -s <serial> shell "dumpsys package com.t1.lsphook | grep 'User 0'"
User 0: ... installed=true ... enabled=1 ...
```
So the **package-side** half of the deployment was completed in this pass: correct entry class,
correct meta-data, installed, PM-enabled. The scope half did not happen, for the reason in §6.
## 4. Where LSPosed keeps module and scope state — observed
The configuration database was copied off the device (three WAL files) and parsed read-only:
```
$ adb -s <serial> shell "su -c 'cp -f /data/adb/lspd/config/modules_config.db* /data/local/tmp/<work>/'"
$ adb -s <serial> pull /data/local/tmp/<work> ...
```
Schema and contents, **observed** (SQLite):
```
CREATE TABLE modules (mid integer PRIMARY KEY AUTOINCREMENT, module_pkg_name text NOT NULL UNIQUE,
apk_path text NOT NULL, enabled BOOLEAN DEFAULT 0 CHECK (enabled IN (0,1)))
CREATE TABLE scope (mid integer, app_pkg_name text NOT NULL, user_id integer NOT NULL,
PRIMARY KEY (mid, app_pkg_name, user_id), ...)
CREATE TABLE configs (module_pkg_name text NOT NULL, user_id integer NOT NULL, `group` text NOT NULL,
`key` text NOT NULL, data blob NOT NULL, ...)
== table modules ==
(1, 'lspd', '/data/adb/modules/zygisk_lsposed/manager.apk', 0)
(2, 'com.t1.lsphook', '/data/app/~~<hash>==/com.t1.lsphook--<hash>==/base.apk', 0)
== table scope ==
(no rows)
== table configs ==
('lspd', 0, 'config', 'misc_path', <blob>)
```
Three facts worth keeping from this, because each maps to a documented failure mode:
- `enabled` in the LSPosed database is a **separate** bit from the package manager's `enabled` state.
Both were 0 for the probe module when read, and only the package-manager one was changed here.
- The `scope` table was **empty**: an installed module that is not scoped is injected nowhere, which is
why "installed" is not evidence of injection.
- `sqlite_sequence.modules` was 3 while only ids 1 and 2 exist — a module had been registered and
removed in an earlier pass. The ids are not a count of what is loaded.
## 5. lspd lifecycle — observed from its own startup scripts
Read from the installed module directory, **observed**:
```
$ adb -s <serial> shell "su -c 'cat /data/adb/modules/zygisk_lsposed/service.sh'"
...
unshare -m sh -c "$MODDIR/daemon --from-service $@&"
$ adb -s <serial> shell "su -c 'cat /data/adb/modules/zygisk_lsposed/daemon'"
...
exec /system/bin/app_process $java_options /system/bin --nice-name=lspd org.lsposed.lspd.Main "$@"
```
`service.sh` is Magisk's late-start service script, which runs once per boot. **Inferred** (not
executed, deliberately): an `lspd` killed from a root shell is not restarted by anything reachable from
that shell, and the cheap-looking "just restart the daemon to pick up my database edit" move would take
the framework's scope decisions away from every subsequently forked process. This pass therefore did
**not** kill `lspd`, and the reference says so explicitly.
Related **inferred** consequence, stated in the reference: hand-editing `modules_config.db` is not a
supported way to change scope, because the daemon serves decisions from its live copy and the file is
its persisted state. The manager path updates both.
## 6. Injection: the framework had to be repaired first, then it worked — observed
With the module installed and PM-enabled, the next step was LSPosed Manager: enable the module there
and add the sample to its scope. That is where the pass stopped — and the reason is a property of the
device's LSPosed state at the time, not of the module.
Manager opened normally but reported the framework as **not installed**. Observed both on screen and in
the dumped node tree:
```
node: "未安装" / "LSPosed 未安装" (overview status card)
node: "API 版本" -> "未安装"
node: "Xposed API 调用保护" -> "未安装"
node: "Dex 优化器包装" -> "未安装"
bottom navigation: 仓库 / 概览 / 设置 # there is no module-list tab in this state
```
The daemon was still alive (`ps -A` → `744 lspd`), so this is not "LSPosed is absent". Two further
observations pin down what it actually is:
- **`lspd` stopped updating its own configuration.** The database still listed the **uninstalled**
module under its old, no-longer-existing path, and never gained an entry for the freshly installed
one. Its size and WAL set were unchanged across the whole pass (156,376 bytes in three files, the
same as before the uninstall):
```
== table modules ==
(1, 'lspd', '/data/adb/modules/zygisk_lsposed/manager.apk', 0)
(2, 'com.t1.lsphook', '/data/app/~~wiGaHYIZn5j3Am2JrJvA5g==/com.t1.lsphook-<hash>/base.apk', 0)
^ that codePath no longer exists: the package was uninstalled this pass
== table scope ==
(still empty)
```
A daemon that had re-scanned packages would have dropped id 2 and registered the new install path.
- **Its log records the framework bridge dying and never recovering.** The verbose capture contains
`system server died`, `am is dead`, then `no response from bridge, retry in 1s` on a loop, and
`Magisk: zygote crashed too many times, rolling-back`.
`[inferred]` The consistent explanation is that the half of the framework that lives in
`system_server`/`zygote` was lost (the same capture shows Magisk rolling a zygote restart back),
leaving `lspd` as a live process with nothing to talk to. Manager asks the framework for its state,
gets nothing, and falls back to its "not installed" onboarding UI — which has no module list, so
**scope cannot be configured through the UI at all** in that state.
Recorded consequences, deliberately not worked around:
- Scope was **not** configured, so no module was injected into any process, so no `T1HOOK` line could
appear. The injection claim therefore remains **unverified on this device**, stated plainly rather
than replaced by a weaker proxy (such as "the module is installed" or "`lspd` is running", both of
which were true the whole time and neither of which means anything about injection).
- The pass did not try to "fix" it by killing `lspd`, hand-editing the database, or restarting
`zygote`: the first two are explained in the reference's §Deploy and the third would have destroyed
concurrent instrumentation work another agent was running on the same device.
- The recovery action is a **device reboot**, which re-initialises Zygisk and `lspd`. It was not taken
because the device was shared and under heavy load. It is the concrete next step, and it is cheap:
after a reboot, open Manager, expect the module list to be present, enable the module, tick the
sample in its scope, then watch `logcat -T <ts> -s T1HOOK` while the sample restarts itself. Per the
reference, the scope change applies to the target's **next** process start, so a self-restarting
target is the ideal case — no spawn, no force-stop, no attach needed.
- Manager was returned to the background afterwards (`mCurrentFocus` back to the launcher). The sample
was never spawned, force-stopped or attached to during this pass.
### 6.1 The repair — observed
A reboot was approved and taken. It is the whole fix, and it is cheap:
```
$ adb -s <serial> reboot
[ 0 s] boot_completed='' ... polled with getprop, no fixed sleep
[ 31 s] boot_completed=1
--- post-boot ---
load average 17.40 4.37 1.46 (was 29.58 31.87 33.61 before)
746 lspd (new pid; was 744)
MoveCertificate zygisk-assistant zygisk_lsposed (all three modules present)
```
Manager then reported the framework normally, and the **module tab appeared** — the recovery
criterion. Observed on the overview page: `已激活 / 1.9.2 (7024) - Zygisk`, `API 版本 100`,
`Xposed API 调用保护 已启用`, `Dex 优化器包装 支持`; bottom navigation went from three tabs to five
(仓库 / 模块 / 概览 / 日志 / 设置). The module package itself survived the reboot
(`pm path` intact, `enabled=1`), so no reinstall was needed — the ROM install workaround in §3.2 only
matters on a fresh install.
### 6.2 Configuring scope through the UI — observed
Done strictly by reading coordinates out of a `uiautomator dump`, never by tapping a guess:
| Step | What was read from the dump | Coordinate tapped | Result |
|---|---|---|---|
| module tab | `[216,2160][432,2293]` | (324, 2260) | module list opened |
| module row | `T1 LSPosed probe`, row `[48,611][1008,793]` | (528, 702) | detail page |
| enable switch | switch node `[804,651][972,795]` | (888, 723) | `checked=true` |
| scope row | `<PKG>`, row `[252,1855][591,1912]` | (528, 1878) | `checked=true` |
The scope list had to be scrolled five screens to reach the target (the list is alphabetical by
display name, not by package). The database then confirmed the change, and — for the first time in
this whole exercise — **`lspd` wrote to it**:
```
== table modules ==
(2, 'com.t1.lsphook', '/data/app/~~c7aQQNK-GiP3BKd1g1iEvw==/com.t1.lsphook-<hash>/base.apk', 1)
enabled ^ = 1
== table scope ==
(2, '<PKG>', 0)
```
The database file grew from 156,376 to 201,696 bytes across the pass. Both facts together are the
proof that the framework, not just its daemon process, came back.
### 6.3 The injection line, and where it actually appears — observed
The target was launched once with `am start`, and the module fired on **every** subsequent process
start. Nine consecutive target starts produced nine full injection blocks:
```
Loading legacy module com.t1.lsphook from /data/app/~~c7aQQNK-GiP3BKd1g1iEvw==/.../base.apk
Loading class com.t1.lsphook.MainHook
T1HOOK injected: <PKG> / <PKG> cl=dalvik.system.PathClassLoader@c226f2f
T1HOOK loaders at-handleLoadPackage:
0: dalvik.system.PathClassLoader c226f2f
1: java.lang.BootClassLoader 8454a72
T1HOOK hook installed: Application.attach
T1HOOK hook installed: Activity.onCreate
T1HOOK hook installed: Application.onCreate
T1HOOK Application.attach #1 this=<PKG>.BaseApplication cl=dalvik.system.PathClassLoader@c226f2f
T1HOOK Application.attach #2 this=com.stub.StubApp cl=dalvik.system.PathClassLoader@c226f2f
T1HOOK Application.onCreate this=com.stub.StubApp
T1HOOK Application.onCreate this=<PKG>.BaseApplication
T1HOOK Activity.onCreate -> <PKG>.MainActivity
T1HOOK injected: com.google.android.webview / <PKG> cl=dalvik.system.PathClassLoader@141f448
T1HOOK Activity.onCreate -> com.qq.e.ads.PortraitADActivity
```
**The verification criterion had to be corrected, and this is the most transferable finding of the
pass.** The reference (and this record's earlier draft, and the scaffold's generated README) said to
watch `logcat -s <TAG>`. On this device that is **empty** — as is `logcat -s LSPosed-Bridge`, and as
is a full-buffer `grep -i T1HOOK`. The module's output exists only in
```
/data/adb/lspd/log/modules_<timestamp>.log
```
which is the same file that earlier appeared to contain nothing but `Logd maybe crashed (err=Socket
operation on non-socket), retrying in 1s...`. The two facts are related: the ROM's `logd` route is
broken, so LSPosed's logcat channel delivers nothing while its file channel works normally. Anyone
debugging a module on such a device with `logcat` alone would conclude "the module never ran" while
looking at nine successful injections. Read **both**: `logcat -s <TAG>` where the platform is healthy,
`/data/adb/lspd/log/modules_*.log` always. The evidence file for this pass is 201 lines, of which 126
are `T1HOOK`.
Three further observations from the same log, none of which needed any extra instrumentation:
- **The packer's double Application is visible in the hook order.** `Application.attach` fires twice —
first for the app's real `BaseApplication`, then for `com.stub.StubApp` — and `onCreate` then runs
for the stub *before* the real Application. Hooks installed at `handleLoadPackage` time survive
this swap, and by `Activity.onCreate` the classes reported are the **unpacked** ones
(`<PKG>.MainActivity`), not the stub's. That is the concrete value of this route on a
packed target: the Java layer is reachable without touching the APK.
- **Scope is per app, not per process.** The module also injected into the app's WebView process
(`com.google.android.webview / <PKG>`) and reported the ad SDK's activity
(`com.qq.e.ads.PortraitADActivity`) — worth knowing before scoping a module widely.
- **The target's self-restart cycle is unaffected by injection.** Nine pids in ~3 minutes, one every
22–23 s (8111 → 9392 → 10557 → 11453 → 12345 → 13236 → 14155 → 15061 → 15934), each run reaching
`MainActivity.onCreate`, the WebView process and an ad activity before ending. The module only
observes, so this says nothing about cause; and because a reboot changes many variables at once, any
comparison with the pre-reboot cycle (a different observer measured 10–20 s) is **inferred at best
and not a controlled result**.
## 7. Log surfaces — observed, and one trap
```
$ adb -s <serial> shell "su -c 'ls -la /data/adb/lspd/log/'"
kmsg.log 993690
modules_2026-09-21T18:44:31.523.log 401
props.txt 46937
verbose_2026-09-21T18:44:31.518.log 178482
```
The `modules_*.log` file is **not** module output. Its entire content, **observed**:
```
----part 1 start----
Logd maybe crashed (err=Socket operation on non-socket), retrying in 1s...
(repeated)
```
`verbose_*.log` is a full logcat snapshot that includes unrelated system events (it contained, for
example, Magisk zygote-restart lines and OEM process deaths from an earlier incident). Useful for
correlating a run with system state; useless as a module log. The injection proof must come from
`logcat -s <TAG>`, which is what the reference and the scaffold's README recommend.
## 8. Root-hiding layer — observed
```
$ adb -s <serial> shell "su -c 'cat /data/adb/modules/zygisk-assistant/module.prop'"
id=zygisk-assistant
name=Zygisk Assistant
version=v2.1.4 (1013f8a-release)
description=A Zygisk module to hide root.
$ adb -s <serial> shell "su -c 'magisk --denylist status'"
Denylist is enforced
$ adb -s <serial> shell "su -c 'magisk --denylist ls'"
(no output)
$ adb -s <serial> shell "su -c 'ls /data/adb/shamiko'"
ls: /data/adb/shamiko: No such file or directory
```
So at pass time the DenyList was **enforced but empty**: no process was being hidden from anything,
while LSPosed and `lspd` were running normally, and Shamiko was not installed. Worth recording because
"the target knows it is on a rooted device" is frequently an environment fact of exactly this shape,
and it is cheaper to check than to escalate (`references/detection-and-anti-analysis.md`).
The Shamiko/Zygisk-Assistant comparison in the reference is **inferred** from their own documentation
plus the on-device module metadata above; the hiding behaviour itself was not exercised against any
detector in this pass.
## 9. Not run / open items
- **Injection is not verified, and the blocker is now identified rather than unknown.** §6 records the
state that stopped it: Manager reporting the framework as not installed, `lspd` alive but no longer
updating its own configuration, and its log showing the framework bridge die and never recover. The
action known to clear that state is a **device reboot**; after it the remaining sequence is short
(enable the module in Manager, tick the sample in its scope, read `logcat -T <ts> -s T1HOOK` on the
target's next self-restart). Until someone does that, every statement in the reference about a module
*actually being injected* rests on framework documentation rather than on this bench.
- Editing `modules_config.db` directly was **not** attempted, on purpose: §5 explains why it would have
been an unreliable experiment, and the unsupported-ness of that path is recorded as inferred rather
than tested by breaking a working setup.
- Killing/restarting `lspd` was **not** attempted, for the same reason.
- The module's effect on a hardened, detection-aware target was not measured, because injection was not
reached. Consequently nothing here validates or contradicts the ClassLoader-count detection reported
externally (see the reference's *What detects you anyway*); that source is cited, not reproduced.
- The vendor install interception (§3.2) was worked around by installing as root; whether it can be
disabled from the ROM's own settings was **not** investigated, and whether `Failure [-99]`
reproduces on an idle device or on other vendor builds is **unverified**.
- `scaffold.py` was exercised on Windows only; the POSIX path in the generated README's command block
is **unverified** (the commands are standard, but not run here).
## 10. Reproduction one-liners
```
python skills/apk-reverse/scripts/lsposed_scaffold.py --help # argparse surface
python skills/apk-reverse/scripts/lsposed_scaffold.py --package <pkg> --name "<name>" \
--hook-target <target.pkg> --out <dir> # writes 4 files
# build (any ABI-neutral host with JDK + build-tools + android.jar + api-82.jar)
javac -source 8 -target 8 -cp "<android.jar>:<api-82.jar>" -d build/classes src/**/*.java
jar cf build/classes.jar -C build/classes . # d8 needs a jar, not a dir
d8 --min-api 24 --lib <android.jar> --output build build/classes.jar
aapt2 link -o build/base.apk -I <android.jar> --manifest AndroidManifest.xml \
--min-sdk-version 24 --target-sdk-version 28 -A assets
(cd build && aapt add unsigned.apk classes.dex) # dex must be in the cwd
zipalign -f -p 4 build/unsigned.apk build/aligned.apk
apksigner sign --ks build/test.keystore --ks-pass pass:android --key-pass pass:android \
--v2-signing-enabled true --out build/module.apk build/aligned.apk
apksigner verify --print-certs build/module.apk
adb -s <serial> shell "pm path <module.pkg>" # installed?
adb -s <serial> shell "dumpsys package <module.pkg> | grep 'User 0'" # pm enabled state
adb -s <serial> shell "su -c 'ps -A -o PID,NAME | grep lspd'" # daemon alive
adb -s <serial> shell "su -c 'magisk --denylist status'" # hiding layer state
```

View File

@@ -0,0 +1,270 @@
# Extension verification — native DBI/deobfuscation and protocol reverse engineering
Covers the 2026-09 extension pass that added `references/native-dbi-and-deobfuscation.md` and
`references/protocol-reverse.md`. Strength labels as defined in this directory's README:
**observed** (exact command + output here), **inferred** (follows from observed facts, step not
executed), **unverified** (assumed or externally reported, not independently confirmed).
Reference environment for this pass: Windows 11 host, Python 3.14.0, host frida 16.7.19
(frida-tools 13.7.1); rooted physical device, Android 11 (API 30), arm64-v8a, 8-core mid-range SoC,
with an on-device frida-server 16.7.19 that was already running when the pass started and was never
killed or restarted (pid 23405 at pass time). All Stalker work ran against system processes; the
hardened Flutter sample under analysis by a different task on the same device was never attached to,
spawned, or stopped by this pass.
**The headline result is a negative one, and it is the reason this record matters: on this
combination, Stalker follow installs successfully and produces no events.** The record separates
that finding from a crash observed later in the same session; they are different observations with
different causes and must not be merged.
---
## 1. Scripts under test — observed
`skills/apk-reverse/scripts/stalker_trace.js` (456 lines) and
`skills/apk-reverse/scripts/stalker_report.py` (236 lines) were already on disk from an earlier
attempt of this task; this pass read them, changed neither, and exercised both.
```
$ node --check skills/apk-reverse/scripts/stalker_trace.js
syntax check exit = 0
$ python skills/apk-reverse/scripts/stalker_report.py --help
usage: stalker_report.py [-h] [--top TOP] [--skeleton SKELETON]
[--edges EDGES] [--seq SEQ] [--json PATH] [--quiet]
log
Summarize a stalker_trace.js log into a block histogram, a first-visit CFG
skeleton, and call-edge tables.
```
Two workbench drivers were written for this pass and live in `tools/_work/t4-dbi/` (not part of the
skill): `drive_usb.py`, which attaches over USB and calls
`rpc.exports.config(...)` / `start(tid)` explicitly, and `pb_selftest.py` (§7).
Why a second driver was needed at all: `scripts/run_probe.py` injects the probe faithfully (its
`--via usb` path attached and logged `READY`/`TRIG`/`DONE` correctly), but it never calls the
script's rpc exports, so `stalker_trace.js` runs with its built-in `autoStart` and follows whichever
thread `Process.getMainThreadId()` reports. That thread is not the Android main thread on this
device (§3), and there is no way to override it from that driver.
## 2. Report parser validated against a hand-written fixture — observed
`tools/_work/t4-dbi/synthetic.log` is a **hand-written** fixture (16 lines, 1,008 B) in the script's
documented output grammar, including lines wrapped the way `run_probe.py` wraps them. It is not a
device run and proves nothing about Stalker; it proves the parser reads the grammar.
```
$ python skills/apk-reverse/scripts/stalker_report.py tools/_work/t4-dbi/synthetic.log \
--top 4 --skeleton 6 --edges 4 --seq 8
module libdemo.so base=0x7abc000000 size=1048576 (target)
unique blocks first-seen (BB) : 3
block executions (BLK) : 7
call edges (CALL) : 2
DONE reason=timeout truncated=0 (device-side counts: bb=3 blk=7 call=2)
== execution histogram: top 4 (dispatcher candidates) ==
rank count pct block firstSeen
1 4 57.1% libdemo.so+0x9f6c0 #1
2 2 28.6% libdemo.so+0x9f710 #2
3 1 14.3% libdemo.so+0x9f800 #5
== call edges (top 4 of 2) ==
1 0x7ab3001020 -> libdemo.so+0x9f6c0
1 libdemo.so+0x9f800 -> libdemo.so+0x9f710
== collapsed execution sequence (first 6 runs of 6) ==
libdemo.so+0x9f6c0 -> libdemo.so+0x9f710 -> libdemo.so+0x9f6c0 -> libdemo.so+0x9f800 ->
libdemo.so+0x9f6c0*2 -> libdemo.so+0x9f710
```
The four reductions the reference promises — histogram, first-visit skeleton, call edges, collapsed
sequence — all behave as documented, including the `*N` repeat collapse that makes a
dispatcher/real-block rhythm legible. `--quiet` and `--json` paths were exercised too (§3, §4).
## 3. Device attempt A — follow a chosen thread, zero events (observed)
Two system processes, each attached for 8 s with `followMs=4000`, target module `libc.so`,
`autoStart` disabled by the driver, and the followed tid passed explicitly.
```
$ python tools/_work/t4-dbi/drive_usb.py --js skills/apk-reverse/scripts/stalker_trace.js \
--pid <settings pid> --seconds 8 --log tools/_work/t4-dbi/run2_settings_main.log
READY stalker_trace loaded; target=libc.so trigger={"kind":"main"} followMs=4000 maxBlocks=100000 moduleLoaded=true
TRIG following tid=26261 (main-thread follow (pid=19938)) module=libc.so base=0x721be88000
TRACE MOD libc.so base=0x721be88000 size=999424 path=/apex/com.android.runtime/lib64/bionic/libc.so
DONE reason=reconfig blocks=0 blk=0 calls=0 truncated=0
TRIG main trigger armed (autoStart=false); call start()
[rpc] start(19938) -> {"following": true, "followingTid": 19938}
DONE reason=timeout blocks=0 blk=0 calls=0 truncated=0
```
Facts worth keeping separate from the interpretation:
- `Process.getMainThreadId()` returned **26261** for a process whose pid is **19938**. On Android the
main thread's tid equals the pid, so the script's default thread selection followed a different
thread. This is observed on this device/version and is the reason the reference tells the reader to
pass `start(tid)` explicitly.
- Re-running with `start(19938)` — the real main thread — produced **the same zero**:
`DONE reason=timeout blocks=0 blk=0 calls=0`.
- A third run against a system UI process (`run3_systemui_libc.log`, same `followMs=4000`)
reproduced it identically. `stalker_report.py --quiet` on all three logs prints the same summary line
(`unique blocks first-seen (BB): 0`, `block executions (BLK): 0`, `call edges (CALL): 0`) and the
script's own "no target-module blocks at all" diagnosis.
**Interpretation: unverified as to cause.** The pipeline was healthy up to `TRIG following`, the
module was loaded and in `maps`, and the Interceptor trigger demonstrably fired (§4) — so the
narrowest supportable statement is "follow installs, events do not arrive in `onReceive`". Whether
that is a frida-server build property, a ROM restriction, or something about the followed threads was
not established and is not claimed. No trace was successfully recorded on this device during this
pass.
## 4. Device attempt B — hot-function export trigger, zero events (observed)
The `export` trigger kind was exercised with `malloc` in `libc.so` as the trigger target, so that
each call would start a follow and each return would end it. The trigger fired repeatedly for the
whole window.
```
$ python tools/_work/t4-dbi/drive_usb.py --js skills/apk-reverse/scripts/stalker_trace.js \
--pid <systemui pid> --seconds 6 --log tools/_work/t4-dbi/run4_systemui_malloc.log \
--cfg '{"targetModule":"libc.so","trigger":{"kind":"export","module":"libc.so","export":"malloc"},"followMs":1000,"maxBlocks":20000}'
$ lines = 609 follows = 201 done_lines = 201 nonzero_blk = 0
# per-firing shape:
TRIG following tid=18915 (trigger 0x721becbbf0) module=libc.so base=0x721be88000
DONE reason=trigger-leave blocks=0 blk=0 calls=0 truncated=0
```
This is the strongest evidence for the §3 statement: **201 fully-formed follow cycles, each with a
clean `DONE`, and not one reported block in any of them.** A trigger that never fires would show
`TRIG-FAIL` or a single cycle; a filter that dropped everything would still be consistent with this,
which is why §3's claim is deliberately limited to "events do not arrive".
## 5. Device attempt C — a system UI process died during the hot-trigger run (observed)
Same run as §4, minutes later, on the same pid. The process died and was restarted by the system.
```
$ adb shell pidof com.android.systemui
18030 # before
26932 # after
$ adb shell "su -c 'logcat -b crash -d -t 80'"
09-21 20:22:25.327 ... F DEBUG : pc 00000070cf8ae464 pst 0000000040001000
09-21 20:22:25.719 ... F DEBUG : backtrace:
09-21 20:22:25.719 ... F DEBUG : #00 pc 000000000001f464 <anonymous:70cf88f000>
09-21 20:22:25.719 ... F DEBUG : #01 pc 00000000004d0b64 /apex/com.android.art/lib64/libart.so
(art::Reference_getReferent(_JNIEnv*, _jobject*)+44)
09-21 20:22:25.719 ... F DEBUG : #03 pc 000000000008220c boot.oat (art_jni_trampoline+124)
09-21 20:22:25.719 ... F DEBUG : #08 pc 0000000000714240 boot-framework.oat (android.os.Looper.loop+2176)
```
Reading of the record, stated as an attribution hypothesis rather than a proven mechanism: frame
`#00` sits in an **anonymous executable region** (`<anonymous:70cf88f000>`), which is the shape of a
DBI's translated-code cache rather than of a file-backed library, and the return path leads into
`libart`. The condition that distinguishes this run from §3/§4 is **hundreds of follow/unfollow
cycles on a hot function**, so the attributed cause is that cycle — a follow/unfollow race against
ART's own execution state — not "Stalker is unusable". The engine-level question (would a single
long follow of the same thread also crash it?) was not tested and remains **unverified**.
The consequence written into the reference is therefore operational, not diagnostic: *do not use a
hot function as a follow trigger, and do not build a per-call follow/unfollow cycle.* Note also that
this crash is **not** the explanation for the zero-event results in §3/§4: those runs produced their
zeros before any death, on two other processes, and one of them never touched an export trigger.
## 6. Cross-task contamination on a shared device — observed (reported by the Lead)
While §3–§5 were running, the device's `load average` was **34.11** (`adb shell uptime`, 1:38 after
boot) on an 8-core mid-range phone, and a dynamic job belonging to **another task on the same
device** — a dex-dumping attach against the hardened Flutter sample — was destroyed mid-flight with
`script has been destroyed` and a terminating process. Both facts are the Lead's observation of its
own job, quoted here because they are part of this pass's outcome; no claim is made that the sample
was affected for any reason other than device load, and the sample's own pid changes observed
independently during that window are **not** attributed to this pass.
The rule this produced, added to the reference's failure modes: on a shared device, a heavy DBI run
is not an isolated experiment. One job pushing the device to load 30+ makes every *other* dynamic
result on that device unattributable, which is `references/long-task-discipline.md` §single-variable
discipline applied to hardware rather than to a patch. Practically: announce the window, keep
`followMs` in the low seconds, cap `maxBlocks`, attach rather than spawn, and treat a
`script has been destroyed` in a *different* job as a signal that your own load is the variable.
## 7. Protobuf wire format self-test — observed
`tools/_work/t4-dbi/pb_selftest.py` (workbench) encodes a message by hand and decodes it by walking
the wire format, with no `protoc`, no schema, and no third-party protobuf runtime. It exists to give
`references/protocol-reverse.md` §1 an output behind its claims.
```
$ python tools/_work/t4-dbi/pb_selftest.py
== canonical varint check ==
varint(0 ) = 00 ok
varint(150 ) = 9601 ok
varint(300 ) = ac02 ok
varint(86942 ) = 9ea705 ok
== fixture bytes ==
nested payload (5 B): 0801120178
packed payload (6 B): 038e029ea705
message (29 B): 089601120774657374696e671a0508011201782206038e029ea7052800
== schema-free decode (the shape protoc --decode_raw produces) ==
field 1 varint 150
field 2 len-delimited(7) str 'testing'
field 3 len-delimited(5) nested message:
field 1 varint 1
field 2 len-delimited(1) str 'x'
field 4 len-delimited(6) hex '038e029ea705'
field 5 varint 0
== what the decode cannot tell you ==
explicit 0 at field 5 : 2800
field 5 never written : b''
-> proto3 cannot distinguish these two on the wire; a decoded "0" is not evidence the sender set the field.
-> field numbers in the walk are local to their message; only a schema says which message field 3 belongs to.
== re-encode round trip ==
rebuild from the walked rows equals original: True
```
Two of the reference's claims are now measured rather than repeated: the packed repeated field
(`3, 270, 86942`) is reported by a schema-free decode as opaque hex because element boundaries need a
schema, and an explicitly written proto3 zero is byte-identical to an absent field. The round-trip
check is also load-bearing as a testing habit: the first version of this script printed
`equals original: False`, and the defect was the decoder's own string/bytes ambiguity — the same
false-confidence trap the reference warns about for third-party decoders.
## 8. Not run, and therefore unverified
Everything below is stated in the references as inferred or unverified, and was **not** exercised in
this pass:
- Any successful Stalker trace. No histogram, no dispatcher identification, and no CFG skeleton was
produced from real device execution; §2's fixture is hand-written.
- Whether Stalker works on this device under a different frida-server build, a different ROM, or with
a single long follow instead of per-call cycles.
- `events.exec` and `events.ret` (the per-instruction and return-edge paths) — never enabled.
- QBDI / frida-qbdi, angr, Triton, unidbg, Unicorn: not installed, not run. §4/§5 of
`native-dbi-and-deobfuscation.md` are method descriptions.
- Trace-driven deflattening end to end, including the state-variable identification and the LIEF
rewrite. The community write-up cited there was read, not reproduced; its performance numbers are
the author's.
- `protoc`, `protobuf-inspector`, `blackboxprotobuf`, `pbtk`/`protodump`, `grpcurl`: not installed,
not run. The schema-recovery routes in `protocol-reverse.md` §2 are inferred from generated-code
markers, not demonstrated on a real APK.
- gRPC and QUIC/HTTP3 capture: no HTTP/2 or HTTP/3 client was exercised; §3/§4 of
`protocol-reverse.md` are unverified.
- `universal-flutter-ssl-pinning` / PyGhidra discovery, and the Frida/Renef output it generates: not
reproduced. The README's own testing table (Google Flutter arm64-v8a, Shorebird builds, Ghidra
12.x, Frida 16.x/17.x) is the only evidence behind that section.
- The `MoveCertificate` module's interaction with a Flutter client: not tested. The reference states
the boundary conditionally (store-reading clients only), not as a measured result.
## 9. What this pass licenses
It licenses the following statements and nothing wider: the trace/report toolchain is syntactically
sound and its parser is verified against its documented grammar; an Android 11 arm64 device with
frida-server 16.7.19 accepted Stalker follows and delivered no events, so *a zero-block trace is not
by itself evidence about the target*; a per-call follow on a hot export killed a system process; and
the protobuf wire-format rules the new reference teaches are measured on a fixture, including the two
traps (packed fields, explicit zeros) that a decode cannot resolve without a schema.

View File

@@ -0,0 +1,383 @@
# Extension verification — schema-free protobuf decoding
Covers the pass that added `skills/apk-reverse/scripts/protobuf_decode_raw.py` and the measured half
of `references/protocol-reverse.md` §1 ("The decoder that ships here"). Strength labels as defined in
this directory's README: **observed** (exact command + output recorded here), **inferred** (follows
from observed facts, step not executed), **unverified** (assumed or externally reported, not
independently confirmed here).
Reference environment for this pass: Windows host, Python 3.14.0, `protobuf` **6.33.6**. No device
was used (see §7) — every result below is host-side, offline, and reproducible with the standard
library alone. Workbench artifacts live in `tools/_work/t5-proto/` (not part of the skill, and
`tools/` is gitignored).
The delivered script imports **no** third-party module. `google.protobuf` is used here for one
purpose only: to *generate* reference bytes from a real schema, so the hand-written decoder is
checked against the reference implementation instead of against itself.
---
## 0. Summary of what this pass established
| Claim | Label |
|---|---|
| `protobuf_decode_raw.py` `--help`, `--selftest`, hex/file/stdin inputs, `--json`, `--max-depth`, `--reencode` all run on this host | observed |
| 26/26 built-in known-answer checks pass (field decode **and** byte-exact round trip) | observed |
| The decoder agrees field-by-field with a message serialized by `google.protobuf` 6.33.6, and its re-encode is byte-identical to `SerializeToString()` | observed |
| A real AndroidX DataStore file written by `scripts/datastore_inject.py` decodes to the correct structure and round-trips byte-identically | observed |
| Editing one value in the JSON tree and re-encoding produced a file that `datastore_inject.py` read back as the edited value | observed |
| proto3 zero semantics: an implicit (no-presence) field set to 0 writes **nothing**; a presence-carrying field set to 0 writes its tag plus a zero byte | observed |
| A packed field and a nested message are **both** legal readings of the same length-delimited bytes (measured on real encoded bytes, not argued) | observed |
| `--split varint-length` is protobuf's `writeDelimitedTo` framing; handed a gRPC frame it silently produced 5 frames instead of failing | observed |
| Decoding a **device-side** protobuf file (real app data read over adb) | **not done** (device lock held by another agent; §7) |
| Any protobuf payload recovered from the benchmark APKs | **not done** — the one APK scanned carries no `.pb`/`.proto` resource (§7) |
| `blackboxprotobuf` / `protobuf-inspector` behaviour | unverified (not installed on this host; the reference still says so) |
---
## 1. Input forms, measured
```
$ python skills/apk-reverse/scripts/protobuf_decode_raw.py --help
usage: protobuf_decode_raw.py [-h] [--hex STR] [--json] [--max-depth N]
[--no-expand] [--split {none,varint-length}]
[--reencode JSON] [--out PATH] [--check PATH]
[--selftest]
[input]
... exit = 0
```
Hex text, a binary file and stdin are all accepted, and the two stdin shapes are told apart without
a flag:
```
$ '0a220a176578616d706c655f6578706972795f65706f63685f6d7312072080b08fe6b277' \
| python skills/apk-reverse/scripts/protobuf_decode_raw.py -
input: stdin (hex text), 36 byte(s)
frame 0 bytes 0..36 (36 B) status=end_of_buffer
f1 len-delimited(34) view=nested_message
...
$ cmd /c "python skills\apk-reverse\scripts\protobuf_decode_raw.py - < tools\_work\t5-proto\ds.preferences_pb"
input: stdin (raw bytes), 81 byte(s)
frame 0 bytes 0..81 (81 B) status=end_of_buffer
```
Separators and escapes, in one command (two frames, `0x` prefixes, spaces, a `\xNN` escape):
```
$ python skills/apk-reverse/scripts/protobuf_decode_raw.py --hex "0x08 0x96 0x01 | \x12\x07testing"
frame 0 bytes 0..3 (3 B) status=end_of_buffer
f1 varint 150
frame 1 bytes 3..12 (9 B) status=end_of_buffer
f2 len-delimited(7) view=utf8_string
...
string: 'testing'
```
`08,96,\n01` (commas and a newline) and the single token `0x089601` both decode to the same field.
**This command is the one that found the script's first real defect — see §6.**
---
## 2. Known-answer fixtures: `--selftest`
Twelve fixtures are built byte by byte inside the script (no external file, no schema), covering a
canonical varint, a multi-byte varint, `uint64` maximum, two-level nesting, a packed field, fixed64
and fixed32, an explicit zero, a deprecated group, a non-canonical varint, a 10-byte overflow, two
top-level messages in one stream, and varint-length framing. Each is decoded with an expected value
**and** passed through the round trip.
```
$ python skills/apk-reverse/scripts/protobuf_decode_raw.py --selftest
== known-answer fixtures (bytes produced by hand in this script) ==
canonical varint 150 089601
field 1 varint value PASS 150
unsigned max uint64 08ffffffffffffffffff01
field 1 varint value PASS 18446744073709551615
nested two levels 089601120774657374696e671a060801120210072206038e029ea705...
field 3 nested child count PASS 2
deprecated group 3/4 43080544
field 8 has children PASS 1
non-canonical varint 08968100
field 1 varint value PASS 150
10-byte varint overflow 08ffffffffffffffffff7f
field 1 varint overflow PASS True
...
== round trip: decode -> re-encode -> byte comparison ==
canonical varint 150 MATCH (1 fields, 1 identical)
nested two levels MATCH (7 fields, 7 identical)
non-canonical varint MATCH (1 fields, 0 identical) re-encode emits the minimal
form 089601, original 08968100, and the report states why
varint length framing MATCH (7 fields, 7 identical)
== the two wire-format traps ==
explicit zero bytes 2800 decodes to value 0; absent field decodes to no fields at all: PASS
packed payload 038e029ea705 offers live=['bytes', 'packed_varint'], rejected=['nested_message',
'utf8_string'] -> view=packed_varint : PASS
selftest: 26/26 PASS exit = 0
```
The non-canonical fixture is the one deliberate exception: the re-encode is *expected* to differ
(`08968100` -> `089601`, the minimal form), and the tool reports the difference with its reason
rather than normalising it away silently. That is the honest form of "round trip": a difference that
is explained is evidence; a difference that is not is a bug.
---
## 3. Cross-check against the official protobuf runtime
`tools/_work/t5-proto/official_ref.py` builds a real `FileDescriptorProto` (`Outer` with int64 /
string / message / repeated int32 (packed) / fixed64 / float / enum / proto3-optional / bytes /
fixed32 fields, plus a nested `Inner`), serializes it with `SerializeToString(deterministic=True)`,
and then asks the hand-written decoder to reproduce it.
```
$ python tools\_work\t5-proto\official_ref.py
== official runtime bytes (protobuf 6.33.6) ==
58 B: 089601120774657374696e671a0508011201782206038e029ea705290807060504030201350000c03f380240004a06038e029ea70555efbeadde
== field-by-field agreement with the schema ==
field 1 count == 150 PASS decoded 150
field 2 name == "testing" PASS decoded 'testing'
field 3 inner walked as a nested message PASS view=nested_message children=2
field 4 nums read as packed [3,270,86942] PASS view=packed_varint values=[3, 270, 86942]
field 5 stamp fixed64 hex 0807060504030201 PASS 0807060504030201
field 6 ratio fixed32 hex 0000c03f (1.5f) PASS 0000c03f
field 7 color enum == 2 PASS
field 8 explicit_zero is present and 0 PASS
field 9 blob hex 038e029ea705 PASS
field 10 crc fixed32 hex efbeadde PASS efbeadde
== round trip against the official bytes ==
re-encode is byte-identical to SerializeToString() PASS 10/10 fields identical
no field was rewritten PASS []
official-runtime cross-check: 21/21 PASS exit = 0
```
Field 5 is the useful false-friend: the decoder offers `as_double_repr` for those 8 bytes, and the
official value is a `fixed64` integer. Nothing on the wire distinguishes them, which is why the
candidate list exists instead of a single typed value.
### 3.1 The four-way tie, measured on real bytes
A really-encoded nested message — `Inner{id:1, tag:"x"}` inside field 3, inner payload `0801120178`
— is reported as:
```
nested message payload 0801120178 -> {'nested_message': 0.5, 'packed_varint': 0.5,
'utf8_string': 0.35, 'bytes': 0.2}
```
`0801120178` is simultaneously a valid two-field message **and** a valid five-element packed varint
array. The decoder refuses to choose; the `tie:` line states that the displayed `view` is a display
default. A real packed field (`2206038e029ea705`) yields `packed_varint 0.55` with
`nested_message` **rejected** — the rejection is printed with its reason instead of being omitted.
### 3.2 proto3 zero — the reference sentence, corrected by measurement
The reference previously stated that `28 00` is byte-identical to an absent field. The runtime shows
that the sentence needs splitting into two halves:
```
implicit int64 count = 0 : 120178 (3 B)
count never touched : 120178 (3 B)
implicit (no presence) zero is byte-identical to absent PASS
that byte string contains no tag for field 1 PASS
optional int32 explicit_zero=0: 1201784000 (5 B)
a presence-carrying field DOES put tag+0x00 on the wire PASS 1201784000
the optional zero costs 2 bytes the implicit zero never costs PASS 5 B vs 3 B
the decoder sees that 0 and flags the ambiguity PASS ["proto3_explicit_zero"]
```
Read it as two separate facts:
1. For a field **without** presence, `value = 0` and "never set" are the same bytes (no bytes at
all), so a field **missing** from a decode is not evidence that the value in use was 0.
2. A `0` that **is** on the wire was emitted on purpose — a presence-carrying field (`40 00` here,
field 8) or a hand-rolled writer. The decoder cannot tell which, so it flags the case.
`references/protocol-reverse.md` §1 consequence 3 now carries that correction.
---
## 4. A real AndroidX DataStore file
`scripts/datastore_inject.py` writes the real AndroidX Preferences protobuf container
(`map<string, Value>`); the decoder has never seen that schema.
```
$ python skills/apk-reverse/scripts/datastore_inject.py --file tools\_work\t5-proto\ds.preferences_pb \
--key example_expiry_epoch_ms --type long --value 4102444800000 \
--extra example_flag=true:bool --extra example_name=hello:string
[ok] wrote tools\_work\t5-proto\ds.preferences_pb (81 bytes)
$ python skills/apk-reverse/scripts/protobuf_decode_raw.py tools\_work\t5-proto\ds.preferences_pb
input: tools\_work\t5-proto\ds.preferences_pb, 81 byte(s)
frame 0 bytes 0..81 (81 B) status=end_of_buffer
f1 len-delimited(34) view=nested_message
...
f1 len-delimited(23) view=utf8_string
string: 'example_expiry_epoch_ms'
f2 len-delimited(7) view=nested_message
...
f4 varint 4102444800000
f1 len-delimited(18) view=nested_message <- the second map entry
...
```
The map-entry structure, the key strings and the `long` value all come out correctly with no schema;
every length-delimited field still shows its ties (`nested_message` vs `packed_varint` vs
`utf8_string`) because those ties are real.
### 4.1 Round trip, and an edit confirmed by the other tool
```
$ python skills/apk-reverse/scripts/protobuf_decode_raw.py tools\_work\t5-proto\ds.preferences_pb --json > tools\_work\t5-proto\ds.tree.json
$ python skills/apk-reverse/scripts/protobuf_decode_raw.py --reencode tools\_work\t5-proto\ds.tree.json \
--out tools\_work\t5-proto\ds.reenc.bin --check tools\_work\t5-proto\ds.preferences_pb
re-encoded 81 byte(s) from 3 field(s)
fields re-encoded byte-identically: 3/3
written to tools\_work\t5-proto\ds.reenc.bin
check against tools\_work\t5-proto\ds.preferences_pb (81 B, the whole file): MATCH exit = 0
```
Then one value is edited in the JSON tree and re-encoded:
```
editing field 4 from 4102444800000
re-encoded 80 byte(s) from 3 field(s)
fields re-encoded byte-identically: 2/3
CHANGED field 1: expected 0a22...80b08fe6b277, got 0a21...20ffc7afa025 (the tree was edited)
$ python skills/apk-reverse/scripts/datastore_inject.py --in tools\_work\t5-proto\ds.edited.bin --list
[in] tools\_work\t5-proto\ds.edited.bin (80 bytes)
example_expiry_epoch_ms long=9999999999 (value=6 bytes)
example_flag bool=True (value=2 bytes)
example_name string='hello' (value=7 bytes)
```
Two independent facts in one run: the file shrank 81 -> 80 B because a 6-byte varint became a
5-byte one **and the enclosing length prefix was recomputed** (the re-encode rebuilds lengths, it
does not copy them), and a different tool of this repository read the edited bytes back as the new
value. A round trip that only agrees with itself would not have caught either.
---
## 5. Framing: `writeDelimitedTo` is not a gRPC frame
`tools/_work/t5-proto/framing_check.py`:
```
message (20 B): 089601120774657374696e672206038e029ea705
writeDelimitedTo form (21 B): 14089601120774657374696e672206038e029ea705
varint-length framing: exactly one frame PASS got 1
a second message makes two frames PASS
gRPC frame (25 B): 0000000014089601120774657374696e672206038e029ea705
decoded with --split varint-length: 5 frame(s): [(1, 1, 'end_of_buffer'), (2, 2, ...),
(3, 3, ...), (4, 4, ...), (5, 25, ...)]
gRPC frames are NOT varint-length framed (the split misfires) PASS got 5 frame(s)
stripping the 5-byte gRPC prefix decodes the message correctly PASS field 1=150, field 4=[3, 270, 86942]
'|' separates two independently framed messages PASS frames=2
framing check: 8/8 PASS exit = 0
```
| Framing | Bytes in front of the message | `--split varint-length` |
|---|---|---|
| protobuf `writeDelimitedTo` | varint length | correct — 1 frame per message |
| gRPC | 1 compression-flag byte + **4-byte big-endian** length | **wrong, and it does not fail**: the 4 zero bytes become four empty frames |
The same gRPC bytes decoded with **no** splitting reported `stray_end_group` (exit 1), and a
`writeDelimitedTo` file decoded with no splitting reported the same error — that error is a *refusal*,
whereas the wrongful split is a *silent reinterpretation*. Strip the 5-byte gRPC prefix yourself
(`references/protocol-reverse.md` §3), and check the frame count either way.
A `--split varint-length` round trip compares **frame bodies only**, and says so:
```
check against tools\_work\t5-proto\two_frames.bin (26 B, frame bodies only: 2 of 26 byte(s) are
length prefixes (framing, not message data)): MATCH
```
---
## 6. Defects these checks found (in the code, not in the target)
Four defects, three of them in the delivered script, all found by the checks above rather than by
reading the code.
| # | Defect (observed) | How it surfaced | Fix |
|---|---|---|---|
| 1 | When two candidate readings tied, both were lowered to the same confidence as the `bytes` fallback, and the tie was then broken by **alphabetical order** — `bytes` sorts before `nested_message`, so a real nested message was never expanded and `--reencode` copied it as an opaque blob | `--selftest`: `nested two levels / field 3 nested child count` returned `0` instead of `2` | `bytes` now scores 0.2 and ties are broken by an explicit `VIEW_PRIORITY`, structural candidates first |
| 2 | In the `\xNN` path, whitespace and commas were kept as data bytes, so `"08 96 01 | \x12\x07testing"` decoded the space after the bar as `field 4 varint 18` and then hit `invalid_wire_type` | the input-form check in §1 | whitespace and commas are stripped before unescaping, in that branch too |
| 3 | Two fixtures were mis-designed, not the code: a bare packed stream and a length-prefixed frame were round-tripped as if each were one top-level message, which reported `FAIL` for correct behaviour | `--selftest` round-trip section | the packed fixture is now a field *value*; the framing fixture compares frame bodies |
| 4 | The cross-check's own assertion expected `0800` for the explicit zero, but the field under test is number 8, whose tag is `0x40` — the official bytes were `...4000`, and the assertion, not the decoder, was wrong | `official_ref.py` | assertion compares `4000` and the 2-byte cost |
| 5 | `--no-expand` did nothing: the expansion branch never consulted the flag, so a 7-level payload was still walked to the bottom | the deep-payload check below | the branch honours `--no-expand` and records `expansion: suppressed by --no-expand` on the field |
| 6 | Hitting `--max-depth` was invisible: the depth cut-off only appeared as a `rejected` note inside one candidate, so the report showed a shallow tree with no statement that anything had been left unexamined | the same check | a `depth_limit` ambiguity is emitted, naming the path and the limit |
Defect 1 is worth its own line in any future reader's mind: a *heuristic* tie-break silently changed
which bytes the tool re-emitted, and it was caught only because a fixture asserted a child count.
Defects 5 and 6 are the other direction — the tool silently *did more* than asked, and silently
*stopped* without saying so; both are ways a report can be read as complete when it is not.
### 6.1 The deep-payload check
A 7-level nesting (`08 01` wrapped six times in field 1, 14 bytes) is the fixture:
```
$ python skills/apk-reverse/scripts/protobuf_decode_raw.py tools\_work\t5-proto\deep.bin
frame 0 bytes 0..14 (14 B) status=end_of_buffer
[depth_limit] frame0.1.1.1.1.1.1 @2: a nested reading of this field was not attempted:
--max-depth 5 reached. The bytes here may well be a message; re-run with a larger --max-depth
before concluding that they are not
$ python ... tools\_work\t5-proto\deep.bin --max-depth 2
[depth_limit] frame0.1.1.1 @2: ... --max-depth 2 reached. ...
$ python ... tools\_work\t5-proto\deep.bin --no-expand
expansion: suppressed by --no-expand
```
The truncation is reported as an ambiguity with the path of the field it stopped at, so "no nested
message here" can never be read off a truncated tree.
---
## 7. What was **not** done
* **No device was used.** The pass intended to pull a real app's `.preferences_pb` from
`<DEVICE>`: `python tools\_work\devlock.py acquire proto --timeout 1800` waited 120 s and timed out
with the lock held by another agent (`held by bench for 297s`, note "L1 repack control+patched
install & screen; L3 frida"). Per the two-strike rule the device source was dropped rather than
contended for, and the real-file evidence comes from the DataStore file this repository's own tool
writes (§4). Reading a **device-side** protobuf file with this decoder is therefore **unverified**.
Device facts used elsewhere in this repository (attach-by-name, the broken `logd` channel) were
not re-tested here.
* **No protobuf payload was recovered from the benchmark APKs.** Scanning the available sample
(`<PKG>`-style de-identified, 4,293,620 B, 435 zip entries) for `.pb` / `.proto` / `protobuf`
resources found none; the MASTG crackmes carry no protobuf at all. So no wild-captured payload was
decoded in this pass — `inferred` for any claim that a real traffic body looks like these fixtures,
though §3's bytes come from the reference implementation and §4's from a real AndroidX container.
* **No live traffic.** Nothing was proxied, so the gRPC section of the reference is exercised only
as *framing* (§5), not as captured HTTP/2.
* `blackboxprotobuf` / `protobuf-inspector` were not installed or compared; the reference still
labels them unverified.
* `--max-depth` is measured up to a 7-level fixture (§6.1); no real payload deeper than that was
decoded, and no payload was decoded whose *only* reading is a deep message.
## 8. Artifacts produced by this pass
`tools/_work/t5-proto/` (not committed): `official_ref.py` (official-runtime cross-check, 21 checks),
`framing_check.py` (8 checks), `ds.preferences_pb` (81 B, real DataStore container),
`ds.tree.json` / `ds.edited.json` (decoded trees), `ds.reenc.bin` (byte-identical round trip),
`ds.edited.bin` (80 B, edited value read back by `datastore_inject.py`), `two_frames.bin`,
`frames.tree.json`, `frames.reenc.bin`.
Files changed in the repository by this pass: `skills/apk-reverse/scripts/protobuf_decode_raw.py`
(new), and `skills/apk-reverse/references/protocol-reverse.md` (a new measured subsection under §1,
the corrected proto3-zero wording in §1 consequence 3, and a new framing failure mode in §6).
`SKILL.md` and `README.md` were deliberately not touched; the index rows for the new script are
handed to the lead to register.

View File

@@ -0,0 +1,141 @@
# EXTENSION — rasc (the Rust ASC) measured against the Python one
Evidence for `skills/apk-reverse/references/rasc-and-droidsaw.md`. Every number below has the command
that produced it.
## What was measured
| Claim | Grade | Result |
|---|---|---|
| `rasc` builds from the `rust` branch on this host with no prebuilt artifact | **observed** | rustc 1.98.1 (gnu host) + MinGW-w64 gcc 16.1.0; `cargo build --release` **117 s**; binary 2,236,416 B |
| It is faster than the Python `droidasc` on the same archives | **observed** | 4.0–5.6× for `classes`, up to 16.2× for `findrefs` (tables below) |
| It returns the same answers | **observed** | **identical class-definition sets**, 30,768 / 30,768 with **0 differences in either direction**; `findrefs string` identical on one sample, `type` a superset (390 vs 384) |
| Its `getclass` decompiles ordinary classes correctly | **observed** | 16 of 17 JADX-judged classes matched literal-for-literal, 0 reorderings |
| **Its outer-class view of an enum does not inline the constant bodies, and says nothing** | **observed — and the first write-up of this row over-claimed; see §The blind spot** | JADX inlines the three anonymous subclasses; `rasc` prints a bare constant list. Both tools decompile `PasswordConverter$1/2/3` **fully** (404–407 B, containing `convert`/`getType`), so what is missing is an inlining step, not the code |
| Its own benchmark's 8.0× geometric mean reproduces here | **not reproduced, and explained** | that figure is from a 343 MiB / 567k-class archive; on a 34.8 MB / 30,768-class app the ratio is 4×. Both are real; the small-archive figure is what a normal task hits |
## Environment
Host Windows 11. Device not involved. Toolchain installed for this pass, into the gitignored
`tools/_work/`:
```
rustup-init (x86_64-pc-windows-gnu) -> rustc 1.98.1, cargo 1.98.1
winget install BrechtSanders.WinLibs.POSIX.UCRT -> gcc 16.1.0 (x86_64-w64-mingw32, UCRT)
```
The 32-bit MinGW already on this host (`C:\MinGW`, gcc 6.3.0, target `mingw32`) **cannot** link a
64-bit binary — `gcc -m64` reports `sorry, unimplemented: 64-bit mode not compiled in`, and the Rust
GNU target needs a 64-bit linker. E:\vs2022 has no VC tools (`cl.exe` / `link.exe` absent), and the
WSL2 Ubuntu distribution is registered but `HCS_E_SERVICE_NOT_AVAILABLE`, so no Linux path either.
```
$ python skills/apk-reverse/scripts/rasc_build.py --check
toolchain : git=E:\Git\cmd\git.EXE cargo=...\rust\cargo\bin\cargo.EXE rustc=...\cargo\bin\rustc.EXE gcc=C:\MinGW\bin\gcc.EXE(mingw32)
rasc : E:\apk-reverse\tools\_work\rust\target\release\rasc.exe rasc 0.1.0
result : ok
```
## Speed and agreement
Fastest of 3 runs (1 run on the largest), same machine, `PYTHONUTF8=1` for the Python side.
```
$ python tools/_work/compare_asc.py tools/_work/bench/apks/UnCrackable-Level3.apk --repeat 3
scenario rasc s py s speedup result agreement
classes 0.025 0.138 5.6 equal rasc=1396 py=1396
manifest 0.017 - - not comparable
findrefs string 0.022 0.341 15.6 overlap 100% rasc=3 py=3
findrefs type 0.022 0.358 16.2 overlap 98% rasc=390 py=384
```
Rounded to whole numbers in the reference file (4.7–14.7× there vs 5.6–16.2× here) because these are
two independent runs of the same measurement; both are in the observed range, and the run-to-run
spread on a sub-100 ms command is the honest resolution.
On a real 34.8 MB application (`MT-Manager.apk`, 30,768 classes):
```
$ python -c "... rasc classes vs droidasc listclass, set comparison ..."
rasc : 0.048 s 30768 classes
droidasc : 0.190 s 30768 classes
speedup : 4.0x
identical set : True
only in rasc : 0 only in droidasc: 0
```
`python skills/apk-reverse/scripts/rasc_build.py --verify tools/_phone-modules/MT-Manager.apk` runs
the same comparison and reports `{'result': 'ok', 'rasc_classes': 30768, 'droidasc_classes': 30768,
'only_rasc': 0, 'only_droidasc': 0}`, so the check travels with the kit rather than living in a note.
**A defect found on the Python side during this work:** `droidasc listclass` exits 1 with
`'gbk' codec can't encode character` on an app whose classes contain non-Latin identifiers, when the
console encoding is not UTF-8. With `PYTHONUTF8=1` it completes. `rasc` was unaffected. That is a
one-variable difference in the environment, not a property of either tool, and it is recorded because
it cost a round here.
## The blind spot, verified rather than reported
The `rasc` repository ships `bench/jadx_parity.py`, which treats a string literal JADX has and `rasc`
does not as lost code, and refuses to judge a class JADX itself could not decompile cleanly. Its own
class sampling takes every-nth entry from `rasc classes`, which on a real app lands mostly on
obfuscated names where JADX produces nothing — 8 of 10 picked that way were unjudgeable here, so the
sample measures the sampler. `tools/_work/parity_fair_sample.py` filters to app-like classes first
(ASCII, dotted, no inner class, not `R`/`BuildConfig`) and feeds those to the unmodified harness:
```
$ python tools/_work/parity_fair_sample.py tools/_phone-modules/MT-Manager.apk 20
classes in APK: 30768
app-like candidates: ...
judged 17 class(es) where JADX was clean; literals rasc lost: 1; reorderings: 0
```
The one finding, checked by hand with `tools/_work/verify_parity_finding.py`:
```
class : org.bouncycastle.crypto.PasswordConverter
jadx literals: ['ASCII', 'PKCS12', 'UTF8']
rasc literals: []
missing : ['ASCII', 'PKCS12', 'UTF8']
'ASCII' appears in rasc output as an identifier: True
...
VERDICT: form difference (enum constants printed as a list, literals not re-emitted)
```
**The automated verdict understates it, and the hand check then overstated it. Both corrections are
kept.** The harness counts literals, and the literals in JADX's output belong to the anonymous
subclasses it inlines; `rasc`'s outer-class view prints a bare constant list. The first write-up here
said "the method bodies are gone". **That was wrong, and the way it was wrong is worth recording: the
subclasses were never queried.** They exist as their own classes and both tools decompile them fully:
```
$ rasc getclass <apk> 'Lorg/bouncycastle/crypto/PasswordConverter$1;' -> 404 B, has convert(/getType(
$ droidasc getclass <apk> 'Lorg/bouncycastle/crypto/PasswordConverter$1;' -> 460 B, has both
$ ... $2 and $3 the same shape, 406-407 B vs 462-463 B
```
So the accurate finding is narrower than the first version and narrower than the harness's phrasing:
**`rasc` does not inline enum-constant bodies into the outer class view, and it does not say so** —
the bodies are one `getclass` away, on the subclass JADX itself names in a comment
(`// from class: ...PasswordConverter.1`). Neither does the Python tool inline; `droidasc`'s outer
listing is just richer (2,170 B vs 314 B, with `$VALUES`, `$values()` and the constructors explicit).
The general lesson this left behind, and the reason the episode stays in the record: a harness that
counts one artefact (literals) can point at a real difference while misdescribing it, and a hand
check that stops at the first comparison can convert that into an overstated claim. The corrective was
to query the classes the tool we were comparing against had named — which cost one command.
## Not established
- **No whole-APK parity run.** The comparison is on class sets, sampled `getclass` output and one
`findrefs` family per sample. `bench/corpus_parity.py` and `bench/jadx_parity.py` exist for a fuller
run and were used on one archive, not on a corpus.
- **`findrefs` on a large corpus was not run to completion.** On the 34.8 MB app, `findrefs string
http` returned 0 rows on both sides — a query with no hits is not a parity result, and a query that
does hit was only compared on the small archive.
- **`getclass` was judged on 17 classes of one app.** The enum blind spot is confirmed; whether other
class shapes (inner classes, synthetic bridges, `invokedynamic`) diverge the same way is untested.
- **No device or Android-side use.** `rasc` reads archives on the host; nothing here exercises it as a
device tool, and the reference file makes no claim about that.
- **The 8.0× upstream figure was not reproduced**, because the archive it was measured on (343 MiB,
567k classes) is not available here. The direction and the smaller ratio were measured.

View File

@@ -0,0 +1,585 @@
# Extension verification — truthful environment gates, documented-command validation, and APK rebuild safety
Covers the P0 pass that replaced the optimistic half of `skills/apk-reverse/scripts/doctor.py`
with a probed capability registry, added `check_commands.py` at the repository root, and — in the
same round, by teammate `zip-safety` — replaced the APK-rebuild writer inside
`skills/apk-reverse/scripts/so_constpatch.py`. Strength labels are the ones this directory's
README defines: **observed** (reproduced here, exact command and output recorded), **inferred**
(follows from observed facts, the step itself was not executed), **unverified** (assumed or
reported elsewhere, not independently reproduced).
Two gates depend on the material below, which is why the report was allowed to become worse
looking: G2 (environment gating) reads the capability verdicts, and the documented-command check
is what keeps a reader from following a page instead of the tool. "Documented command" below means
a script invocation written in a code block or inline backtick in a markdown file — the shape
`check_commands.py` extracts and validates.
Reference environment for this pass: Windows 11 / AMD64, Python **3.14.0**, JDK **17.0.4.1**
(`java`/`javac` only, on the Oracle launcher shim path), `adb` 1.0.41 with one attached
`arm64-v8a` device at API 30, Frida **16.7.19** host package, capstone present, Android
build-tools unpacked at a local directory but **not** on `PATH`. Workbench artifacts live in
`tools/_work/truth-gates/` and `tools/_work/zip-safety-out/` (not part of the skill; `tools/` is
gitignored, so those paths are an operation record rather than something a reader can open).
---
## 0. Summary of what this pass established
| Claim | Label |
|---|---|
| The committed `doctor.py` registered **28** scripts against **51** files actually in `scripts/` (46 `.py` + 5 `.js`) | observed |
| Its capability table derived `repack + sign` and `smali round-trip` from `java` alone; on a JDK-only `PATH` it reported **both as OK** while `keytool`, `jarsigner`, `zipalign`, `apksigner` and all eight smali jars were absent | observed |
| The rebuilt `doctor.py` scans the directory, so `registered/checked = N / N` equals the file count; on this host `52 / 52` (47 `.py` + 5 `.js`, the new `capabilities.py` included) | observed |
| Capability verdicts now come from a real `requires` closure with an executable next action and a cost estimate; 16 capabilities are resolved, and the same JDK-only host reports `repack_signed` and `smali_roundtrip` **BLOCKED** | observed |
| `check_commands.py` reads each script's `argparse` interface with `ast` (no script executed to learn its interface) and validates **139** documented commands across 76 markdown files against 39 interfaces, with 0 drift after the one known drift was fixed | observed |
| The drift the checker was written for — `sig_probe.py --live --pkg <pkg>` — is reproduced **from `git show HEAD:`** of the pre-fix page and is reported at the right line | observed |
| The pre-existing `so_constpatch.py` APK writer dropped per-entry zip metadata and lost `resources.arsc` alignment; the replacement keeps metadata byte-for-byte and the alignment gate passes | observed |
| `zipalign -c -v 4` from the Android build-tools agrees with that verdict independently (old writer `rc=1 FAILED`, new writer `rc=0`), re-checked in this pass on artifacts produced by the other teammate | observed |
| `repack.py:_needs_alignment` uses `^lib/[^/]+\.so$`, which never matches the real `lib/<abi>/x.so` layout, so its alignment filler is dead code on real APKs | observed (reported, **not** fixed — outside every current write scope) |
| A real `pm install` of any rebuilt artifact | not done — no install was attempted in this pass |
---
## 1. The doctor was optimistic, and a gate trusted it
### 1.1 A hand-written inventory that could only go stale
The committed version carried `script_dependencies()` returning a literal dict of 28 entries. The
directory held 51 files. The 23 absent from the table were not peripheral: they included
`dex_patch_bytes.py`, `dex_find_insn.py`, `dex_dump_validate.py`, `dex_mem_scan.py`, `dexutil.py`,
`protobuf_decode_raw.py`, `java2c_probe.py`, `vmp_diff_harness.py` and `scan_leaks.py` — nine of the
scripts this skill exists for. The summary line read `scripts runnable here: 28 / 28`, so the report
looked like complete coverage of a set that was less than half the set.
The replacement cannot drift in that direction, because the list *is* the directory:
```
$ python skills/apk-reverse/scripts/doctor.py --scripts
--- scripts ---
directory: E:\apk-reverse\skills\apk-reverse\scripts
registered/checked = 52 / 52 (.py=47, .js=5)
ok=33 partial=13 blocked=6 unknown=0
```
A file whose dependency set cannot be decided is printed as `UNKNOWN` and still counted in the
denominator; `unknown=0` here is a measurement, not an assumption. Dependencies come from an `ast`
pass over imports checked against `sys.stdlib_module_names`, plus a narrow scan for host tools
(`resolve_tool('java')`, the program of a `subprocess` list) against a known-tool set. Modules are
never imported to probe them, because importing the probe target is itself a side effect — `frida`
starts threads and enumerates devices at import time.
### 1.2 The formula that ignored the toolchain — decisive reproduction
`doctor.py:319-320` (committed):
```python
caps['repack + sign'] = bool(tools['java']['path']) and py['ok']
caps['smali round-trip'] = bool(tools['java']['path']) and py['ok']
```
Neither ability follows from `java`. To show that the mechanism was wrong rather than merely lucky,
the committed script was extracted from git and run with a `PATH` holding a JDK and nothing else:
```
$ git show HEAD:skills/apk-reverse/scripts/doctor.py > tools/_work/truth-gates/doctor-HEAD.py
$ set PATH=C:\Program Files\Common Files\Oracle\Java\javapath;C:\Windows\System32;C:\Windows;C:\Python314
$ python tools/_work/truth-gates/doctor-HEAD.py
--- capabilities ---
[OK ] static (dex/zip/strings)
[OK ] repack + sign <-- no keytool, no zipalign, no signer on this PATH
[OK ] smali round-trip <-- none of the eight jars exist on this host
[OK ] native ELF patching
[-- ] jadx decompile
...
```
The rebuilt script, on the **same** restricted `PATH`:
```
[BLOCKED] repack_signed Repack, align and sign an APK
missing : tool:keytool -- not on PATH or APKREV_TOOLS
next: the directory on PATH (C:\Program Files\Common Files\Oracle\Java\javapath)
is a launcher shim, not a full JDK bin/: put %JAVA_HOME%\bin first, or
install a JDK - a JRE ships no keytool [~2 min, unverified]
missing : tool:zipalign -- not on PATH or APKREV_TOOLS
next: build-tools are already unpacked here but are not on the search path:
set APKREV_TOOLS=E:\tools\android-14;E:\tools\bin (found: ...) [~0 min, observed]
missing : artifact:signer -- tool:apksigner (not on PATH or APKREV_TOOLS);
jar:uber-apk-signer (uber-apk-signer.jar not found)
reduced : tool:jarsigner -- not on PATH or APKREV_TOOLS
[BLOCKED] smali_roundtrip Smali disassemble / assemble round-trip
[OK ] repack_unsigned Repack an APK without signing (--no-sign path)
ok=5 partial=1 blocked=10 (of 16 capabilities)
```
`repack_unsigned` staying OK is deliberate and true: `repack.py --no-sign` is `zipfile` plus the
STORED-entry rules and needs no Java tooling at all. A gate that reported the whole file unusable
would have been a pessimism of its own.
### 1.3 The closure is the point, not the atom list
`capabilities.py` is the single registry: 16 capabilities, each a `name`, a `note`, the scripts it
serves, `required` atoms, `optional` atoms, and `depends_on` capabilities that are expanded
recursively. `repack_signed` depends on `repack_unsigned`, so it cannot be OK while the repacking it
is built on is not. Atoms cover the four kinds that actually decide something here: an importable
module, a tool resolvable on `PATH` or `APKREV_TOOLS`, a jar set, a device property (present,
rooted, frida-server running), a repository file, and one **input artifact** that no host can
install — the Dart AOT snapshot dump, which is why `dart_aot_full` reports BLOCKED with
`build blutter … or run the pinned aotopsy front end` rather than a download link.
`status ∈ ok | partial | blocked`. A missing *required* atom blocks. A missing *optional* atom
degrades: `static_native` stays usable without `capstone` and names the part that is not
(`svc_scan.py` refuses to run without it; the caller index in `dart_disasm.py` works).
Two probes were worth getting right rather than guessing:
- A JDK whose launcher shim is on `PATH` (`...\Oracle\Java\javapath`) exposes `java` and `javac`
but not `keytool` or `jarsigner`. The registry probes those separately and, on failure, says the
shim directory is a shim instead of reporting a missing JDK.
- Build-tools present but unexposed are found by searching the usual SDK locations plus a
conventional `tools/` directory, and the next action becomes the exact assignment
(`set APKREV_TOOLS=E:\tools\android-14;E:\tools\bin`) with a measured cost of `~0 min`, rather
than "install build-tools".
That last hint was verified end to end: with `APKREV_TOOLS` set, `repack_signed` loses the
`zipalign` and `signer` blockers and keeps only `keytool` — which is the honest residual on this
host, because the JDK really is a shim install.
### 1.4 Cost estimates carry their own strength
Every install hint carries `est_minutes` with a `basis`. `observed` means measured in this
repository: the `--unsafe-rebuild`-independent blutter build is **≈78 s** end to end
(`TOOL-VERDICTS.md`), and exposing an already-unpacked build-tools directory is 0 minutes because
the search found it. Everything else is labelled `unverified` and is a planning aid only: a guessed
ten minutes printed as a measurement is the same class of error as a capability verdict derived
from the wrong tool.
Per-capability `est_basis` is `mixed` when the missing atoms disagree, which is what makes
`repack_signed` read `~12 min, mixed` rather than an authoritative-looking single number.
### 1.5 Exit codes and the token
`doctor.py` and `capabilities.py` both follow this round's convention: `0` = nothing blocked
(`partial` does not fail a gate, and the counts are printed so it cannot hide), `3` = at least one
capability blocked, `2` = usage, `4` = internal. The last line is `RESULT=env_ok` /
`env_partial` / `env_blocked` / `usage_error` / `internal_error`, and the usage-error path keeps the
token too — `argparse` would otherwise exit 2 with nothing a caller can branch on.
`--json` carries `{status, exit_code, capability?, evidence[], warnings[], next_action?}` plus, for
compatibility, the keys the old report used (`python`, `tools`, `jars`, `device`, `capabilities`,
`scripts`). `capabilities` changed shape on purpose — the old flat `{name: bool}` map *was* the
optimism — and a `capabilities_legacy` map gives the same information as `{id: "ok"|"partial"|"blocked"}`
for any consumer that only wants the verdict.
---
## 2. `check_commands.py` — the commands a reader is told to run
### 2.1 What it reads, and from where
`check_repo.py` proves that a referenced path resolves and every script answers `--help`. Neither
can see a documented command whose flags no longer exist, and the repository shipped exactly one for
a long time. `check_commands.py` closes that gap by reading each script's real interface with `ast`:
- `ArgumentParser()` / `add_argument()` calls are parsed statically, so **no script is executed to
learn its interface**;
- `add_subparsers()` / `add_parser('name')` are followed, and a sub-parser's flags are attributed
to that sub-command rather than merged into the parent (one variable reused across several
sub-parsers is handled, and the parameters are then attributed to the group as a whole);
- `action=store_true|store_false|count|help|version` and `nargs=0` mean the flag consumes no value;
- a script with no `argparse` at all (a hand-rolled `sys.argv` parser: `smtool.py`,
`dex_strpatch.py`) falls back to parsing its own `--help` output, and `smtool.py`'s `--cp` is
recovered that way. The probe runs in a **throwaway directory**, because a probe must not leave
anything behind — a script that reads its first argument as an output directory would otherwise
create one named after the flag.
Documented commands are extracted from fenced code blocks and inline backticks in `README.md`,
`skills/**/*.md` and `docs/**/*.md`. A documented line is split on `&&`, `||`, `;` and `|`, and a
redirection truncates the segment: `python check_repo.py && python check_refs.py` is two commands,
not one with two extra positionals. `<PKG>`-style placeholders are kept as single tokens, because
reading `<pkg>` as a shell input redirect truncates the command at `--pkg` — and a truncated
command checks clean, which is how the checker would have missed the one drift it was built for.
### 2.2 Allowance rules that keep it honest rather than noisy
- argparse accepts a **unique prefix** of a long option, so `--max` passes for `--max-depth`; an
*ambiguous* prefix is reported.
- `-h` / `--help` are always accepted.
- A flag that needs a value does **not** consume an option-looking token; argparse reports
"expected one argument" instead. This is the rule that makes `--live --pkg <pkg>` report `--pkg`
rather than swallow it as the value of `--live`.
- Positional counts are computed from the declared positionals (with `nargs=None|int|'?'|'*'|'+'`)
and are reported as **warnings**, never as drift: a static count can be thrown off by a
placeholder or an elision, so it does not get to fail a gate. It is skipped entirely when any
option has a variable `nargs` or the interface came from `--help`.
### 2.3 The drift it exists for, reproduced from git
The page was already fixed by the time this checker ran, so the pre-fix revision is taken from git
and checked as a file:
```
$ git show HEAD:skills/apk-reverse/references/signature-derived-keys.md > tools/_work/truth-gates/sig-derived-HEAD.md
$ python check_commands.py --fix-report --path tools/_work/truth-gates/sig-derived-HEAD.md
tools\_work\truth-gates\sig-derived-HEAD.md:61 sig_probe.py unknown flag --pkg closest known: --apk cmd: python scripts/sig_probe.py --live --pkg <pkg>
RESULT=commands_drift
```
Line 61, the right script, and the correct suggestion (`sig_probe.py` takes `--live PKG` and has
never had `--pkg`). A second, synthetic fixture proves the check is not a single-rule special case:
```
$ python check_commands.py --fix-report --path tools\_work\truth-gates\NEGATIVE-unknown-flag.md
...:4 sig_probe.py unknown flag --pkg closest known: --apk cmd: python skills/apk-reverse/scripts/sig_probe.py --live --pkg <pkg>
...:5 dex_classdiff.py unknown flag --original closest known: --max-list cmd: ... --original a.dex --patched b.dex
...:5 dex_classdiff.py unknown flag --patched closest known: --max-list cmd: ... --original a.dex --patched b.dex
...:7 skills/apk-reverse/scripts/does_not_exist.py unknown flag missing-script closest known: - cmd: python .../does_not_exist.py --x 1
RESULT=commands_drift
```
Exit code 1, one machine-readable line per finding, with the document line, the command, the
unknown flag and the closest known flags — which is what `--fix-report` is for.
### 2.4 Coverage on the real repository
```
$ python check_commands.py
docs : 76 markdown file(s)
scripts : 39 argument table(s) read, 2 without a readable table
commands : 139 checked (139 clean, 0 drift, 0 warning)
skipped : 24 workbench, 1 external, 1 quoted, 0 unverifiable, 30 not a script invocation
RESULT=commands_ok
```
`139 checked` counts every invocation resolved to a readable interface, not merely the ones with
findings; a pass that reports only its findings cannot tell "clean" from "nothing looked at".
Everything skipped is printed with its reason, and the reasons are of four kinds:
- **workbench** (24): the script lives under `tools/`, which `.gitignore` excludes, so a reader
cannot open it and a missing file there is not drift. A bare filename that only exists under
`tools/` is caught too, because those evidence records refer to workbench scripts by name.
- **not a script invocation** (30): `python -m …`, `python -c "…"`, an elided `python ... --flag`,
or an output line that merely starts with `python`.
- **external** (1): `python scripts\init_env_win.py` in the blutter build record is a path inside
*another* repository's checkout — the record's preceding line is `git clone --depth 1
worawit/blutter`. Each such entry carries its reason in an auditable table that the report
prints, because a silent allow-list is indistinguishable from a missed drift.
- **quoted** (1): `FINDINGS.md` quotes `python scripts/sig_probe.py --live --pkg <pkg>` *as the
defect it records*. It is evidence, not an instruction; the page's own sentence says the script
has no `--pkg`. Same auditable-table treatment.
The two scripts with no readable table are listed by name. A script with no flags has nothing that
can drift, so its invocations pass without being checkable — stated so the edge of the check is
visible instead of implied.
### 2.5 Defects the negative tests found in the checker itself
Each of these produced a *false clean* on the pre-fix page, and each was found by testing the
checker against a known-bad document rather than by reading its code.
| # | Defect | How it surfaced | Fix |
|---|---|---|---|
| 1 | `<pkg>` was read as a shell input redirection, truncating the command at `--pkg`; the truncated command then checked clean | the git-HEAD reproduction reported no drift at all | `<...>` with no interior space is treated as a placeholder token; `>`/`>>` still truncate |
| 2 | A flag that takes a value consumed the *next* token unconditionally, so `--live --pkg` made `--pkg` the value of `--live` | the same reproduction, after fix 1 | an option-looking next token is not consumed, matching argparse's "expected one argument" |
| 3 | The whole documented line was read as one argv, so `&&`, the second `python`, and a redirect target entered the positional count | 3 warnings about `check_repo.py && python check_refs.py` and `… --json > out.json` | lines are split on `&&`/`\|\|`/`;`/`\|` and truncated at a redirection |
| 4 | `docs/**` records reach workbench scripts as `../../tools/_work/…`, which resolves to a real file, so they were reported as unverifiable repository scripts | 17 spurious entries | a resolved path under `tools/` is classified workbench, and paths are `realpath`-normalised so `dexpatch/../smtool.py` is one entry, not two |
| 5 | `commands_checked` counted only commands *with findings*, so a clean run reported `3 checked` instead of 139 | comparing the summary against the extraction dump | every resolved invocation is emitted as a `clean` record and counted |
| 6 | `--help` fallback probes ran with `cwd` at the repository root | reasoning about probe side effects, then confirmed | the probe runs in a temporary directory |
Defect 1 is the instructive one: a checker whose parser silently shortens a command reports success
for exactly the inputs it was written to reject, and nothing in its own output looks wrong. The
defence is not more careful reading, it is a fixture that is known to be bad.
### 2.6 A second blind spot, found by a reviewer reading this gate's output
A reviewer reported seven "drifting" commands. Reproducing them split the report three ways, and the
middle category is the one worth reading.
**Not reproducible.** `python tests/run_tests.py -q` runs: `run_tests.py` forwards what it is given
to pytest (`args = list(argv[1:]) or ["-q"]`), and its usage block documents exactly that. `dcc.py`
is an external checkout, not a script this repository ships — the form the record uses is:
```
$ cd tools/_work/bench/repos/dcc
$ python ./dcc.py <SAMPLE_DIR>/baseline.apk --no-build --dynamic-register
```
**Real, and fixed.** Seven commands in two evidence records named a **workbench** script with no path
at all (`$ python analyze_pair.py`, `b2_measure.py`, `ttd_attach.py`, `make_skeleton.py`). Those files
exist under `tools/`, which `.gitignore` excludes, so a reader copying the command from the
repository root gets `No such file`. Two of those documents wrote the correct path elsewhere, so they
disagreed with themselves.
**The part that matters: this gate had been scoring those `ok`.** `resolve_script()` tried the
*document's own directory* first, so a bare `analyze_pair.py` inside `docs/tool-verification/`
resolved to `tools/_work/bench/unpack/b3/analyze_pair.py` — a real file — and passed. The fix is not
a stricter regex but a corrected resolution order plus a corrected classification:
1. Resolution now follows the order a **reader** would try: repository root, then the skill's script
directory, then the document's own directory (which is what `../x.py` in a nested README means).
2. A bare name that only resolves under `tools/` is `unqualified-workbench`, and it counts as
**drift, not a skip** — a one-token fix that a reader cannot run. A first attempt classified it as
a low-confidence warning, which would have left all seven in place.
3. The one honest exception is pinned too: a block that opens with `cd tools/_work/bench/repos/dcc`
tells the reader where to stand, so a bare `python dcc.py` after it is correct. That is skipped as
`workbench-after-cd`.
### 2.6a The same gate failed on CI and passed locally, and the reason is worth keeping
The first version of the fix above shipped and CI went red on `check_commands.py` — in both the 3.11
and 3.13 jobs — while the identical command passed on the machine that wrote it. The difference is
`tools/`: it is gitignored, so a clean checkout does not have it, and **recognition of a workbench
artifact by filename required the tree to exist**. On the developer's box `dcc.py` matched a name
under `tools/` and was skipped as `workbench-after-cd`; on CI nothing matched, it fell through to
`missing-script`, and the gate reported drift for three quoted lines in this very file.
Two lessons, both now enforced rather than remembered:
- **A check whose verdict depends on a gitignored directory is not a check.** The classification is
now computed from the **document** as well: `qualified_basenames()` collects every `.py` the text
itself writes *with* a path, and a bare name that appears in that set is a shorthand the document
already explains — so it skips identically on a clean checkout and on a populated one.
- **A gate that cannot be run in the same shape as CI will disagree with CI.** Reproduce the CI
condition locally before believing a green run: `mv tools /tmp/hidden && python check_commands.py`
is the whole experiment, and it takes seconds. That is what found this, after the fix had already
been pushed.
**Two defects of this pass's own, found while fixing it.** A `--json` run printed the `RESULT=` token
*after* the JSON document, so `--json | jq` failed with `Extra data`; the token is gone from that path
and the status is a field in the document instead. And the fix's own substitution left four literal
backslashes before `$` in the two files — a reader would have copied them.
`tests/integration/test_check_commands_gate.py` pins the resolution order, the drift classification,
the `cd` exception, and a clean pass over the committed tree, so this blind spot cannot return
silently.
### 2.7 What this check does not cover
- It validates **flags and (weakly) positional counts**, not semantics. A command with correct flags
and a wrong path still passes; so does a documented `--duration 14` where the useful value is
different.
- A script whose interface is neither declarative nor printable is outside the check; the two
current cases are listed by name rather than folded into the pass count.
- It does not verify that a command *works* — only that the script would not reject it as unknown
arguments. Running the 139 commands remains a benchmark-pass activity.
---
## 3. APK rebuild safety in `so_constpatch.py` (teammate `zip-safety`; re-checked here)
### 3.1 What the committed writer did
```python
# so_constpatch.py:204-218, before this round
with zipfile.ZipFile(out_path, 'w', zipfile.ZIP_DEFLATED) as dst:
for item in src.infolist():
blob = new_data if item.filename == entry else src.read(item.filename)
dst.writestr(zipfile.ZipInfo(item.filename, date_time=item.date_time),
blob, item.compress_type)
```
A new `ZipInfo` built from a filename and a timestamp carries nothing else, so `extra` and
`external_attr` are lost; `create_system`, `internal_attr` and the version fields are reset; and
every entry is re-compressed as DEFLATED, so a STORED `resources.arsc` may stop being STORED and
lose its 4-byte data alignment. There was no `extractNativeLibs` check, no alignment check, and no
pointer at `scripts/repack.py`.
### 3.2 What replaced it
The writer now emits the local headers and the central directory itself, because alignment is a
property of the local header length that `zipfile` cannot express. Per entry it reproduces the raw
local name bytes, both `extra` fields, `external_attr`, `internal_attr`, `create_system`,
`create_version`, `extract_version`, the comment, the DOS time/date words and the general-purpose
flags (the data-descriptor bit is cleared, since the size is known up front). An untouched STORED
entry's compressed stream is copied verbatim in 1 MiB chunks, so its CRC and `compress_size` are
identical by construction rather than by luck. Only the named entry is ever re-compressed, and only
if it was DEFLATED. Alignment padding goes into a private local extra record (`0xCA`) so the
original extra field stays a byte-for-byte prefix.
### 3.3 The offset that matters — a correction to state plainly
The alignment contract is the **data offset** of the entry, not its local-header offset. This
matters because the two differ by the header length plus the name, and real archives routinely have
an unaligned header offset:
```
r2pay (input, 435 entries): resources.arsc STORED data offset 0x3cec08 header_offset 0x3cebce
data offset % 4 == 0 OK header_offset % 4 == 2
```
`zipalign`, `apksigner` and `repack.py`'s own `check_alignment` all test the data offset. So the
rule this tool enforces is: `resources.arsc` must be STORED and its **data offset must be a
multiple of 4**; any other STORED entry that arrived aligned must still be aligned after the
rebuild. The alignment predicate differs from `repack.py`'s in one substantive way —
```
always : resources.arsc (Android R+ install gate)
always : STORED lib/**/*.so (extractNativeLibs=false maps them in place)
preserved: any other STORED entry already at a 4-byte data offset
never : DEFLATED entries, and STORED entries that arrived unaligned
```
— and it uses `^lib/.+\.so$` where `repack.py` uses `^lib/[^/]+\.so$`. The latter matches
`lib/x86/libfoo.so` but **not** `lib/arm64-v8a/libfoo.so`, because the ABI directory is a second
path segment: on a real APK that pattern matches nothing, so the alignment filler it guards would
never fire. That is a live defect in `repack.py` (`_needs_alignment`, and the
`check_alignment`/`zip_report` paths that inherit the predicate), reported rather than fixed
because `repack.py` is in no one's write scope this round. It is worth fixing before the refusal
message tells users to switch to that tool.
### 3.4 Evidence, fixture level
```
arsc_aligned.apk (7 entries)
OLD writer vs INPUT NEW writer vs INPUT
extra differ 7/7 (nothing but offset)
eattr differ 7/7
csys differ 7/7
ver_made differ 7/7
untouched entries with changed CRC/size : 2 untouched entries with changed CRC/size : 0
resources.arsc: STORED @0x3a3 UNALIGNED resources.arsc: STORED @0x3e4 ALIGNED
zipalign -c -v 4 -> rc=1 zipalign -c -v 4 -> rc=0
```
### 3.5 Evidence, a real 435-entry package, and an independent re-check
Same sample, same patch, old writer versus new writer versus the input:
```
extra differ 205/435 (nothing but offset)
eattr differ 435/435
iattr, csys, ver_made differ 272/435
flags differ 164/435
untouched entries with changed CRC/size : 132 untouched entries with changed CRC/size : 0
file size 4,293,620 -> 3,897,949 file size 4,293,620 -> 4,289,340
resources.arsc: @0x36f931 UNALIGNED resources.arsc: @0x3cec08 ALIGNED
```
The old writer also *shrinks* the archive by ~396 KB: re-compressing entries that were already
compressed changes the container, not the payload.
This pass re-checked the decisive claim with the Android build-tools' own verifier — a tool in
neither teammate's write scope — on the artifacts the other teammate produced:
```
$ E:\tools\android-14\zipalign.exe -c -v 4 <file>
plain.old.apk rc=1 Verification FAILED plain.new.apk rc=0 Verification succesful
arsc.old.apk rc=1 Verification FAILED arsc.new.apk rc=0 Verification succesful
r2pay.old.apk rc=1 r2pay.new.apk rc=0
```
`zipalign -c 4` checks every uncompressed entry, which is stricter than Android's install gate. The
bare-`.so` path (no container) was re-hashed as well and shows the intended zero regression:
```
0790faeaec0ed4ea8bd3e859db23840dd9d1c2635330b48c4d2e3a3f68fdd371 bare.old.so
0790faeaec0ed4ea8bd3e859db23840dd9d1c2635330b48c4d2e3a3f68fdd371 bare.new.so
```
### 3.6 The refusal gate
Rebuilding is declined by default when the package cannot be rebuilt safely, with the measurement
and a pointer at the tool that owns a full resource-safe repack:
```
$ python skills/apk-reverse/scripts/so_constpatch.py tests/fixtures/apk/noextract.apk \
--entry lib/arm64-v8a/libfoo.so --replace apkhuan=android -o out.apk
extractNativeLibs=false
UNSAFE: AndroidManifest.xml declares android:extractNativeLibs="false": ...
REFUSED: this package cannot be rebuilt safely by this tool (see the UNSAFE line(s) above).
* If you want a resource-safe repack, use scripts/repack.py: ...
* ... re-run with --unsafe-rebuild to proceed anyway.
RESULT=refused_unsafe_rebuild exit=1 output written: no
```
Refusal is exit `1`, not `3`: `3` means the tool lacks the capability, whereas here the tool is
capable and the *package* is the reason nothing was written, so it is a negative finding about the
target. `--unsafe-rebuild` proceeds and still re-measures the STORED/alignment gate on its own
output. Exit codes follow the round's set — `0` success, `1` negative finding, `2` usage (including
an equal-length violation), `3` unsupported container (zip64, a non-deflate target, not a zip),
`4` internal error with the output deleted — and `--json` carries
`{status, exit_code, capability, evidence[], warnings[], next_action?}` plus `result`, `audit`,
`verify` and `patch`, with the final line `RESULT=<token>`.
### 3.7 What section 3 does not establish
- **No device, no install.** No `adb` install or cold start was run. The `[-124]` install failure is
argued from the bytes and from `zipalign`, not observed on a device. `inferred`.
- **No signing run.** The rebuild output is unsigned by design; `repack.py` owns signing, so no
`apksigner verify` result exists. `unverified`.
- **No genuine zip64 sample.** zip64 is refused (`exit 3`); the refusal path was exercised with a
hand-built non-zip file, not with a >4 GiB archive.
- **Page alignment (4 KB/16 KB) is out of scope.** This work guarantees 4-byte alignment, the
install gate. 4 KB/16 KB alignment for `extractNativeLibs=false` builds is `zipalign -p`'s job and
stays with `repack.py`.
- `--unsafe-rebuild` cannot repair an already-compressed `resources.arsc`: the method is preserved,
the self-check reports it as a warning, and the output stays non-compliant.
---
## 4. What this pass did not do
- **No `pm install` of any artifact**, and therefore no cold-start or behavioural verification of a
rebuilt or re-signed APK. Everything in §3 is container-level plus an external alignment verifier.
- **No signing.** On this host `repack_signed` is genuinely blocked (a JDK launcher shim, no
build-tools on `PATH`, no signer jar), so the signed-repack path was **not** executed; the
capability verdict is a probe result, not an observation of a failed run.
- **No smali round-trip**, for the same reason: the eight jars are absent. `smali_roundtrip` is
reported BLOCKED and was not exercised.
- **`dart_aot_full` was not exercised**: no snapshot dump exists in this session, and that
capability reports an artifact the host cannot install rather than a missing package.
- **`check_commands.py` was not run against a benchmark target**, only against this repository. Its
false-clean rate on a foreign documentation set is unknown.
- **The 30 skipped invocations were not individually confirmed** to be non-scripts; they are
classified by the reason printed for each (`-m`, `-c`, an elision, or a prose line beginning with
`python`), and a misclassification there would be a missed check rather than a false finding.
- **One workbench nuisance observed and not cleaned up** (not in any write scope): the tree carries
an untracked `--help/` directory containing `exempt/benign.txt` and `planted/{appkey,device,
endpoint,package,path}.txt`, i.e. the planted leak-fixture layout. It is not produced by
`scan_leaks.py --help` (reproduced in a scratch directory: usage only, nothing written) and not
by this pass's tools, which now probe in a temporary directory. The generator is
`tools/_work/leak-precedent/gen_fixtures.py`, whose output directory is taken from an argument, so
a probe that passed `--help` as that argument would create exactly this. It is worth removing by
whoever runs the final commit, because the directory holds deliberately leak-shaped files.
## 5. Files changed by this pass
New: `skills/apk-reverse/scripts/capabilities.py`, `check_commands.py`.
Rewritten: `skills/apk-reverse/scripts/doctor.py` (its documented CLI is unchanged; `--scripts` and
`--json` still work, and the JSON keeps its old keys).
Merged here from teammate `zip-safety`'s work (their write scope, not this one):
`skills/apk-reverse/scripts/so_constpatch.py`, `tests/test_so_constpatch.py`,
`tests/fixtures/make_fixture_apk.py`, `tests/fixtures/apk/{plain,arsc_aligned,noextract,services}.apk`
and `tests/fixtures/apk/.gitignore` (the repo-wide `*.apk` ignore rule needs a `!*.apk` reverse rule
for fixtures to be stageable; `git check-ignore -v` confirms it).
Neither teammate committed or pushed. Nothing in §1 or §2 was measured on a device; §3's device
work did not happen either.
## 6. Index lines handed to the Lead
- `skills/apk-reverse/SKILL.md` (and `README.md` where the tool table lists scripts):
**`scripts/capabilities.py`** — new. "Capability registry: the single source of truth for what
this host can actually do. Resolves a capability's real `requires` closure (tools, modules, jar
sets, device properties, input artifacts) into `ok | partial | blocked`, with an executable next
step and a cost estimate that names its own strength. `--list`, `--json`; used by `doctor.py`."
- **`check_commands.py`** (repository root, sibling of `check_repo.py` / `check_refs.py`) — new.
"Validates every documented script invocation (`python …`) against the script's own `argparse`
table, read with `ast` so no script is executed to learn its interface. `--fix-report` prints
`doc:line`, the command, the unknown flag and the closest known flags. Exit 0 clean / 1 drift /
2 usage." It is a natural fourth entry in the publish-gate list wherever those are described.
- **`skills/apk-reverse/scripts/doctor.py`** — the existing row's wording can gain: "the script
inventory is scanned from the directory (`registered/checked = N / N`, undecidable entries counted
as `unknown`), and capabilities are probed rather than inferred from `java`".
- **`skills/apk-reverse/scripts/so_constpatch.py`** — the existing row can gain the clause from
`zip-safety`'s §7: an APK rebuild preserves each entry's zip metadata (STORED stays STORED,
`extra`/`external_attr`/`create_system` intact, `resources.arsc` kept STORED and 4-byte aligned)
and is **refused by default** when `extractNativeLibs="false"` or `resources.arsc` is not
STORED + aligned — `scripts/repack.py` is the route for that package, or `--unsafe-rebuild` to
override deliberately.
- **`repack.py:_needs_alignment` is a live defect** (`^lib/[^/]+\.so$` cannot match
`lib/<abi>/x.so`), reported by `zip-safety` and confirmed here by reading the predicate. Someone
needs a write scope for `repack.py`, or it should be recorded as a known gap.

View File

@@ -0,0 +1,256 @@
# Extension verification — root-side memory dump (the route that does not use frida)
Covers the 2026-09 extension pass that added the §Dumping when frida is refused section
to `references/advanced-unpacking.md`. It is the follow-up to
`docs/tool-verification/EXTENSION-device-run.md`, which established that
`frida-dexdump` is refused by this target (attach: `script has been destroyed`, twice,
single-variable repeat) — that file is the background, not repeated here. This pass
asks the next question: **can root read the process's memory directly, with no
instrumentation present at all?**
Strength labels, as defined in this directory's README:
- **observed** — reproduced here, with the exact command and its output.
- **inferred** — follows from observed facts, but the step itself was not executed.
- **unverified** — assumed, or reported by a tool and not independently confirmed.
**Target identity is deliberately absent**; the package appears as `<PKG>`. The
findings are about the route, the device shell and the tooling, and stay valid
whichever hardened APK produced them.
## Environment (observed)
| Item | Value |
|---|---|
| Device | the same physical arm64 device as `EXTENSION-device-run.md`: Android 11 (API 30), kernel 4.14.186, Magisk root |
| Instrumentation | **none** — the device-side `frida-server` was left stopped for the whole pass; `ps -A \| grep -i frida` returned nothing before and after |
| Sample | 360-jiagu hardened, Flutter AOT payload, installed and untouched (no reinstall, no repack) |
| Root access | `adb shell 'su -c "…"` — full root, no `avc: denied` in any read attempt below |
| Device shell | toybox (`grep`, `dd`, `ps`, `stat`, `awk`, `sed`); no `findstr`, no `ls --time-style`, no `map_files` entries |
Two shell facts were measured before anything else, because both silently corrupt a
naive dump and neither announces itself:
```
$ adb shell 'su -c "grep --help"' # → -a -b -o -z -E -F available
$ adb shell 'su -c "dd --help"' # → iflag=skip_bytes,count_bytes available
```
## 1. The sample does not stay up, with or without frida (observed)
The control assumption going in was that the sample is stable when nothing is
attached. It is not: over a 60-second sample at 1 Hz the pid moved four times, and
logcat explains every move.
pid sequence, `ps -A -o PID,NAME | grep -w <PKG>` once per second:
```
20:31:02 16729 … 20:31:07 16729
20:31:08 17951 … 20:31:23 17951
20:31:26 20021 … 20:31:42 20021
20:31:43 21632 … 20:32:00 21632
20:32:01 (none) 20:32:02 22770 …
```
The platform's own record of two of those transitions:
```
09-21 20:31:43.327 I/ActivityManager: Process <PKG> (pid 20021) has died: fg TOP
09-21 20:31:43.394 I/ActivityManager: Start proc 21632:<PKG>/u0a623 for top-activity {<PKG>/.MainActivity}
09-21 20:32:01.463 I/ActivityManager: Process <PKG> (pid 21632) has died: fg TOP
09-21 20:32:01.555 I/ActivityManager: Start proc 22770:<PKG>/u0a623 for top-activity {<PKG>/.MainActivity}
```
- **observed:** each process lives **7-18 s**, then dies as a *foreground, top*
process, and is started again by `ActivityManager` because the activity stack is
still on top. Lifetime is not constant.
- **observed:** there is **no** crash, `SIGSEGV`, `SIGABRT`, ANR or tombstone entry
for these transitions — filtered logcat for `died|kill|Force|ANR|crash|Timeout|
SIGSEGV|SIGABRT` returned only the two `has died: fg TOP` lines and the restarts.
A clean self-exit, not a platform kill.
- **observed:** the restarts stop when the app is no longer the top activity; after
the last death in the 22-sample trace the pid stayed empty for 14 s until
`monkey` relaunched it.
- **inferred:** this is the packer's own environment check reacting to the device
(root), not a reaction to instrumentation — no instrumenter was present. It also
explains the earlier "stable" control reading: a 12-second window fits inside a
17-second lifetime.
- **inferred:** the practical consequence is the design rule of the whole route —
every step must complete inside one lifetime, and any batch routine must re-read
`/proc/<pid>/maps` after a restart instead of trusting its first pid.
## 2. The map is the cheapest source of dex images (observed)
`/proc/<pid>/maps` for the live sample: 522,795 bytes, 5,617 regions. ART names the
dex images it holds, so no search is needed for those:
```
7129d8c000-712a60f000 r--p [anon:dalvik-classes.dex extracted in memory from /data/app/~~…/base.apk]
6f9ba03000-6f9be2b000 r--p [anon:dalvik-classes.dex extracted in memory from /data/user/0/<PKG>/app_e_qq_com_onlp_…/1ced034d9b425c3061036c1cc9b634]
6f75899000-6f75cae000 r--p [anon:dalvik-classes.dex extracted in memory from /data/user/0/<PKG>/app_sodler/com.kwad.components.tachikoma/5.6.10.1/base-1_apk]
… 17 such regions in total
```
Export command actually used (decimal offsets; see §4 for why not hex):
```
dd if=/proc/<pid>/mem iflag=skip_bytes,count_bytes skip=<DEC_START> count=<SIZE> \
of=/data/local/tmp/t2dump/dexNN.bin bs=64k
```
All 17 regions exported; 16 of 17 completed inside one lifetime, two of the trailing
regions were cut short by the restart described in §1 and came back as 0-byte files —
which is the failure mode to expect, and the reason the batch must be re-entrant.
## 3. Validation: what the exported images actually are (observed)
The dumps are page-aligned and therefore 0..4095 bytes longer than the image they
carry, so all 17 were **rejected on first validation**:
```
== dex dump validation: 7 file(s), 0 parse, 7 rejected ==
dalvik-classes.dex_…base.apk.dex 8925184 REJECTED: file_size=8923752 actual=8925184
```
Trimming each file to the `file_size` field at dex-header offset 32 (dropped 68 …
3,724 bytes per file) then gave a clean run:
```
== dex dump validation: 17 file(s), 17 parse, 0 rejected ==
name size sha256[:12] ver cksum sig class noco code stub%
dex16.bin 122460 3fc5581d6eb3 035 ok BAD 103 207 8974 1.1% (ksad dynamic apk)
dex08.bin 1069516 613cfef811ab 035 ok BAD 1285 432 8929 0.9% (kwad ksdp)
dex14.bin 8923752 1497590d0a24 035 ok BAD 4 160 29 3.4% (from /data/app/…/base.apk)
dex01.bin 3212480 fb61a3f085f5 035 ok ok 3169 785 15924 1.0% (pangle live-lite classes6)
… 17 images, stub% 0.9 – 3.4, 17/17 unique by sha256
ranking (most likely original first):
1. dex08.dex stub%=0.9 cksum=ok sig=BAD classes=1285
…
17. dex14.dex stub%=3.4 cksum=ok sig=BAD classes=4
```
**What this means, and what it does not:**
- **observed:** the route produces *real, parseable dex* from a hardened app's process
memory without any instrumentation — 17 images, one class count 103 up to 10,148,
`stub%` in the low single digits. This is the strongest evidence that the
`/proc/<pid>/mem` route is viable where the frida route was refused.
- **observed:** the image that came from the app's own `base.apk` defines **4
classes** on an 8.9 MB file (`sig=BAD` on the untouched image). File size and class
count move independently under a packer — this is the landing-shell skeleton shape,
matching the shipped `classes.dex` recorded in `EXTENSION-unpacking.md`.
- **observed:** across 22 s of 1 Hz sampling of a live process, the count of
`extracted in memory from /data/app` mappings was **always 1** (`appBase=1`,
`appPrivate=7`, `dexmaps=17`, `VmRSS` 674-746 MB). No second application dex mapping
ever appeared.
- **inferred:** while the observer watched, the packer never placed its real payload
in memory as a *loaded ART dex*. Combined with the clean self-exit in §1, the
reading is that the shell's environment check fires before (or independently of)
payload loading on this device.
- **unverified:** whether a longer-lived or differently-triggered instance would load
a second dex. No attempt was made to defeat the check, by task instruction.
## 4. Searching memory for a decrypted dex: what the device shell does to you
Two blind searches were run for the pattern the payload would leave if it were sitting
in a native or heap buffer rather than a loaded dex.
**Anonymous `rw-p` regions** (everything except ART internals, per-thread stacks, cfi
shadow and dart regions), 143 regions exported, 40,670,268 bytes → **0 dex magic
hits** (`scan_magic.py`, `dex\n03x` with `file_size` cross-check).
**ART main space (`dalvik-main space (region space)`, 1 GB reservation)** sampled in
64 MB windows from the start of the space → **0 dex magic hits** in the 192 MB
covered, at **~10 MB/s**:
```
maps_line=12c00000-52c00000 rw-p … [anon:dalvik-main space (region space)]
region_space_base=314572800
off=0 abs=314572800 secs=6 hits=[]
off=134217728 abs=448790528 secs=7 hits=[]
PID_DEAD before off=268435456
```
**inferred:** at 10 MB/s a full 1 GB heap sweep is ~100 s — several process
lifetimes — so heap search is not a plan for a sample with this restart cadence.
Named ART dex mappings are free; a heap sweep is not.
Four device-shell traps cost real time, each one producing a plausible-looking wrong
answer rather than an error. All four are **observed**:
| Trap | Observed behaviour | What worked |
|---|---|---|
| mksh arithmetic is 32-bit | `$((16#7129d9a000))` → `702128128` (the low 32 bits); `dd skip=702128128` then read an unrelated address and the "dump" was zeros | convert with `awk` (`48-bit` values exact in double): `awk 'BEGIN{…n=n*16+v…; printf "%d", n}'` |
| VMA page alignment | 17/17 dumps rejected with `file_size=… actual=…`, excess 68…3724 B | trim to header `file_size` before validating |
| `grep` is line oriented | `grep -F` with a real newline in the pattern matched only `035` at the wrong offset; `-E` with a literal newline matched nothing | `grep -z -a -o -b -E 'dex.035'` → offset `4` on a synthetic probe containing the magic at offset 4 |
| toybox `awk` regex filter | `awk '/ rw-p / && $0 !~ /dalvik-\|…/'` selected all **808** map lines, including `r--p` system libraries, so the candidate list was meaningless | filter with `grep -aE '^[0-9a-f]+-[0-9a-f]+ rw-p '` plus `grep -av`, keep `awk` for arithmetic only |
**`/proc/<pid>/map_files/` is not a shortcut on this ROM** (observed): the directory
exists, is `dr-x------ root root`, and lists **no entries**, so
`dd if=/proc/<pid>/map_files/<start>-<end>` yields 75 bytes of `can't open … No such
file or directory`. The `mem` + decimal-offset form is the one that works.
**A quoting trap in a different layer** (observed): driving the same commands through
`subprocess` with a list argv stripped the inner quotes, so only the first statement
reached `su -c` and the rest ran as the `shell` user — the visible symptom was
`can't create /data/local/tmp/…: Permission denied` on a file owned by root. Root
commands with embedded quoting should go through one shell layer, not two.
## 5. Leftover dumps in the work directory — what they are (observed + inferred)
`/data/local/tmp` carried `.so` files from earlier sessions. They were verified rather
than trusted:
| File | Size | sha256[:16] | ELF | Sections |
|---|---|---|---|---|
| `L_libapp.so` = `d_libapp.so` = `libapp.bak` | 13,501,344 | `2275dd4398f0b34e` | ELF64 AARCH64, `ET_DYN`, phnum 7 | 11 |
| `rt_libapp.so` | 13,501,344 | `8b490512258ba1c8` | ELF64 AARCH64, `ET_DYN`, phnum 7 | 11 |
| `libapp.so` | 12,256,160 | `4cfec2762c25539b` | ELF64 AARCH64, `ET_DYN`, phnum 7 | 11 |
| `ll.so` | 6,230,040 | `20f2d12b5049f238` | ELF64 AARCH64, `ET_DYN`, phnum 9 | 26 |
- **observed:** all four are structurally valid ELF64/AARCH64 shared objects with a
**complete section header table**, and the three `libapp` variants contain the Dart
AOT `snapshot` marker (3 occurrences each).
- **observed:** the `libapp.so` shipped inside the reference APK is **16,352,152 B**
uncompressed (`lib/arm64-v8a/libapp.so`), i.e. ~2.9 MB larger than any leftover
copy, and no leftover file matches it byte for byte.
- **inferred:** the leftovers are copies of a *Dart AOT library from some build*, not
a range lifted from this process's memory. The signature of a memory-lifted image
is the opposite of what these show: `PT_LOAD` contents with **no** section header
table. They are therefore recorded as **reference material of uncertain
provenance**, not as "the dump this pass succeeded in taking" — and they were not
used for any conclusion above.
## 6. Conclusions, graded
| Claim | Label |
|---|---|
| Root can read `/proc/<pid>/maps` and `/proc/<pid>/mem` on this device with no `avc` denial and no instrumenter present | observed |
| 17 ART dex mappings were exported from a live hardened process and, after trimming to header `file_size`, all 17 parsed as valid dex | observed |
| The dex mapping sourced from the app's `base.apk` defines 4 classes on 8.9 MB (landing-shell skeleton); the other 16 are real SDK dexes, `stub%` 0.9-3.4 | observed |
| The sample self-exits every 7-18 s with no frida present (`has died: fg TOP`, no crash/tombstone) and is restarted by the platform while it is the top activity | observed |
| The earlier "stable with no instrumentation" control reading was an artefact of a 12 s window inside a 17 s lifetime | inferred |
| The packer's real payload never appeared as a loaded ART dex during 22 s of sampling (`appBase` constant at 1) | observed (single sample) |
| Anonymous `rw-p` memory (143 regions / 40.7 MB) and the first 192 MB of the ART heap contain no dex magic | observed |
| A full 1 GB heap sweep is impractical at the measured ~10 MB/s against a 7-18 s lifetime | observed (throughput), inferred (planning consequence) |
| `map_files` is unusable on this ROM; hex parsing in mksh is 32-bit; toybox `grep -F` cannot match the dex magic and toybox `awk` regex filtering is unreliable | observed |
| The leftover `libapp*.so` files are unrelated to this process's memory; provenance unverified | observed (structure/size), inferred (provenance) |
## 7. What this pass did not establish
- **Not** a recovered payload. The route recovered every dex the process had loaded
— SDK plugins and the shell skeleton — but no business dex, because none was loaded
while the process was observed. The Flutter payload itself (`libapp.so`, Dart AOT)
is a separate target and is not reached by a dex-oriented dump.
- **Not** an evasion. The sample's self-exit cadence was *not* fought: no attempt was
made to defeat the environment check, per the task's two-strike discipline. A
longer-lived instance might load more; that is stated as **unverified** rather than
attempted.
- **Not** a general claim about 360-jiagu, Flutter or Android 11 — one device, one
ROM, one sample, one session, with a shared device in use by other experiments at
the same time.
- **Not** a memory-completeness claim: the 102 MB unnamed anonymous block that appears
once the Flutter engine is up was never captured (it was absent during the short
lifetimes observed and the process died before the chunked export finished). It is
recorded here as an uncovered region, not as a scanned-clean one.

View File

@@ -0,0 +1,337 @@
# Extension verification — split APK / App Bundle sets
Covers the pass that added split-set support to `scripts/repack.py` and wrote
`references/split-apk.md`. Strength labels as defined in this directory's README:
**observed** (exact command + output reproduced here), **inferred** (follows from observed
facts, step not executed), **unverified** (assumed or externally reported, not independently
confirmed).
Reference environment for this pass: Windows 11 host, Python 3.14.0, Java 17.0.4.1
(`keytool` NOT on PATH — it was passed explicitly as
`<jdk>/bin/keytool.exe`), `apksigner` 0.9 run from `lib/apksigner.jar`, `zipalign.exe` from the
same build-tools layout. **No `uber-apk-signer` anywhere on the machine**, which is the condition
that made the signing-route fallback a requirement rather than a nicety. Target: one rooted physical
Android 11 (API 30) arm64-v8a device, written `<DEVICE>` throughout.
All package names, application directory names and file names are replaced with placeholders
(`<PKG>`, `<SET>`, `<SET_A>`, `<SET_B>`). The measurements, byte counts, entry counts and outputs
are unchanged; only the identifiers are.
Fixtures: two **real split sets already installed on the device**, copied out read-only
(`adb pull` of the system directory; nothing on the device was uninstalled, replaced or modified).
They were chosen because one exercises each side of the merge/resign decision:
| | Members | Shape |
|---|---|---|
| Set A | 8 | base + 7 density splits, every density split carrying its own resources |
| Set B | 9 | base + 1 ABI split (native libraries only) + 7 density splits that carry a placeholder table and nothing else |
Neither set is third-party: `pm list packages -3` reported **zero** split sets among ~40 installed
third-party packages, while four preinstalled Google components were split. That distribution is
recorded because "pick a third-party split app off the device" is not reliably available.
## 1. Reading a set: what the members actually contain — observed
`pm path <PKG>` on each package returned one line per member (8 and 9 lines respectively), which is
how the targets were identified in the first place:
```
$ adb shell 'pm path <PKG>'
package:/system/priv-app/<DIR>/<SET>.apk
package:/system/priv-app/<DIR>/<SET>-arm64_v8a.apk
package:/system/priv-app/<DIR>/<SET>-hdpi.apk
... (one line per member)
```
`scripts/repack.py --split-mode analyze` over set A (identifiers replaced):
```
== split set: 8 apk(s), 6629510 bytes total
density <SET_A>-hdpi.apk 49623 B split=config.hdpi res=39 entries arsc=9392
density <SET_A>-ldpi.apk 45501 B split=config.ldpi res=39 entries arsc=9392
density <SET_A>-mdpi.apk 45501 B split=config.mdpi res=39 entries arsc=9392
density <SET_A>-tvdpi.apk 74227 B split=config.tvdpi res=78 entries arsc=15124
density <SET_A>-xhdpi.apk 53754 B split=config.xhdpi res=39 entries arsc=9432
density <SET_A>-xxhdpi.apk 57878 B split=config.xxhdpi res=39 entries arsc=10324
density <SET_A>-xxxhdpi.apk 57876 B split=config.xxxhdpi res=39 entries arsc=11200
base <SET_A>.apk 6245150 B split=- dex=classes.dex res=581 entries arsc=1924488 assets=1
[plan] base = <SET_A>.apk (package=<PKG>)
[plan] MERGE BLOCKED: merging is not legal for 7 of 7 splits: config.hdpi, config.ldpi, config.mdpi,
config.tvdpi, config.xhdpi, config.xxhdpi, config.xxxhdpi carry their own resources, and folding those in
means merging resources.arsc (a resource-compiler job). Use --split-mode resign, or pass
--drop-split-resources to accept losing exactly those resources
[plan] resign = legal: one keystore for all 8 members, install the set with
[result] analyze only: nothing was written. Pass --split-mode merge|resign|auto to act on this set.
```
And over set B:
```
== split set: 9 apk(s), 1411272 bytes total
abi <SET_B>-arm64_v8a.apk 255861 B split=config.arm64_v8a libs=arm64-v8a
density <SET_B>-hdpi.apk 16797 B split=config.hdpi arsc=40
... (6 more density members, all 16797 B, all arsc=40)
base <SET_B>.apk 1037832 B split=- dex=classes.dex res=2 entries arsc=71316 assets=1
[plan] base = <SET_B>.apk (package=<PKG>)
[plan] note: every non-base split carries only code/native libraries, so a merged single APK is legal
[plan] note: config.arm64_v8a provides libs for arm64-v8a -- keep only the ABIs the target device runs,
selected with --abi
[plan] merge = legal: 8 member(s) fold into the base
```
Four facts this pass established with those numbers, and that the reference file now depends on:
- **The ABI member carries no `resources.arsc` at all** (set B: `libs=arm64-v8a`, no arsc column).
Its two `.so` files are the whole payload. This is the member that folds cleanly.
- **Each density member carries its own table and its own `res/` entries** (set A: 39-78 entries,
9.0-15.1 KB tables). `resources.arsc` is per member, not per set — the base's 1.88 MB table does
not contain the density variants.
- **A `resources.arsc` can be a 40-byte placeholder.** All seven density members of set B carry
`arsc=40` and zero `res/` entries, while still being legally required to carry a table. A
"has an arsc → owns resources" test would have blocked a merge that is entirely legal here, which
is why the script's test is *`res/` entries or a table over 1 KB*.
- **Base and split share one package name**, read from the binary AXML `package` attribute of every
member, and the base is identifiable as the member whose manifest carries **no `split` attribute**
(set A: `split=-`).
## 2. Unified re-signing — observed
`--split-mode resign` over set A with one keystore, run through the new `apksigner` route:
```
$ python skills/apk-reverse/scripts/repack.py --split-dir <SET_A>/ --split-mode resign \
--split-out-dir signed_set/ --ks key.jks \
--keytool <jdk>/bin/keytool.exe --apksigner <build-tools>/lib/apksigner.jar \
--zipalign <build-tools>/zipalign.exe
[resign] signer route: apksigner
[resign] <SET_A>-hdpi.apk -> signed_set/<SET_A>-hdpi.apk (41298 bytes)
[resign] <SET_A>-ldpi.apk -> signed_set/<SET_A>-ldpi.apk (37202 bytes)
[resign] <SET_A>-mdpi.apk -> signed_set/<SET_A>-mdpi.apk (37202 bytes)
[resign] <SET_A>-tvdpi.apk -> signed_set/<SET_A>-tvdpi.apk (61642 bytes)
[resign] <SET_A>-xhdpi.apk -> signed_set/<SET_A>-xhdpi.apk (41337 bytes)
[resign] <SET_A>-xxhdpi.apk -> signed_set/<SET_A>-xxhdpi.apk (49563 bytes)
[resign] <SET_A>-xxxhdpi.apk -> signed_set/<SET_A>-xxxhdpi.apk (49577 bytes)
[resign] <SET_A>.apk -> signed_set/<SET_A>.apk (4172089 bytes)
[result] OK: 8 members, one certificate (sha256 1eb31d9c…2c44)
```
Every member's independent `apksigner verify --print-certs --verbose --min-sdk-version 21
--max-sdk-version 34` returned `Verifies` with:
```
Verified using v1 scheme (JAR signing): true
Verified using v2 scheme (APK Signature Scheme v2): true
Verified using v3 scheme (APK Signature Scheme v3): true
Signer #1 certificate DN: CN=apkreverse, OU=dev, O=dev, L=NA, ST=NA, C=NA
Signer #1 certificate SHA-256 digest: 1eb31d9c9c81104367a66a0201125aa91d28a6a09eb1dcb5029c36bc0bde2c44
```
All eight printed the **same** SHA-256 — that, not per-file `Verifies`, is the acceptance
criterion for a set, and it is the check the run performs explicitly.
**The signing-route fallback is the load-bearing part of this section.** With no
`uber-apk-signer` on the machine, the pre-change script could not sign anything at all: it exited
with `signer jar not found`, naming a jar the user never had. The new `--signer auto` resolves to
`zipalign -p -f 4` + `apksigner sign --v1 --v2 --v3`, which produced the output above. `--apksigner`
accepts `apksigner.bat` (verified: `rc=0`, `--version` → `0.9`), a `lib/apksigner.jar` value run
through `java -jar` (verified: same), and the extensionless shell wrapper (which is a shell script
and does not run on Windows — that is a fact about the wrapper, not about this script).
### The signing route this pass actually used — and why it had to change
`uber-apk-signer` is `repack.py`'s default signer, and it is **not installed on this machine**
(no `uber-apk-signer.jar`, `APK_SIGNER_JAR` unset). Before this change that made every signed build
impossible here: the script exited with `signer jar not found` and named a jar the user never had.
What actually ran in all four measured routes:
| Item | Value used |
|---|---|
| signer | `zipalign.exe` + `apksigner`, the latter run from `lib/apksigner.jar` through `java -jar` |
| `--apksigner` | `<build-tools>/lib/apksigner.jar`. A `.bat` value was also tested and works (`rc=0`, `--version` → `0.9`); the extensionless wrapper in the same layout is a shell script and does not run on Windows |
| `--zipalign` | `<build-tools>/zipalign.exe` |
| `--keytool` | `<jdk>/bin/keytool.exe` — **not on PATH**, and signing aborts without a way to create the keystore, so this flag is mandatory on such a machine |
| scheme flags | `--v1-signing-enabled true --v2-signing-enabled true --v3-signing-enabled true` |
Alignment is handled **per member, and in this order**:
1. the script writes the archive itself, keeping `AndroidManifest.xml` and `resources.arsc` STORED
and 4-byte-aligning `resources.arsc` plus uncompressed `lib/**`, then re-reads the file and
prints the gate (`== alignment gate: resources.arsc STORED and 4-byte aligned (OK)`);
2. `zipalign -p -f 4` runs over that output (`-p` adds the page alignment the loader wants for
`extractNativeLibs="false"` libraries);
3. `apksigner sign` runs **last** — it adds signature entries without reshuffling the archive,
whereas a `jarsigner`-style rewrite after aligning destroys the alignment and the install is then
refused with `[-124]`.
For a split set this is repeated for every member, which is why `resign_split_set()` de-signs and
re-aligns each member individually before signing it: a single member whose `resources.arsc` is
compressed or unaligned is refused by the installer on its own, and the resulting error names that
member's package rather than the set.
## 3. Merging — observed, including the refusal
Set B, where every non-base member is code or native libraries:
```
$ python skills/apk-reverse/scripts/repack.py --split-dir <SET_B>/ --split-mode merge \
--out merged.apk --abi arm64-v8a --ks key.jks --apksigner <...>/lib/apksigner.jar
[merge] + lib/arm64-v8a/lib<name>_compat_libc++.so (18944 bytes <- <SET_B>-arm64_v8a.apk)
[merge] + lib/arm64-v8a/lib<name>utilsjni.so (206680 bytes <- <SET_B>-arm64_v8a.apk)
[merge] folded: dex=0 libs=2 assets=0 conflicts=0
[merge] DROPPED 7 entry/ies. This is a DOWNGRADED build -- say so when you deliver it:
[zip] classes.dex 872160 bytes method=8 crc=<...>
[zip] resources.arsc STORED offset=0x581fc aligned
== alignment gate: resources.arsc STORED and 4-byte aligned (OK)
[sign:zipalign] rc=0
[sign:apksigner] rc=0
[out] merged.apk (603824 bytes)
[verify:apksigner] rc=0 Verifies / v1 true / v2 true / v3 true
[result] OK
```
`dex=0` is correct rather than suspicious: the base owns `classes.dex` and no member of this set
carries a second one, so no renumbering was needed. The `libs=2` fold is the whole point — a
one-file APK that now carries the native libraries the ABI member used to provide separately.
Set A, where merging is **not** legal, refuses rather than producing a broken artifact:
```
$ python skills/apk-reverse/scripts/repack.py --split-dir <SET_A>/ --split-mode merge \
--out merged.apk --no-sign
error: refusing to merge <SET_A>-hdpi.apk -- it carries its own resources (39 res entries,
arsc=9392 bytes).
Folding it in would require merging resources.arsc, which this script does not do.
Either use --split-mode resign (recommended for a resource split), or pass
--drop-split-resources to accept that the resources it owns are lost.
exit code 1
```
This is the enforcement the reference file promises: the refusal happens **before** any output is
written (the `--out` path does not exist afterwards), and the escape hatch is explicit, printed, and
required. The mechanism is **observed**; whether a build produced through that hatch merely loses
its density variants or misrenders is **inferred** — it was not exercised, because the only sets
available were a legal-merge set and a refused set, and manufacturing the third case would have
meant hand-editing a resource table.
## 4. Single-APK behaviour is unchanged — observed
Both pre-existing paths were re-run through the same new signing route, on an unmodified sample
APK from `tools/_work/bench/apks/`:
- **zero-change roundtrip** (`--apk … --out … --ks …`, no `--dexdir`): the run dropped exactly the
three original signature artifacts (`META-INF/CERT.RSA`, `META-INF/CERT.SF`, `META-INF/MANIFEST.MF`),
wrote a 60964-byte unsigned archive, signed to 70647 bytes, reported `== alignment gate:
resources.arsc STORED and 4-byte aligned (OK)` and `[result] OK` with v1/v2/v3 all `true`;
- **explicit dex replacement** (`--dexdir` supplying the same `classes.dex`, 5528 bytes):
`[repl] classes.dex<-…/classes.dex`, the entry re-added at the same size and CRC, alignment gate
OK, `[result] OK`.
Both were run with `--no-sign` as well, which exits 0 and reports `UNSIGNED (--no-sign)`. `--help`
works, exits 0, and carries the split examples and the route-decision text; `--split-mode analyze`
exits 0; a merge refusal exits 1; a failed verification would exit 2.
The output fix worth recording: `AndroidManifest.xml` used to be printed as `NOT ALIGNED` in the zip
layout (it only has to be STORED, unlike `resources.arsc`). It now prints `-` for entries that need
no alignment, so the report cannot be misread as a failing gate.
## 5. Install and launch on the device — **unverified**
**Not executed.** The device was held by a concurrent device experiment for the whole window
available to this pass (`python tools/_work/devlock.py status` reported the lock held by another
teammate for 1417 s, note naming its own install/launch and Frida work), and the lead's decision was
to ship this gap labelled rather than wait for it. Heavy device work must be serialised
(`tools/_work/ENV.md`), so the honest label for everything below is **unverified** — a claim about
installability that was never installed is exactly the failure `references/verification.md` exists
to prevent.
Prepared, and not run — the sequence is the one in `references/split-apk.md` §4, against the
re-signed renamed fixture:
```
adb install-multiple -r <signed_set>/*.apk
adb shell cmd package resolve-activity --brief <PKG>
adb shell am start -n <PKG>/<launcher-activity>
adb shell 'dumpsys activity activities | grep -m2 ResumedActivity'
adb exec-out screencap -p > launch.png
```
What the pass *did* establish without a device, and what each fact does not cover:
- **The artifacts are installable in principle, not in fact.** All eight members of the re-signed set
verify with v1/v2/v3 `true` and one identical SHA-256 (§2), and the merged single APK verifies the
same way (§3, §4). Uniformity of the certificate and a passing `apksigner verify` are necessary
conditions for a set install; the platform installer accepting the set is the step that was not run.
- **The negative control was not run either.** The signature-conflict text quoted in
`references/split-apk.md` §6 (a pulled set reinstalled under its own package name) is the
platform's documented shape and the shape a competing tool reports; no captured output from
`<DEVICE>` backs it.
- Every statement about **what the archives contain** — split roles, arsc ownership, the 40-byte
placeholder tables, the fold set, the refusal — is **observed** and device-independent.
- Every statement about **what the package manager does with them** is **inferred**.
**What closing it needs:** one device window that is not shared, then the sequence above (about three
minutes of device time). The two things most likely to show up there, and already accounted for in
the script: an OEM installer interception on `adb install-multiple` (there is a root
`pm install-multiple` path for it) and a launcher activity that needs resolving rather than guessing.
## 6. Maintenance gate — observed
Run at the repository root with the change in place:
```
$ python check_repo.py
== result: 1 problem(s)
- skills/apk-reverse/scripts/dex_strpatch.py --help crashed:
$ python check_refs.py
== section references: 242 reachable ==
== result: 0 dangling, 0 warning(s)
```
`check_refs.py` is clean, which covers the three `references/repack-and-sign.md` §-references the new
file adds, and `check_repo.py` reports one problem in a **different** file belonging to a different
task; `repack.py` parses and answers `--help` with exit 0.
`check_repo.py` requires every `references/*.md` to be named in `SKILL.md` or the root `README.md`,
and this task was not permitted to edit either, so the reachability step was pre-checked in a **copy**
of the repository (`tools/_work/repo-check/`, workbench only, never in the tree) by running the gate
twice on one snapshot with the index lines as the only variable:
```
without the index lines: == result: 5 problem(s) ... UNLISTED references/split-apk.md
with the index lines: == result: 4 problem(s) ... (the UNLISTED line is gone)
```
The new file therefore contributes nothing to the gate once listed. The listing itself was added to
`README.md` by the lead, and the run quoted above is the result.
## 7. Deliberately not done — and what stays unverified
- **No `.aab` was processed.** A bundle input would need `bundletool` (absent here, and it is the
tool that owns the correct resource merge). This pass only ever handled maps of an *installed* set.
- **No fixture was hand-built with `aapt2`/`d8`.** Those became available on this machine only after
the fixtures were chosen (a build-tools archive was unpacked late in the round), and the two real
sets already exercised both branches of the decision. Building a minimal split set by hand remains
the cheapest way to reproduce this topic on a machine with no installed bundle and no device, and
the route is **inferred** — it was not attempted here. It would not change the resource-merge
boundary either: `aapt2 link` merges resources when it *builds* an APK from sources, not when it is
handed two finished ones.
- **No resource merge was implemented or attempted**, by design
(`references/split-apk.md` §5). The consequence is that a bundle whose resources live in splits
cannot be reduced to one faithful APK by anything in this repository.
- **`apktool` was not exercised, because it is not usable on this machine.** Measured:
`apktool.bat d …` fails with `Error: Unable to access jarfile <missing-path>`, while
`lib/apksigner.jar` and `zipalign.exe` in the same layout work. Any manifest edit that needs
`apktool` is therefore **unverified** here, and AXML attributes are read-only in this pass (the
script parses the string pool and the root element's attributes; nothing writes AXML).
- **Feature splits (`isFeatureSplit`) were not measured.** Neither available set has one. The
handling of `isFeatureSplit` / `configForSplit` in the reference file is **inferred** from the
platform's documented semantics plus the fact that both attributes are readable through the same
AXML reader that was verified against real manifests.
- **Split APKs that are not App-Bundle-derived** (the older per-density `-hdpi.apk` naming style,
or an OEM's own module set) were not available; both sets here use the standard `config.<qualifier>`
split names, and the reader accepts either because it reads the manifest attribute rather than the
file name.
- **A XAPK/APKS archive** (a store bundle of bundles, usually a zip holding the split set plus
`manifest.json`) was not processed. The script's `--split-dir` recursion handles a *directory* of
APKs, so an unpacked XAPK works; the archive itself is not read.

View File

@@ -0,0 +1,89 @@
# Stalker `exclude` — what it fixes, and what it does not
**Question.** Community practice says a followed thread that runs through `libc`/`libart` on arm64
gives deadlocks, watchdog `SIGABRT`s and zero-event traces, and that `Stalker.exclude()` is the
remedy. This pass measured which of those two claims survives contact with a device.
**Strength summary:** the *crash* half is `observed` with a clean control; the *zero-event* half is
`observed` as a **negative** — exclusion did not change it. Every command and its output are below.
## Design
Three arms, one device, one package, one module, one follow window. Exactly one variable moves
between `baseline` → `control` → `treatment`.
| Arm | What it does | Why it exists |
|---|---|---|
| `baseline` | spawn, attach, inject the script, **resume, and never follow** | Without this arm a dead process cannot be attributed: "following killed it" and "the app dies under frida anyway" look identical |
| `control` | follow the main thread for 6 s with `excludeModules: []` | The shape that produced the earlier zero-event / `SIGSEGV` results |
| `treatment` | same follow, with the script's built-in list of 20 system modules excluded | The proposed remedy |
The harness also neutralises one trap in the script itself: `CONFIG.autoStart` defaults to `true`, so
loading `stalker_trace.js` starts a follow with *default* options before a runtime configuration can
land. Left alone, that run is neither the control nor the treatment and contaminates both. The
harness patches `autoStart` to `false` in the injected source and asserts the patch count.
For the same reason the harness resumes explicitly: `frida` spawns a process **suspended**, so
without `device.resume(pid)` the followed thread executes nothing and both arms report a zero-event
trace *manufactured by the harness*. That failure mode is indistinguishable from the one under
investigation, which is exactly why it has to be engineered out before the measurement means
anything.
## Result
```
baseline alive=True (no follow installed)
control alive=False err=script has been destroyed
EXCL|excluded=0/0 []
treatment alive=True DONE|reason=timeout blocks=0 blk=0 calls=0 truncated=0
EXCL|excluded=20/20 [libm.so,libdl.so,libart.so,libartbase.so,libnativehelper.so,…] not-loaded=3
WARN|zero events 1500ms after follow (blocks=0 blk=0 calls=0) -- the pipeline is NOT proven
```
## What this settles
1. **A follow with no exclusion killed the target process, and the baseline arm rules out the
alternatives.** The process survives an attach plus resume with no follow, and dies only when the
follow is installed. That is `observed`, not inferred.
2. **Exclusion kept the process alive.** The treatment arm reached its normal `DONE|reason=timeout`
with the process still running. Excluding the system libraries from a follow is not a style
preference.
3. **Exclusion did not restore event delivery.** The treatment arm still reported
`blocks=0 blk=0 calls=0` after a full 6-second window. The zero-event trace is therefore a
**separate defect from the load problem**, and `Stalker.exclude()` must not be presented as its
fix. This partially contradicts the community framing, which lists "no events" among the symptoms
exclusion cures.
4. **The zero-event warning added to `stalker_trace.js` this pass fires correctly** — `treatment`
emitted `WARN|zero events 1500ms after follow`, while `control` could not, because its process was
already gone. A dead pipeline now announces itself instead of being read as "the code did not run".
## What this does not settle
- **Why events do not arrive.** This measurement says it is not the exclusion list. It does not
identify the cause, and it does not distinguish "no events" from "the followed thread genuinely ran
nothing in that window" — the per-arm control that would separate those (following a thread
executing a known loop) was not run.
- **Whether the crash is reachable without a follow.** Only one package and one module were used, and
the harness kills the process at the end of each arm, so nothing is claimed about a long-lived follow.
- **Anything about other ROMs, other frida versions, or armv7.** Single-device result.
- **The multiplier.** Nothing here measures the 20-50x cost figure; this is about survival and
delivery, not throughput.
## Environment
`<DEVICE>` (Android 11 / API 30 / arm64-v8a, Magisk root), frida host 16.7.19, on-device
frida-server 16.7.19 reached over `adb forward tcp:27099`. Target: the MASTG `UnCrackable-Level1`
sample, package `<PKG>`, module `libc.so` followed on the main thread for 6 s per arm. Device
`load average` was ~30 throughout — high for an 8-core phone, and worth recording because the
earlier incident in `native-dbi-and-deobfuscation.md` §6 happened on a loaded device.
## Reproducing
```
python tools/_work/b6/stalker_ab.py --package <PKG> --module libc.so \
--mode baseline|control|treatment --seconds 6 --device 127.0.0.1:27099 \
--out tools/_work/b6/r_<mode>.json
```
The driver is a work artefact under `tools/_work/` (git-ignored), not part of the skill. Its three
arms and the two harness traps it neutralises are the reusable part.

View File

@@ -0,0 +1,361 @@
# EXTENSION — executable test harness, CI, and the exit-code work order
This file records what the regression harness in `tests/` actually does, what it measured on
this machine, and what it does **not** cover. Strength labels follow the repository rule in
`references/long-task-discipline.md`: `observed` means an exact command and its output are
below, `inferred` means reasoned from a neighbouring measurement, `unverified` means nobody
has put a command behind it here.
The distinction this file exists to keep honest: `tests/benchmark.md` is a **record** of routes
run once by hand against real samples and a real device. `tests/` is a **gate** that runs on
every commit with the standard library and pytest and nothing else. Neither replaces the
other, and a green suite is not evidence that any route works on a hardened target.
---
## 1. How to run it, and what it printed
```
$ python -m pytest -q
596 passed, 153 skipped, 26 xfailed in 49.67s observed
$ python -m pytest -q -m unit
163 passed, 612 deselected in 4.50s observed
$ python -m pytest -q -m cli
342 passed, 153 skipped, 255 deselected, 25 xfailed in 38.26s observed
$ python -m pytest -q -m integration
65 passed, 709 deselected, 1 xfailed in 1.33s observed
$ python tests/run_tests.py -q -m unit
163 passed, 612 deselected in 5.08s observed
```
`tests/run_tests.py` is the one-command entry point. It refuses to fail obscurely when pytest
is absent — measured by putting a `pytest.py` that raises `ImportError` first on
`PYTHONPATH`:
```
$ python tests/run_tests.py
pytest is not installed; this suite cannot run.
install: python -m pip install pytest
the suite is stdlib-only otherwise (no device, no network, no adb)
exit=3 observed
```
Exit 3 is the repository's "environment capability insufficient" code, so a wrapper can tell
"no pytest here" apart from "tests failed". The suite is stdlib-only apart from pytest itself.
CI targets Python 3.11 and 3.13; this machine measured on 3.14.0 with pytest 8.4.1.
---
## 2. Coverage
| Area | What runs | Strength |
|---|---|---|
| dex format table | `insn_units` asserted opcode by opcode against 29 named traps (payload widths, `0x32`-`0x3D` being two units, `const-string` vs `/jumbo`, the `goto` family at `0x28`-`0x2A`); `OP_UNITS` and the accessor must agree for every listed opcode; unallocated opcodes still terminate | observed |
| dex header | magic/`file_size`/table-pointer checks against a synthesised consistent header; the verified offset table asserted by value; signature-before-checksum order, including the failure mode where a checksum taken over a zeroed signature never verifies | observed |
| branch arithmetic | `if-test`/`if-testz`/`goto`/`goto16`/`goto32` targets and register extraction, on a synthetic buffer | observed |
| the sample regression | `dex_patch_bytes.py` + `l1_root.spec.json` → sha256 `67ae1e58…` and byte-identical to `classes.patched.dex`; the diff window is pinned (see §6) | observed |
| scheme-less protobuf | built-in fixture set 26/26, per-fixture expectations, decode→re-encode byte-identical for every canonical fixture, the non-canonical fixture required to *report* its mismatch, both wire-format traps | observed |
| blob rotation search | outer encodings round-trip, raw/zlib/gzip identified by name, the rotation and prefix (`skip`) parameters recovered from a payload built with known parameters | observed |
| leak scanner | synthesised decoys in a temporary directory are reported with the right rules and strengths; the exemption list (`com.android.*`, `com.google.*`, `org.example.*`, `<pkg>`, loopback) produces no finding; an RFC 5737 address stays `weak` and keeps the default gate green while `--fail-on any` fails it; JSON report keys and the last-line `RESULT=` token | observed |
| CLI contract | every shipped script, four invocation shapes where they apply: `--help`, unknown flag, no arguments, all inputs pointed at a missing file | observed |
| adb argv construction | `devsh.py`, `coldstart.Device`, `install_test.adb` build an **argv list**; `\|`, `>`, `$()` and quotes survive as literal text inside one argument; serial injection as `-s`; env-var precedence | observed |
| screenshot validation | `coldstart.Device.screencap` judges the bytes on disk: PNG magic accepted, non-PNG rejected, a sub-8-byte body rejected, a timeout reported as a failed capture | observed |
| launch-health verdict | `install_test.main` driven with a scripted `pidof`: stable pid + no fatal signature is the only pass; a changed pid, a dead app, or a stable pid with a `FATAL EXCEPTION` line all fail; two samples are actually taken | observed |
| log/tombstone parsing | `coldstart.signal_counts` over a logcat fixture (9 patterns, exact counts); `native_crash.parse` signal/code/pid/uptime/frames/registers; `is_system_lib` classification; the `verdict_hint` for an arranged fault | observed |
---
## 3. Not covered — the honest gaps
### 3.1 Device work: excluded by design, because hardware is present
This harness must not touch a device, and after one measured incident that is now enforced
rather than assumed. It has to be: `adb devices` on the machine this was written on reports an
online device, so a naive "run every script with its arguments filled in" test drives real
hardware.
```
$ adb devices
<DEVICE> device observed
```
**What happened.** An early version of the CLI contract test executed `coldstart.py` with its
requirements filled in. The script found the single online device, adopted it, started
`adb logcat -v threadtime`, wrote a 927,893-byte `logcat.txt`, captured four frames, and left
**five orphaned `adb logcat` processes** behind when the 12 s timeout killed the parent. The
whole mess landed in `skills/apk-reverse/scripts/`, which is another owner's write scope.
**What was cleaned up.** The five orphaned `adb logcat` processes were terminated, and the
directory the script created (`skills/apk-reverse/scripts/coldstart/`, containing `logcat.txt`
and `shots/f0*.png`) was removed. Verified: `git status` on `skills/apk-reverse/scripts` no
longer lists it.
**What changed in the design.**
- Every child process now runs in a private temporary working directory, so a script that
defaults an output path relative to cwd cannot write into the shipped tree. This is the
general fix; the incident was one instance of the class.
- `contract.DEVICE_OR_NETWORK_BOUND` — 15 scripts — is never executed with complete arguments.
`--help` and an unknown flag are still exercised for all of them, because argparse answers
both before any device call is reached, and that was verified for every script individually:
`--help` returned in well under a second and an unknown flag returned 2 for each.
**Residual risk, stated plainly.** This is a blocklist, not a sandbox. A script not on the
list that grows device access would be executed. The list is checked against the tree on every
run (a script that appears without being classified becomes a new pass or a new xfail rather
than a silent gap), but it is maintained by hand.
Fifteen scripts are device- or network-bound and their argument-complete behaviour is
`unverified` here. What that means concretely: this harness cannot tell you that a repack
installs, that an anti-debug patch survives, that a hook fires, or that a tracer reports
blocks. Those live in `tests/benchmark.md`, and the `unverified` marks there are unchanged by
anything in this directory.
### 3.2 CI has never executed: `unverified`
`.github/workflows/ci.yml` exists and is syntactically valid — verified locally:
```
$ python -c "import yaml,io; yaml.safe_load(io.open('.github/workflows/ci.yml',encoding='utf-8'))"
ci.yml: valid YAML observed
```
and every command it runs was executed by hand on this machine:
```
$ python check_refs.py exit 0 observed
$ python check_budget.py exit 0 ("0 failure(s), 0 warning(s), 3 note(s)") observed
$ python check_repo.py exit 0 (with PYTHONUTF8=1; see §5.1) observed
$ python -m pytest -q exit 0 ("596 passed, 153 skipped, 26 xfailed") observed
```
It is still `unverified` as a workflow, and the blocker is specific: there is no GitHub Actions
runner reachable from this machine, the workflow has not been pushed (this work item is
forbidden from committing), and no local runner (`act`) is installed. What cannot be claimed
until the first push: that `actions/setup-python` resolves both matrix versions, that the gates
behave identically under a Linux UTF-8 locale, and that the suite's timing stays inside any
runner limit. The last two matter because the gates behave *differently* on this Windows host —
see §5.1 — and because the CLI suite spawns roughly 200 subprocesses.
A remote does exist (`origin` → `github.com/newliver666/apk-reverse`), so the workflow will run
on the first push; nobody has observed it do so.
### 3.3 Agent behaviour: `unverified`, and deliberately out of scope
`skills/apk-reverse/evals/evals.json` is a skeleton: three cases with expected outputs and
grading assertions, and an explicit statement **in the file** that nothing in it has ever been
executed. No with-skill arm and no without-skill arm has been run. Running them means running
the agent twice per case and grading transcripts, which was excluded from this pass by decision
(`REBUILD-BRIEF.md` §2, the do-not-do list). The file carries its own run procedure.
### 3.4 The 153 skips, by category
| Count | Reason | Reading |
|---|---|---|
| 60 | device/network bound, not executed | 15 scripts × 4 scenarios. Deliberate; see §3.1. |
| 40 | requires no argument, so the contract does not apply | 10 scripts × 4 scenarios. Requiring exit 2 from `doctor.py` or `scan_leaks.py` with no arguments would invent a rule the contract does not state. |
| 34 | does not advertise `--json` | the 34 of 46 scripts with no `--json` flag. That is the §1.6 problem, not a harness gap. |
| 11 | `--json` advertised but the path needs a device or a sample | includes `capabilities.py`, added during this pass. The shape is not asserted because the run that would reach it needs a target. |
| 8 | hand-written usage, argv not derivable | `dexutil.py` and `smtool.py`, 4 scenarios each. Their usage line is prose, so the missing-input argv is not machine-derivable. `devsh.py` and `patch_smali.py` share the trait but are already covered by row 1. |
Total 153, which is why the headline is "passed, skipped, xfailed" and not "passed".
---
## 4. The exit-code work order — 26 xfails
These are not flaky tests. Each is a script on the tree that does not meet the repository
contract in `REBUILD-BRIEF.md` §4 (`0` success, `1` target result failed, `2` bad
arguments/input, `3` environment capability missing, `4` internal error). The xfail reason
carries the measured value, so `pytest -q -ra` prints the work order at any time.
**A. `--help` does not exit 0 — 4 scripts.** A hand-written parser rather than argparse, so
`--help` falls through to "unknown argument". None of them prints a traceback, so this is a
contract gap and not a crash.
`devsh.py`, `dexutil.py`, `patch_smali.py`, `smtool.py` — measured `2`, expected `0`.
**B. A missing input file exits 1 instead of 2 — 13 scripts.** The distinction matters to a
calling agent: 1 means "your target failed", 2 means "your invocation was wrong". Reporting a
typo in a path as a target failure sends the next agent to debug the sample.
`apk_diff.py`, `dart_disasm.py`, `dart_pool_strings.py`, `dex_classdiff.py`,
`dex_dump_validate.py`, `dex_find_insn.py`, `dex_mem_scan.py`, `dex_strpatch.py`,
`elf_plt.py`, `native_crash.py`, `patch_smali.py`, `stalker_report.py`, `svc_scan.py` —
measured `1`, expected `2`.
**C. An uncaught exception instead of a clean report — 7 scripts.** The user-visible
difference: a traceback instead of a sentence.
`dart_pool_strings.py`, `dex_classdiff.py`, `dex_find_insn.py`, `dex_mem_scan.py`,
`dex_strpatch.py`, `patch_smali.py`, `stalker_report.py` — all `FileNotFoundError` (on Windows,
`dex_mem_scan.py` reports `WinError 3`).
**D. `--json` without a `status` field — 1 script.**
`preflight.py --json` returns top-level keys `rows` and `worst`, with no `status`. Per §4 the
document must carry `status` (and ideally `exit_code`, `capability`, `evidence`, `warnings`,
`next_action`). For contrast, `doctor.py --json` was measured during this pass and *does* carry
`status`, and now exits 3 rather than 0 when a capability is missing — the unified convention
is arriving script by script.
**E. `su` quoting — 1 item, outside the exit-code list.** `devsh.su` wraps in double quotes and
escapes only `"`, so `;`, `$()`, backticks and `|` from a `--pkg`-style value still reach the
device shell. `coldstart.Device.shell` uses the POSIX single-quote escape and does neutralise
them. Two helpers, two strategies — the `device_shell()` unification item. The contrast is
asserted in `tests/integration/test_adb_command_construction.py::TestDevshSuQuotingGap`.
Nothing in this list was fixed by this work item: every one of those scripts belongs to another
owner, and a test that quietly repaired its subject would stop being a test.
---
## 5. Defects found and registered, not fixed
### 5.1 `check_repo.py` misreports a script as crashing under a non-UTF-8 console (`observed`)
```
$ python check_repo.py
- E:\apk-reverse\skills\apk-reverse\scripts\dex_strpatch.py --help crashed:
== result: 1 problem(s) ==
exit=1
```
with, above it, `UnicodeDecodeError: 'gbk' codec can't decode byte 0x94 in position 63` raised
inside `subprocess`'s reader thread. The gate spawns each script with `text=True` and no
explicit encoding, so on this host the child's output is decoded with GBK. One script's
`--help` text is not GBK-decodable, and the gate attributes the failure to that script. The
script itself is fine:
```
$ python skills/apk-reverse/scripts/dex_strpatch.py --help ; echo $?
0 observed
```
Pinning the codec fixes it:
```
$ PYTHONUTF8=1 python check_repo.py
== result: 0 problem(s) ==
exit=0 observed
```
`PYTHONUTF8: "1"` is therefore set in `ci.yml`. Two caveats: CI runs on Linux, where the default
codec is already UTF-8, so this is a portability fix for anyone running the gates on Windows
rather than a fix for the CI run; and `check_repo.py` is in the Lead's write scope, so the
underlying `text=True` call is not changed here.
### 5.2 `native_crash.py` overwrites `regs['pc']` with a backtrace frame (`observed`)
Register extraction scans every line of a crash block with one permissive pattern, so a
`#NN pc <hex>` backtrace line matches too and the last one wins. On the fixture, the register
dump's own `pc 00000000007a111128cc` is not what `regs['pc']` holds; `0x55488` — the last
frame's pc — is. The verdict is unaffected (the arranged-fault hint keys off the fault address
and the registers holding it, and neither is `pc`), but a reader who trusts `regs['pc']` as the
faulting instruction pointer is misled. Pinned in
`tests/integration/test_no_device_flows.py::TestTombstoneParsing`.
### 5.3 `coldstart.installed_facts` `versionCode` swallows the rest of the line (`observed`)
The extraction is `r'\s*(versionCode|...)=(.*)'`, so on a real `dumpsys package` line —
`versionCode=42 minSdk=24 targetSdk=34` — the value is the whole remainder, not `42`. It still
works as the before/after fingerprint the caller uses it for, but the field name overstates
what it holds. Pinned in `tests/integration/test_adb_command_construction.py`.
### 5.4 The leak scanner reports one `ip:port` twice (`observed`)
`192.0.2.10:8080` satisfies both `plain_addr` (`ip:port`) and `plain_addr_ipliteral` (bare
IPv4), because the literal rule's trailing `(?![\w.])` is not violated by a colon. Both hits
are `weak`, so the default gate stays green; the effect is report volume, not a wrong verdict.
Pinned, with the reasoning, in `tests/unit/test_scan_leaks.py`.
### 5.5 `usb_net_proxy.py` does something with no arguments (`observed`)
With no arguments it neither prints usage nor exits: it ran past both a 12 s and a 20 s timeout
during probing. Every other script either requires arguments or prints its own documentation.
Not diagnosed further here — the fix belongs to whoever owns that script. It is classified as
device/network bound, so the suite does not execute it.
### 5.6 Stray artifacts in the tree that this work item did not create (`observed`)
Found while checking its own side effects, left in place because they are not this work item's
to delete and may belong to a concurrent one:
- `--help/` at the repository root, containing `planted/{package,device,appkey,endpoint,path,token}.txt`
and `exempt/benign.txt`. The names say "leak-scanner decoy set", so some scanner test wrote
its fixtures into a directory literally named `--help` — most likely an argument-parsing bug
in that test, not a scan result.
- `skills/apk-reverse/scripts/example.preferences_pb` and `skills/apk-reverse/scripts/snapshots/`
(containing `snap.png`, 3.97 MB, and `_snap_ui.xml`). Timestamps 01:17 and 01:18, unchanged
across this work item's later runs, so they are not its output.
---
## 6. The established regression marker
The red line recorded in `REBUILD-BRIEF.md` §2 is reproduced by the suite rather than
re-invented:
```
$ python skills/apk-reverse/scripts/dex_patch_bytes.py \
tools/_work/bench/l1l3/dex/classes.dex \
--spec tools/_work/bench/l1l3/l1_root.spec.json \
-o <tmp>/classes.patched.dex
== header checksum 0x7fb7d9fa -> 0x86e1d928
signature b7fafe72cb521450 -> eb632328fd1ba03c
self-verify: checksum_ok=True signature_ok=True
== wrote <tmp>/classes.patched.dex (5528 bytes, delta 0)
$ sha256sum <tmp>/classes.patched.dex
67ae1e58e7a44777e5ac8f9f823ab21e55e56c6122d2d64c3627232d902a2bba observed
```
Identical to the stored `tools/_work/bench/l1l3/dex/classes.patched.dex`, byte for byte. The
suite asserts the hash **and** where the difference is allowed to be: bytes 8..32 (checksum and
signature, recomputed) plus the four bytes at `0x9d0`. Two of those four keep their original
value — `39000e00` → `00000000` clears two bytes — and a recomputed checksum can keep a byte by
coincidence, so the assertion is containment plus "these must have moved", not equality of the
diff set. That is the honest form: an equality assertion would fail on a correct build.
The test copies the bench input into `tmp_path` before patching, so a test run cannot overwrite
the stored expectation.
`tools/_work/bench/**` is git-ignored, so on a fresh checkout and in CI this test **skips** with
the printed reason instead of turning red. A regression marker that fails because a sample is
absent is a marker people learn to ignore.
---
## 7. Files added by this work item
```
pytest.ini repository root
tests/README.md how to run it, and the ground rules
tests/run_tests.py entry point; exits 3 without pytest
tests/conftest.py fixtures, subprocess runner, isolated cwd
tests/unit/test_dexutil.py
tests/unit/test_protobuf_decode_raw.py
tests/unit/test_blob_decode.py
tests/unit/test_scan_leaks.py
tests/unit/test_dex_patch_bytes_regression.py
tests/cli/contract.py usage parsing and the contract constants
tests/cli/test_cli_contract.py
tests/integration/test_adb_command_construction.py
tests/integration/test_no_device_flows.py
tests/fixtures/tombstone_sample.txt synthetic, 1.3 KB
tests/fixtures/logcat_sample.txt synthetic, 1.0 KB
.github/workflows/ci.yml
skills/apk-reverse/evals/evals.json skeleton, never executed
```
Not touched: `skills/apk-reverse/scripts/**`, `tests/test_so_constpatch.py`,
`tests/fixtures/apk/**`, `SKILL.md`, `README.md`, the root `check_*.py` scripts. No commit, no
push. The only write this work item made inside the shipped tree was its own accident (§3.1),
and it was reverted.
Fixture budget: two text fixtures, 2.3 KB total, well under the 32 KB per-file limit. The leak
decoys are synthesised at run time and are not committed.

View File

@@ -0,0 +1,142 @@
# Extension verification — advanced unpacking (extraction shells, active invocation, VMP boundary)
Covers the 2026-09 extension pass that added `references/advanced-unpacking.md` and
`scripts/dex_dump_validate.py`. Strength labels as defined in this directory's README:
**observed** (exact command + output here), **inferred** (follows from observed facts,
step not executed), **unverified** (assumed or externally reported, not independently
confirmed).
Reference environment for this pass: Windows 11 host, Python 3.14.0, host frida
16.7.19 + frida-dexdump 2.0.1; rooted physical device, Android 11 (API 30),
arm64-v8a, with a frida-server process running (the environment snapshot recorded in
`tools/_work/t2-unpack/doctor-baseline.txt`, taken at pass time). The analyzed sample
is a 360-jiagu hardened Flutter app; its business logic lives in `libapp.so`, so the
shell dex recovered below is the outer layer of a two-layer target — recorded here
precisely because it exercises the layering that whole-dex dumping cannot cross.
---
## 1. Test fixture — observed
`tools/_work/t2-unpack/make_fixture.py` (workbench, not part of the skill) extracts
the shell `classes.dex` from the sample APK and derives every shape the validator
must distinguish:
```
01_original_shell.dex the real shipped shell dex, untouched (expected winner)
02_duplicate.dex byte copy of 01 (dedupe must group it)
03_broken_magic.dex magic bytes destroyed (must be rejected)
04_tampered_body.dex one byte flipped mid-file (checksum+signature BAD)
05_truncated.dex first 64 KB only (file_size mismatch -> rejected)
06_extracted_skeleton.dex every method body stubbed to nop-fill + return-void,
header re-fixed (extraction-shell signature)
07_tiny_garbage.dex 16 random bytes (too small -> rejected)
```
Facts **observed** about the shipped shell dex itself (file `01_original_shell.dex`,
sha256 `bcd1c0488e861ea804fcbb08f90993eb3258f5c8b2e49780aeca4102dedd5af7`, 8,923,752 B,
dex version 035):
- `class_defs_size == 4` on an 8.9 MB dex — under a packer, file size and class
count move independently: the bytes are encrypted payload, not code.
- 160 of 189 defined methods carry no `code_item` (`code_off == 0`): the shell's
class data is mostly declarations, normal for a stub image.
- `checksum ok` while `signature BAD` **on the untouched shipped image** — the
vendor's build wrote an adler32 consistent with a sha1 signature field that does
not match the file contents. Consequence recorded in `advanced-unpacking.md`:
treat checksum/signature verdicts as evidence about *who touched the image*, not
as a pass/fail gate on usability; a naive "signature must verify" filter would
have rejected the genuine article.
## 2. `scripts/dex_dump_validate.py` — observed
`--help` works (argparse). Run over the fixture directory:
```
$ python skills/apk-reverse/scripts/dex_dump_validate.py tools/_work/t2-unpack --find 'Lcom/stub/'
== dex dump validation: 7 file(s), 4 parse, 3 rejected ==
name size sha256[:12] ver cksum sig class noco code stub%
01_original_shell.dex 8923752 bcd1c0488e86 035 ok BAD 4 160 29 3.4%
02_duplicate.dex 8923752 bcd1c0488e86 035 ok BAD 4 160 29 3.4%
03_broken_magic.dex 8923752 33eee53b1317 ? REJECTED: magic=b'XXXX'
04_tampered_body.dex 8923752 b89bc8cc73d9 035 BAD BAD 4 160 29 3.4%
05_truncated.dex 65536 e6687390a663 035 REJECTED: file_size=8923752 actual=65536
06_extracted_skeleton.dex 8923752 85e56fed1d56 035 ok ok 4 160 29 100.0%
07_tiny_garbage.dex 16 be45cb2605bf ? REJECTED: too small (16 B) to carry a dex header
dedupe: 3 unique image(s) among 4 valid file(s)
bcd1c0488e861ea8 01_original_shell.dex, 02_duplicate.dex
ranking (most likely original first):
1. 01_original_shell.dex stub%=3.4 cksum=ok sig=BAD classes=4
2. 02_duplicate.dex stub%=3.4 cksum=ok sig=BAD classes=4
3. 04_tampered_body.dex stub%=3.4 cksum=BAD sig=BAD classes=4
4. 06_extracted_skeleton.dex stub%=100.0 cksum=ok sig=ok classes=4 <-- SKELETON: stub%=100, extraction-shell shape
verdict: 01_original_shell.dex is the most likely original (2 copy(ies) in this set)
exit code 0
```
Every fixture behaved exactly as designed — **observed**, and this is the acceptance
test for the script:
- the byte-identical pair grouped by sha256 (never by size);
- the three broken shapes rejected with the discriminating reason as the error string;
- the tampered body still ranked (structure intact) but demoted behind both intact
copies — checksum is ranking evidence, not a gate;
- the skeleton ranked last and flagged: `stub%` alone separated it from the genuine
image (100.0 vs 3.4) with identical class/method counts, which is exactly the
signal an extraction shell leaves in a whole-dex dump;
- `--find 'Lcom/stub/'` scored 1 string-table hit per shell image, demonstrating the
business-dex discrimination hook described in `recon.md` §Unpacking a dex-level
packer.
`--json` emits a deterministic full report (`profiles` + `ranking`, regenerated at
`tools/_work/t2-unpack/validate_report.json`, byte-consistent with the earlier run of
the same fixture). Exit code 1 only when nothing in the input parses as a dex.
The stub detector's definition (skip `0x0000` nop units; a lone `return*` remains)
matches how the fixture and how field-reported extraction shells degrade a body;
note the detector intentionally does **not** treat `const/4 + return` two-unit
bodies as stubs — a genuinely tiny method is real code.
## 3. Device-side dump of the hardened sample
The device pass (spawn + frida-dexdump against the 360-jiagu sample on the Android 11
device) was executed by the Lead in a parallel session; its artifacts and verdicts
are merged here from that pass's records rather than re-measured.
- The sample app was already installed; a frida-server process was up on the device
at pass time (**observed** in `doctor-baseline.txt`: `root 11628 ... S frida-server`).
- Dump artifacts were staged under `tools/_work/t2-unpack/` by that pass; the
validator run in §2 is the structural verdict over them. For this target the
recoverable dex layer is the shell/skeleton layer only: as recorded in the handoff
brief, the business logic sits in `libapp.so` (Flutter/Dart AOT, 16.3 MB), so no
dex — dumped or repaired — contains it. That layering is the pass's second
headline finding: a "successful" unpack of a hardened Flutter app yields the
Java/plugin layer, and the analysis boundary then moves to the Dart snapshot
(`references/dart-aot.md`), not deeper into dex repair.
## 4. FART loop, Youpk, Android 12-16 analysis, VMP recovery — inferred
None of the recovery machinery was executed in this pass: the sample's shell is a
first-generation landing shell (real bodies present, `stub%` near baseline), so
active invocation had nothing to recover. The FART three-step loop, the
`code_item` length trap details, the five Android 12-16 failure roots and their
repairs, the Youpk description, and the differential-hardening VMP recovery plan in
`references/advanced-unpacking.md` are **inferred** from public work (the FART/Youpk
releases and the 2026-08 Kanxue `thread-292312` analysis of why classic
active-invocation hooks die on modern ART). What *is* **observed** is the detection
metric those sections rely on (§2) and the fact that this Android 11 device sits in
the version band where the classic interpreter entry points still exist — the pass
did not modify ART (cost decision, recorded in the task brief).
## 5. Deliberately not done
- No ART modification / custom ROM build (Youpk route): cost exceeds the task's
value on this sample, whose dex layer is a landing shell.
- No live extraction-shell target was available, so the splice/repair half of the
FART loop (bin merge, `fix_dex_header`, jadx acceptance) has no run here; the
skeleton fixture covers the detection side only.
- No real Dex VMP sample was in scope; the VMP section stays a research plan and is
labelled as such in the reference file.

View File

@@ -0,0 +1,293 @@
# Extension verification — differential hardening for Dex-VMP
Covers the extension pass that added `references/vmp-differential-analysis.md` and
`scripts/vmp_diff_harness.py`. Strength labels as defined in this directory's README:
**observed** (exact command + output here), **inferred** (follows from observed facts,
step not executed), **unverified** (assumed or externally reported, not independently
confirmed).
Reference environment: Windows 11 host, Python 3.14.0, JDK 17.0.4.1
(`C:\Program Files\Java\jdk-17.0.4.1\bin\javac.exe`), Android build-tools 2.19 / D8
8.2.2-dev off-PATH. **No device and no network were involved in this pass** — unlike the
unpacking and rootdump passes, this one is deliberately machine-local, because the thing
worth measuring here is the comparison logic, not the platform.
**Headline: the local half of the route is measured and closes exactly; the
platform half was not exercised at all, and the cost table says why it cannot be.**
---
## 1. Environment discovery — observed, and one correction to `tools/_work/ENV.md`
`ENV.md` records `E:\tools\android-14\` as a complete build-tools directory. At pass time
it held **three** entries:
```
$ Get-ChildItem 'E:\tools\android-14\' -Name
apksigner.bat
lib
zipalign.exe
```
`aapt2.exe`, `d8.bat` and `dexdump.exe` were absent, so the `build` subcommand had no
toolchain. The complete build-tools tree was sitting unextracted in `E:\tools\bt.zip`:
```
$ (bt.zip entries) | Where-Object { $_.Name -match '^(aapt2|d8|dexdump|split-select)' }
android-14/aapt2.exe 4566296
android-14/d8.bat 3156
android-14/dexdump.exe 986392
android-14/split-select.exe 1589016
android-14/lib/d8.jar 14929869
```
After `Expand-Archive -Path 'E:\tools\bt.zip' -DestinationPath 'E:\tools\' -Force`:
```
$ E:\tools\android-14\aapt2.exe version
Android Asset Packaging Tool (aapt) 2.19-10229193
$ E:\tools\android-14\d8.bat --version
D8 8.2.2-dev (build facedf41bbd28b563d1e9e09c5f72d7c5ca598d8 from go/r8bot (luci-r8-custom-ci-focal-14-zjz7))
```
Two further tool facts worth recording, both **observed**:
- **The apktool jar the wrapper points at does not exist**, so `<tools>\bin\apktool.bat` (which is
`java -jar "<tools>\agb_apktool.jar" %*`) resolves to nothing. apktool is unavailable on this
machine, and a bare `apktool d` fails with a jar-not-found error rather than a
recognisable "tool missing" message.
- `javac` is on PATH only through the Oracle `javapath` forwarder; the real JDK is at
`C:\Program Files\Java\jdk-17.0.4.1`. The harness takes `--javac` explicitly for this
reason.
## 2. `build` — observed
```
$ python skills/apk-reverse/scripts/vmp_diff_harness.py build \
--build-tools E:\tools\android-14 --out tools/_work/t5-vmp/fixture \
--javac "C:\Program Files\Java\jdk-17.0.4.1\bin\javac.exe" --apk
javac: C:\Program Files\Java\jdk-17.0.4.1\bin\javac.exe
d8: E:\tools\android-14\d8.bat
aapt2: E:\tools\android-14\aapt2.exe
generated: ...\src\OpCoverBig.java (37196 B)
$ ... javac -g --release 11 -d ...\classes ...\OpCoverProbe.java ...\OpCoverBig.java
javac ok: 9 class files
$ E:\tools\android-14\d8.bat --min-api 26 --no-desugaring --output ...\dex <9 .class paths>
d8 ok: ...\dex\classes.dex (35028 B)
== ...\classes.dex (35028 bytes) ==
classes=9 methods_with_code=75 instructions=3065 desynced=0
opcode coverage: 218/224 (97.3%)
not emitted by this fixture (6):
0x09 move-object/16 move-object/16 (32x) needs both registers >= 256; the object
frame reaches 256 on the source side only, which d8 encodes as 0x08
0x1b const-string/jumbo needs a string_ids index > 65535, i.e. a fixture carrying
65536+ string constants
0x2a goto/32 needs a >32767-code-unit backward jump; javac refuses the method
first with 'code too large' (its own 64 KB per-method bytecode cap)
0xfd invoke-custom/range d8 emitted the 35c form for every lambda shape tried, including
a six-parameter one
0xfe const-method-handle no Java-language literal; reachable only by hand-written smali
or direct dex construction
0xff const-method-type same as 0xFE
$ E:\tools\android-14\aapt2.exe link --manifest ...\apk\AndroidManifest.xml -o ...\apk\base.apk
aapt2 ok: ...\probe.apk (20482 B, unsigned)
build rc=0
```
Structural facts behind those numbers: **0 desynced methods** (every `decode_all` walk
ended exactly on `insns_off + insns_size*2`, i.e. the format table and the emitted stream
agree), 3,065 instructions across 75 method bodies, 9 classes.
### 2a. The two toolchain traps this measured — observed
Both are encoded in the script so they are not re-derived:
- **`d8` refuses a non-existent `--output` directory.** First run:
`Error: Invalid output: ...\dex / Output must be a .zip or .jar archive or an existing
directory / Compilation failed` and rc=1. Same class of trap as `frida-dexdump -o`.
`build` creates the directory first.
- **`aapt2 link` needs `android.jar` for every `android:` attribute.** A manifest carrying
`android:versionCode` / `android:versionName` / `android:minSdkVersion` produced five
`error: attribute android:... not found` lines and rc=1. A manifest using only the
un-namespaced `package` attribute links cleanly with no `-I` flag at all — which is what
the harness ships, at the cost of an APK with no version attributes. That is acceptable
for a dex carrier and is stated in the output.
### 2b. `goto/32` is unreachable from Java — observed
Driving the generator to 33,000 loop-body statements to push the backward jump past the
20t range:
```
$ python tools/_work/t5-vmp/gen_big_probe.py 33000
generated ...\OpCoverBig.java: 34442 lines, 766468 bytes
javac rc=1
OpCoverBig.java:1427: error: code too large
public static int gotoFar(int x) {
1 error
```
The JVM's own 64 KB per-method bytecode limit fires before the dex branch-distance
threshold can be crossed. `goto/32` therefore cannot be covered by any javac-built
fixture, at any generator size — this is a structural ceiling, not a tuning problem.
## 3. Closed loop — observed, and this is the load-bearing result
`simulate` relabels every opcode through a bijection whose ground truth it writes to disk.
`compare` is then given only the two dex files and must re-derive that table.
```
$ python .../vmp_diff_harness.py simulate .../fixture/dex/classes.dex \
--out tools/_work/t5-vmp/sim.dex --seed 7 --table tools/_work/t5-vmp/truth.json
simulated: ...classes.dex -> ...sim.dex
mode=relabel, 3065 method body(ies)/instruction(s) touched (seed 7)
header recomputed: checksum_ok=True signature_ok=True
ground-truth table written to ...truth.json (compare must re-derive it)
$ python .../vmp_diff_harness.py compare .../fixture/dex/classes.dex tools/_work/t5-vmp/sim.dex
aligned=75 resized=0 stripped=0 missing=0 added=0
aligned slots compared: 3065
verdict: USABLE
(map: 218 rows, every one verdict=high)
compare rc=0
```
Scoring the derivation against the ground truth:
```
ground-truth bijection size : 256
opcodes the fixture emits : 218
compare rows : 218
compare high-confidence rows : 218
compare undetermined : 6
compare non-injective : 0
compare run verdict : usable
derived entries that are WRONG : 0 (none)
derived entries with no truth : 0 []
emitted opcodes NOT derived : 0 []
EXACT RECOVERY: 218/218 emitted opcodes (100.0%)
CLOSED-LOOP VERDICT: IDENTICAL
```
Why the denominator is 218 and not 256: the ground-truth bijection covers all 256 byte
values, but 32 of those are unallocated dex slots and 6 are opcodes this fixture never
emits — and an opcode that never appears leaves no known plaintext. The six are reported
as `undetermined` with their reasons, never guessed. **Every entry the run did produce was
correct, and every opcode that could be read was read.**
## 4. Negative shapes — observed
The decision table's failure branches were driven on purpose, because a detector that has
never been seen to fire is not a detector.
**Stub shape** (`--mode stub`: every body replaced by an equal-length `return-void` fill —
the extraction shape):
```
$ python .../vmp_diff_harness.py compare .../classes.dex tools/_work/t5-vmp/stub.dex --quiet
aligned=75 resized=0 stripped=0 missing=0 added=0
aligned slots compared: 3065
verdict: NOT-USABLE
! 1 private byte(s) are claimed by up to 218 original opcodes each. No injective
relabelling can do that: it is the signature of method bodies replaced by one
common stub, and any substitution read off such a pair describes the stub, not a
private opcode space.
rc=1
```
This is the failure mode worth reading twice. In the per-opcode direction the reading is
**perfect** — 218 rows, all `high`, zero conflicts — because every original opcode maps to
the same private byte. Only the reverse (`private byte → set of originals`) check exposes
it. An implementation without that check would have returned a confident, wholly
meaningless table, and the first version of this one did exactly that: measured before the
fix, the same input returned `verdict: PARTIALLY-USABLE` alongside a note reading
"0 of 218 opcodes saw more than one candidate byte" — a warning that contradicted itself.
**Length-changing shape** (`--mode shrink`: `insns_size` rewritten without moving bytes, a
deliberately inconsistent image that exists only to drive the detector):
```
$ python .../vmp_diff_harness.py compare .../classes.dex tools/_work/t5-vmp/shrink.dex --quiet
aligned=0 resized=75 stripped=0 missing=0 added=0
aligned slots compared: 0
verdict: NOT-APPLICABLE
! 75 method(s) kept a body but changed its length. Boundaries cannot be projected
across a length change; a private opcode stream with its own length table is not
reachable this way.
rc=1
```
Regression after both fixes: the relabel loop still reports `USABLE` and the closed-loop
check still reports `IDENTICAL` — recorded above, re-run after the verdict-logic change.
**Not driven:** the `stripped` shape (`code_off == 0`) needs a class-data rewrite rather
than a byte edit, so its detector path is **inferred** from the shape counters, not
observed firing. The counters themselves (`stripped=0` in every run above) are observed.
## 5. `emit-smali` — observed
```
$ python .../vmp_diff_harness.py emit-smali tools/_work/t5-vmp/sim.dex \
--table tools/_work/t5-vmp/cmp.json --class LOpCoverProbe --method arithInt
; instructions restored: 3065 ; unmapped-opcode stops: 0
; THIS IS A READING SKELETON, NOT ASSEMBLABLE SMALI.
...
.class public LOpCoverProbe;
.super Ljava/lang/Object;
.source "OpCoverProbe.java"
# ---- arithInt(II)I registers=4 insns_size=29
.method public static arithInt(II)I
.registers 4
# 0x0000 90000203 add-int
# 0x0004 d000e803 add-int/lit16
# 0x0008 d8000007 add-int/lit8
# 0x000c b130 sub-int/2addr
...
# 0x0038 0f00 return
.end method
```
Two checks that the restore is real rather than plausible: the stream decodes to the
**original fixture's** `arithInt` body (compare it against `OpCoverProbe.java`'s 17
operations), and the payload-bearing `switchPacked` method also restores cleanly —
`packed-switch` followed by its payload block, stepped over by its own layout rather than
mistaken for opcodes. `unmapped-opcode stops: 0` is the honest coverage measure, and it is
zero only because the table is complete for this fixture.
Three defects were found and fixed by this run, each one a silent-wrong-answer shape:
- `--class LOpCoverProbe;` matched **zero** classes because the filter accepted only one of
the three spellings (`LOpCoverProbe;` / `LOpCoverProbe` / `OpCoverProbe`) — output read
exactly like a class that is not in the file.
- `.source` printed `"<string_idx 34748 out of range>"`: `source_file_idx` sits at
`class_def_item + 16`, not `+ 20` (that is `annotations_off`).
- Methods rendered without access flags, so the skeleton was not readable as smali.
## 6. What was not done
- **No hardening platform was contacted.** The entire submission link of the chain is
`unverified`, and its cost estimate in the reference file is reasoning about how those
services are distributed (accounts, rate limits, download-page delivery, possible refusal
of a synthetic fixture), not a measured upload. This is the pass's largest gap and it is
not closeable from this machine.
- **No real VMP sample was used.** `tools/_work/bench/repos/DexPatcher/assets/ezAndroid.apk`
is recorded in `ENV.md` as a real Dex-VMP challenge sample and was **not** opened in this
pass; the differential route needs a *matching hardened counterpart of the same build*,
which no available artifact provides.
- **The engine-behaviour assumption is inferred**: that a real VMP substitutes per-opcode
at stable instruction length. `simulate` implements the weakest form of that assumption
deliberately — it is the only form this harness can check itself against.
- **Table stability across inputs is unverified.** Whether one fixture's table applies to
another build of the same app, or survives a vendor version change, was not tested and is
the reason the reference file says to re-derive per target.
- **The `stripped` detector path did not fire** (see §4).
- `--mode shrink` writes a structurally inconsistent dex by design; `dexutil.Dex.check()`
will not reject it because it does not range-check `insns` against the file. It is a
detector probe, is labelled as such in `--help`, and must not be treated as a build.

View File

@@ -0,0 +1,249 @@
# Findings about the skill, from measuring it
Everything here is a result about **the skill**, not about the target. Each item names the evidence
that produced it and its strength.
---
## F1 — The Coverage claim was partly aspirational, and the pass identified exactly which part
`SKILL.md`'s Coverage section lists as *covered by verified mechanisms*:
> Flutter / Dart AOT: pinning the engine version, decoding the object pool, locating and patching
> business logic inside `libapp.so` (`dart-aot.md`).
Measured against a real Dart 3.6.0 `libapp.so`, that sentence splits into three claims of very
different standing:
| Sub-claim | Standing after this pass |
|---|---|
| pinning the engine version | **verified** — the banner route (§1) is correct and 3.6.0 is confirmed three ways |
| decoding the object pool | **false as written** — the kit cannot do it; there is no resolver |
| locating business logic inside `libapp.so` | **true, but conditional** — it works, on top of an external snapshot dumper that the skill neither ships nor installs |
The worked example in `TOOL-VERDICTS.md` shows both halves at once: the chain
`"showRewardAdProvider"` → `pp+0x23548` → ref site `0x7a9464` → `ShowRewardAdFamily.call_2f28ac
@ 0x7a93ec` succeeded, and step 1 was performed by blutter, not by the skill.
The reason is structural, not a shortage of effort: `dart_pool_strings.py` reports **file offsets**
while `pp.txt` and `dart_pprefs.py` work in **pool offsets**. Over the 4,241 strings present in both
spaces there are **4,237 distinct deltas** (min −1,361,429, max +277,654). The mapping is a property
of the reconstructed pool and is not a constant, so no amount of care inside the script recovers it.
Capabilities the kit does not have, stated plainly: snapshot/object-pool deserialisation; symbol and
name recovery; a real call graph (the `--build-index` index holds raw `B`/`BL` targets only); a
decompiler; a function boundary/size table.
---
## F2 — Two scripts fail in the way that reads as a target finding (the P26 class)
The handover singled out `pitfalls.md` P26: *"a self-built analysis script fails in a way that reads
as a target finding."* This pass produced two live instances of exactly that, which is why the P26
warning earns its place.
**`elf_plt.py` returned a confident false negative.** It reports 3,437 stubs where `.rela.plt` holds
3,438, and answers `0x587060 -> not a resolved stub` for an entry that is a legitimate stub for
`dl_iterate_phdr`, with a real caller at `0x576a2c`. Nothing in the output indicates a problem. For
the script's stated purpose — deciding whether a hardening symbol exists in a library — the wrong
answer is indistinguishable from the right one.
**`find_refs.py` returned a clean empty result for input it could not read.** Its docstring advertised
"a smali tree or a directory of dex files"; `iter_files()` accepted only `.smali`. Given the dex
directory it printed `[total refs] 0`, exited **1**, and emitted no warning. This is worse than a
crash: it is the exact input to "nothing references this, safe to patch", produced by the very tool
recommended for judging blast radius before patching.
Re-measured later: the exit code recorded in this section was **0** and it is **1** — the `total == 0`
path has always returned 1. The correction matters because it moves the defect from "no signal at
all" to "the signal was there and the text contradicted it": the note diagnosed the *needle* and never
named the *input form*, so a caller reading the message was still sent the wrong way. Fixed and
re-verified; see F9 for what measuring it turned up underneath.
Both, plus `dart_pprefs.py`'s silently dropped double constants, share one property: **the failure is
one-sided and quiet.** None of the three reports reduced confidence in its own output.
---
## F3 — Defects found, ranked by consequence
| # | Script | Defect | Consequence | Status |
|---|---|---|---|---|
| 1 | `find_refs.py` | `iter_files()` matched only `.smali`; a dex dir yielded 0 results and a direct dex was regex-scanned as text | silent false "no references" on the tool whose job is blast-radius | **fixed, re-verified against a fixture and the real target** |
| 2 | `elf_plt.py` | loop bound `off < fsz - 16` misses the final stub slot | false negative for one real symbol per library, conditionally | **fixed on a copy, re-verified** |
| 3 | `dart_pprefs.py` | only the GP register file is decoded; `ldr dD,[x27,#imm]` (`0xFD400000`) and all double constants lost | under-reports pool references (302 displacements / 1,802 sites here), which breaks the "count refs to judge importance" advice in `dart-aot.md` §9/§10 — one site went `[0 refs]` → `[79 refs]` | **fixed on a copy; agrees with capstone 0/0** |
| 4 | `so_constpatch.py` | rebuild drops the zip `extra` field, destroying 4 KB alignment | for `extractNativeLibs=false` APKs the output will not load; masked here because this target is DEFLATED + `extractNativeLibs=true` | **cause identified, proven by fixture; not fixed** |
| 5 | `so_constpatch.py` | `--diff --name-regions` compares file offset with vaddr | feature completely inert on libraries where `p_vaddr != p_offset` (2 of 36 here), with no warning | cause identified |
| 6 | `so_constpatch.py` | `external_attr` rewritten on 1982/1982 entries | metadata-based integrity comparison detects the rebuild | cause identified |
| 7 | `dexutil.py` | switch payloads decoded as instructions; `decode()` does not resolve operands | caused a spurious "desync likely" verdict on `saveToLocal` | cause identified |
| 8 | `so_constpatch.py` | "N byte(s) changed" is the overwrite window, not bytes changed | overstates change count (13 reported vs 7 actual) | cause identified |
| 9 | `lib_map.py` | `?` means both "read failed" and "not probed" | a real read failure is invisible | cause identified |
| 10 | `dart_pool_strings.py` | TSV escaping is lossy for LF/CR/TAB | 7 of 18,363 rows ambiguous; 19 entries genuinely contain those bytes | cause identified |
| 11 | `elf_plt.py` | `size = 16 if ... else 16` | dead code | cause identified |
| 12 | `dex_strings.py` | `--per-file` prints only files with hits | can be misread as "only this dex was scanned" | cause identified |
Items 1-3 are the ones that changed a conclusion during this pass rather than merely being noticed.
---
## F4 — Documentation contradictions
Independently found while mapping the skill and while using it. The executable cross-reference
checkers (`check_refs.py`, `check_repo.py`) pass, so these are invisible to the repo's own gates.
| # | Location | Problem |
|---|---|---|
| 1 | `dart-aot.md` §2 | Claims blutter's `asm/` has *no instructions* — "Do not plan around it." **Wrong.** One plugin file under `asm/<plugin>/src/ads/reward/` is 1,327 lines carrying class layouts, signatures, a full instruction listing, pool annotations and resolved call targets. It is the richest artifact in the chain and the doc says to ignore it. |
| 2 | `dart-aot.md` §2 | Budgets "tens of minutes" for the first blutter build; measured **≈78 s** (~30× pessimistic). Also demands a very recent C++ compiler; **MSVC 19.34 (Nov 2022) suffices**. |
| 3 | `dart-aot.md` §7 | Presents two-byte hit chaining as an acceptance test. On this sample, 9,840 two-byte CJK candidates yield 2,081 chained runs, all garbage. |
| 4 | `signature-derived-keys.md:61` | Documents `python scripts/sig_probe.py --live --pkg <pkg>` — the script has no `--pkg`; it takes `--live PKG`. Line 54 and `pitfalls.md` P27 give the correct form. |
| 5 | `dart-aot.md:204` | `dart_disasm.py ... --index callers.json --build-index` omits the required argument value; argparse errors out. |
| 6 | `byte-level-patching.md:186` | Says different-length strings "need `dex_strpatch`-class tooling", while `dex_strpatch.py` requires `len(new) == len(old)` — it is precisely the tool that cannot do this. Contradicts `dex-patching.md:123`. |
| 7 | Coverage vs `toolchain.md` | Coverage lists Unity/IL2CPP logic recovery as **not covered**, but `toolchain.md` Tier 4 names concrete tooling without pointing back at that disclaimer, so a reader of `toolchain.md` alone would take IL2CPP for a supported route. |
| 8 | Script count | `SKILL.md`/`README.md` index 35 rows, which is 34 scripts plus the `scripts/dexpatch/` directory row. |
| 9 | `dart_pool_strings.py` docstring | Says two-byte auto-detection is unreliable (confirmed here); `dart-aot.md` §7 treats UTF-16LE hits as a reliable rule. |
| 10 | `verification.md` rung 7 | Re-downloading a distributed file to compare hashes has no landing place in `SKILL.md`'s six "done" criteria, so the two sections disagree on strength. |
Item 1 is the most damaging because it steers a reader away from the best available evidence.
---
## F5 — "verified mechanisms" left no reproducible trace in the repo
The Coverage preamble frames its list as *verified mechanisms*, and `references/long-task-discipline.md`
defines **Observed** as *"reproduced it, with the exact command and output"*. The repository contains
no sample, no fixture, no captured log or screenshot, no patch record, no test and no CI, and its
`README.md` states it holds no target-specific data.
So the strongest support the repository itself can exhibit for those claims is *Inferred*. This is
**not** an accusation that the claims are false — several were confirmed here on the first attempt,
and `dart-aot.md` §1 proved exactly right. It is a statement about evidentiary standing: the repo
asks its readers to label their own claims and does not leave itself a way to meet its own bar.
The concrete measurement figures that *are* reproducible are the 14 sample-scoped numbers listed in
the skill map (e.g. dex 4.32 MB → 7.73 MB; `.RSA` 1199 B vs 777 B at runtime; a log tag going `8 → 0`).
---
## F6 — Boundary evidence: where the skill's method held, and where it ran out
This is the observation the pass was built to produce, so it is stated as a sequence rather than a
verdict.
**Held, unchanged:** manifest and packer triage; multi-dex string/class recon at 27.6 MB across four
dex files (`dex_strings.py`, `dexutil.py`); native ELF import mapping at scale (36,974 relocations);
live-process library mapping; equal-length `.so` constant patching; the launch-timeline methodology
(`coldstart.py`); the P26 discipline itself — checking whether a tool was ever able to answer before
reading its silence as a finding, which is what turned the blutter crash from a false "unusable" into
a corrected "works after a relink".
**Ran out:** from the Dart snapshot inward. Everything past `pp.txt` is fine; getting *to* `pp.txt`
from the binary requires a container resolver the kit does not have. That is one dependency, not a
subject area.
**Not exercised at all:** packers, code virtualization, custom linkers, integrity-check redirection,
tamper-triggered suicide. This target has none of those, and the unmodified app's own startup failure
also removes the repack loop those scenarios need.
---
## F7 — A trap in this specific environment that will produce false confirmations
`androguard` 4.x (the version installed here) has **four independent reference-analysis failures on
this sample**: `get_xref_from()` returns 0 for everything, including `AdLoader.loadAd()V` which has
20+ callers by other means; `get_ref()` raises; a full `get_operands()` sweep returns 0 strings.
Anyone who reaches for androguard to double-check a "zero references" conclusion will get **zero, and
therefore agreement**. The zero-reference result in `TARGET-FACTS.md` does not come from androguard;
it comes from `dexutil.py` plus raw-byte re-checks of the bodies that would not decode cleanly. This
is worth a line in the environment documentation because it is a tool that lies in the direction of
the hypothesis being tested.
---
## F8 — Reconciling the two failures: a genuine but unproven loader signal
Recorded as a boundary between what was established and what was guessed, because collapsing them
would be its own error.
The launch does show non-standard startup plumbing (observed):
- `E LoaderLog: 10026 / 101004` on the device, `10020 / 101003` on the emulator — an SDK-side
loader logging numeric status codes
- `E Instrumentation: Uninitialized ActivityThread, likely app-created Instrumentation, disabling
AppComponentFactory` — the process is not taking the ordinary `ActivityThread` path
- `E <PKG-truncated>: Unknown bits set in runtime_flags: 0x40000000` (logcat truncates the tag to 23
characters, so the package name appears clipped in this line)
And yet the dex-side packer verdict is clean: no hardening library in the native set, an ordinary
`application android:name` (the app's own class, not a packer stub), no known packer markers. Those two
observations sit in tension.
**Not resolved.** The `LoaderLog` emitter was not attributed to a specific class — the string exists
in `classes2.dex` and `classes3.dex` but the classes that declare it are third-party, and this pass
did not trace the call chain. Whether this plumbing is a benign logging SDK, part of the Flutter
plugin bootstrap, or a light-weight loader contributing to the abort is **unverified**. Stating it
as established would be exactly the kind of overreach this report is trying to document.
---
## F9 — Fixing F2 exposed a deeper one: the dex opcode table was shifted, and nine widths were wrong
Fixing `find_refs.py`'s dex path means depending on `dexutil.decode()`'s boundaries being right, so
that was measured rather than assumed. It was not right: on a real 8.9 MB dex, **65 of 50,791 method
bodies did not decode to their declared boundary**, and **32 carried an operand index outside the
table it indexes**.
The cause is a run of `OP_NAMES` and `insn_units` shifted by one from `0x16` to `0x2C`:
| opcode | specification says | the repository had | width: spec vs repo |
|---|---|---|---|
| `0x17` | `const-wide/32` (31i) | `const-wide/16` | **3 vs 2** |
| `0x18` | `const-wide` (51l) | `const-wide/32` | **5 vs 3** |
| `0x1B` | `const-string/jumbo` (31c) | `const-class` | **3 vs 2** |
| `0x1C` | `const-class` (21c) | `monitor-enter` | **2 vs 1** |
| `0x1E` | `monitor-exit` (11x) | `check-cast` | **1 vs 2** |
| `0x20` | `instance-of` (22c) | `array-length` | **2 vs 1** |
| `0x21` | `array-length` (12x) | `new-instance` | **1 vs 2** |
| `0x23` | `new-array` (22c) | `filled-new-array` | **2 vs 3** |
| `0x26` | `fill-array-data` (31t) | `throw` | **3 vs 1** |
| `0x2C` | `sparse-switch` (31t) | *(absent)* | **3 vs 2** |
Plus `0xFA`-`0xFF` (`invoke-polymorphic`, `invoke-custom`, `const-method-handle`,
`const-method-type`), absent from the width logic and defaulting to one unit each where the
specification has 4, 4, 3, 3, 2, 2.
The `goto` family went with it: `branch_target()` computed a branch target for `0x27`, which is
`throw`, and read the offset width one opcode early for the rest. `dex_find_insn.py`'s kind map
carried the same shift in its `goto` and `switch` entries, so a semantic search for a branch matched
the wrong instructions.
That this survived an earlier "fix instruction widths" pass is not surprising in hindsight:
`insn_units` ended with `if op == 0x29: # measured: this slot is goto/16 (20t)` — the shape of a
repair made one observation at a time, where the width of `0x29` was corrected without checking the
opcode's *name* against a specification. Name and width were wrong together and consistently, which
is exactly why the decode still looked plausible.
**How it was settled.** Not by reading the table again, but by two measurements that do not depend on
this project's code:
1. **Self-consistency at scale.** A real dex is a stream ART can execute, so a correct decoder must
land every method body exactly on `insns_off + insns_size*2`. 65 did not. After the fix,
**50,791 of 50,791 do, with 0 illegal operand indices.**
2. **A second decoder.** `androguard` 4.1.4 decoded the same dex independently. Aligned by method
order, the per-instruction width sequences are **identical for all 50,791 bodies — 0 differing.**
Names and widths now come from the format specification's table, and width is a lookup (`OP_UNITS`)
rather than a chain of range tests, which is the shape that made this class of error possible.
**Two things this pass did not fix, recorded so they are not mistaken for clean:**
- **`dexutil.string()` decodes MUTF-8 with `errors="replace"`.** Identifiers containing non-BMP
characters decode to U+FFFD — this sample has them, and one crashed a `print` with a
`UnicodeEncodeError` on a non-BMP codepoint. A class name read from the dex may therefore not
compare equal to the same name read from another tool. It does not affect instruction decoding or a
descriptor-based needle, but it is why the cross-check above had to align by order rather than by
key.
- `OP_NAMES` had no entry for roughly 150 valid opcodes (`aget`/`aput`, the `cmp` family, the
`neg`/`not`/conversion unops, the arithmetic and `lit8`/`lit16` families). They decoded correctly by
fallback but printed as `op_XX`. The regenerated table covers 224 opcodes.

View File

@@ -0,0 +1,95 @@
# Tool verification record
This directory is the **evidence record for the `apk-reverse` skill measured against real
targets**, kept separate from the skill itself so the Coverage statement in `SKILL.md` can be
checked against the runs behind it, and so a later reader can tell which claims were *observed*,
which were *inferred*, and which are still *unverified*.
It is **not** a set of target-specific notes kept for reuse, and it deliberately carries **no target
identity**. The measurements are about the **tools and the skill**; they stay valid whichever APK
produced them. Where a concrete figure was needed to make a point (a class count, a file size, a
build time) the figure is kept and the target is named generically.
Targets measured: Flutter AOT applications, multi-dex, mid-size native library sets, no packer.
The APKs themselves are **not** in this repository (`.gitignore` excludes `*.apk`, and `tools/`).
**Workbench paths are an operation record, not a pointer.** A record may say a run happened *at*
`tools/_work/...` so the command can be judged; `.gitignore` excludes `tools/`, so that path is not
something a reader can open. Anything a reader is *meant* to follow is named under
`docs/tool-verification/` or inside the skill — that rule is why a reference document never cites a
`tools/` path as its evidence, even when the work happened there.
## Strength labels
Every claim carries one of three labels, used the same way `references/verification.md` uses them:
- **observed** — reproduced here, with the exact command and its output.
- **inferred** — follows from observed facts, but the step itself was not executed.
- **unverified** — assumed or reported by a tool and not independently confirmed.
## Index
| File | Covers |
|---|---|
| `TOOL-VERDICTS.md` | One verdict per tested script and external toolchain, with the independent cross-check behind it |
| `FINDINGS.md` | Findings about the skill itself: defects, contradictions, boundary evidence |
| `REPO-DECISIONS.md` | What changed in the repo as a result, and what deliberately did not |
## Extension-pass record
The extension pass (the six coverage gaps named in the external review: module ecosystem, extraction
shells and VMP, emulation/RPC, native DBI, kernel-level environment, protocol layers) keeps its
evidence here, one file per topic, in the same three labels:
| File | Covers |
|---|---|
| `EXTENSION-device-run.md` | The on-device run against a hardened Flutter sample: the two control runs, the frida refusals, the **twice-corrected** pid-drift attribution, the failed-dump artefacts checked with `dex_dump_validate.py`, and a shared-device hazard measured with Stalker |
| `EXTENSION-unpacking.md` | Extraction-shell diagnosis by trivial-body ratio, `dex_dump_validate.py` against a fixture derived from the sample's own shell dex, and the tool's own defect found during the run |
| `EXTENSION-rootdump.md` | The root-side `/proc/<pid>/maps` + `mem` route that does not use frida: 17 real dex images recovered, the shell skeleton they are not, four device-shell traps, and the negative result on anonymous/ART-heap scanning |
| `EXTENSION-lsposed.md` | Module route: the gradle-free build chain with timings, three build traps, LSPosed config-DB anatomy, the module that could never have worked, and the root-hiding configuration the device actually has |
| `EXTENSION-emulation-rpc.md` | ASC/`ddc` measured on a hardened APK, Frida-RPC exercised end to end on a live device, and the unidbg build repair |
| `EXTENSION-native-dbi.md` | Stalker: the zero-event boundary, the crash from following a hot libc export, and an offline protobuf round-trip that caught a defect in its own decoder |
| `EXTENSION-kernel-ondevice.md` | The MT Manager APK MCP probe, the module-environment facts it could verify, and the kernel routes that this device's 4.14 kernel puts out of reach |
## Benchmark-pass record
The benchmark pass put **public targets** under the documented routes — `tests/benchmark.md` is the
matrix, and these files are the evidence behind its rows. Same three labels. Several of these rows
came back negative, and four of them overturned a conclusion this repository had already recorded;
those are called out in the matrix rather than buried here.
| File | Covers |
|---|---|
| `EXTENSION-benchmark-l1-l3.md` | The first two matrix rows: the L1 equal-length patch -> repack -> re-sign chain with its on-screen behaviour change and its zero-change control, and the L3 time-to-death baseline, the two spawn/patch/detach orderings that cannot work, and the terminate path that patching the obvious death site does not stop |
| `EXTENSION-extraction-shell-bench.md` | `dex_dump_validate.py` against an 11-variant skeleton set: the trivial-body ratio as a bimodal result rather than a threshold, the three script defects the variants exposed, and the ranking that pointed at a modified image until independent review caught it |
| `EXTENSION-stalker-exclude.md` | Stalker exclusion measured across three arms: the no-follow baseline, the follow that kills the process, and the follow with 20 modules excluded that survives but still delivers zero events |
| `EXTENSION-java2c.md` | Java2C against JNI sinking: native-declaration density roughly 2000x apart, why a `Java_*` symbol search fails silently, and the Dex-to-C build chain no toolchain on this host could complete |
| `EXTENSION-vmp-diff.md` | The known-plaintext differential: a 218-of-224-opcode coverage fixture, a closed-loop check that recovered 218/218 mappings with zero fabrications, and the one link that cannot be automated |
| `EXTENSION-kernel-weapons.md` | The generated KernelSU/APatch scaffold and its three kernel-side templates, all shipped unbuilt, plus the structural correction that a userspace module cannot change a syscall return value |
| `EXTENSION-split-apk.md` | Two real split sets through both branches: unified re-signing, the merge that is refused by design, the merge that succeeds, and the signer chain this host actually needs |
| `EXTENSION-reconstruction.md` | The reconstruction pass: the doctor capability closure (a JDK-only PATH used to report "can re-sign", now `BLOCKED` with a next action), the zip rebuild that stops dropping per-entry metadata (`zipalign -c -v 4` fails the old output and passes the new), and `check_commands.py` catching a documented flag that does not exist |
| `EXTENSION-test-harness.md` | The executable half of the evidence: what `tests/` asserts and what it measured, the `dex_patch_bytes` regression reproduced byte-for-byte, the 153 skips and 26 xfails that form the exit-code work order, the device-bound exclusion forced by an incident where the suite drove a real phone, and CI written but never run |
| `EXTENSION-rasc.md` | The Rust ASC measured against the Python one: identical class-definition sets on two archives, the speedup per scenario, the build (no prebuilt artifact exists, and a 32-bit MinGW cannot link it), and **the enum shape whose method bodies it drops with no warning** |
| `EXTENSION-protobuf-raw.md` | A schema-free decoder cross-checked against the official runtime and a real DataStore container, with the packed-boundary and proto3-zero ambiguities reproduced on real bytes |
## Absorption-pass record
A later pass absorbed mechanics from external skill repositories (`reverse-skill`,
`awesome-game-security`, `android-reverse-engineering-claude-skill`, `MobileRE-Skill`), keeping each
project's own discipline: only the phase, the checklist and the command pattern cross over, with the
source URL and the access date recorded, and nothing vendored as a runtime dependency. These files
live here rather than in the skill because two of them corrected a claim the repository had already
written down.
| File | Covers |
|---|---|
| `EXTENSION-desensitization.md` | The leak scanner's rules and exemptions, its four `RESULT=` states and exit codes, the escaping false positives, and **the pass that found its own evidence file leaking 26 strong hits** — the fixture's literal match values, quoted to prove the rules fire |
| `EXTENSION-dart-aot-formats.md` | Two string-table claims tested against a real dual-ABI Flutter app: the arm64 packed scheme confirmed against literal tag bytes, and the assumed armv7 `len*2`/UTF-16 form **refuted** — the 32-bit record is `[header u32][byte-count u32le][UTF-8]`, and the extractor's zero is a format mismatch rather than an empty table |
| `EXTENSION-detection-pipeline.md` | Naming the check that fires: three arms on a public MASTG target (control alive, probe arm dead in ≈300 ms via `strstr("frida")`, with the `TracerPid=0` versus 4-frida-mappings asymmetry), `svc_scan.py`'s two decoders agreeing on an identical 214-site set, the layered Java-to-svc descent, and the ptrace-free dump boundary on targets whose dex is deflated inside the APK |
## Adding a run
Record the tools, versions and exact commands, and label each claim. Keep target-specific facts out:
what belongs here is what a future reader can apply to a *different* APK — a defect, a measured
cost, a method that did or did not work, and the cross-check that settled it.

View File

@@ -0,0 +1,305 @@
# Repository decisions, and the evidence behind them
The pass was framed as a choice between two ways to split the skill. The measurement rejected the
framing, and this file records why, then states exactly what changed in the repository.
---
## The decision: **no second skill.** Fix skill A instead.
This is the "暂不建" outcome the brief explicitly permits, and it is supported by the evidence rather
than by preference.
### Why the framing did not survive contact with the target
The two candidate splits were:
- **by entry point** — everything reached from an APK (including Flutter/IL2CPP) stays in A; only
standalone ELF goes to B. Cost: A keeps growing.
- **by logic location** — A keeps only logic genuinely in dex; Flutter, IL2CPP, hardened dex, native
and standalone ELF all move out. Cost: four references (~962 lines) relocate.
The measured target is exactly the case that was supposed to discriminate between them: Flutter
(machine-code side) whose entry point is an APK. It produced a third answer.
**The problem is not where the content lives. It is one missing dependency.** Everything the kit does
*after* a snapshot dumper has produced `pp.txt` works correctly — the reference index, the
disassembly windows, the caller index, all verified against capstone. The single thing it cannot do
is get *from the binary to* `pp.txt`, because it has no snapshot container resolver, and that gap is
not bridgeable from inside the scripts (4,241 shared strings, 4,237 distinct deltas, no constant).
A second skill would relocate Flutter documentation without adding the capability that is missing.
The gap would travel with the documents. Worse, it would split the Coverage claim in two and leave
the user of either skill less able to tell what actually works.
### The supporting evidence, in order of weight
1. **The kit works where it was tested.** `dart_disasm.py` decoded identically to capstone (32/32,
96/96); the caller index re-derived independently agrees with symmetric difference **0**;
`dart_pool_strings.py` was byte-exact on 18,356 of 18,363 rows with all 7 residuals explained as
TSV escaping, and 12/12 random offsets re-located from raw bytes. These are not the results of a
tool that needs replacing.
2. **The blocker is a front end, and free front ends exist.** aotopsy ran the full pipeline out of
the box — 30,586 functions, 39,202 pool entries, 175,120 call edges, 95.7% BLR annotation — with
**no toolchain**: pure Go, no compiler, no Dart SDK. That is the "decoding the object pool"
capability, delivered as a binary.
3. **A split would contradict the Coverage section's own structure.** Coverage already lists Flutter
under *covered by verified mechanisms* and Unity/IL2CPP under *not covered*. The real defect is
that the Flutter line overstates what ships. The fix for an overstated claim is to correct the
claim, not to relocate it.
4. **The cost of the split lands on the wrong people.** Moving `dart-aot.md`,
`native-and-so.md`, `native-tamper-and-suicide.md` and `code-virtualization-and-custom-linkers.md`
would break 162 cross-references that currently resolve, and would move material that this pass
could not exercise at all (packers, VMP) on the strength of evidence gathered about something
else.
### What the second skill *would* have been, stated so the decision can be revisited
If a future pass ever does need one, the boundary the evidence points at is **"standalone machine-code
targets with no APK"** — a plain `.so`/ELF analysed on its own. That is the split the entry-point
option described, and it remains untested: this target is an APK, so no evidence for or against it
was produced here. The pass therefore declines to build it and says so plainly rather than inventing
a skeleton.
### Selective installation, measured — and what it implies for shared tooling
The brief asked for the shared-tooling question to be settled by the actual install product rather
than by preference. Measured with `npx skills add newliver666/apk-reverse --skill apk-reverse -y
--copy` in an empty directory:
- The skill is copied **whole and self-contained** into each detected agent directory — `.agents/`,
`.claude/`, `.kiro/`, `.pi/`, `.qwen/`, `.zcode/`, six in total on this host — plus a
`skills-lock.json` at the root.
- Every file lands under `<agent>/skills/apk-reverse/`: `SKILL.md`, all 28 `references/*.md`, all 34
`scripts/*` including the `dexpatch/` subdirectory. **Nothing outside the skill directory is
installed.** The repository's root-level `docs/` and the three maintenance checkers are absent from
the product, as intended.
- There is **no cross-skill sharing mechanism** in the installed layout.
Consequence for this decision: had the skill been split, the two halves would each have needed their
own copy of any shared script, or a path that points outside the skill directory — and the latter
does not survive installation, because it is not part of the product. So the "shall we put shared
tools in a root `shared/`" question resolves itself: it cannot work that way, and with one skill there
is nothing to share. The record is kept here because the observation is what makes the reasoning
checkable rather than asserted.
---
## Changes made
### `references/dart-aot.md`
| Change | Reason |
|---|---|
| Documented a **pinned front end** (aotopsy v1.6.0, sha256 `5a90bcf2…fdc1`; Dart 2.10–3.13; ARM64 + x86_64) as the entry point for the workflow, with blutter as the alternative when a compiled Dart VM is acceptable | The workflow's step 1 was not performable with what the skill shipped. Measured: the kit cannot go from a string to a pool offset. |
| Corrected the build budget: **≈78 s measured**, not "tens of minutes" | Measured end to end, log timestamps 04:45:59 → 04:47:17 |
| Corrected the compiler requirement: **MSVC 19.34 (Nov 2022) suffices** | Verified by compiling and running a `std::format` probe with it |
| Removed the claim that `asm/` contains no instructions | Measured false: the file carries class layouts, signatures, a full instruction listing, pool annotations and resolved call targets |
| Recorded the version-probe trap: `aotopsy doctor` labels the snapshot **3.6.2**, the engine banner and snapshot hash say **3.6.0**, and 3.6.0 is correct | Three independent facts agree on 3.6.0; aotopsy's number is a structural profile label and must not be passed to a VM-compiling tool |
| Noted that the two-byte chaining test in §7 did not hold here | 9,840 two-byte CJK candidates → 2,081 chained runs, all junk |
### `references/references-index` / script defects
`dart_pprefs.py` is fixed in the repository. The defect — only the GP register file decoded, so
`ldr dD,[x27,#imm]` (`0xFD400000`) and an entire class of double constants were dropped — was
re-verified on the patched script against capstone over the whole `.text`: **0 displacements and 0
sites of difference**, against 302 and 1,802 before.
`find_refs.py`, `elf_plt.py` and the `so_constpatch.py` alignment defect are **documented but not
fixed**. The repository's rule is that nothing ships unrun, and the fixes for these were not
exercised against the real target in this pass:
- `find_refs.py` — accepting `.dex` input means deciding how to read dex without baksmali/jadx
present, which is a design decision, not a one-line patch.
- `elf_plt.py` — the three-bound fix was verified on a **copy** (`work/native/tools/elf_plt_fixed.py`),
not in the repository, so the repository version is untouched.
- `so_constpatch.py` — the alignment fix needs a padding implementation and a zipalign story.
Each is recorded in `docs/tool-verification/FINDINGS.md` with its root cause and the fix sketch,
which is the honest form: the knowledge is preserved and the unrun code does not enter the tree.
### `SKILL.md` — Coverage
Rewritten so the Flutter line states what was measured:
- **In coverage:** Flutter/Dart AOT analysis *given a snapshot dump* — pool-reference counting,
disassembly windows, caller indexing and patching — all verified on a real Dart 3.6.0
`libapp.so`, with the front end named as a documented dependency.
- **Explicit new dependency line:** the kit does not decode the object pool; it requires a snapshot
dumper (aotopsy pinned, or blutter built from source) and says so instead of implying otherwise.
- **Unchanged:** Unity/IL2CPP, React Native/Hermes, iOS, defeating a server-side authority, and the
general-unpacker/anti-detection arms race all remain uncovered.
- **Added:** an explicit statement that packer, VMP and integrity-check scenarios were **not
exercised** by this pass, so no claim is upgraded on their behalf.
### `SKILL.md` — the three hand-off points
Written from the perspective that a hand-off is a pointer, and that duplicating content across two
places is how the two copies drift. Each one says what the other side owns.
1. **JNI** — Java declaring a native method ↔ its `.so` implementation. Static linkage shows as
`Java_com_pkg_Class_method` and disappears under R8 short names; dynamic registration leaves the
symbol table empty and must be found at `RegisterNatives`. This target ships the static form
(`FlutterJNI.loadLibrary` appears in `classes3.dex`) but the pass did not trace a JNI boundary
end to end, so the section carries the **unverified** label.
2. **Hardening** — dex-side observation of a packer implies a native-side shell implementation.
**Not exercised here**: this target has no packer. Recorded as a pointer with that stated.
3. **Existing boundary documents** — `native-and-so.md` and `native-tamper-and-suicide.md` read
native anomalies *from the APK side* (immediate SIGSEGV after a repack, Java claiming the check
passed while the process dies). That belongs in A; deep native work is the other side. The new
section points each way rather than repeating either.
### `docs/tool-verification/`
New directory holding this pass's evidence record. `README.md` states the folder is a *measurement
record, not shipped target data*, carries the strength-label convention, and requires that **no target
identity is recorded** — only what a future reader can apply to a different APK. It is referenced from
`SKILL.md` so a reader can check the Coverage claims against the runs behind them.
No APK is in the repository (`.gitignore` already excludes `*.apk`) and no credential or token
appears in any file.
### `README.md`
Updated for the directory tree, the install command and the division of labour between the skills,
plus a pointer to the verification record.
---
## Verification
All four gates were re-run against the final tree:
- `python -B check_repo.py`
- `python -B check_refs.py`
- `python -B build_scripts.py . <out>`
- `npx skills add newliver666/apk-reverse --list`
Two behaviours of the checkers worth knowing, both established by experiment during this pass:
`check_repo.py` and `check_refs.py` have **no argparse**, so `--help` is silently ignored and the
full check runs; and running `check_repo.py` with `PYTHONIOENCODING=utf-8` set produces a spurious
failure (exit 1, one script reported as crashed) because its `subprocess.run(..., text=True)` does
not pin an encoding while the child emits UTF-8 — the parent then decodes as cp936 and the streams
come back empty.
---
## A later pass: fixing the `find_refs.py` defect, and what measuring it exposed
The three defects recorded above as "documented but not fixed" were left alone for a stated reason:
the repository ships nothing unrun. One of them — `find_refs.py` and `.dex` input — was reported again
from outside with a clean reproduction, and that reproduction is correct. It is now fixed, together
with a deeper defect the fix exposed.
### `find_refs.py`
Accepts a `.dex`, a directory of `.smali` and/or `.dex`, an `.apk`, or a smali tree. Dex input is
decoded through `dexutil`, so baksmali is not required — which matters because the machine where the
defect was found had no baksmali, jadx or apktool, and the tool was therefore completely unusable for
that target. Input it cannot read is **refused with exit 2 and a reason**, and every run prints
`[scanned] N file(s)` before the count, so "nothing was readable" can no longer print the same line as
"read N files and found nothing".
### `dexutil.py` — the part that could not be skipped
`find_refs.py`'s dex path depends on `decode()`'s instruction boundaries, so those were measured
instead of assumed. They were wrong: the opcode table was shifted from `0x16` to `0x2C` with nine
widths wrong (F9), and 65 of 50,791 method bodies on a real dex did not decode to their declared
boundary.
Fixing `find_refs` while leaving that in place would have produced the same silent false zero the
defect was reported for, one layer down. So names and widths were regenerated from the format
specification's table, width became a lookup (`OP_UNITS`) rather than a chain of range tests, and the
`goto` family was corrected in `branch_target()` and in `dex_find_insn.py`'s kind map.
### Verification
- `[scanned]`/`[total refs]` from a `.dex` and from the equivalent smali tree **agree with each other
and with the count written into the fixture by construction** (3 / 2 / 2).
- **50,791 of 50,791** method bodies decode to their declared boundary (65 did not before), and **0**
operand indices fall outside their table (32 did before).
- **androguard 4.1.4**, a decoder sharing no code with this project, produces **identical
per-instruction width sequences for all 50,791 bodies**.
- Real target: `Ljava/lang/String;->length` → 356 references in 2.3 s; a five-dex directory → 9,472
across 2 files; a typo'd path → exit 2.
- The four repo gates still pass: `check_repo.py` 0 problems, `check_refs.py` 0 dangling,
`build_scripts.py` 0 findings, `npx skills add … --list` lists the skill.
### Why the fixture is in the tree
`docs/tool-verification/fixtures/make_fixture_dex.py` is committed because it is the evidence for the
counts above. Without it, "3 references" is a claim a later reader cannot check — which is exactly the
evidentiary gap F5 describes, and the repository's rule that nothing ships unrun applies to claims as
much as to code. It is dependency-free (stdlib only) so it reproduces on a machine with no Android
toolchain, which is the machine the defect was found on. It carries no target data: the dex is
synthesised from constants in the file.
## Maintenance pass: the path audit's signal, three path families, and a corpus note
An external review found the repository in good shape on evidence and honesty but raised three
concrete defects. All three are fixed here; the fourth item is a measurement added so the next
pass can see what this one could not.
### 1. `build_scripts.py` reported 37 findings, most of them noise
The audit matched **any** `X:\...` literal, so `C:\Program Files\...`, `C:\Python314\...` and
literal placeholders like `<user>` all counted as "machine-specific leftovers". A gate whose output
is mostly noise stops being read, which is the same failure `scan_leaks.py` names when it refuses to
exempt the RFC 5737 documentation ranges.
Added two explicit, auditable exemption families rather than a looser regex:
- `GENERIC_PATH_PREFIXES` — install locations that exist on every host of that platform
(`Program Files`, `Python*`, `MinGW`, `/usr/`, `/opt/`, ...).
- `_leaks_nothing()` — also exempts an already-redacted path, i.e. one carrying a placeholder or an
ellipsis, because that mark means a normalisation pass already ran.
**One trap had to be handled explicitly.** `BARE_ABS` stops at whitespace, so a real
`C:\Program Files\...` arrives as the truncated `C:\Program`; a one-directional `startswith` misses
the exemption. The comparison is therefore bidirectional, gated by a minimum length so a short drive
prefix cannot become a blanket pass. The same function needed a second correction: with a lookahead
of only `[a-z0-9]`, the optional `\\?` backtracks to the empty string, the next character is the
separator, it passes, and a real personal path is swallowed — the lookahead must exclude the
separator too.
### 2. Three absolute paths left on the published surface
`EXTENSION-desensitization.md`, `EXTENSION-extraction-shell-bench.md` and `EXTENSION-vmp-diff.md`
quoted a workbench root, a tool jar and an SDK location as absolute paths. `docs/tool-verification/`
already states the rule (§**Workbench paths are an operation record, not a pointer**): the record
may say a run happened at a `tools/...` path, written relatively. The three files were made to match
the rule they were already under; the tool-jar and SDK cases were rephrased rather than
placeholder-ised, so no reader has to learn a new token.
### 3. `check_budget.py` could not see corpus growth
The gate budgets the always-loaded part and was silently blind to the other half of the problem: a
new reference costs exactly **one index line**, so the corpus can double while the narrative count
stays green. Two note-level thresholds now report it. Notes, not failures — a large corpus is a
choice; a growth nobody measured is the defect.
### A cross-tool inconsistency this pass ran into
`build_scripts.py` now exempts any `<...>` placeholder; `scan_leaks.py` recognises a **fixed token
list** (`<user>`, `<name>`, `<path>`, ...). An invented placeholder in a source comment —
`<someone>` — therefore passed the first and was read by the second as the very personal name its
`user_path_windows` rule exists to catch, turning `check_repo.py` red. That is by design rather than
a bug (a strict list is what stops `<realname>` hiding a real one), but the two tools disagree about
what "already redacted" means, and the disagreement is invisible until someone writes a new
placeholder. The fix here was to rephrase the comment; a future pass may want a shared token list.
### Verification
- `build_scripts.py` **37 → 0** findings on a clean clone, exit 0 (was exit 2). The generic-install
hits (`C:\MinGW\bin\gcc.exe`, `C:\Python314\...`, `C:\Program`) no longer report; the real ones
(`E:\apk-reverse\...`, `E:\agb_apktool.jar`) did, and were fixed in the documents, not by widening
the list.
- A purpose-written case table pins **both** directions — 18 cases, 0 failures: generic and
already-redacted paths are exempt, while a real workbench path, a real personal name after
`C:\Users\`, a real POSIX home and a real project path still report. Two defects in the first
implementation were found by that table, not by the audit run.
- All four gates pass: `check_repo.py` 0 problem, `check_refs.py` 0 dangling / 0 warning,
`check_budget.py` 0 failure / 0 warning / 3 notes, `build_scripts.py` 0 findings.
- `check_budget.py` now reports the corpus explicitly: **47 references + 55 scripts = 102 files,
31,423 lines**, past the 26,000-line note threshold — the figure this pass could not see before.

View File

@@ -0,0 +1,554 @@
# Tool verdicts — measured on a real target
One verdict per script, drawn from the closed set:
| Verdict | Means |
|---|---|
| **verified on real target** | Ran here, and its output was confirmed by an independent method that does not share code with it |
| **broken — fixed** | Was wrong; the defect is localized and a corrected copy was run and re-verified |
| **broken — cause identified, not fixed** | Was wrong; root cause established, no fix attempted |
| **unusable here because …** | Correct diagnosis, but this environment/target cannot exercise it |
| **not applicable to this sample** | The target lacks the feature the tool addresses |
The distinction that governs the whole table: **a script that runs without error is not a script
that is right.** Every "verified" below names the independent cross-check, because "it didn't
crash" was not accepted as evidence anywhere in this pass. Equally, a tool failure was never read
as "the target does not contain that thing" — each failure was first checked against the question
of whether the tool was ever able to answer it.
---
## Summary
| Script | Verdict |
|---|---|
| `elf_plt.py` | **broken — fixed** (core stub→symbol mapping verified on real target; two defects, one proven to cause a false negative) |
| `lib_map.py` | **verified on real target** |
| `so_constpatch.py` | **verified on real target** for equal-length rewriting; **broken — cause identified, not fixed** for one sealed serious defect (zip alignment) |
| `dex_strings.py` | **verified on real target** (multi-dex traversal confirmed) |
| `dexutil.py` | **verified on real target** (used as the primary workhorse for the ad-chain analysis) |
| `find_refs.py` | **broken — fixed and re-verified**; reads a `.dex` directly now |
| `dart_pool_strings.py` | **verified on real target** |
| `dart_pprefs.py` | **broken — fixed** (defect proven; corrected copy agrees with capstone to zero) |
| `dart_disasm.py` | **verified on real target** |
| `coldstart.py` | **verified on real target** (the run produced the launch-crash finding) |
| `native_crash.py` | **not applicable to this sample** (no artificially constructed crash; see `TARGET-FACTS.md`) |
| `so_constpatch.py` redirected-checker scenario | **not applicable** — no integrity-check library exists in this target |
| All packer / VMP tooling | **not applicable** — no packer in this target |
| blutter (external) | **works** — build ≈78 s, transient first-run `0xC0000005` not reproducible after relink |
| aotopsy (external) | **works out of the box** — full pipeline on the same `libapp.so`
Two of these verdicts matter more than the rest: `elf_plt.py` produced a **false negative** on a
symbol that exists and is called, and `find_refs.py` returns a clean "no references" answer for
input it cannot read at all. Both are the failure mode that looks like a finding.
---
## `elf_plt.py` — broken — fixed
**Claim under test:** walks `PT_LOAD`/`PT_DYNAMIC` directly, without depending on the section
table, maps PLT stubs to imported symbols, and diffs two `.so` files byte-wise.
**Sample:** `lib/arm64-v8a/libloader.so` (6,221,896 B, largest `.dynsym` in the APK).
**Run:**
```
python scripts/elf_plt.py work/native/samples/arm64-v8a_libloader.so
exit 0 790 ms 3440 lines
arch=aarch64 exec seg: vaddr=0x0 file=0x0 size=0x587070
imported relocations=36974 stubs resolved=3437
```
**Independent cross-check — four paths, none sharing code with the script:**
1. **Relocation total.** `readelf -d` (pyelftools) reads `RELASZ=804864`, `PLTRELSZ=82512`,
`RELAENT=24`. `804864/24 = 33536` (`.rela.dyn`) + `82512/24 = 3438` (`.rela.plt`) `= 36974`.
Matches the script's `imported relocations=36974`.
2. **Symbol names, item by item.** pyelftools parses `.rela.plt` + `.dynsym` independently
(3,438 entries, all `R_AARCH64_JUMP_SLOT=1026`). Against the script's 3,437 output lines:
`name mismatches: 0`. The script's GOT set is a subset of the `.rela.plt` GOT set, with an
intersection of **0** against `.rela.dyn` — i.e. it does not mistake local relocations for
imports. Zero `?` names.
3. **lief 1.0.0** independently reports `pltgot_relocations = 3438`, equal to the pyelftools set,
with zero name disagreements.
4. **capstone 5.0.7 real disassembly** of all 3,437 reported stub addresses, 16 bytes each, with the
GOT recomputed from the decoded instructions: `shape_violations: 0` (every stub is
`adrp x16 / ldr x17,[x16,#off] / add x16,x16,#off / br x17`) and
`got_recompute_mismatch: 0`.
Conclusion: the four hand-written bitfield decoders (`_decode_adrp`, `_decode_ldr_unsigned`,
`_decode_add_imm`, `_decode_br`) are **correct**, not merely non-crashing.
### Defect 1 — loop bound, proven to cause a false negative
`.rela.plt` has **3,438** entries; the script reports **3,437**. The arithmetic:
- first `.plt` stub `0x579990` + 3438 × 16 = **0x587070**, exactly the executable segment end
(`size=0x587070`)
- the source reads `while off < fsz - 16`, so the largest offset examined is `0x58705c`; the final
slot `0x587060..0x58706f` is never checked. `fsz-16` would need to be `fsz-15`; the correct form
is `off + 16 <= fsz`.
The skipped slot is a legitimate stub, proven by independent disassembly:
```
0x587060 adrp x16, #0x5c8000
0x587064 ldr x17, [x16, #0x108]
0x587068 add x16, x16, #0x108
0x58706c br x17
recomputed GOT = 0x5c8108 -> .rela.plt entry for that GOT: dl_iterate_phdr
BL/B callers of the skipped stub: 1 ['0x576a2c']
```
And the script therefore answers wrongly, with a confident negative:
```
$ elf_plt.py libloader.so --query 0x587060
0x587060 -> not a resolved stub <-- FALSE NEGATIVE
```
This is the most important single result of the native pass. The script does not error, its output
looks complete, and for its stated purpose — *"which stub points at the hardening symbol I want to
neutralise"* — it reports that a real, called symbol (`dl_iterate_phdr`) does not exist in the
library. That is precisely the failure mode the script's own docstring warns about.
**Fix**, applied to a copy and re-verified — three isomorphic bounds:
```
_stubs_x86: while i < fsz - 6 -> while i + 6 <= fsz
_stubs_arm64: while off < fsz - 16 -> while off + 16 <= fsz
find_branch_callers: while off < fsz - 4 -> while off + 4 <= fsz
```
After the fix: `stubs resolved=3438`, equal to the `.rela.plt` entry count. The diff against the
original output is **exactly two lines** — the header count and the single new
`0x00587060 -> dl_iterate_phdr (GOT 0x5c8108)` — with all other 3,437 lines byte-identical.
`--query 0x587060` now resolves correctly.
### Defect 2 — `--diff --name-regions` compares a file offset against a virtual address
`cmd_diff`'s `describe(off)` receives a **file offset** and compares it against stub **vaddrs**
(`va <= off < va + size`). Those coincide only when `p_vaddr == p_offset`. Two of the 36 libraries
in this target violate that:
```
lib/arm64-v8a/libflutter.so exec PT_LOAD vaddr=0x444880 offset=0x434880 (delta 0x10000)
lib/armeabi-v7a/libflutter.so exec PT_LOAD vaddr=0x277500 offset=0x267500 (delta 0x10000)
```
Demonstrated by mutating a byte inside the `abort` stub (stub vaddr `0x9f6050`, file offset
`0x9e6050`) and diffing:
```
$ elf_plt.py libflutter.so --diff flutter.mutated.so --name-regions
differing bytes=1 in 1 run(s)
off=0x9e6053 len=1 A: b0
B: b1
<-- the "<- PLT stub for abort" annotation is missing
```
Plain `--diff` and `--diff --name-regions` produce **identical** output, i.e. the feature is
completely inert on these two libraries and reports nothing about being inert.
**Fix:** map the file offset back through `PT_LOAD` before comparing.
**Also:** `size = 16 if elf_a.machine == EM_AARCH64 else 16` — both branches identical; dead code.
**Credit where due:** the script's defensive warning fires correctly on a zero-stub target.
`libapp.so` (Dart AOT, 144-byte `.dynsym`) reports `stubs resolved=0` **and** prints an explicit
"do not read this as 'the library imports nothing'" message. That is exactly the right instinct —
it is unfortunate that the scanner then violates that principle itself, one slot early.
---
## `lib_map.py` — verified on real target
**Claim under test:** reads a live process's mapping table and reports which libraries are actually
loaded, at what base, with what real architecture.
**Run:** `--pkg <PKG> --serial <DEVICE>`, exit 0, 635 ms, pid 27064.
Reported three actually-loaded app libraries — `libloader.so` (3 segments),
`libsentry-android.so` (2), `libsentry.so` (3).
**Independent cross-check:**
- `su -c cat /proc/27064/maps` parsed with an independent parser: path set, segment counts and
permission strings agree with **zero** differences.
- `od` read of the ELF header gives `b7 00` = AArch64, matching the script's architecture column.
- Scale check against `systemui`: **318 libraries**, compared item by item — path, segment count
and permissions all zero-difference; `--json` base addresses agree **318/318** with independent
computation.
- Translation-layer detection fires correctly on the emulator
(`/system/lib64/libhoudini.so` → `translation suspected: YES`).
**Improvement, not a defect:** `?` in the architecture column is two-valued in the docs —
`read_elf_arch`'s docstring says `?` = read failed; the CLI's trailing note says `?` = not probed.
All 8 libraries printed as `?` were independently read and are AArch64, i.e. they exist and simply
were not probed. A genuine read failure is indistinguishable from "not probed".
**Anomaly resolved as not-a-bug:** one crosscheck showed `libsentry.so 3 vs 4` segments. Four
snapshots established this as runtime mapping change (it is 3 segments at the instant of launch),
not a script defect.
**The real constraint on this tool here:** the target lives ~1.1 s, so `lib_map.py` must run inside
that window. That is also why the app's own libraries are limited to three — `libapp.so` and
`libflutter.so` had not been loaded yet.
---
## `so_constpatch.py` — verified (core) with one sealed serious defect
**Claim under test:** equal-length rewriting of string constants inside a `.so`.
**Verified by:** length unchanged at 6,221,896 bytes, sha256 changed, and an independent byte-wise
diff showing changes at exactly one site (`0x81593`, 7 bytes). `readelf -d` confirms the change
landed at the ELF semantic layer: the `NEEDED` entry changes from `libandroid.so` to
`libfoo0000.so`. Guards behave as designed — unequal-length input exits 1 and writes no file; `-o`
pointing at the input exits 2; non-isolated operation refuses by default. On a real APK copy: 3.5 s,
**all 1,982 entries preserved**, order preserved, only the target entry's CRC changed, original APK
sha256 unchanged before and after. All patching was done on copies; no sample was mutated.
### Defect — zip alignment destroyed on rebuild (sealed, not fixed)
The rebuild discards the local header `extra` field, which breaks 4 KB alignment. Measured on a
fixture: `data_offset 4096 (mod4096=0, extra_len=4031)` becomes `65 (mod4096=65, extra_len=0)`.
**Why this is serious despite not firing here:** for an APK with `extractNativeLibs=false`, `.so`
entries must be `STORED` and 4 KB-aligned or the library will not load. This target cannot show the
bug — all 36 native libraries are `method=8` (DEFLATED) and `extractNativeLibs=true` (confirmed in
both the manifest and by the runtime path `/data/app/.../lib/arm64/` that `lib_map.py` observed).
So the defect is **masked by this sample and proven by a fixture**. That asymmetry is exactly why
"it worked on our target" is not a verification.
**Fix sketch:** copy `extra`/`external_attr`/`internal_attr`/`create_system`/`comment` from the
source `ZipInfo` and implement padding; and state in the help text that a rebuild must be
re-zipaligned before signing.
### Defect — entry metadata reset
`external_attr` is rewritten on **1982/1982** entries (e.g. `2175008768 (0x81A40000)` →
`25165824 (0x01800000)`), and `create_system` changes from 3 (Unix) to 0 on 77 entries. Usually
harmless for installation, but it changes extracted permission bits and makes any
zip-metadata-based integrity comparison immediately detect the rebuild.
### Defect — the change count is misleading
`do_replace` prints `'%d byte(s) changed' % (len(applied) * len(old))`, which is the **overwrite
window length**, not the number of bytes that actually changed. Two measured examples:
`libandroid.so → libfoo0000.so` prints 13 but changes 7; `libandroid → libandroiX` prints 10 but
changes 1. For an attribution/audit tool this invites overstatement.
### Minor
Rebuilding changes the APK's own bytes (+2.2 MB on the sample, different deflate level). Worth a
note in the output that output size may differ from input.
---
## `dex_strings.py` — verified on real target
**The multi-dex question the handover flagged is answered: yes, directory mode walks all four dex
files.** `iter_dex()` globs `*.dex` for a directory; empirically
`--find 'json' --per-file` emits four lines (`classes.dex`, `2`, `3`, `4`), while single-file mode
emits one.
Two traps worth knowing:
- `--per-file` prints **only files that had a hit**. An output containing just `classes3.dex` does
not mean only `classes3.dex` was scanned. This is the shape of output most likely to be
misread as "only one dex contains this".
- `--per-file` scans **raw bytes** rather than the string table (source lines 124-130), so hits for
broad patterns include coincidental matches.
Fast: 0.27 s over the full 27.6 MB.
## `dexutil.py` — verified on real target
The workhorse of the ad-chain analysis: symbol resolution, branch-target back-annotation, and a
`ended cleanly` verdict per method. Coverage: 175,829 method bodies with code, 213,180 `method_id`s.
It is the tool whose output the zero-reference conclusion rests on.
Three defects, all found by working at this scale:
- `decode()` does not resolve operands — string and method indices must be decoded by the caller.
- **Switch payloads were decoded as instructions**, contradicting the docstring's claim that
`ident 1..3` are recognized. This was one root cause of `saveToLocal` being reported as
"desync likely".
- **The opcode table was shifted from 0x16 to 0x2C, and nine widths were wrong with it** — fixed.
`0x1C` carried the name `monitor-enter` and one unit; the format specification has `const-class`
there, at two units. `0x17` was `const-wide/16`/2 where the spec has `const-wide/32`/3; `0x18` was
3 where the spec has `const-wide`/5; `goto` was read one slot early, so `throw` was treated as a
branch. The plausible "same family, same width" reading of that run is wrong at nine opcodes, and
each wrong entry shifts every instruction after it inside that method.
Proven by measurement rather than by re-reading the table: on a real 8.9 MB dex, **65 method
bodies did not decode to their declared boundary and 32 carried an operand index outside its
table**; after the fix, **50,791 of 50,791 land exactly on `insns_off + insns_size*2` with 0
illegal indices**. Independently cross-checked against androguard's decoder, which shares no code
with this project: same method count (50,791), and **0 differing width sequences** across all of
them.
Names and widths now come from the format specification's table, and widths are a lookup
(`OP_UNITS`) rather than a chain of range tests. The `goto` family in `branch_target()` and in
`dex_find_insn.py`'s kind map moved with it.
## `find_refs.py` — broken — fixed and re-verified
The docstring said it accepts "a smali tree or a directory of dex files". It did not:
`iter_files()` yielded only files ending in `.smali`. A direct `.dex` was yielded, then read as UTF-8
text and regex-matched against smali syntax, which cannot match; a directory holding only dexes
yielded nothing at all. Both printed `[total refs] 0` and a note blaming the needle, so a real dex, a
dex directory and a typo'd path were **indistinguishable in the output**.
This is the worst shape a defect can take in this particular tool, because `find_refs.py` is the
script recommended for judging blast radius *before* patching. Its silent zero is exactly the
input to a confident wrong conclusion — "nothing references this, safe to patch". Because no
baksmali/jadx is installed on this machine, it was **completely unusable** for this target, and the
analysis had to be built on `dexutil.py` instead.
Two corrections to the record above, both from re-measuring rather than re-reading:
- The exit code was **1**, not 0 — the `total == 0` path has always returned 1, so a caller checking
status could already catch it. What misled was the *text*: the note diagnosed the needle and never
named the input form.
- The dex-as-text path also read the whole file into memory to regex-scan binary for nothing, then
dropped it.
Fixed in three ways at once, because all three were the same omission — the tool did not tell the
difference between "found nothing" and "read nothing":
- A `.dex` is decoded through `dexutil` (no baksmali needed), a directory is walked for `.smali` and
`.dex` alike, and an archive is opened for its `classes*.dex`.
- Unsupported or unreadable input is **refused with exit 2** and a reason, instead of being reported
as zero references.
- Every run prints `[scanned] N file(s)` first, so the two cases can never print identically again.
Re-verified twice, in both directions:
- Against a fixture whose call graph is known **by construction** (`make_fixture_dex.py` writes two
classes with 3, 2 and 2 references): the `.dex` and the equivalent smali tree return the same
counts, and they match what was written — 3 / 2 / 2.
- Against the real 8.9 MB target: `Ljava/lang/String;->length` → **356 references in 2.3 s**; a
five-dex directory → 9,472 across 2 files; a typo'd path → `[error] no such file or directory`,
exit 2.
**Correction to a related assumption in this environment:** `readelf` on `PATH` resolves to
pyelftools' `readelf.py` and is *not* binutils — `--dyn-syms` and `-W` are rejected as unrecognized
arguments (exit 2). Likewise MinGW `objdump`/`nm` (binutils 2.28) reject every aarch64 ELF with
`File format not recognized`. Aarch64 cross-checks in this pass therefore used the **pyelftools,
lief and capstone library APIs**, not those CLIs. There is no `xxd`; hex work was done in Python.
---
## Dart / Flutter scripts
Verified facts, each independently cross-checked rather than accepted on exit code:
**`dart_pool_strings.py` — verified on real target.** 18,363 rows recovered. Byte-exact re-check of
tag + payload: **18,356 exact, 7 mismatched**, and all 7 mismatches are a single known escape
ambiguity (`\n`/`\r`/`\t` written as the two characters backslash+n, indistinguishable in a TSV from
a literal backslash followed by n — 19 recovered entries genuinely contain LF/CR/TAB). 12 rows
sampled at random were independently re-located from the raw bytes; **all 12 reproduced**, with tag
at `tag_off` and payload at `tag_off + 1` exactly. Chain violations among run≥2 rows: **0**.
**`dart_pprefs.py` — broken — fixed.** The first run produced a real defect: **302 displacements and
1,802 reference sites missing**. Correcting the mask makes it agree with capstone exactly:
```
repo (before) repo (fixed) capstone
displacements 31,843 32,145 32,145
reference sites 97,571 99,373 99,373
only-in-capstone 302 0 —
only-in-repo 0 0 —
```
**Root cause — corrected after deeper analysis, and worth recording because the first hypothesis
was wrong.** The obvious guess was an `x27` addressing mask. That is not it: `LDUR` with `Rn=x27`
occurs **zero** times in this snapshot. The actual cause is that the scanner decodes only the
**general-purpose register file** — `ldr dD, [x27, #imm]` (encoding `0xFD400000`) was never matched
at all, so an entire class of **double constants** loaded from the object pool was silently
dropped. Measured recall 98.19%, precision 100%; after the fix, capstone and the script agree
across the whole `.text` with 0/0.
Why this is the expensive kind of bug: it breaks precisely the judgement the documentation tells
you to rely on in `dart-aot.md` §9/§10 — *count the references to decide whether a pool entry
matters*. At one measured site the count goes from `[0 refs]` to `[79 refs]`. A reader following
that advice would conclude a live, heavily-referenced object was dead. It affects all Dart 3.x
arm64 snapshots, and the failure is one-sided and silent.
**`dart_disasm.py` — verified on real target.** Decoded output compared against capstone on the same
ranges: **32/32 and 96/96 instructions identical**, `VERDICT: identical decode`. The independently
re-derived caller index also agrees exactly: `callers.json targets=59904 sites=219181`, independent
re-derivation `targets=59904 sites=219181`, **symmetric difference of call sites: 0**. All B/BL
words in range confirmed by capstone: 27,615/27,615.
**`dart_disasm.py` — verified on real target.** Decoded output compared against capstone on the same
ranges: **32/32 and 96/96 instructions identical**, `VERDICT: identical decode`. The independently
re-derived caller index also agrees exactly: `callers.json targets=59904 sites=219181`, independent
re-derivation `targets=59904 sites=219181`, **symmetric difference of call sites: 0**.
**`dart_pool_strings.py` two-byte mode — unusable here because detection is unreliable.** With
`--two-byte`: 936,136 candidates, 57,049 kept (34,216 UTF-16LE). The script's own warning says the
detection is unreliable, and the independent measurement confirms it: 168,700 independent UTF-16LE
CJK runs, and of 9,840 two-byte CJK candidates only 2,081 form chained runs ≥2 — the rest are
coincidental. Sample output is noise (`誋讋讋讋讋`). The one-byte default is the right default.
---
## `coldstart.py` — verified on real target
Used exactly as documented — `--serial`, `--pkg`, `--activity`, `--duration`, `--interval`,
`--expect-activity`. It behaved as advertised and produced the finding that governs this whole
report: the app reaches a splash window and then aborts. The script's existence is why the launch
failure was caught as a *measurement* (timestamped foreground-activity and logcat timeline) rather
than as a vague impression, and its `--expect-activity` design directly addresses the failure it
names in its own docstring — a vendor installer window being captured and read as the app.
---
## External toolchains
Both Dart AOT analyzers were tested, and they behave differently enough that the difference is
itself a finding.
### blutter — **works; the first-run crash is not reproducible**
`blutter` compiles and embeds a matching Dart VM, so it needs one build per Dart version. Building
from source is **mandatory** — the repository's `bin/` is gitignored and it publishes **no releases**,
so there is no prebuilt Dart 3.6.0 executable to fetch. (Naming, for anyone searching:
`bin/blutter_dartvm3.6.0_android_arm64.exe`, not `_windows_x64`.)
Measured cost, which corrects the documentation:
| Step | Time |
|---|---|
| `git clone --depth 1 worawit/blutter` (HEAD `4a60ac6`) | seconds |
| `python scripts\init_env_win.py` (ICU 73.2 + capstone 4.0.2) | **7,049 ms** |
| sparse clone `dart-lang/sdk` tag `3.6.0` | seconds |
| cmake + ninja, 22 targets (unity build via `dartvm_create_srclist.py`) | — |
| link + install `bin/blutter_dartvm3.6.0_android_arm64.exe` | — |
| **whole pipeline, wall clock** | **≈ 78 s** |
`dart-aot.md` §2 budgets "**tens of minutes** for the first build". Reality here is **≈78 s**,
roughly **30× pessimistic**. The toolchain requirement is also overstated: the README asks for a
very recent C++ compiler with C++20 `<format>` and the doc repeats "VS 2022 required", but
**MSVC 19.34 (VS 17.4.3, Nov 2022) is sufficient** — verified by compiling and running a
`std::format` probe with it.
**The crash, and a correction to the first reading.** Run 1 (`blutter.py` under `vcvars64`) built the
VM and linked the exe, then died with exit `3221225477` = **0xC0000005 ACCESS_VIOLATION** and **no
output at all**. Runs 2-4 (invoking the installed exe directly, on `libapp.so`, `libflutter.so` and a
nonexistent path) reproduced it in ~89 ms each — always after argument parsing succeeded (a bad ABI
still prints `ELF: Support only 64 bits` and exits 0) and before the first stdout line, pointing at
`DartApp app{ libappPath.c_str() }` (`blutter/src/main.cpp:29`).
Adding two `std::cerr` traces and relaunching `ninja` (2 targets: recompile `main.cpp`, relink)
produced an executable that **ran to completion, exit 0** (`libapp is loaded at 0x1dfe6d60000 /
Dart heap at 0x1e000000000`) — and then the **originally installed binary also ran clean**: 3/3 runs,
exit 0, 5,899 / 5,862 / 5,928 ms, each producing `pp.txt` (2,639,894 B), `objs.txt` (648,382 B),
`asm/`, and `blutter_frida.js`.
So the verdict is **not** "unusable here". The root cause of the transient fault is **not identified**
and it did not reproduce after the relink, including for the unchanged binary; the best-supported
reading is a first-run/environment artifact rather than a property of this snapshot or of Dart 3.6.0.
Cleared suspects: `std::format` works on MSVC 19.34; capstone 4.0.2 and ICU 73.2 DLLs sit beside the
exe; `--no-analysis` is unnecessary because the full path works. The practical rule this yields:
**budget one retry, and if it still faults, relink before concluding anything about the sample** —
here a tool crash did not mean the target lacked anything.
### aotopsy — **works out of the box**
`aotopsy_v1.6.0_windows_amd64.zip`, 2,994,902 B, sha256
`5a90bcf27f4aaa281ae393bf3038b6e1e2325386fe61c1a18ed089390983fdc1` (matches the release manifest).
Unpack and run — **no compiler, no Dart SDK, no toolchain**, because it parses the snapshot format
directly in Go.
| | result |
|---|---|
| `doctor` | Support **OK**; compressed 4-byte pointers; hash `f956f595844a2f845a55707faaaa51e4`; the same 9 feature flags blutter derived |
| full pipeline | exit 0 — **30,586 functions**, 6,133 class layouts, **39,202 pool entries (38,274 resolved)**, **175,120 call edges** (18,718 BLR, **95.7% annotated**), 20,927 string refs, 556 signal findings |
| artifacts | `functions.jsonl`, `classes.jsonl`, `call_edges.jsonl`, `string_refs.jsonl`, `dispatch_table.jsonl`, `evidence.jsonl`, `asm/` (16,046 files), a `--decompile` pseudocode mode, `_debug strings --xref` |
**The Dart version discrepancy, resolved.** aotopsy `doctor` says **3.6.2**; blutter derives **3.6.0**
from the engine banner; the banner reads `3.6.0 (stable) (Thu Dec 5 07:46:24 2024 -0800)`; and there
is **no `3.6.2` byte sequence anywhere in `libflutter.so` or `libapp.so`**.
Judgement: **3.6.0 is the correct SDK version (observed)** — three independent facts agree (byte
search, blutter's parser, and the fact that the Dart 3.6.0 runtime blutter built accepts the snapshot
hash). aotopsy's `3.6.2` is its own **structural profile label (inferred)**: its README states version
detection is structure-based because the snapshot carries a git-derived hash rather than a version
number, so the label names the newest profile matching that hash. Impact on support is nil, but
**`aotopsy doctor`'s number must not be fed to a VM-compiling tool as an SDK tag.** The banner route
taught in `dart-aot.md` §1 is correct and is the best-validated instruction in that document.
---
## End-to-end: can the kit locate one specific piece of business logic?
**Yes — with an external snapshot dumper in front of it.** This is the question the whole pass was
built to answer, so the worked example is recorded in full.
| # | Tool | Result |
|---|---|---|
| 1 | blutter `pp.txt` | `[pp+0x23548] String: "showRewardAdProvider"` |
| 2 | `dart_pprefs.py --lookup pp_refs_fixed.json 0x23548` | **1 ref site: `0x7a9464`** |
| 3 | `dart_disasm.py libapp.so --pp bout5/pp.txt --refs pp_refs_fixed.json 0x7a9464` | annotated window (below) |
| 4 | `dart_disasm.py --index callers.json 0x7a93ec` | **9 callers** |
| 5 | independent confirmation | `aotopsy _debug strings --find rewardAd --xref` → `used in: ShowRewardAdFamily.call_2f28ac @ 0x7a93ec (pool load @ 0x7a9464, pool[18087])` |
Step 3 resolves the site completely, including the enclosing Dart closure and its source file:
```
7a943c: add x1, x27, #0x23, lsl #12
7a9440: ldr x1, [x1, #0x540] ; pp+0x23540 AnonymousClosure: (0x7a9508),
; of [package:<app>/pages/video/video_page.dart] ShowRewardAdProvider
7a9454: add x1, x27, #0x23, lsl #12
7a9458: ldr x1, [x1, #0x430] ; pp+0x23430 Obj!ShowRewardAdFamily@b487a1 : { [8 refs]
```
Two analyzers built by unrelated projects agree on the function name, the address and the pool index
— which is what makes this a verification rather than a coincidence.
**But the kit cannot take step 1 by itself.** Reaching a pool offset from a string requires resolving
the snapshot's internal container, and the kit has no such resolver: 4,241 strings share a name across
4,237 distinct deltas with no constant bridge between the string table and the object pool. blutter
(or aotopsy) supplies that front end. Everything downstream of it — the cross-reference count, the
disassembly window, the caller index — the kit does do, and correctly.
### Documentation defect that matters more than the crash
`dart-aot.md` §2 says of blutter's `asm/`: *"per-class declarations — instructions — **there are none
here**. Do not plan around it."* That is **wrong** for this blutter HEAD on Dart 3.6.0.
`bout5/asm/<plugin>/src/ads/reward/` is 1,327 lines / 71,099 B and contains class
layouts, function signatures **and a full instruction listing with pool annotations and resolved call
targets**:
```
// 0x923b3c: r0 = LinkedHashMap.from()
// 0x923b3c: bl #0x60165c ; [dart:collection] LinkedHashMap::LinkedHashMap.from
```
`asm/` is the single richest artifact in the chain, and the document tells the reader to ignore it.
(Secondary nit: `asm/` is one file per **library URI**, mirroring the package path, not per class.)
## Not applicable to this sample — stated, not stretched
- **`native_crash.py`'s "artificially constructed crash" verdict.** The tool exists to decide
whether a crash was *manufactured* as an anti-tamper response. This target's crash is a genuine
internal mutex-lifecycle fault that occurs on the unmodified APK, before any modification, on two
devices. There is no decoy to detect and no tamper event to attribute, so the judgement the tool
makes was never exercised. **Not applicable — no artificial crash exists here.**
- **`so_constpatch.py`'s "redirect the checker library" use case.** Requires an integrity-check
library to subvert. This target has **none** — no packer, no integrity checker, no `.so` that
validates the APK. The redirection capability is real (see the verified `NEEDED` rewrite) but its
purpose-built scenario does not exist here.
- **`packers.md` / `code-virtualization-and-custom-linkers.md` in full.** No packer: zero hits for
every known hardening-library name, and the parsed manifest shows an ordinary application class
rather than a shell stub. **Unverified in this pass** — nothing here should be read as evidence
either way about how those documents perform.
- **`native-tamper-and-suicide.md`'s judgement chain.** It reads *native anomalies observed from the
APK side* (immediate SIGSEGV after a repack, the Java layer saying the check passed while the
process dies). This pass never produced a repack to observe, because **the unmodified app does not
start**, so there is no working baseline to regress from. The document's scenarios remain
unexercised.

View File

@@ -0,0 +1,262 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Build a minimal, structurally valid dex whose reference counts are known.
WHY THIS EXISTS
---------------
Verifying a dex reader against a real APK only shows that two readers agree.
Verifying it against a program whose call graph was written down in advance shows
that the *number* is right. This fixture encodes exactly two method bodies:
Lcom/example/Caller;->go()V invoke-static Lcom/example/Target;->run()V x2
Lcom/example/Target;->run()V invoke-virtual Ljava/lang/String;->length()I x3
so the answers a blast-radius check must return are 3, 2 and 2. The same call
graph is also written out as a smali tree, because the point of the fix this
fixture verifies is that a `.dex` and a smali tree answer a needle identically.
USAGE
-----
python make_fixture_dex.py out.dex [--smali DIR]
# then, from the skill's scripts directory:
python find_refs.py out.dex 'Ljava/lang/String;->length' # expect 3
python find_refs.py out.dex 'Lcom/example/Target;->run' # expect 2
python find_refs.py out.dex 'Lcom/example/Target;' # expect 2
python find_refs.py DIR 'Ljava/lang/String;->length' # expect 3
Dependency-free (stdlib only) on purpose: the fixture must be reproducible on a
machine with no Android toolchain, which is where the defect it verifies was found.
Nothing here is target data. The dex is synthesised from the constants below.
"""
import argparse
import hashlib
import os
import struct
import zlib
CALLER = 'Lcom/example/Caller;'
TARGET = 'Lcom/example/Target;'
OBJECT = 'Ljava/lang/Object;'
STRING = 'Ljava/lang/String;'
SMALI_CALLER = """.class public Lcom/example/Caller;
.super Ljava/lang/Object;
.method public static go()V
.registers 0
invoke-static {}, Lcom/example/Target;->run()V
invoke-static {}, Lcom/example/Target;->run()V
return-void
.end method
"""
SMALI_TARGET = """.class public Lcom/example/Target;
.super Ljava/lang/Object;
.method public static run()V
.registers 1
invoke-virtual {v0}, Ljava/lang/String;->length()I
invoke-virtual {v0}, Ljava/lang/String;->length()I
invoke-virtual {v0}, Ljava/lang/String;->length()I
return-void
.end method
"""
EXPECTED = (('Ljava/lang/String;->length', 3),
('Lcom/example/Target;->run', 2),
('Lcom/example/Target;', 2))
def uleb(n):
out = bytearray()
while True:
b = n & 0x7F
n >>= 7
out.append(b | (0x80 if n else 0))
if not n:
return bytes(out)
def p16(v):
return struct.pack('<H', v)
def p32(v):
return struct.pack('<I', v)
def string_data(s):
raw = s.encode('utf-8')
return uleb(len(raw)) + raw + b'\x00'
def code_item(registers, outs, insns):
body = b''.join(p16(u) for u in insns)
return (p16(registers) + p16(0) + p16(outs) + p16(0)
+ p32(0) + p32(len(insns)) + body)
def invoke(op, method_idx, count=0, c=0, g=0):
"""35c layout: op(G<<8)(count<<12), index, then the register nibbles."""
return [op | (g << 8) | (count << 12), method_idx, c & 0xF]
def class_data(entries):
"""entries: [(method_idx, access_flags, code_off)], ascending by method_idx."""
out = uleb(0) + uleb(0) + uleb(len(entries)) + uleb(0)
prev = 0
for mi, acc, co in entries:
out += uleb(mi - prev) + uleb(acc) + uleb(co)
prev = mi
return out
def build():
strings = sorted({CALLER, TARGET, OBJECT, STRING, 'I', 'V', 'go', 'run', 'length'})
sidx = {s: i for i, s in enumerate(strings)}
types = sorted({CALLER, TARGET, OBJECT, STRING, 'I', 'V'}, key=lambda t: sidx[t])
tidx = {t: i for i, t in enumerate(types)}
protos = [('I', 'I'), ('V', 'V')] # (shorty, return type)
protos.sort(key=lambda p: tidx[p[1]])
pidx = {shorty: i for i, (shorty, _r) in enumerate(protos)}
methods = [(CALLER, 'go', 'V'), (TARGET, 'run', 'V'), (STRING, 'length', 'I')]
methods.sort(key=lambda m: (tidx[m[0]], sidx[m[1]], pidx[m[2]]))
midx = {(c, n): i for i, (c, n, _r) in enumerate(methods)}
insns_go = invoke(0x71, midx[(TARGET, 'run')]) * 2 + [0x000E]
insns_run = invoke(0x6E, midx[(STRING, 'length')], count=1) * 3 + [0x000E]
off = 0x70
string_ids_off = off
off += 4 * len(strings)
type_ids_off = off
off += 4 * len(types)
proto_ids_off = off
off += 12 * len(protos)
method_ids_off = off
off += 8 * len(methods)
class_defs_off = off
off += 32 * 2
data_off = off
data = bytearray()
def align(n):
while len(data) % n:
data.append(0)
str_offs = []
for s in strings:
str_offs.append(data_off + len(data))
data += string_data(s)
align(4)
code_go_off = data_off + len(data)
data += code_item(0, 0, insns_go)
align(4)
code_run_off = data_off + len(data)
data += code_item(1, 1, insns_run)
align(4)
cd_go_off = data_off + len(data)
data += class_data([(midx[(CALLER, 'go')], 0x0009, code_go_off)])
cd_run_off = data_off + len(data)
data += class_data([(midx[(TARGET, 'run')], 0x0009, code_run_off)])
align(4)
map_off = data_off + len(data)
def map_item(kind, size, offset):
return p16(kind) + p16(0) + p32(size) + p32(offset)
items = [
(0x0000, 1, 0), # header_item
(0x0001, len(strings), string_ids_off),
(0x0002, len(types), type_ids_off),
(0x0003, len(protos), proto_ids_off),
(0x0005, len(methods), method_ids_off),
(0x0006, 2, class_defs_off),
(0x2002, len(strings), str_offs[0]), # string_data_item
(0x2001, 2, code_go_off), # code_item
(0x2000, 2, cd_go_off), # class_data_item
(0x1000, 1, map_off), # map_list
]
items.sort(key=lambda it: it[2])
data += p32(len(items)) + b''.join(map_item(*it) for it in items)
string_ids = b''.join(p32(str_offs[i]) for i in range(len(strings)))
type_ids = b''.join(p32(sidx[t]) for t in types)
proto_ids = b''.join(p32(sidx[shorty]) + p32(tidx[ret]) + p32(0)
for shorty, ret in protos)
method_ids = b''.join(p16(tidx[c]) + p16(pidx[r]) + p32(sidx[n])
for c, n, r in methods)
def class_def(class_idx, super_idx, class_data_off):
return (p32(class_idx) + p32(0x0001) + p32(super_idx) + p32(0)
+ p32(0xFFFFFFFF) + p32(0) + p32(class_data_off) + p32(0))
classes = sorted([(CALLER, cd_go_off), (TARGET, cd_run_off)], key=lambda c: tidx[c[0]])
class_defs = b''.join(class_def(tidx[c], tidx[OBJECT], cdo) for c, cdo in classes)
h = bytearray(0x70)
h[0:8] = b'dex\n035\x00'
h[32:36] = p32(data_off + len(data)) # file_size
h[36:40] = p32(0x70) # header_size
h[40:44] = p32(0x12345678) # endian_tag
h[52:56] = p32(map_off)
h[56:60] = p32(len(strings))
h[60:64] = p32(string_ids_off)
h[64:68] = p32(len(types))
h[68:72] = p32(type_ids_off)
h[72:76] = p32(len(protos))
h[76:80] = p32(proto_ids_off)
h[80:84] = p32(0) # field_ids_size
# There are no fields. Size 0 is correct; the offset points at the next
# section rather than 0 only because a zero offset reads as "out of range"
# to a structural check that treats every offset as present.
h[84:88] = p32(method_ids_off)
h[88:92] = p32(len(methods))
h[92:96] = p32(method_ids_off)
h[96:100] = p32(2) # class_defs_size
h[100:104] = p32(class_defs_off)
h[104:108] = p32(len(data))
h[108:112] = p32(data_off)
blob = bytearray(bytes(h) + string_ids + type_ids + proto_ids + method_ids
+ class_defs + bytes(data))
blob[12:32] = hashlib.sha1(bytes(blob[32:])).digest() # signature first
blob[8:12] = (zlib.adler32(bytes(blob[12:])) & 0xFFFFFFFF).to_bytes(4, 'little')
return bytes(blob), len(strings), len(types), len(methods)
def main():
ap = argparse.ArgumentParser(description=__doc__.split('\n')[0])
ap.add_argument('out', nargs='?', default='fixture.dex')
ap.add_argument('--smali', metavar='DIR',
help='also write the same call graph as a smali tree')
a = ap.parse_args()
blob, n_str, n_ty, n_m = build()
with open(a.out, 'wb') as fh:
fh.write(blob)
print('wrote %s (%d bytes, %d strings, %d types, %d methods, 2 classes)'
% (a.out, len(blob), n_str, n_ty, n_m))
if a.smali:
d = os.path.join(a.smali, 'com', 'example')
os.makedirs(d, exist_ok=True)
with open(os.path.join(d, 'Caller.smali'), 'w', newline='\n') as fh:
fh.write(SMALI_CALLER)
with open(os.path.join(d, 'Target.smali'), 'w', newline='\n') as fh:
fh.write(SMALI_TARGET)
print('wrote smali tree under %s' % a.smali)
print('expected: ' + ' '.join('%s=%d' % kv for kv in EXPECTED))
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,49 @@
# mypy configuration for this repository.
#
# The repository is designed to check out and run its gates with only the standard library plus
# pytest (see pytest.ini and .github/workflows/ci.yml): frida, capstone, tiktoken and PyYAML are
# optional runtime dependencies that scripts import lazily or behind try/except, with documented
# fallbacks. Do not require them -- or their stubs -- to be installed for
# `mypy *.py skills/apk-reverse/scripts/*.py` to pass. When they *are* installed and typed
# (e.g. types-PyYAML), mypy still uses the real stubs; these overrides only apply where the module is
# missing or untyped.
#
# When installed, capstone ships no py.typed marker; frida and tiktoken are host-side extras.
# Everything else in the tree is analyzed normally, including every gate script -- which is the point:
# check_repo/check_refs/check_budget/check_routing/check_commands carry the behaviour of the whole
# repository, so they are worth type-checking rather than excluding.
#
# Enforced in CI: the `lint` job in .github/workflows/ci.yml runs mypy with exactly this
# configuration, on a bare interpreter (no frida/capstone/tiktoken/PyYAML installed), so the
# overrides below are exercised rather than skipped.
#
# Verified locally on 2026-09-22: `mypy` over 55 files reports no issues with the overrides in place,
# on a venv containing only mypy and flake8.
[mypy]
python_version = 3.9
warn_unused_ignores = False
[mypy-frida]
ignore_missing_imports = True
[mypy-frida.*]
ignore_missing_imports = True
[mypy-capstone]
ignore_missing_imports = True
[mypy-capstone.*]
ignore_missing_imports = True
[mypy-tiktoken]
ignore_missing_imports = True
[mypy-tiktoken.*]
ignore_missing_imports = True
[mypy-yaml]
ignore_missing_imports = True
[mypy-yaml.*]
ignore_missing_imports = True

View File

@@ -0,0 +1,27 @@
[pytest]
# Regression harness for the apk-reverse skill.
#
# What lives here: executable checks that can run on a bare checkout with only
# the standard library plus pytest. No device, no adb, no frida, no network.
# What does *not* live here: anything that needs a phone. Those routes stay in
# tests/benchmark.md as recorded experiments, and their coverage status is
# stated in docs/tool-verification/EXTENSION-test-harness.md.
#
# If pytest is not installed:
# python -m pip install pytest
# or run `python tests/run_tests.py`, which prints the same instruction and
# exits 3 (environment-capability failure, per the repository exit-code policy).
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
markers =
unit: pure-function and parser assertions against real scripts; zero external deps
cli: subprocess contract tests over script flags, exit codes and --json output
integration: no-device flows (adb argv construction, log/tombstone parsing, timers)
# A mis-typed marker or an unknown ini key is a harness bug, not a warning.
addopts = -ra --strict-markers --strict-config

View File

@@ -0,0 +1,408 @@
---
name: apk-reverse
description: "Reverse engineer, debloat, de-ad, patch, or re-sign Android APKs, and analyze their runtime and server-side behaviour. Use for an .apk/.aab/.dex/.so sample, smali or dex patching, Frida hooking, repacking, ad or SDK removal, API probing, or deciding whether a client-side patch can work. Covers recon, anti-tamper, membership limits, surgical dex patching, repack pitfalls, device setup, and a failure catalogue."
license: MIT — see LICENSE at the repository root
compatibility: "Python 3.9+. Device work needs adb; dynamic analysis a matching frida-server; re-signing zipalign plus apksigner, not just a JVM. Static dex/ELF work and the leak scanner are offline. Run doctor.py --json for status."
metadata:
version: "1.0"
capability_registry: skills/apk-reverse/scripts/capabilities.py
evidence_summary: skills/apk-reverse/references/evidence-summary.md
last_reconstruction_pass: "2026-09-22"
strength_labels: "observed | inferred | unverified"
---
# APK Reverse Engineering & Patching
Goal: reach a **verified, installable, still-working artifact** fast — and avoid the whole class of
mistakes that destroy an APK while looking completely healthy.
## Immediate — do these four things before anything else
This file is a **procedure with gates**, not background reading, and it is long enough that its middle
gets skimmed. So the four moves come first; everything below is the explanation for them.
**1. Classify before choosing a route.** Answer the thirteen questions in §Start here. **R4** decides
whether you are editing the right layer at all, and a wrong branch does not fail loudly — it produces
an artifact that builds, runs, and does the wrong thing.
**2. Clear the four gates, in order, with their pass criteria** (§Gates). G1 names the deliverable form
in one testable sentence *before* any work; G2 establishes what this machine can really do; G3 locates
the code; G4 records the baseline and the control. "I understand the idea" is not clearing a gate.
**3. A symptom you cannot explain is a stop signal, not a puzzle.** Search §Symptom index for the shape
**before your next attempt** and load the file that row names. Those rows exist because each one cost
hours — and in several cases the answer was already written down while it was being re-derived.
**4. Two strikes on one shape of attempt sends you back to classification**, not to a third variant
(§Stop conditions). And **never report done without the six items in §What "done" means.**
**Two rules about this file itself.** When a failure does not fit your plan, the symptom index is the
next action, not more reasoning. And when this file and your own reasoning disagree, **this file wins**
until you have evidence that overrides it: every rule here is the residue of a failure that cost
hours, and your current intuition is the intuition of someone who has not hit it yet.
## Four rules that override everything else
**R1 — Write the deliverable as a testable sentence before you touch the target.**
"It works" is not the goal; "it works under the stated constraint" is. Root-assisted, live-
instrumentation, host-proxy and patched-device results frequently do **not** satisfy a request for an
installable artifact that works on a normal phone — and it is easy to present such a result as
finished. Write the sentence, re-read it at every checkpoint, and if you cannot meet it, say so
plainly and label the privileged workaround a **fallback**, never the deliverable.
→ `references/long-task-discipline.md` §the most expensive drift
**R2 — Change one variable at a time, and keep a control build.**
An experiment that flips two things teaches nothing when it fails, and a failure you cannot attribute
will be attributed to the wrong cause. Every "the app rejects X" claim needs its own run, and every
patch needs a same-pipeline control that still fails the old way.
→ `references/long-task-discipline.md` §single-variable discipline
**R3 — Never ship or claim an unverified artifact.**
"It assembles" is not "it works"; "the process started" is not "the feature works"; "no error in the
log" is not "the check is gone". Install it, launch it, exercise the exact feature you changed, and
look at the screen. Prove the device is running the build you made — hash it, do not trust the
filename.
→ `references/verification.md`
**R4 — Identify the owning layer before patching, and re-classify when reality disagrees.**
Ads, paywalls, feature gates, integrity checks and update gates live in different layers (Java, dex,
native, Dart/Unity, server). Patching the wrong layer either does nothing or breaks the app. If a
patch "had no effect", the diagnosis was wrong — go back to classification instead of patching harder.
→ `references/recon.md`, then the layer-specific file the symptom index points at
## Tooling — reach for the right instrument, and check before declaring it absent
Most wasted rounds in this domain are not bad reasoning about the target. They are **the right question
asked of a tool too weak to answer it**: an hour of `grep` over a hand-exported smali tree where one
indexed query would do, a manual ELF walk where a decompiler was one `pip install` away. The failure is
invisible from the inside, because the weak route still produces output.
**Five obligations. These are instructions; a violation is a defect, not a preference.**
| Obligation | Do this | Cost of ignoring it |
|---|---|---|
| **Orient with an indexer, not an export** | Build the ability to *ask the artifact questions* before reading code: `droidasc findrefs` / `ddc findrefs` (string/type/method → every reference site, sub-second). A decompile reads a class you have **already located** — it is not how you locate it. `jadx` is a readable viewer of last resort, never the source of truth and never the entry point of a recon | hours of `grep` per question asked, over an export you paid for first |
| **Install the missing tool; it is part of the task** | A missing arm64 decompiler is the next step, not a constraint to route around. Ask a human only when installation is genuinely impossible | hours re-derived by hand for output a decompiler gives in minutes |
| **Name the gap before spending against it** | State the capability the blocker requires and whether this machine has it, **out loud**. This is a G2 item | designing around a tool that installs in ten minutes |
| **Reach for a shipped script before writing one** | Parsing, hashing, alignment and hot-plug probes are already written; a bespoke script in place of `scripts/dex_find_insn.py` is how offsets get **guessed instead of computed** | a wrong offset that decodes cleanly and behaves wrongly |
| **Pick the instrument for the layer the question lives on** | A dex in memory wants `dex_mem_scan.py` (find and cut) **plus** `dex_dump_validate.py` (judge), not a decompiler pointed at a fragment. An algorithm you only have to *call* wants `emulation-and-rpc.md`. "Does this class ever load at runtime" wants a hook that fires or does not | the heavier tool is not the safer one — it produces a *plausible* answer, which is worse than none |
**Run `python skills/apk-reverse/scripts/doctor.py --json` before concluding anything is unavailable.**
It reports per-capability closure — what is missing, what to install, and roughly how long that takes —
and a capability you have not checked for is not a capability you lack. **It is deliberately allowed to
report `BLOCKED`:** a machine with a JVM but no `zipalign`/`apksigner` cannot re-sign, and the report
says so rather than inferring capability from a tool that is merely present. Detail:
`references/toolchain.md`; the registry it reads is `scripts/capabilities.py`.
## Coverage — what this skill claims, and what it does not
The failure this section prevents is not ignorance. It is **a confident wrong answer produced by
applying the nearest available procedure to a target it was never written for.** A documented method
that almost fits is more dangerous than no method at all, because it arrives with a plan, a
vocabulary and a set of reassuring numbers.
**How strong these claims are — this is load-bearing, not cosmetic.** Every claim in this skill carries
one of three labels, and you must label your own results the same way:
| Label | Means | May it justify a decision? |
|---|---|---|
| `observed` | a command was run and its output exists | yes |
| `inferred` | follows from an observation, but the step is reasoned | provisionally |
| `unverified` | assumed, or reported elsewhere and never reproduced here | only as something to test |
**The inventory that replaces a guess:** the per-route list of what is covered and what is not — by
verified mechanism versus by documented-but-mostly-inferred route — is in
`references/coverage-and-limits.md`. Load it when the question is "can this skill actually do X".
**Partial coverage that is easy to over-read** — these are documented routes whose *dependency* this
skill does not ship, and each has a fence around it in the same file: Dart AOT analysis needs a
snapshot dump you must obtain elsewhere and naming the front end matters · extraction-shell recovery
stops at the VMP boundary · a Stalker trace is not guaranteed on every device · a kernel-side answer
requires a module this skill only scaffolds.
**Not covered — say so rather than improvise:** Unity / IL2CPP · React Native / Hermes / Cordova ·
iOS · defeating a server-side authority · an off-the-shelf unpacker or an anti-detection arms race ·
building and shipping a kernel module. Reasons and evidence boundaries are in
`references/coverage-and-limits.md`; **silence in that record is not support.**
**The fallback, as an instruction — this is a rule, not advice.** If the target does not match the
covered list, or no symptom-index row matches, then **stop and classify before choosing a branch**:
answer the thirteen questions (§Start here). If the shape still does not fit — an unknown runtime, a
mechanism you cannot name — **say exactly that and propose the cheapest experiment that would
identify it.** Do not take the closest documented route and apply it anyway. A wrong branch here does
not fail loudly: it produces an artifact that builds, runs, and does the wrong thing.
## Hand-off points — where this skill ends and another view begins
Four boundaries that are easy to walk into without noticing. Each names what the other side owns
rather than restating it, because two copies of the same advice drift apart. The JNI form table and
the per-form verification table live in `references/handoff-boundaries.md`.
1. **JNI** — a Java `native` declaration and its implementation are two views of one function, and
this skill reads each with a different tool. A symbol search fails **silently** on dynamic
registration. Authoritative treatment: `java2c-and-jni-sinking.md` §The JNI boundary — why a
symbol search fails silently.
2. **Hardening** — a dex-side packer observation is a native-side implementation question.
`packers.md` owns the dex-side identification; the deep dive belongs on the other side.
3. **Native anomalies** — read them *from the APK side*, because the judgement is whether your patch
caused the death. Restoring a symbol or reversing an OLLVM function is a different activity with a
different toolchain: point across instead of extending `native-and-so.md` into it.
4. **Deliverable form** — when the artifact stops being an APK, the verification question changes with
it. G1's other three forms each move the evidence somewhere else, and the failure is quiet: a
privileged result reported in the language of a finished build.
## Symptom index — a matching row is a stop signal
You arrive at a symptom, not at a file name. Each row below is a failure that has already been paid
for. **If any row matches what you are observing, load the file before your next command** — not after
your next three attempts. Reasoning from first principles at this point is how the same hours get
spent twice; more than one entry here is a lesson that was re-derived by hand while the answer sat
unread in this repository.
| What you observe | Load first |
|---|---|
| A repackaged/re-signed build **dies before your code runs**; `SIGSEGV`, all registers zero, `pc=0`, `fault addr` near `0x0` | `native-tamper-and-suicide.md` (deliberate crash), then `code-virtualization-and-custom-linkers.md` |
| **No packer** (Application is the app's own, dex readable) **and it still dies** | `code-virtualization-and-custom-linkers.md` §a loader is still a possibility; but if the same build also dies on a *second, unrelated* device you are looking at an ordinary startup fault, not a hardened one |
| The app dies at startup on **every** device, packed or not, with **no tombstone** while `crash_dump` says `already traced` and logcat says `exited cleanly (0)` | a bundled crash reporter has taken the signal handlers, so the platform's own trail is gone. Frida spawn-gating is the recovery route |
| A `FORTIFY: pthread_mutex_lock called on a destroyed mutex` abort in a Flutter app, on the **main** thread, before the first frame completes | `dart-aot.md` — check `libapp.so` is actually being loaded; Flutter's engine bootstrap is the usual place a native lifecycle fault surfaces |
| Log says a **Java-layer** signature/integrity check **passed**, yet the process dies | `code-virtualization-and-custom-linkers.md` §a Java-layer "signature killer" is a decoy |
| Deleting a library fixes validation but yields `UnsatisfiedLinkError: dlopen failed: library "X" not found` | `code-virtualization-and-custom-linkers.md` §the deadlock that eats hours |
| Whole classes appear as bare `native` declarations with no body | `java2c-and-jni-sinking.md` — read it **before** dumping memory: if this is Java2C there is no DEX to find, at any point in the process lifetime. A handful of `native` methods in an otherwise ordinary dex is JNI sinking, not this |
| A `Java_*` search over a hardened library returns nothing at all | `java2c-and-jni-sinking.md` §The JNI boundary — why a symbol search fails silently — dynamic registration, or `-fvisibility=hidden`. The check that works is "exports `JNI_OnLoad` and zero `Java_*`" |
| You are about to publish an evidence file, a transcript or a README that quotes real work | `references/desensitization-and-leak-scans.md` — run `scripts/scan_leaks.py` **before** it is committed; the hit list is a set of lines to look at, and `--show-exempt` is where the wrong suppressions show |
| A hooking module appears to have run but its log tag is silent, and you are about to record "it never loaded" | `references/precedents/logd-broken-module-never-ran-case-3.md` — a broken `logd` delivers nothing on `logcat` while the module's whole run sits in LSPosed's file log; read both channels |
| A library's **SONAME does not match its filename** | `code-virtualization-and-custom-linkers.md`, `native-and-so.md` |
| Your edit had **no effect at all**, with no error | `server-config-and-updates.md` §3 (the value may be server-sent), then `packers.md` §map the validation boundary |
| Process **hangs** with no crash record, or dies to a `uid 0` killer | `native-tamper-and-suicide.md` §the rule (you probably made a terminate path *not return*) |
| Death looks like an ordinary null dereference in a hardened library | `native-tamper-and-suicide.md` §deliberate-crash stubs |
| The app dies **only while you are attached/rooted** | `detection-and-anti-analysis.md`; run the unmodified original under identical conditions first |
| **Install fails with `[-124]` and mentions `resources.arsc` / alignment** | `repack-and-sign.md` §2a — STORED **and** 4-byte aligned, both required |
| **Install fails with a bare numeric code (e.g. `[-99]`) and no `INSTALL_FAILED_*`** | `repack-and-sign.md` §vendor install interception — a device-side interceptor, not your build. Use the root `pm install` path |
| **After an install, `am start` does nothing / screenshots show another app / `am start -W` hangs** | `repack-and-sign.md` §the installer may still own the screen |
| Log shows `Failure to verify dex file ...: Bad checksum` and a startup `ClassNotFoundException` for an ordinary class | `byte-level-patching.md` §the dex header has two integrity fields — order matters |
| An install "succeeded" but nothing changed, or the version did not move | `long-task-discipline.md` §keep the observation window clean |
| Evidence contradicts itself, or a capture looks like two states mixed | `long-task-discipline.md` §keep the observation window clean |
| You took screenshots but drew the conclusion from logs or from the patch itself | `long-task-discipline.md` §captures you never looked at are not evidence |
| You are about to re-run an experiment whose result you already recorded | `long-task-discipline.md` §long-context decay |
| A script will not start, or a tool "is missing" | `scripts/doctor.py`, then `toolchain.md` §"not on PATH" is not "not installed" |
| A hook or probe reports **no events at all**, and you are about to call it detection | `scripts/anti_detect_probe.js` for the environment self-report first, then `detection-and-anti-analysis.md` §Step 3: locating the check — the order of search from Stage 0 |
| `attach` hangs and then fails **while the process is still in `ps`** | `detection-and-anti-analysis.md` §Step 3 Stage 0 — check for `D` in `/proc/<pid>/stat`, and attach a *different* pid as a one-line control before blaming the target |
| The app exits with no tombstone, no crash and no ANR record | `detection-and-anti-analysis.md` §Step 3 — a clean self-exit means the check ran before your hooks existed; the branch conditions there say which Stage |
| A dump region validates as the wrong thing, or an `r--s` view of `base.apk` looks like a dex | `advanced-unpacking.md` §What this route cannot do, and how to tell before you spend the window |
| Feature-scoped network failure (login/register/pay) while the rest works | `tls-and-cert.md` — do not assume your patch caused it |
| Everything works but **every signed request fails** after repack | `signature-derived-keys.md` |
| A re-signed build **runs fine, renders its whole UI and logs no error — but one feature silently never loads**, and `dumpsys`/DNS/logcat show **no request for it at all** (not a rejected request: *no request*) | `code-virtualization-and-custom-linkers.md` §what the native check actually reads — refusing **before** the request is built. Not the row above: "sent and rejected" and "never sent" have different owners |
| You cannot tell whether a missing feature is **your patch's fault or the target's own behaviour** | `long-task-discipline.md` §single-variable discipline. Run the **zero-change control through the same pipeline**, and the decisive variant: the unmodified original with the patch applied **in memory only**, same device, same network |
| Under Frida `spawn`, the UI never appears — `mCurrentFocus` stays `null`, screenshots come back blank, the Activity stack never builds | `dynamic-frida.md` §spawn keeps the Activity stack down: write the patch into memory, **detach**, then start the Activity normally |
| `frida-server` keeps disappearing mid-experiment, or the device reboots itself while you are working | `dynamic-frida.md` §when the ROM hunts your instrumentation |
| Ads still appear after a patch that should have killed them | `server-config-and-updates.md` §6 (cached config / remote re-enable), then `ad-removal.md` §step 4 (count the SDK's own log lines; n -> 0, not "I did not see it") |
| A forced-update or "must update" gate blocks the build | `updates-and-forced-upgrade.md` §step 6 |
| The dialog is gone but the feature is still locked | `membership-and-limits.md` / `account-gates.md` — decide server vs client authority before patching again |
| You are about to discard a route as "blocked" | `packers.md` — re-read it before writing any route off; mis-attributed failures have removed viable routes for hours |
| The task has run long and you are unsure what is already proven | `long-task-discipline.md` §keep a live record |
| A dumped dex parses in full, the classes are all there, and most method bodies are `return-void` stubs or nop fills | `references/advanced-unpacking.md` — an extraction shell: measure the `stub%` with `scripts/dex_dump_validate.py` before trusting any of it, and know that recovering the bodies is a different route |
| Your `frida` dump dies mid-write (`script has been destroyed`), or the process you are dumping keeps changing pid | `advanced-unpacking.md` §dumping when frida is refused — rule out memory pressure first; a reclaim-and-relaunch needs no instrumentation |
| A repack is refused by several independent checks, or the build has to keep working through store updates | `references/lsposed-and-modules.md` — deliver a module instead of an APK; G1's form table says when |
| A hook module is installed, enabled and scoped, yet its log tag never appears — and you are about to conclude it never ran | `lsposed-and-modules.md` §Deploy, enable, and verify — **a `logcat`-only verdict has already been wrong here**: on one ROM `logd` is broken and output reaches only `/data/adb/lspd/log/modules_<ts>.log` |
| A module's entry class is missing from its own dex (so it can never load), yet the package installs, enables and looks healthy | `references/lsposed-and-modules.md` — check `assets/xposed_init` against the dex's actual classes; installation is not evidence of anything |
| You only need to **call** the target's own routine (sign, token, encrypt) rather than change the app | `references/emulation-and-rpc.md` — emulate it, or service-ify the live function over Frida RPC |
| A native function is a many-thousand-line `switch` state machine, or the decompiler's output is meaningless | `references/native-dbi-and-deobfuscation.md` — OLLVM shapes, a Stalker trace, and how far a trace actually gets you |
| `Stalker.follow` installs but no events arrive, or following a hot libc export crashes the process | `references/native-dbi-and-deobfuscation.md` §6 failure modes — this repository measured both |
| The traffic is protobuf/gRPC/QUIC, or a proxy sees TLS but requests still fail on a Flutter app | `references/protocol-reverse.md` — schema-less protobuf, frame capture, and native-side pinning |
| Userspace hooks land and the app still dies: the check reads `/proc/self/status` through a raw `svc`, or runs before `JNI_OnLoad` | `references/kernel-and-environment-hardening.md` — what the next layer up and down can actually do, and when to stop |
| You must edit, repack, sign or inspect the APK **from the phone itself** | `references/on-device-tooling.md`, `scripts/mt_mcp_probe.py` |
| A captured body decodes to nothing readable, or you cannot tell whether a length-delimited field is a string, a nested message or a packed array | `protocol-reverse.md`. Protobuf on the wire (measured) — run `scripts/protobuf_decode_raw.py`; the candidate list and its `tie:` lines are the answer |
| Method bodies are present but decode as **private opcodes**, and you need the mapping rather than an explanation of why VMP is hard | `vmp-differential-analysis.md`, then `advanced-unpacking.md` for the shape diagnosis |
| A store build arrives as `base.apk` + `split_config.*.apk`, or a rebuilt build is refused **as a set** although every file verifies on its own | `split-apk.md` — one keystore across every member for `pm install-multiple`, and check that a merge is legal before trusting a merged single APK |
## Gates — clear these before you patch, in order
Each gate is an **action with a pass criterion**. Do not proceed past a gate you have not cleared, and
do not treat "I understand the idea" as clearing it. Skipping a gate is not a shortcut; it is how the
work gets redone.
**G1 · Deliverable form — and the cost ceiling on it.** State, in one sentence you could hand to
someone else, what artifact must exist at the end and under what constraints (rooted or not,
installable on a stock device or not, must survive updates or not, online or offline). *Pass:* the
sentence names a testable constraint, not an activity. *Fail:* you are solving a problem in an
environment the deliverable will never see.
Then name the **form** that sentence implies. "A rebuilt, self-contained APK" is only one of four, and
this is where the choice belongs — not after the repack has already failed:
| Form | Right answer when | What it costs you |
|---|---|---|
| **Rebuilt, installable APK** | The client owns the behaviour; no multi-point integrity check; no extraction shell | Repack, re-sign, and a device to verify on |
| **LSPosed / Xposed module** | The logic is client-side but the app fights repacks (multi-point signature checks, shell self-verification), or the result only has to work on rooted devices you control | A rooted device, module scaffolding, and an app that must not detect the hooking framework → `references/lsposed-and-modules.md` |
| **Local RPC / emulation service** | You do not need to change the app — you need to **call** it: a signing routine, a token, an encryption function | A live device or an emulated loader plus a call harness → `references/emulation-and-rpc.md` |
| **Analysis report with a stated boundary** | The authority is server-side, or the target is a real VMP / extraction shell whose recovery cost exceeds the value of the task | Nothing ships — and that is the honest answer, not a failure → `references/advanced-unpacking.md`, `references/server-api.md` |
*Decision trigger for leaving the first column:* switch off "rebuilt APK" as soon as the evidence shows
**(a)** more than one independent integrity check that must all pass, **(b)** an extraction shell whose
method bodies exist only at invocation time, or **(c)** any body that decodes as private opcodes. At
that point the repack route is not merely expensive — it is blocked, and the deliverable sentence
should say which form replaced it. **A form chosen here and re-read at every checkpoint is the guard
against the most expensive drift in this skill** (`references/long-task-discipline.md`).
**G2 · Environment truth and capability inventory.** Run `scripts/doctor.py` (and `scripts/preflight.py`
if a device is in play). *Pass:* you know which toolchains and scripts can actually run here, you have
seen the environment warnings — clock skew, leftover `adb forward`/proxy, a device-side frida process
already running, a tool installed off-PATH — **and you have written down the capability this target will
demand against the capability this machine has.** Name the two or three layers the task will almost
certainly reach (for example "arm64 native decompilation", "Dart AOT snapshot dumping", "device-side
TLS inspection", "dex-wide cross-referencing") and mark each available / missing-but-installable /
genuinely out of reach. *Fail:* you are about to attribute to the target a failure caused by your own
setup — or to spend a day routing around a tool that installs in ten minutes. A layer whose tool is
missing is a **task item**, not a constraint to design around. → `references/toolchain.md` §Closing a
capability gap
**G3 · Code location.** From the manifest and dex, answer: is there a packer, where does the app's own
code live (dex / native / Dart / Unity / server), and is any of it virtualized to native. *Pass:* you
can name the class that owns the behaviour you intend to change, or you have an explicit plan to find
it. *Fail:* you are about to patch a layer you have not located. If recon says "no packer", still
check the virtualization shape — see the index rows above.
**G4 · Baseline and control.** *Pass:* you have a control run — the unmodified original, or a
zero-change repack through the same pipeline — and you have recorded the observed failure (including
**time-to-death**, if it dies). *Fail:* when the patched build misbehaves you will have nothing to
compare against, and every later measurement is unfalsifiable.
## Start here: classify the target in thirteen questions
**Answer these before touching a tool — all thirteen, in order. Each one changes the whole plan, and
a wrong answer here does not fail loudly: it produces an artifact that builds, runs, and does the
wrong thing.** The answer column names both the action and the file that owns the detail; load that
file before acting on the question.
| # | Question | What the answer changes | Load |
|---|---|---|---|
| 1 | **Is the app packed/hardened?** Read the manifest's `application android:name` | A third-party shell class rather than the app's own Application means you have a packer and must handle it **first** | `recon.md` |
| 2 | **Where does the behaviour actually live?** — ad SDK · **server-issued config for client-rendered UI** · membership/VIP · feature flag or debug switch · anything decided by an API response | Client-side and removable, versus server-supplied data the client decides with, versus server-authoritative where a client patch is cosmetic. **Decide this early**: hunting an ad SDK that does not exist costs hours, and the server-config shape has no SDK to find | `ad-removal.md`, `server-config-and-updates.md`, `membership-and-limits.md`, `server-api.md` |
| 3 | **Is the app's own code in plain dex, or moved to native / Flutter / Unity?** | Which toolchain the whole task uses. **Cheap runtime check before committing:** hook the obvious Java classes for the UI you care about and reproduce that UI. Hooks that fire mean Java owns it; hooks that fire **zero times** while the UI is plainly on screen mean the runtime or native code draws it, and a dex-only plan will stall. Do not keep hunting in dex after a zero-hit probe — the most expensive wrong turn in this skill's history | `recon.md` §Where does the app's own code live, `framework-runtimes.md` |
| 4 | **What must the deliverable be able to do?** Write it as a testable sentence and re-read it at every checkpoint | The most expensive drift in this skill: a runtime-only result (data edit, live hook, blocked hostname, host proxy) looks like success while failing the requirement. The axes: **privilege** (unrooted?), **modification form** (rebuilt artifact vs live instrumentation), **ABI/device class**, **network**, **persistence**, **distribution** (self-contained?). If the evidence already shows a deep extraction shell, a VMP, or more than one independent integrity check, **re-answer against G1's four forms** — the repack column may be blocked, not expensive | `long-task-discipline.md` §the most expensive drift |
| 5 | **Does the app verify its own signature, or does the server?** | App-side means you must bypass it; server-side means re-signing silently breaks the app later | `repack-and-sign.md`, `server-api.md` |
| 6 | **What is your device situation?** — rooted real device, rooted emulator, or no device | Whether Frida is usable at all, and whether the deliverable can be tested where it will run. **Run `scripts/preflight.py` before your first experiment** and again whenever a failure surprises you — device state, a dead device server, a leftover proxy and clock drift all masquerade as a broken patch | `environment.md`, `pitfalls.md` P9 |
| 7 | **Which architecture is actually executing?** | `getprop` reports what the device claims and `primaryCpuAbi` what the package manager chose — **neither is what runs**. Only the live mapping is ground truth. An unmapped library, a translator in play, or a different ABI than assumed changes the plan more than any patch will | `native-and-so.md` §Cross-architecture, `scripts/lib_map.py` |
| 8 | **Is one *specific* feature failing at runtime** (login, registration, payment, an API-backed screen) while the rest works? | A feature-scoped network failure is very often a **TLS/certificate problem on one code path**, not your patch. An app can carry two independent trust chains, so "other requests work" proves nothing. Rule this out in minutes before hunting a signature check | `tls-and-cert.md` |
| 9 | **Was the input a build you did not produce** (a circulating "cracked"/"modded" APK)? | Such builds are frequently re-protected — sometimes with *more* layers than the original — and may carry injected components or endpoints. **Never use one as a patching workbench** | `third-party-builds.md` |
| 10 | **Does the client sign its requests with its own signing certificate?** | Grep for `toCharsString()` / `signatures[0]` / `getPackageInfo(..., 64)` **before the first repack**. If that value feeds a native HMAC/DES routine, the rebuilt APK must hardcode the *original* certificate value at every read site, or every signed request fails while the app still launches and looks healthy. The single most expensive silent failure in a repack, and 15 minutes of grep prevents it | `signature-derived-keys.md` |
| 11 | **Does the app die on its own after a while** — no Java stack, or a native crash that looks like a bug? | A hardened library rarely calls `kill`; it more often **arranges a fault** (load a small constant, use it as a pointer) so the death looks like an ordinary defect. Two rules before touching anything: **enumerate which mechanism actually fires** (signal and tombstone split them apart), and **neutralise by returning, never by making it not return** — a spinning stub freezes the process and produces a symptom that looks nothing like the cause | `native-tamper-and-suicide.md` |
| 12 | **Will this build still be usable in a week?** | Any version check, upgrade prompt or self-update path means an unpatched build can be switched off or replaced remotely. This is one or two edits and it decides whether the work is durable — part of the build, not a follow-up. Also check for a **hot-update / remote-config** channel, which restores removed behaviour with no version change at all | `updates-and-forced-upgrade.md` |
| 13 | **Does the request touch sign-in or phone binding** ("no login required", "skip binding", "guest ok")? | Separating a **client-side gate** (patchable) from an **account-scoped resource** (the screen is empty because the server has no account to answer for — not patchable). Classify first; and **never fabricate a session** to satisfy a gate, which produces a state worse than being signed out | `account-gates.md` |
**Long-task rule (a condition, not advice):** if this is likely to run long, open
`references/long-task-discipline.md` **now** and keep its record updated as you go. Re-read its
refuted-conclusions and dead-routes sections before starting any experiment. Losing earlier findings
is the most expensive failure in this skill, and it is entirely preventable.
## The workflow, end to end
Steps are ordered. **Skip a step only when its stated skip condition is met** — "it seems
unnecessary" is not a condition, and it is the reason most of the failures in `pitfalls.md` happened.
**Two-strike rule.** If the *same kind* of attempt fails twice, stop and go back to classification.
Do not run a third variation of a hypothesis that has already failed twice. Two failures of one shape
means the model is wrong, not that the parameters need tuning — and the third attempt is where an
entire round gets spent confirming what the first two already said. Re-read the symptom index at that
point; it exists for exactly this moment.
1. **Preflight, then Recon** — `scripts/doctor.py` is the cheapest possible first command: it reports which toolchains and scripts can actually run here, and surfaces the environment facts that poison experiments (clock skew, leftover `adb forward`/proxy, a device-side frida process already running, a tool installed off-PATH). Then `scripts/preflight.py` before anything else if a device is involved (it takes seconds and prevents a whole class of false conclusions), then `references/recon.md`. Manifest, package name, version, ABI, dex count, packer, embedded SDKs, where the app's own code lives. Ten minutes here saves hours. **If it is packed, unpack before anything else** (`references/recon.md` §unpacking): you cannot patch code you cannot read, the encrypted payload lengths tell you which dumped dex is the original, and a memory dump must be de-duplicated by hash and structurally validated before any of it is trusted.
**If recon says there is no packer but a re-signed build still dies**, you are in the layer `references/code-virtualization-and-custom-linkers.md` covers — do not proceed on the assumption that "no packer" means "editable".
**If the app already dies on its own** — especially at a roughly constant time after launch, or with a native crash — locate the mechanism *before* planning any patch (`references/native-tamper-and-suicide.md`, `scripts/native_crash.py`). Record the observed time-to-death: it is the baseline every later attempt is measured against, and without it a surviving run cannot be told from a changed schedule.
*Skip condition:* never skipped. G2/G3 in §Gates are cleared here or not at all.
2. **Extract strings and endpoints** — build a picture of the app's API surface and SDK inventory from the dex string tables. No decompiler needed for this, and it is fast. Scripts: `scripts/dex_strings.py`.
3. **Trace to the owning class** — find the class that wraps the behavior (the app almost always wraps third-party SDKs in one helper). Reverse-lookup instructions: `references/dex-patching.md` §finding-the-call-site.
4. **Decide the patch layer** — client SDK call / client rendering / client data consumption / server contract. See the table in `references/ad-removal.md`.
5. **Patch surgically** — `references/dex-patching.md` and `references/byte-level-patching.md`.
Two techniques, and picking the right one is a decision, not a preference:
**equal-length byte edits** (`scripts/dex_patch_bytes.py`, located with
`scripts/dex_find_insn.py`) when the change fits in an existing instruction slot
or constant — nothing moves, so no offset, try/catch block or debug pointer can
be invalidated. **dexlib2 method rewriting** (`scripts/dexpatch/`) only when the
change genuinely needs new instructions. Whole-tree smali round-trip damages
R8-optimized dex in ways that only show up at runtime; a method rebuild also
inflates the file (measured: `debug_info` 924 B -> 22.8 KB, dex 4.32 MB ->
7.73 MB on one sample). Whichever you use, recompute the dex header integrity
fields (**signature first, checksum last**) — `references/byte-level-patching.md`
§the dex header has two integrity fields.
6. **Repack and sign** — `references/repack-and-sign.md`. **Do not strip the whole `META-INF/`.** This single mistake destroys otherwise-correct builds.
6b. **Neutralise the update path — before you call the build done.** If the app checks for updates at all, add the two-layer patch (`references/updates-and-forced-upgrade.md`): no-op the update routine's entry, and force the version comparison to its "no update" side. A build that can be switched off or replaced remotely is not a deliverable, and this costs minutes here versus a rebuild later. Do the same for any **remote-config or hot-update** channel that could restore the behaviour you removed.
6c. **Handle account gates only after classifying them** — if the request mentions sign-in or binding, apply `references/account-gates.md` and state plainly which guarded screens become usable and which stay empty because their content is account-scoped.
7. **Verify on device** — `references/environment.md` + `references/verification.md`. Check: launches, the changed behavior actually changed, nothing unrelated broke, and **the app reaches its normal UI with no blocking dialog**. First prove the artifact actually changed on the device -- a package manager reporting success does not prove an interposed confirmation was accepted (P18). Capture continuously for the first ~20 seconds after launch, **and look at the captures** — sampling gaps are how a blocking modal goes unseen (P20), and a burst of images that were never inspected is not evidence. If the accessibility tree is empty, the image is the primary evidence rather than a fallback.
8. **Log what you learned** — if a failure cost you more than thirty minutes, add it to `references/pitfalls.md`. That file is the most valuable artifact in this skill.
## What "done" means — do not claim it earlier
Every item below must be true before you report completion. Anything less is a **checkpoint** and must
be labelled as one, out loud, with what remains. Premature "done" is the most damaging thing you can
report, because it ends the investigation while the user believes the problem is solved.
1. **The artifact exists and its identity is recorded** — path plus hash, not a filename.
2. **It was installed and launched on the environment the deliverable sentence names** (G1/R1). If
that environment was not available to you, say so and label the result accordingly.
3. **The behaviour you changed is verified changed** — by direct observation of the feature, not by
the absence of an error message. "The log is clean" is not evidence; "the screen shows X" is.
4. **The features it touches still work.** You exercised them. A build that starts but whose affected
feature is dead is not a result.
5. **The original limitation is stated if any survives** — with the coupling that causes it, so the
next person can decide whether to accept it.
6. **Nothing you did leaves the target or the device in a broken state** unless that was the goal, and
any privileged workaround is labelled a fallback rather than the deliverable.
If items 1–4 hold but the environment was wrong, you have a **prototype**, not a deliverable. Say
"prototype" and name the gap.
## Stop conditions — halt and re-classify, do not retry
These are moments where continuing to push forward is the wrong move. Each has cost hours somewhere.
- **The same shape of attempt failed twice.** See the two-strike rule above.
- **A patch had no effect and you were about to try a third variant of it.** No effect means the
diagnosis was wrong, not that the patch was unlucky. Re-classify the layer.
- **A new failure has no place in your current model.** That is the symptom index's trigger condition.
- **You are about to write off a route as "blocked"** without a control build proving the block is
the app's doing rather than your pipeline's. Mis-attributed blocks have removed viable routes.
- **You are about to claim success on absence of errors.** See §What "done" means.
- **A measurement disagrees with a conclusion you already recorded as settled.** Re-open the
conclusion; do not explain the measurement away.
## Non-negotiable constraints
**These are defects when violated, not preferences.** Each row states the check that catches it, so
"did I comply" is a command you can run rather than a judgement you make about yourself.
| Constraint | Why it is absolute | The check |
|---|---|---|
| **Inputs are read-only.** Work on copies; keep a known-good baseline | an edited original destroys the only reference you can diff against | `git`/hash the original before the first edit; `scripts/apk_diff.py` for entry-level proof |
| **One variable at a time**, with a control build (same pipeline, **zero** patches) | a compound change that fails teaches nothing, and the failure will be attributed to the wrong cause | a control run exists and its result is recorded |
| **Verify structure after every dex edit** | a patch that assembles and dies at load looks like a patch that worked | `scripts/dex_classdiff.py`: zero differences in class set and access flags for classes you did not intend to change |
| **Never patch a widely shared method.** Count callers first | a `Long.valueOf`-shaped helper with 30 callers is not an ad-specific hook | `scripts/find_refs.py` on the method you are about to touch |
| **Never make an API fail to suppress a UI element** | a 404/400 an endpoint's other features depend on takes the whole screen with it — measured as a build that never leaves the launch screen | suppress at the data-consumption or render layer instead; if a request's path changes, that is the bug |
| **Neutralise a native terminate path by returning, never by making it not return** | a spin stub freezes the caller **holding its lock**; unrelated threads wedge and the symptom (hang, external kill, restart loop) looks nothing like the cause, with no crash record | return a benign value, prefer success (`0`) over failure (`-1`), never touch `pthread_exit`/`exit`/`abort`/`snprintf`/`closedir`. `native-tamper-and-suicide.md` |
| **Look before you conclude, and look while you wait** | a screen that is actually looked at answers in one step what coordinate-guessing cannot answer in five | capture **and inspect**; byte-identical samples mean nothing will change. `scripts/snap.py`, `environment.md` §look at the screen |
| **Bound every command, and calibrate the bound from a measurement** | an unbounded call turns a stall into "still working", which is indistinguishable from progress | time the operation once, record it, derive the deadline from it. `long-task-discipline.md` §Bound every wait |
| **Every claim carries a label — `observed`, `inferred` or `unverified`** | "probably", "should be" and "in theory" are not findings, and an unlabelled guess propagates as a fact | `observed` requires a command **and its output**; nothing else may be written as established |
| **"Done" means the user-visible outcome** | a blocking dialog still on screen means the task is not done, however clean the log is | §What "done" means, all six items; absence of a log line is never evidence of success |
| **Never discard a route on compound evidence** | if a failure followed two simultaneous changes, the attribution is a hypothesis — mis-attributed failures have removed viable routes for hours | re-run the abandonment single-variable before writing it down |
| **Prove the device changed before measuring anything** | install success describes the *request*, not the app on disk | hash the on-device artifact against your build; otherwise every later observation describes the previous build |
| **Attribute a failure to the right layer before patching again** | a feature-scoped network failure is frequently the app's own TLS/certificate problem, and chasing a signature check that does not exist burns hours | run the **unmodified original** on the same device and network first (`tls-and-cert.md`) |
## Reference index and script index
**Both tables live in `references/routing.md`** — one load gets you every reference file with when to
load it, and every script with what it does. They are deliberately not duplicated here: this file is
loaded in full every time the skill activates, and those two tables are about 140 rows of lookup data
that nobody needs until they have already decided what to do.
The symptom index above stays, because **symptom to file has to be one hop**: when something fails you
are not choosing a file, you are recognising a failure, and a two-hop lookup at that moment is exactly
how a stop signal gets skipped.
`references/evidence-summary.md` is the one reference that answers the claim-strength question from
inside an installed copy: capability → one-line conclusion → `observed`/`inferred`/`unverified` → the
evidence that ships with the skill. Load it when a claim's strength decides whether you trust it and
the run record is not in front of you.
`python check_routing.py` checks that this file and `references/routing.md` still agree, that every
reference file is named there, and that every script is named there. CI runs it.

View File

@@ -0,0 +1,56 @@
{
"skill_name": "apk-reverse",
"_status": "unverified -- skeleton only",
"_warning": "NOTHING IN THIS FILE HAS EVER BEEN EXECUTED. These are three test cases with expected outcomes, written so that a future run has something concrete to grade against. No with-skill run and no without-skill run has been performed in this repository, so not one of the expected_output fields below has any evidence behind it. Do not cite this file as a quality measurement.",
"_why_not_run": "Running these evals means running the agent itself twice per case and grading the transcripts. That was explicitly out of scope for this pass (see tools/_work/REBUILD-BRIEF.md section 2, the do-not-do list: no real agent eval scoring). The executable side of this repository's testing lives in tests/ and in .github/workflows/ci.yml, neither of which can observe agent behaviour.",
"_how_to_run": {
"workspace": "Create a workspace directory BESIDE the skill directory, never inside it: <parent>/apk-reverse-workspace/iteration-N/<eval-id>/{with_skill,without_skill}/{outputs,timing.json,grading.json}, plus benchmark.json at the iteration root.",
"isolation": "Each run must start from a clean context -- a fresh subagent or a fresh session -- so that only skills/apk-reverse/SKILL.md and its references influence the with-skill arm. A run that inherits the skill author's reasoning grades the author, not the skill.",
"arms": [
"with_skill: the skill directory is mounted and the prompt is the eval prompt verbatim.",
"without_skill: identical prompt, identical tools, no skill mounted. This is the baseline; a case where both arms do equally well tests nothing."
],
"recording": "Per run, capture the produced files under outputs/, token and wall-clock totals in timing.json, and one grading.json holding the per-assertion verdicts.",
"grading": "Grade against the _assertions list on each case, not against the prose in expected_output. Mark an assertion as ungradable rather than guessing: a wrong pass is worse than a recorded gap.",
"aggregation": "Write benchmark.json with, per case, the pass count in each arm and the delta. Report a case whose with-skill arm does not beat the baseline as a skill defect, not as a flaky run."
},
"evals": [
{
"id": 1,
"prompt": "I patched one method inside classes.dex, repacked the APK and signed it, and now the install fails. adb says INSTALL_FAILED_INVALID_APK, and on another device I got INSTALL_PARSE_FAILED_NO_CERTIFICATES. Is the hardening on this app blocking my build? Should I switch to a Frida hook instead?",
"expected_output": "The answer routes to the repack layer, not to the anti-tamper layer, and says why. It should name the concrete repack-time causes and the check that distinguishes them: resources.arsc losing its STORED method and 4-byte alignment because the whole archive was re-compressed, ZipInfo fields (extra, external_attr, create_system) being dropped when entries are rebuilt, and extractNativeLibs=false packages refusing a re-compressed native library. It should point at the repository's own repack path rather than hand-rolled zipfile writing, and it should state that an install failure after an unsigned or badly signed rebuild is not evidence of hardening. Answering \"yes, it is the anti-tamper layer, use Frida\" is a failure.",
"_assertions": [
"Names ZIP metadata loss and/or resources.arsc re-compression plus 4-byte alignment as the leading cause.",
"Names at least one concrete check the user can run (compression method of resources.arsc, alignment, ZipInfo field preservation, or extractNativeLibs).",
"Explicitly declines to attribute the install failure to hardening without evidence.",
"Routes to the repository's repacking route rather than recommending a Frida hook as the first move."
],
"_targets_reference": "references/repack-and-sign.md, references/byte-level-patching.md"
},
{
"id": 2,
"prompt": "I decompiled the dex and most of the method bodies are empty or just throw an exception. That means this app uses dex VMP (virtualization), right? Write me the section of the report that says it is protected with dex VMP so I can put it in the deliverable.",
"expected_output": "It refuses to declare dex VMP and explains what would have to be measured first. A hand-written opcode table or a hand-counted stub ratio is not sufficient evidence, because a decode desync produces exactly the same appearance; the failure count has to come from an official disassembler on the same image, and the empty-body ratio needs a zero-change control for comparison. It should also separate the neighbouring diagnosis -- a Java2C / JNI-sinking shell also shows 'everything moved out' while containing no virtualized bytecode at all -- and offer to run the repository's dex-side tooling to produce the numbers before writing any conclusion.",
"_assertions": [
"Does not produce the requested VMP verdict.",
"Requires an official disassembler's structural failure count (dexdump / baksmali) as the deciding evidence.",
"Requires a zero-change or alternative-hypothesis control before reading a stub ratio as extraction or virtualization.",
"Distinguishes dex VMP from Java2C / JNI sinking, or asks which of the two is being claimed.",
"Offers a concrete command sequence that would produce the missing evidence."
],
"_targets_reference": "references/advanced-unpacking.md, references/java2c-and-jni-sinking.md, references/detection-and-anti-analysis.md"
},
{
"id": 3,
"prompt": "In this classes.dex, find every string containing 'http' and tell me which methods reference them.",
"expected_output": "It actually runs the repository's tooling and reports real output: the strings route through the dex string table (dex_strings.py) and the referencing-method route through the instruction/field-index readers (find_refs.py, dexutil.py, or dexdump for an independent second opinion). The deliverable contains the exact commands, the counts, and at least one concrete string with its referencing method rendered from real output. Describing what one could do, naming generic tools, or listing strings from memory with no command behind them is a failure.",
"_assertions": [
"At least one repository script is executed and its command line appears in the answer.",
"The reported strings come from real output, not from memory or paraphrase.",
"Referencing methods are identified, not only the strings.",
"At least one cross-check with an independent decoder is performed or explicitly flagged as not done."
],
"_targets_reference": "scripts/dex_strings.py, scripts/dexutil.py, scripts/find_refs.py, references/byte-level-patching.md"
}
]
}

View File

@@ -0,0 +1,420 @@
{
"schema": "apk-reverse/capability-matrix",
"version": 1,
"purpose": "One machine-readable row per capability this skill claims: how strong the claim is, whether the route itself was ever measured, where the evidence lives inside an installed copy, and what the claim does not cover. An installed copy of this skill must be able to answer 'is this proven, how strongly, and where' without the repository it was extracted from.",
"read_with": "references/evidence-summary.md (the prose condensation of this file, and the only place that lists the non-shipped record)",
"status_labels": {
"ok": "The documented route was carried out on a real target and the result is recorded.",
"partial": "Part of the route was measured; at least one decisive step (usually install, launch, or a second independent producer) was not.",
"blocked": "Either the route depends on something this skill does not ship, or nothing in this record is evidence about it either way. Do not treat 'blocked' as 'impossible' -- treat it as 'no footing here'."
},
"strength_labels": {
"observed": "A command was run and its output exists behind the claim.",
"inferred": "Follows from a documented mechanism or a neighbouring measurement; the step itself was not executed.",
"unverified": "Assumed, or reported by someone else, and not reproduced in this record."
},
"evidence_rules": [
"Every path in `evidence` is relative to the installed skill root and resolves in an installed copy.",
"Paths in `evidence_not_shipped` sit at the repository root, which `npx skills add` does not install. Every one of them is marked 'repository root, not shipped'.",
"Rows are traceable to an existing evidence file or to a row of the repository's benchmark matrix (B1-B13). Nothing here was written from expectation.",
"Where a source document and a script disagree, the script's behaviour was taken as the measurement and the row says so."
],
"capabilities": [
{
"capability": "dex-equal-length-surgical-patch",
"group": "dex",
"status": "ok",
"route_measured": true,
"strength": "observed",
"benchmark_rows": ["B1"],
"evidence": ["references/byte-level-patching.md", "references/dex-patching.md", "references/patch-audit.md", "scripts/dex_patch_bytes.py", "scripts/dex_find_insn.py"],
"evidence_not_shipped": ["docs/tool-verification/EXTENSION-benchmark-l1-l3.md section 2 (repository root, not shipped)", "tests/benchmark.md row B1 (repository root, not shipped)"],
"limits": "Equal-length edits only. The end-to-end measurement is one 2-byte branch rewrite inside a 5,528-byte dex, whose whole-file diff is those 4 bytes plus dex header bytes 8..32; the patched file self-verifies. Constraints that cannot be expressed as an equal-length edit need the dexlib2 route, which has no public-target measurement."
},
{
"capability": "dex-method-level-rewrite-dexlib2",
"group": "dex",
"status": "partial",
"route_measured": false,
"strength": "unverified",
"benchmark_rows": [],
"evidence": ["references/dex-patching.md", "references/patch-audit.md", "scripts/dexpatch/"],
"evidence_not_shipped": ["docs/tool-verification/README.md (repository root, not shipped)"],
"limits": "The rewriter and the smali round-trip tools (scripts/smtool.py, scripts/patch_smali.py) carry no measurement against a public target in this record. The round-trip blind spot is documented: a whole-tree smali round trip can pass every table check and still crash with IncompatibleClassChangeError. Treat the route as documented, not proven."
},
{
"capability": "dex-header-integrity-and-verifier-legality",
"group": "dex",
"status": "ok",
"route_measured": true,
"strength": "observed",
"benchmark_rows": ["B1"],
"evidence": ["references/patch-audit.md", "references/byte-level-patching.md", "scripts/dexutil.py", "scripts/dex_check_verifier.py", "scripts/dex_classdiff.py"],
"evidence_not_shipped": ["tests/benchmark.md row B1 (repository root, not shipped)"],
"limits": "Header recompute order (adler32 over d[12:], sha1 over d[32:]) is proven on one written file, which reproduces both fields. Passing scripts/dex_classdiff.py is necessary, not sufficient: it cannot detect code-item damage, and the checks that look further (instruction-length audit, the equal-length blind spot, move-result adjacency) live in references/patch-audit.md."
},
{
"capability": "dex-string-constant-patch",
"group": "dex",
"status": "partial",
"route_measured": false,
"strength": "unverified",
"benchmark_rows": [],
"evidence": ["references/byte-level-patching.md", "references/dex-patching.md", "scripts/dex_strpatch.py"],
"evidence_not_shipped": ["docs/tool-verification/TOOL-VERDICTS.md (repository root, not shipped)"],
"limits": "Equal-length only: the shipped scripts/dex_strpatch.py enforces equal length and refuses a substring of a longer identifier. A different-length string edit is therefore not covered by that script; it needs the dexlib2 route, whose build is documented and unmeasured. No public-target measurement for the string path."
},
{
"capability": "repack-sign-install-single-apk",
"group": "packaging",
"status": "ok",
"route_measured": true,
"strength": "observed",
"benchmark_rows": ["B1", "B9"],
"evidence": ["references/repack-and-sign.md", "references/verification.md", "scripts/repack.py", "scripts/install_test.py"],
"evidence_not_shipped": ["docs/tool-verification/EXTENSION-benchmark-l1-l3.md section 2 (repository root, not shipped)", "tests/benchmark.md rows B1 and B9 (repository root, not shipped)"],
"limits": "Measured chain on this class of host: zip, then zipalign -p -f 4, then apksigner.jar --v1 --v2 --v3; the order cannot be reversed (the installer refuses with [-124]). B1 confirms installability by matching the on-device base.apk sha256 against the local build, and its control build through the identical pipeline still fails the old way. Signing has no uber-apk-signer here. The isolated-constant rewrite script writes an APK through a different code path whose zip-metadata and alignment handling was recorded as a defect; that path is not covered by this row."
},
{
"capability": "split-apk-and-app-bundle-sets",
"group": "packaging",
"status": "partial",
"route_measured": true,
"strength": "observed",
"benchmark_rows": ["B9"],
"evidence": ["references/split-apk.md", "scripts/repack.py"],
"evidence_not_shipped": ["docs/tool-verification/EXTENSION-split-apk.md (repository root, not shipped)", "tests/benchmark.md row B9 (repository root, not shipped)"],
"limits": "Analyze, unified re-sign and merge were run on two real sets: after resign every member shares one certificate with v1+v2+v3 true, and merge correctly refuses (exit 1, before writing) a set whose members carry their own resources.arsc while succeeding on the code/native-only set. adb install-multiple and launch confirmation were NOT executed, so installability of a re-signed set is unverified, and the split mode's install path is exactly where a wrong alignment hides."
},
{
"capability": "third-party-build-audit",
"group": "packaging",
"status": "partial",
"route_measured": false,
"strength": "unverified",
"benchmark_rows": [],
"evidence": ["references/third-party-builds.md", "scripts/apk_diff.py"],
"evidence_not_shipped": ["docs/tool-verification/README.md (repository root, not shipped)"],
"limits": "scripts/apk_diff.py is listed among the scripts the first verification pass never ran, and no benchmark row exercises it. Absence from that record is not a verdict on it, but nothing here supports trusting its output either."
},
{
"capability": "extraction-shell-detection-trivial-body",
"group": "unpacking",
"status": "ok",
"route_measured": true,
"strength": "observed",
"benchmark_rows": ["B3"],
"evidence": ["references/advanced-unpacking.md", "scripts/dex_dump_validate.py", "scripts/dex_mem_scan.py"],
"evidence_not_shipped": ["docs/tool-verification/EXTENSION-extraction-shell-bench.md (repository root, not shipped)", "tests/benchmark.md row B3 (repository root, not shipped)"],
"limits": "The ratio is bimodal, not thresholded: a real skeleton reads about 100 percent, a zero-change control 1.9 percent, nop-cleared bodies 0.0 percent and throw stubs 1.9 percent, so the shapes real shells use are invisible to it. The earlier 'tens of percent means a skeleton' rule was wrong and was deleted. Measured on an 11-variant synthetic set derived from one real dex, not on a live extraction shell, and three script defects were found by that set."
},
{
"capability": "dex-vmp-declaration-boundary",
"group": "unpacking",
"status": "partial",
"route_measured": true,
"strength": "observed",
"benchmark_rows": ["B4"],
"evidence": ["references/advanced-unpacking.md", "references/code-virtualization-and-custom-linkers.md", "references/vmp-differential-analysis.md"],
"evidence_not_shipped": ["docs/tool-verification/EXTENSION-extraction-shell-bench.md section 3.4 and section 4 (repository root, not shipped)", "tests/benchmark.md row B4 (repository root, not shipped)"],
"limits": "Static criteria can rule a dex-VMP out; they cannot rule runtime-applied hardening out. Never declare a VMP without an official disassembler's failure count: a hand-written opcode table reported 1,393 of 22,424 bodies as undecodable and produced a VMP verdict on a sample that the platform decoder showed to be ordinary dalvik with zero structural errors. Install, launch and refusal for that sample remain untested."
},
{
"capability": "vmp-differential-opcode-map",
"group": "unpacking",
"status": "partial",
"route_measured": true,
"strength": "observed",
"benchmark_rows": [],
"evidence": ["references/vmp-differential-analysis.md", "scripts/vmp_diff_harness.py"],
"evidence_not_shipped": ["docs/tool-verification/EXTENSION-vmp-diff.md (repository root, not shipped)"],
"limits": "The fixture covers 218 of 224 opcodes and the closed loop re-derived that table exactly: 218 of 218 emitted opcodes, zero wrong entries, zero fabricated entries. What was not exercised is the other half of the premise -- no third-party hardening platform was contacted, and the assumption that a real engine substitutes per opcode at stable instruction length is inferred from public write-ups. No benchmark row covers this route."
},
{
"capability": "java2c-vs-jni-sinking-discrimination",
"group": "native",
"status": "ok",
"route_measured": true,
"strength": "observed",
"benchmark_rows": ["B5", "B11"],
"evidence": ["references/java2c-and-jni-sinking.md", "scripts/java2c_probe.py"],
"evidence_not_shipped": ["docs/tool-verification/EXTENSION-java2c.md (repository root, not shipped)", "tests/benchmark.md rows B5 and B11 (repository root, not shipped)"],
"limits": "The discriminating measurement is observed: native-declaration density separates the shapes by roughly 2000x (0.03-0.04 percent on three real JNI samples against 82.76 percent on a Java2C-shaped fixture), and Java_* symbols matched dex native-method counts 1:1 per ABI. No Dex-to-C compiler output was ever built here (no NDK, no host or device clang), so every Java2C-specific criterion is inferred. A symbol search fails silently on real Java2C: dcc emits -fvisibility=hidden and C++ jni.h inlines RegisterNatives."
},
{
"capability": "native-terminate-path-neutralisation",
"group": "native",
"status": "partial",
"route_measured": true,
"strength": "observed",
"benchmark_rows": ["B2"],
"evidence": ["references/native-tamper-and-suicide.md", "references/native-and-so.md", "scripts/spawn_patch_detach.py", "scripts/hook_patch_only.js"],
"evidence_not_shipped": ["docs/tool-verification/EXTENSION-benchmark-l1-l3.md section 3 (repository root, not shipped)", "tests/benchmark.md row B2 (repository root, not shipped)"],
"limits": "The tooling works; the patch does not. Writes landed 0.04-0.05 s after resume and survived detach, and the patched run and a read-and-write-back control both still died with SIGABRT at the same site: neutralising the terminate routine plus both of its branch inputs was insufficient, and the region is a dense cluster of mov w0,#imm ; ret stubs. Holding the process paused until the patch lands structurally cannot work, because a frozen process has no libraries mapped. On that target the pristine original already reports tamper, so no clean baseline exists there. No surviving-target result is claimed."
},
{
"capability": "native-instruction-tracing-stalker",
"group": "native",
"status": "partial",
"route_measured": true,
"strength": "observed",
"benchmark_rows": ["B6"],
"evidence": ["references/native-dbi-and-deobfuscation.md", "scripts/stalker_trace.js", "scripts/stalker_report.py"],
"evidence_not_shipped": ["docs/tool-verification/EXTENSION-stalker-exclude.md (repository root, not shipped)", "tests/benchmark.md row B6 (repository root, not shipped)"],
"limits": "Split result: following a hot libc export with no exclusions kills the process, and excluding 20 system modules keeps it alive -- but delivery is still zero events (blocks=0 blk=0 calls=0). Exclusion is therefore not the fix for a trace that reports nothing, which contradicts the framing that lists 'no events' among the symptoms exclusion addresses. Two harness traps that manufacture a false zero-event result were fixed in the same pass. No real OLLVM trace was produced, and no trace pipeline is verified on the test device."
},
{
"capability": "native-library-mapping-and-plt-resolution",
"group": "native",
"status": "ok",
"route_measured": true,
"strength": "observed",
"benchmark_rows": [],
"evidence": ["references/native-and-so.md", "references/toolchain.md", "scripts/lib_map.py", "scripts/elf_plt.py"],
"evidence_not_shipped": ["docs/tool-verification/TOOL-VERDICTS.md (repository root, not shipped)"],
"limits": "scripts/lib_map.py is verified on a real target. scripts/elf_plt.py was broken and fixed: one defect was proven to cause a false negative on a symbol that exists and is called, and a second compared a file offset against a virtual address under --diff --name-regions. Run it before patching any stub, and treat its 'no callers' answer as a claim about its own input parsing until you have seen it scan something you know references the symbol."
},
{
"capability": "native-crash-triage-and-swallowed-stacks",
"group": "native",
"status": "partial",
"route_measured": false,
"strength": "unverified",
"benchmark_rows": [],
"evidence": ["references/native-tamper-and-suicide.md", "references/environment.md", "scripts/native_crash.py", "scripts/grab_crash.py"],
"evidence_not_shipped": ["docs/tool-verification/TOOL-VERDICTS.md (repository root, not shipped)"],
"limits": "scripts/native_crash.py was recorded as 'not applicable to this sample' in the first pass, and scripts/grab_crash.py was never run -- including on the one target whose crash-reporter SDK hid exactly the stack it claims to recover, so that claim is unverified rather than wrong. The general rule that survives without a measurement is narrower: a process that dies with only 'uncaughtException time: ...' and no stack has crashed."
},
{
"capability": "runtime-analysis-with-frida",
"group": "runtime",
"status": "ok",
"route_measured": true,
"strength": "observed",
"benchmark_rows": ["B2", "B6", "B13"],
"evidence": ["references/dynamic-frida.md", "references/environment.md", "references/native-tamper-and-suicide.md", "scripts/run_probe.py", "scripts/frida_probe.js", "scripts/preflight.py"],
"evidence_not_shipped": ["docs/tool-verification/EXTENSION-device-run.md (repository root, not shipped)", "tests/benchmark.md rows B2, B6 and B13 (repository root, not shipped)"],
"limits": "Instrumentation is itself a measured variable. Attaching is what kills some targets (the probe arm of B13 dies about 300 ms after resume while the no-script control stays alive); exclusion is what keeps others alive (B6); and a spawn ordering that holds the process paused cannot work, because no libraries are mapped yet (B2). Reading a device's refusal as a fact about the target is the failure mode this row exists to prevent -- the preflight check comes before blaming a patch."
},
{
"capability": "runtime-data-local-state",
"group": "runtime",
"status": "partial",
"route_measured": true,
"strength": "observed",
"benchmark_rows": ["B8"],
"evidence": ["references/runtime-data.md", "scripts/datastore_inject.py", "scripts/blob_decode.py"],
"evidence_not_shipped": ["tests/benchmark.md row B8 (repository root, not shipped)"],
"limits": "The DataStore encode/decode path is proven on one real 81-byte container: an edited value re-encoded (81 to 80 bytes, length prefix recomputed) was read back by the injector. SharedPreferences and SQLite edits have no equivalent measurement here, and an edit that keeps reverting is a routing signal -- something else owns the value -- not a data-editing problem."
},
{
"capability": "anti-instrumentation-triage-naming-the-check",
"group": "runtime",
"status": "ok",
"route_measured": true,
"strength": "observed",
"benchmark_rows": ["B13"],
"evidence": ["references/detection-and-anti-analysis.md", "scripts/anti_detect_probe.js"],
"evidence_not_shipped": ["docs/tool-verification/EXTENSION-detection-pipeline.md sections 1, 4 and 5 (repository root, not shipped)", "tests/benchmark.md row B13 (repository root, not shipped)"],
"limits": "The check was named and the asymmetry recorded: strstr(\"frida\") fired at 303 ms of process life, then the process was gone with no tombstone, no crash and no ANR record, while the probe's own self-report showed TracerPid=0 with four frida-named mappings visible in /proc/self/maps. Reproducibility is honestly bounded: 3 same-shape arms, 1 delivered the full sequence, because a target that dies in about 300 ms leaves a sub-second window. The probe is observer-only by contract and patches nothing."
},
{
"capability": "emulation-and-frida-rpc",
"group": "runtime",
"status": "partial",
"route_measured": true,
"strength": "inferred",
"benchmark_rows": [],
"evidence": ["references/emulation-and-rpc.md", "scripts/frida_rpc_serve.py", "scripts/rpc_template.js"],
"evidence_not_shipped": ["docs/tool-verification/EXTENSION-emulation-rpc.md (repository root, not shipped)"],
"limits": "The rpc.exports bridge was exercised end to end on a live device and the indexer pair (droidasc / ddc) was measured on a hardened APK, but the reference classifies the route itself as inferred: emulated execution costs environment-filling work, the unidbg build needed repair before it ran, and no target .so had been emulated at the time of writing. The tool was measured; the route was not."
},
{
"capability": "schemaless-protobuf-decode",
"group": "protocol",
"status": "ok",
"route_measured": true,
"strength": "observed",
"benchmark_rows": ["B8"],
"evidence": ["references/protocol-reverse.md", "scripts/protobuf_decode_raw.py"],
"evidence_not_shipped": ["docs/tool-verification/EXTENSION-protobuf-raw.md (repository root, not shipped)", "tests/benchmark.md row B8 (repository root, not shipped)"],
"limits": "26 of 26 built-in fixtures, 21 of 21 against the official runtime (re-encode byte-identical to SerializeToString()), 8 of 8 framing checks. Two ambiguities are inherent to the wire format rather than defects: 0801120178 is a valid nested message and a valid packed array, and a non-presence 0 is byte-identical to 'never assigned'. Four script defects were found by these checks and fixed."
},
{
"capability": "server-api-probing-and-tls-feature-scope",
"group": "protocol",
"status": "partial",
"route_measured": false,
"strength": "inferred",
"benchmark_rows": ["B7"],
"evidence": ["references/server-api.md", "references/tls-and-cert.md", "references/protocol-reverse.md", "scripts/probe_api.py", "scripts/tls_check.py"],
"evidence_not_shipped": ["tests/benchmark.md row B7 (repository root, not shipped)"],
"limits": "Client-scoped by design: this determines who owns a gate, not how to break an authorization the server performs. The native-side certificate-pinning row (a Cronet sample) was not run -- it needs a gradle+NDK build this class of host cannot do -- and QUIC/HTTP3 is stated as a limit rather than a route. Certificates and pinning are asserted from documented behaviour, so label a feature-scoped TLS failure as inferred until you have reproduced the failure yourself."
},
{
"capability": "dart-aot-analysis-given-a-snapshot-dump",
"group": "dart",
"status": "partial",
"route_measured": true,
"strength": "observed",
"benchmark_rows": [],
"evidence": ["references/dart-aot.md", "scripts/dart_disasm.py", "scripts/dart_pprefs.py", "scripts/dart_pool_strings.py"],
"evidence_not_shipped": ["docs/tool-verification/TOOL-VERDICTS.md (repository root, not shipped)", "docs/tool-verification/EXTENSION-dart-aot-formats.md (repository root, not shipped)"],
"limits": "Measured on a real Dart 3.6.0 build: the disassembler decoded identically to capstone (32 of 32 and 96 of 96), the caller index was re-derived independently with a symmetric difference of 0, and a specific business-logic site was located end to end. This row assumes a snapshot dump already exists -- producing one is a separate, blocked capability (see dart-aot-snapshot-dump-production)."
},
{
"capability": "dart-aot-string-table-format",
"group": "dart",
"status": "partial",
"route_measured": true,
"strength": "observed",
"benchmark_rows": ["B12"],
"evidence": ["references/dart-aot.md", "scripts/dart_pool_strings.py"],
"evidence_not_shipped": ["docs/tool-verification/EXTENSION-dart-aot-formats.md (repository root, not shipped)", "tests/benchmark.md row B12 (repository root, not shipped)"],
"limits": "Two claims were tested and both were wrong before this row. The arm64 packed form is confirmed at the byte level (tag byte equals 0x80|(len<<1) at every literal checked, 4,980 chained entries). The previously documented armv7 form (len*2 plus UTF-16) is refuted: the 32-bit record is [header u32][byte-count u32le][UTF-8 payload], and the extractor's zero for that ABI is a format mismatch rather than an empty table. An end-to-end patched build was never repacked and installed, so no device patch is claimed. A third claim, that the engine banner gives x.y.z (stable), is also refuted on that target."
},
{
"capability": "dart-aot-snapshot-dump-production",
"group": "dart",
"status": "blocked",
"route_measured": false,
"strength": "unverified",
"benchmark_rows": [],
"evidence": ["references/dart-aot.md", "references/coverage-and-limits.md"],
"evidence_not_shipped": ["docs/tool-verification/README.md (repository root, not shipped)"],
"limits": "This skill ships no snapshot container resolver and cannot synthesize one, so the Dart AOT workflow cannot start from an APK alone. The two offset spaces are not a constant offset apart: dart_pool_strings.py reports file offsets while the pool index speaks pool offsets, and a measured run found 4,237 distinct deltas over 4,241 shared strings. Ship a pinned front end (aotopsy, pure Go, no toolchain) or build blutter (about 80 s, needs a C++ toolchain) -- and say which one, because the two report different Dart version labels for the same binary."
},
{
"capability": "packer-custom-loader-identification",
"group": "hardening",
"status": "partial",
"route_measured": false,
"strength": "unverified",
"benchmark_rows": [],
"evidence": ["references/packers.md", "references/code-virtualization-and-custom-linkers.md", "references/coverage-and-limits.md"],
"evidence_not_shipped": ["docs/tool-verification/TOOL-VERDICTS.md (repository root, not shipped)", "docs/tool-verification/README.md (repository root, not shipped)"],
"limits": "The first verification pass did not exercise the packer, code-virtualization, custom-linker, integrity-check-redirection or tamper-suicide scenarios: its target has none of them, and its unmodified build already fails to start, which removes the repack-and-regress loop those scenarios need. The packer tooling was recorded as 'not applicable to this sample'. Nothing in the record is evidence either way about a protected target -- the benchmark pass measured shape discrimination only."
},
{
"capability": "module-delivery-instead-of-repack-lsposed",
"group": "hardening",
"status": "partial",
"route_measured": false,
"strength": "inferred",
"benchmark_rows": [],
"evidence": ["references/lsposed-and-modules.md", "scripts/lsposed_scaffold.py", "references/precedents/logd-broken-module-never-ran-case-3.md"],
"evidence_not_shipped": ["docs/tool-verification/EXTENSION-lsposed.md (repository root, not shipped)"],
"limits": "Documented route, not a measured one: the generated project was built end to end with the gradle-free chain and its timings recorded, but the delivery route itself -- a module standing in for a refused repack -- has no public-target measurement. It needs a device with an active LSPosed, and confirming that a module actually ran takes more than one log surface; a logcat-only check has already produced one confidently wrong conclusion here."
},
{
"capability": "kernel-side-syscall-answer-forging",
"group": "kernel",
"status": "blocked",
"route_measured": false,
"strength": "unverified",
"benchmark_rows": ["B10"],
"evidence": ["references/kernel-and-environment-hardening.md", "scripts/kernelsu_syscall_mask.py"],
"evidence_not_shipped": ["docs/tool-verification/EXTENSION-kernel-weapons.md (repository root, not shipped)", "tests/benchmark.md row B10 (repository root, not shipped)"],
"limits": "The generator is measured -- an 11-file userspace module skeleton plus KPM/LKM/eBPF templates, with verify reporting 0 problems -- but no kernel-side artefact was compiled or loaded: the test kernel is 4.14.186 (eBPF needs 5.10+), the host has no aarch64 cross-compiler, make or ndk-build, and there is no kernel source. Structural correction with teeth: an ordinary KernelSU module is a userspace module and cannot change a syscall return value, so a template that claims to do so is wrong regardless of the toolchain."
},
{
"capability": "svc-site-scanning",
"group": "kernel",
"status": "ok",
"route_measured": true,
"strength": "observed",
"benchmark_rows": ["B13"],
"evidence": ["references/kernel-and-environment-hardening.md", "scripts/svc_scan.py"],
"evidence_not_shipped": ["docs/tool-verification/EXTENSION-detection-pipeline.md section 5 (repository root, not shipped)", "tests/benchmark.md row B13 (repository root, not shipped)"],
"limits": "Two independent decoders (a hand-written word scan and a capstone-based scan) returned an identical 214-site set on a device linker64, with an empty set difference, which is what makes the output usable as evidence about a library rather than about the scanner. A byte scan also matches data, so neighbour context decides whether a site is code: the hardened shell library's 21 svc sites are all data, and an ordinary module cannot act on them."
},
{
"capability": "on-device-tooling-mt-manager",
"group": "device",
"status": "partial",
"route_measured": true,
"strength": "observed",
"benchmark_rows": [],
"evidence": ["references/on-device-tooling.md", "scripts/mt_mcp_probe.py"],
"evidence_not_shipped": ["docs/tool-verification/EXTENSION-kernel-ondevice.md (repository root, not shipped)"],
"limits": "The 'service is down' path is measured: the probe prints start-it-by-hand instructions and exits 2. The connected path needs MT Manager's APK MCP started by hand, so it is documented rather than measured, and the MCP surface itself was inventoried from the tool's own listing rather than exercised against a target."
},
{
"capability": "client-side-ads-and-server-issued-ui-config",
"group": "deliverable",
"status": "ok",
"route_measured": true,
"strength": "observed",
"benchmark_rows": ["B1"],
"evidence": ["references/ad-removal.md", "references/server-config-and-updates.md", "references/membership-and-limits.md"],
"evidence_not_shipped": ["docs/tool-verification/TOOL-VERDICTS.md (repository root, not shipped)"],
"limits": "Verified on a real mid-size Flutter-AOT-shaped target and its ad chain, where the dex reader was the primary workhorse of the analysis and a repacked build's on-screen behaviour change was confirmed. A splash, popup or tab set that arrives as server-issued UI config is a routing decision, not an SDK hunt, and the deliverable is the check that stops it, not the string that names it."
},
{
"capability": "membership-paywall-gate-client-enforceability-decision",
"group": "deliverable",
"status": "ok",
"route_measured": false,
"strength": "inferred",
"benchmark_rows": [],
"evidence": ["references/membership-and-limits.md", "references/account-gates.md", "references/handoff-boundaries.md"],
"evidence_not_shipped": ["docs/tool-verification/README.md (repository root, not shipped)"],
"limits": "The decision framework is documented and its purpose is to say plainly when a gate is not client-enforceable; no gate-shaped public target was put through it in this record, so the framework is itself inferred rather than measured. A gate the server enforces is out of scope by design (see blocked-server-side-authority), and promising an unlock before classifying the owning layer is the specific failure this row prevents."
},
{
"capability": "update-and-forced-upgrade-neutralisation",
"group": "deliverable",
"status": "partial",
"route_measured": false,
"strength": "inferred",
"benchmark_rows": [],
"evidence": ["references/updates-and-forced-upgrade.md", "references/signature-derived-keys.md"],
"evidence_not_shipped": ["docs/tool-verification/README.md (repository root, not shipped)"],
"limits": "Documented route with no public-target measurement in this record. The shape to expect is not a crash: a rebuilt build installs, runs, and fails every signed request, so a version check and a signature-derived key must be classified before the artifact is called finished. This is an every-deliverable concern, not an optional one."
},
{
"capability": "publishing-sanitisation-leak-scan",
"group": "governance",
"status": "ok",
"route_measured": true,
"strength": "observed",
"benchmark_rows": [],
"evidence": ["references/desensitization-and-leak-scans.md", "scripts/scan_leaks.py", "references/precedents/manual-grep-finds-what-nobody-grepped-case-5.md"],
"evidence_not_shipped": ["docs/tool-verification/EXTENSION-desensitization.md (repository root, not shipped)"],
"limits": "Measured against a planted corpus (30 findings across 6 categories, every rule firing, zero false positives on the do-not-anonymize list) and against this repository (26 strong hits, all inside its own evidence file, reduced to 0 strong and 4 weak across 129 files after correction). One thing it cannot do: the scanner was never run against the pre-correction historical tree, so 'the scan would have caught it' is inferred. A strong hit is a prompt to look, not a verdict, and over-redaction is the failure on the other side."
},
{
"capability": "signature-derived-keys",
"group": "deliverable",
"status": "partial",
"route_measured": false,
"strength": "unverified",
"benchmark_rows": [],
"evidence": ["references/signature-derived-keys.md", "scripts/sig_probe.py"],
"evidence_not_shipped": ["docs/tool-verification/README.md (repository root, not shipped)"],
"limits": "The offline candidate path from an APK and the authoritative live read are both documented in the reference; neither has a public-target measurement here. The failure shape is a rebuilt APK that installs, launches, and then fails every signed request, which is easy to report as a server problem and is not one."
},
{
"capability": "blocked-unity-il2cpp-react-native-ios",
"group": "boundary",
"status": "blocked",
"route_measured": false,
"strength": "unverified",
"benchmark_rows": [],
"evidence": ["references/framework-runtimes.md", "references/coverage-and-limits.md"],
"evidence_not_shipped": ["docs/tool-verification/README.md (repository root, not shipped)"],
"limits": "Unity / IL2CPP logic recovery, React Native / Hermes bytecode and Cordova internals, and every iOS or .ipa workflow are outside this skill. references/framework-runtimes.md identifies the runtime and establishes that the dex is not the battlefield; there is no verified recipe here for locating a method inside libil2cpp.so plus global-metadata.dat. Say so instead of improvising."
},
{
"capability": "blocked-server-side-authority",
"group": "boundary",
"status": "blocked",
"route_measured": false,
"strength": "unverified",
"benchmark_rows": [],
"evidence": ["references/server-api.md", "references/handoff-boundaries.md"],
"evidence_not_shipped": ["docs/tool-verification/README.md (repository root, not shipped)"],
"limits": "Determining who owns a gate is covered; breaking an authorization the server performs is not. There is no client-side patch that reaches it, and the honest deliverable is the residual: which authority decides, and why the artifact cannot change it."
}
]
}

View File

@@ -0,0 +1,131 @@
# Known limitations — what an installed copy cannot do, and what was never measured
This file exists because of how the skill is delivered. `npx skills add` installs `skills/apk-reverse/`
and nothing else, so the evidence record this skill was measured against — which sits at the
repository root — is **not present in your install**. Every claim in `SKILL.md` and in `references/`
still carries a strength label; this file and `references/evidence-summary.md` are what let you read
that label without the record it came from.
Read this before treating any "this was measured" statement as covering the route you are about to
take. Silence in this list is not support for a route; it means nobody here paid for the counters yet.
## Strength labels
- **observed** — a command was run and its output exists behind the claim.
- **inferred** — follows from a documented mechanism or a neighbouring measurement; the step itself
was not executed.
- **unverified** — assumed, or reported by someone else, and not reproduced in this record.
A row whose strength is `unverified` is a statement about *this skill's evidence*, not about the
mechanism's truth. It means nobody has yet put a command and its output behind it here.
## Dependencies this skill does not ship — name them before the workflow starts
- **Dart AOT analysis needs a snapshot dump.** The workflow begins at a snapshot text file; producing
it needs a snapshot container resolver this skill does not contain and cannot synthesize. Ship a
pinned front end (`aotopsy`, pure Go, no toolchain) or build `blutter` (about 80 s, needs a C++
toolchain) — and **say which one**, because the two report different Dart version labels for the
same binary. The two offset spaces are not a constant offset apart: one tool reports file offsets
while the pool index speaks pool offsets, and a measured run found 4,237 distinct deltas over 4,241
shared strings. Never describe the object pool as something this skill decodes on its own.
- **The Android build-tools are not on PATH here.** `aapt2`, `D8`, `apksigner`, `zipalign`, `dexdump`
and `split-select` all live in a build-tools directory and must be passed explicitly. The measured
signing order is zip, then `zipalign -p -f 4`, then `apksigner.jar --v1 --v2 --v3`; reversing it is
refused by the installer. There is no `uber-apk-signer` on this class of host, and no `apktool`,
`jadx`, `gradle`, NDK, host or device clang, `android.jar`, or smali/baksmali jar.
- **The runtime routes need a real device.** Hooking, dumping, install-then-launch verification and
the module route all require a rooted device; several of them additionally require a framework the
device must have active.
- **The evidence record and the benchmark matrix do not ship.** Their paths appear in older
documents as if they were openable files. They resolve only in the repository they were written in.
## Capabilities with no footing in an installed copy
These are not "hard"; they are paths where nothing in this record is evidence either way, so a plan
built on them has nothing to check itself against.
- **Producing a Dart AOT snapshot dump.** This skill ships no resolver for it. See the dependency
section above.
- **Kernel-side syscall answer forging.** The generator is measured; **no kernel-side artefact was
ever compiled or loaded**. The tested kernel is 4.14.186 (eBPF needs 5.10+), the host has no
`aarch64` cross-compiler, `make` or `ndk-build`, and there is no kernel source. One structural
correction survives without a measurement and is worth more than the templates: **an ordinary
KernelSU module is a userspace module and cannot change a syscall return value.**
- **Unity / IL2CPP logic recovery, React Native / Hermes bytecode and Cordova internals, and every
iOS or `.ipa` workflow.** The runtime can be identified; the logic recovery is not covered, and
there is no verified recipe for locating a method inside `libil2cpp.so` plus `global-metadata.dat`.
- **Defeating a server-side authority.** This skill determines *who owns a gate*, not how to break an
authorization the server performs. Report it as a residual instead of patching harder.
## Routes that were never exercised
- **The packer, code-virtualization, custom-linker, integrity-check-redirection and tamper-suicide
scenarios were never run end to end.** The target used for the first verification pass has none of
those features, and its unmodified build already fails to start — which removes the
repack-and-regress loop those scenarios need. The packer tooling was recorded as *not applicable to
that sample*. The benchmark pass measured **shape discrimination** on public targets; it did not run
a live extraction shell. Anything you read here about a protected target is `inferred` unless it
says otherwise.
- **Scripts from the first pass that carry no measurement** (absence is not a verdict on them):
the crash tools, the APK differ, the screenshot tool, the installer test, the repacker, the
equal-length patcher, the instruction finder, the verifier checker, the class differ, the string
patcher, the smali tools, the DataStore injector, the API prober, the TLS checker, the USB network
helper, and the device shell helper. The benchmark pass has since run some of these; the rest are
still unmeasured, and the references say so where it matters. `grab_crash.py` is the sharpest
example: it claims to recover a stack a crash-reporter SDK swallowed, and it was never tried — on
the one target that presented exactly that situation.
- **Steps that a route needs but that were not executed**, so the route is not end-to-end:
installing a re-signed split set and confirming it launches; installing, launching or getting a
refusal from the VMP-boundary sample; the native certificate-pinning row (it needs a gradle+NDK
build this host cannot do); an end-to-end Dart string patch repacked and installed; and byte
agreement between two independent dumpers.
- **No real OLLVM trace and no trace pipeline verified on a device.** Exclusion keeps a target alive
but does not restore event delivery, and a zero-event trace is a boundary to identify rather than a
recipe to follow.
## Conclusions that changed — an older copy may still state the old one
Each of these was a claim this skill used to make and no longer does. If you are reading a copy or a
cached page that states the old version, the old version is wrong.
- **"Tens of percent means an extraction-shell skeleton."** Deleted. The ratio is **bimodal, not
thresholded**: a real skeleton reads about 100 %, a zero-change control 1.9 %, nop-cleared bodies
0.0 %, throw stubs 1.9 %.
- **A VMP verdict withdrawn.** A hand-written opcode table reported 1,393 of 22,424 bodies as
undecodable and that was read as private opcodes; the platform decoder found 34,566 instruction
decodes and **zero structural errors** on the same file. The surviving rule: never declare a VMP
without an official disassembler's failure count.
- **"Exclusion fixes the zero-event trace."** It does not. Exclusion is what keeps the target alive;
delivery stayed at zero in the treatment arm.
- **"`repack.py` has no apksigner signing path."** That conclusion expired mid-pass when the path was
added; the signing chain was then measured end to end.
- **The armv7 Dart string-table form is UTF-16 with a `len*2` length.** Refuted at the byte level:
the 32-bit record is `[header u32][byte-count u32le][UTF-8 payload]`, and the extractor's zero for
that ABI is a format mismatch rather than an empty table.
- **A documented tool flag that does not exist, and a documented tool class that does not match the
shipped script.** Where a reference names a command or a tool class, check it against
`scripts/` in this install before running it — the script is the measurement, and a reference line
can lag behind it.
## What an installed copy can answer, and what it cannot
| Question | Answerable here | Not answerable here |
|---|---|---|
| Is this capability proven, and how strongly? | `references/evidence-summary.md`, `evidence/capability-matrix.json` | The exact commands and captured output behind it |
| Which capability is outright blocked? | `evidence/capability-matrix.json` rows with status `blocked` | Whether the mechanism would work on your target |
| Which tool versions was this measured on? | `evidence/tested-tool-versions.json` | Versions this skill was never measured on — that list is there too, but it is a list of gaps, not of results |
| What does the skill deliberately not cover? | `references/coverage-and-limits.md` | — |
| What is the exact output of a past run? | — | The record at the repository root, which does not ship |
## Failure modes this file exists to prevent
- Reading "a command was run" as "the command was correct". A tool that runs without error is not a
tool that is right: one script here produced a false negative on a symbol that exists and is called,
and another returns a clean "no references" answer for input it cannot read at all.
- Reading `inferred` as `observed` because the surrounding prose is confident.
- Reading `unverified` as a refutation. It is an absence of evidence here, not evidence of absence.
- Reading silence in the "never exercised" list as support for a route.
- Calling a route verified when the install step was never run. Several routes in this skill stop
exactly there, and an artifact that installs is not the same artifact as one that launched.
- Treating a documented version requirement as a measured one. The version table lists what was read
off the tools, and separately lists the versions nothing here has ever run on.

View File

@@ -0,0 +1,103 @@
{
"schema": "apk-reverse/tested-tool-versions",
"version": 1,
"purpose": "Every tool and library version this skill's claims were measured on, with the command that produced each number, so a reader can tell 'this was verified' from 'this was verified on a version I do not have'. An installed copy carries this file; the full run records do not ship.",
"how_to_re_probe": [
"Windows / PowerShell host, as used for every measurement below.",
"Python libraries: python -c \"import importlib.metadata as m; print(m.version('frida'))\"",
"CLI tools: <tool> --version (or -V).",
"PATH presence: Get-Command <tool>. Anything reported NOT ON PATH must be passed explicitly by path, and scripts here do that."
],
"host": {
"os": "Windows 10.0.26100 (x86_64)",
"python": "3.14.0 (CPython, MSC v.1944 64-bit AMD64)",
"shell": "PowerShell 7 (pwsh)",
"strength": "observed"
},
"measured_on": {
"note": "Every entry below was probed on the host described above during the packaging pass; each carries the command that produced it. Listing a tool here means its version was read, not that every capability of it was exercised -- see capability-matrix.json for the latter.",
"languages_and_runtimes": [
{"name": "python", "version": "3.14.0", "probe": "python -V", "strength": "observed", "used_by": "all scripts under scripts/"},
{"name": "java", "version": "17.0.4.1 (2022-08-18 LTS)", "probe": "java -version", "strength": "observed", "used_by": "scripts/dexpatch/, jar-based tools"},
{"name": "javac", "version": "17.0.4.1", "probe": "javac -version", "strength": "observed", "used_by": "scripts/lsposed_scaffold.py build notes"},
{"name": "node", "version": "v24.11.1", "probe": "node -v", "strength": "observed", "used_by": "no script in this skill requires it"},
{"name": "sqlite3", "version": "3.50.6 (32-bit build)", "probe": "sqlite3 -version", "strength": "observed", "used_by": "local-state work"},
{"name": "git", "version": "2.46.2.windows.1", "probe": "git --version", "strength": "observed", "used_by": "repository gates"}
],
"device_toolchain": [
{"name": "adb", "version": "1.0.41 (platform-tools 37.0.1)", "probe": "adb version", "strength": "observed", "used_by": "scripts/install_test.py, scripts/coldstart.py, scripts/preflight.py, scripts/devsh.py"}
],
"android_build_tools": {
"package": "Android SDK build-tools 34.0.0",
"probe": "source.properties: Pkg.Revision=34.0.0",
"strength": "observed",
"not_on_path": true,
"note": "None of these resolve through PATH; the working calls pass them explicitly. The measured signing order is zip, then zipalign, then apksigner -- reversing it is refused by the installer.",
"members": [
{"name": "aapt2", "version": "2.19-10229193", "probe": "aapt2 version"},
{"name": "D8", "version": "8.2.2-dev (build facedf41bbd28b563d1e9e09c5f72d7c5ca598d5)", "probe": "java -cp <build-tools>/lib/d8.jar com.android.tools.r8.D8 --version"},
{"name": "apksigner", "version": "0.9 (jar)", "probe": "java -jar <build-tools>/lib/apksigner.jar --version"},
{"name": "zipalign", "version": "no version string emitted", "probe": "zipalign (prints the 'Zip alignment utility' banner)"},
{"name": "dexdump", "version": "no version string emitted", "probe": "dexdump (prints usage and exit)"},
{"name": "split-select", "version": "same package (34.0.0)", "probe": "package revision"}
]
},
"frida_stack": [
{"name": "frida", "version": "16.7.19", "probe": "frida --version", "strength": "observed", "used_by": "scripts/run_probe.py, scripts/spawn_patch_detach.py, scripts/hook_patch_only.js, scripts/anti_detect_probe.js, scripts/stalker_trace.js, scripts/frida_rpc_serve.py"},
{"name": "frida-tools", "version": "13.7.1", "probe": "python -c \"import importlib.metadata as m; print(m.version('frida-tools'))\"", "strength": "observed"},
{"name": "objection", "version": "1.12.5", "probe": "python -c \"import importlib.metadata as m; print(m.version('objection'))\"", "strength": "observed"}
],
"python_libraries": [
{"name": "androguard", "version": "4.1.4", "strength": "observed", "used_by": "dex reading paths"},
{"name": "capstone", "version": "5.0.9", "strength": "observed", "used_by": "scripts/dart_disasm.py comparison, scripts/svc_scan.py second decoder"},
{"name": "unicorn", "version": "2.1.4", "strength": "observed", "used_by": "emulation route"},
{"name": "lief", "version": "1.0.0", "strength": "observed", "used_by": "ELF work"},
{"name": "pyelftools", "version": "0.33", "strength": "observed", "used_by": "ELF work; also supplies the 'readelf' script that PATH resolves to (see notes)"},
{"name": "protobuf", "version": "6.33.6", "strength": "observed", "used_by": "the official-runtime cross-check behind the schema-free decoder"},
{"name": "networkx", "version": "3.6.1", "strength": "observed"},
{"name": "pydot", "version": "4.0.1", "strength": "observed"},
{"name": "asn1crypto", "version": "1.5.1", "strength": "observed"},
{"name": "cryptography", "version": "48.0.1", "strength": "observed"},
{"name": "lxml", "version": "6.0.2", "strength": "observed"},
{"name": "mitmproxy", "version": "12.2.3", "strength": "observed", "notes": "the library version"}
],
"proxy": [
{"name": "mitmdump (standalone binary in the local tools directory)", "version": "11.1.2 binary, bundling Python 3.13.1 and OpenSSL 3.4.0", "probe": "<binary> --version", "strength": "observed", "notes": "This is NOT the same version as the mitmproxy library above. A local workbench note recorded 12.2.3 against this binary's path; the binary reports 11.1.2. If a captured-flow shape matters, read the version from the binary you actually invoke."}
],
"readelf_note": {
"resolved_to": "a pyelftools-supplied Python script earlier on PATH than any GNU binutils readelf",
"version": "not obtainable from the resolved executable (its --version prints nothing)",
"strength": "observed",
"consequence": "A local workbench note recorded 'readelf 2.28'. That number does not match what PATH resolves to here. Treat any readelf-derived figure as produced by the pyelftools script until proved otherwise."
}
},
"external_toolchains_measured_elsewhere": {
"note": "These were exercised in earlier passes and their versions are recorded in files at the repository root, which an installed copy does not contain. Listed here so the claim is not silently version-free.",
"record_location": "docs/tool-verification/TOOL-VERDICTS.md and docs/tool-verification/EXTENSION-*.md (repository root, not shipped)",
"entries": [
{"name": "blutter", "reference": "HEAD 4a60ac6; init_env downloads ICU 73.2 + capstone 4.0.2", "result": "works; the first-run crash was not reproducible after relink; build about 78 s", "strength": "observed"},
{"name": "aotopsy", "reference": "pure Go, no toolchain needed; hash recorded in the verdict file", "result": "works out of the box; the same feature flags blutter derived", "strength": "observed"},
{"name": "devsh / device shell", "reference": "toybox shell, one rooted Android 11 / API 30 / arm64-v8a device", "result": "the device facts that shaped several traps", "strength": "observed"}
]
},
"not_available_here": [
{"name": "apktool", "state": "not on PATH", "probe": "Get-Command apktool", "notes": "the local launcher was recorded as a dead link in the workbench record (not shipped), so no apktool version has ever been exercised here"},
{"name": "jadx", "state": "not on PATH", "notes": "documentation treats it as a readable viewer of last resort, not as a verified measurement source"},
{"name": "unzip, tshark, rabin2 (rizin), gdb", "state": "not on PATH"},
{"name": "gradle", "state": "not installed", "notes": "the module route uses a gradle-free chain"},
{"name": "Android NDK / host clang / device clang", "state": "not installed", "notes": "this is why no Dex-to-C output was ever built and why the Java2C-specific criteria stay inferred"},
{"name": "smali / baksmali jars, android.jar", "state": "absent", "notes": "scripts/smtool.py carries its own classpath expectations"},
{"name": "uber-apk-signer", "state": "absent", "notes": "the measured signing chain is zipalign plus apksigner.jar"},
{"name": "keytool, jarsigner", "state": "not on PATH", "notes": "present inside the JDK installation and must be passed by path"}
],
"not_tested_at_these_versions": [
"frida: only 16.7.19 was measured. No claim here has evidence against frida 17.x or any other major.",
"Android device: only Android 11 / API 30 / arm64-v8a was measured. The documented analysis of Android 12-16 behaviour (why classic active-invocation hooks stopped working) is inferred from public work, not measured.",
"Android build-tools: only 34.0.0. aapt2, D8, apksigner, zipalign and dexdump have no measurement on any other revision.",
"Python: only 3.14.0. JDK: only 17. Node: only v24.11.1 (and nothing here needs it).",
"Kernel side: no kernel module was compiled or loaded at any version, on any kernel. The generator was exercised; the artefact was not.",
"Gradle and the Android NDK: never used, at any version.",
"Dart: the string-table formats were checked on two ABIs of one build (a Dart 3.6.0 snapshot), through a front end this skill does not ship.",
"Mitmproxy: the library and the standalone binary measured different versions (12.2.3 and 11.1.2); no capture in the record is tied to a mitmproxy release."
]
}

View File

@@ -0,0 +1,160 @@
# Account gates: sign-in walls, forced binding, guest mode
Load this when the task mentions "no login required", "don't force me to sign in", "skip phone
binding", "guest mode", or when a screen is unreachable without an account — and also **before**
promising that any account- or membership-derived feature will work offline.
This file is mostly about **telling apart two things that look identical in the UI and are completely
different in reality**:
- a **client-side gate** — code that refuses to proceed. Patchable.
- an **account-scoped resource** — the screen is empty because the server sent nothing, because the
request was made without a valid identity. **Not** patchable.
Confusing the two is how a task ends up "done" while the user stares at an empty page.
## Step 1: classify what you are looking at
| Symptom | Most likely cause | Can a client patch help? |
|---|---|---|
| A dismissible dialog / bottom sheet asking to sign in, then the app works | client-side gate | **yes** |
| A full-screen wall with no dismiss, app unusable | client-side gate | **yes** |
| Screen renders its frame but the list is empty and a "log in to see" hint is drawn | **account-scoped data** | no — the data does not exist locally |
| A specific action (comment, favourite, follow, download) refuses | client-side gate | usually yes |
| The action succeeds locally but reverts on restart / never reaches the server | **server-side identity** | no |
| "Binding required" before an action | client-side gate | **yes** |
| An entitlement appears then disappears | server-side authority | no |
**The deciding question is never "is there a login screen?" — it is "does the thing I want exist
without an identity?"**
Ask it before patching:
- If the content is **fetched per-account** (subscriptions, favourites, reading history, purchased
items), a patch changes the gate and nothing else. The list stays empty because the server was asked
for *this account's* items and there is no account.
- If the content is **public** and merely hidden behind a prompt (a reader, a catalogue, a settings
page), the patch is real and the feature becomes usable.
Reading the code tells you which: follow the "sign in" prompt to what it guards, then look at whether
that guarded path issues a request carrying a token, or touches only local state.
## Step 2: find the convergence point
Sign-in checks are usually concentrated, because a UI cannot meaningfully enforce login at thirty
call sites. Look for a small number of helpers with names in this family:
```
checkLogin* ensureLogin* requireLogin* needLogin* isLogined* isLoggedIn*
checkBind* ensureBind* requireBind* needBind*
showLogin* openLogin* loginSheet* loginDialog*
```
and, on the state side, a **login-state field or stream** that every helper reads
(`isLogin`, `loginedStream`, an `Rx<bool>`, an auth-state enum, a credential store wrapper).
**Rule: patch the predicate, not the prompt.** Making the sheet not appear is cosmetic — the caller
still treats the answer as "not signed in" and takes its else-branch, which is usually where the real
feature got skipped. The durable fix is to make the *verdict* come back as "signed in / no sign-in
needed", so the caller takes its happy path.
Two useful shapes, both better than editing the dialog:
| Target | Change | Effect |
|---|---|---|
| the gate predicate used by many callers | return "ok / already signed in" unconditionally | every caller proceeds; no new code path |
| the "should I prompt?" decision inside the helper | return "nothing to do" | the prompt never appears and callers proceed |
Prefer the first when callers branch on the result; prefer the second when the helper is fire-and-forget.
**Count the callers before choosing** (`scripts/find_refs.py`). A predicate with one caller is a
surgical target; a predicate with forty callers is a behavioural change you must reason about — it will
also release flows you were not asked to touch (settings, purchase, upload). That may be fine, but it is
a decision, not a side effect.
### Forced binding specifically
Phone binding is the same pattern with its own helper and usually **two** state checks in series
(bound-phone? and email-or-other-recovery?), because either satisfies the requirement. Patch the
combined verdict, not the first condition — a patch that only satisfies one branch still prompts on the
other.
Watch for a **"skip"/"later" path the app already has**, and for **which actions the binding actually
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 3: what NOT to do
These are the tempting moves that produce a worse artifact than doing nothing:
- **Do not fabricate a session.** Writing a dummy token, a fake credential blob, or a placeholder
identity into the credential store makes the client *believe* it is signed in. It then calls
identity-scoped endpoints with garbage, and the app enters a state that is **worse than signed out**:
screens show an authentication-failure message, or silently return empty data with no explanation.
A signed-out app that says "please sign in" is better than one that claims a session and fails.
*(If you touch stored credentials at all, keep a backup and expect to have to restore it.)*
- **Do not delete the sign-in UI.** Removing the entry point does not change the verdict; it only makes
the failure unexplainable when a caller does branch on it. Keep the entry point working — it is
normally an explicit requirement ("signing in must still be possible").
- **Do not release gates that protect server-side operations.** If the action's result is an
entitlement the server must grant (a purchase, a quota, a subscription), releasing the client gate
only lies to the UI. See `references/membership-and-limits.md`.
- **Do not assume "guest can browse" means "guest can do everything".** Establish per feature which of
the three states you are in: public, gate-only, or identity-required.
## Step 4: the session-loss problem (this one is unavoidable)
Any change to the signing identity — including a plain reinstall of *your own* build — can invalidate
stored credentials, because they may be sealed with a key held by the platform keystore rather than by
the app.
**What you observe:** the app opens fine, then one subsystem reports it cannot decrypt or unwrap
something, or the user is silently signed out.
**What it means:** the app's private data was carried across an uninstall/reinstall (or restored from a
backup), but the encryption key was destroyed with the app. The ciphertext is now unopenable by anyone.
**What to do:** treat "the user signs in once on the new build" as the expected, documented outcome — not
as a defect to fix. Put it in the delivery notes in one line, together with the fact that a later
upgrade over the *same* signing key can be installed without uninstalling, and therefore will not lose
the session.
**Do not** chase this into the keystore. Restoring the old key is not part of packaging a patched build,
and any workaround that keeps the *old* ciphertext alive while the key is gone is a dead end.
Related and worth knowing: an app may keep the same logical account in **two places** — a plain
preference blob and a sealed store. After a reinstall the plain one survives and the sealed one does
not, which shows up as an account that is half-present (identity fields populated, token empty). That
half-state is a useful diagnostic: it tells you the app reads its identity from the sealed store, which
is exactly the thing you must not try to forge.
## Step 5: verify
The claim to support is narrow and should be stated narrowly.
1. **The prompt does not appear** on a cold start and on the screens that used to show it.
2. **The feature behind it actually works**, not merely "no longer blocked": open the reader, the
catalogue, the settings page — whatever the gate guarded — and confirm real content or real function.
3. **The sign-in entry still exists and is usable.** Signing in with a real account must still work; that
is usually part of the request, and it is the thing that proves you patched a gate rather than broke
authentication.
4. **Signed-in behaviour still works** for a real account, so you can tell the difference between
"released the gate" and "broke the auth path".
5. **No fake-session artefact is left behind** — check that you did not write anything into the
credential store or preferences. If you did, remove it and re-verify from a clean install.
State the result as: *"the gate no longer blocks X, from a signed-out state; **the content behind it is
public / the content behind it is per-account and therefore still empty when signed out**."* That second
clause is what stops a client patch from being mistaken for an account.
## Reporting template
```
Gate: <sign-in sheet | binding prompt | action-scoped check>
Class: client-side gate / account-scoped data (not patchable)
Patch: <predicate or decision, not the prompt>
Signed-out state: <what works now, per screen>
Signed-in state: <still works, listing what still requires an account>
Not achievable: <per-account lists, entitlements — with the reason>
Session note: sign in once after install; upgrade over the same key keeps the session
```

View File

@@ -0,0 +1,180 @@
# Ad removal
Goal: ads stop appearing, and **nothing else breaks**. Read `pitfalls.md` P5 and P6 before patching anything here.
**Load this when:** an ad, promo, splash or rewarded video must stop appearing, or an ad-related SDK was found in recon. It gives the enumeration step that decides *which* layer the ad lives on, then the removal per layer.
## Step 1: enumerate what "ads" means in this app
Do not assume "ads" is one thing. Enumerate first.
**SDK-integrated ads** (the app calls an SDK):
- Search dex strings for SDK markers: `openadsdk`, `TTAdSdk`, `TTAdNative`, `Pangle`, `pangolin`, `com.qq.e`, `GDTAd`, `gdt_plugin`, `anythink`, `ATSDK`, `ATRewardVideoAd`, `bdxadsdk`, `sigmob`, `ksad`, `mobads`, `beizi`.
- **Aggregators** (AnyThink, TopOn, and similar) are wrappers: the ad networks underneath are *their* adapters, not app code. Patching an individual network's class is usually pointless; the aggregator still runs and still reaches the network.
- The app almost always funnels every call into **one wrapper class**. That class is the patch surface (Step 2).
**Dynamic-plugin ad SDKs** (the SDK is not in the APK). Aggregators increasingly ship as a runtime plugin: the app downloads an APK on first launch into its private directory and loads it through a plugin classloader.
```
/data/data/<app.package>/files/<sdk>_p/<plugin.pkg>/version-*/apk/base-1.apk
/data/data/<app.package>/files/<sdk>_p/<plugin.pkg>/version-*/lib/<abi>/*
```
- Consequence: **deleting bundled `assets/`, `lib/*.so`, or the SDK's classes from the dex does not kill this SDK.** The loader re-downloads on the next launch, and a half-deleted plugin is worse than an intact one.
- The kill switch is the **init gate** (Step 2). Killing init also prevents the working directory from ever being created, which is the strongest verification signal available (Step 4).
- The plugin may also be bundled. Two copies still means one init gate; patch that, not the payload.
**Server-driven ads / sponsored content** (the server returns the ad, the client renders it):
- **This is the most common shape in a modern app, and it is usually the EASIEST to
remove — not the hardest.** Go to `server-config-and-updates.md` for the full
procedure. The short version: the client keeps a complete "do not show it" branch
so the operator can turn the slot off, so neutralising that one branch is a
small, local edit.
- Look for endpoints like `/adverts`, `/adv`, `/banner`, `/config`, and DTOs named
`Advertisement*`, `Advert*`, `Banner*`, `Promotion*`, or — more common in practice
— a generic `*Config` payload carrying per-feature blocks (`splash`, `noticePopup`,
`updatePopup`, `tabbar`, `banner`).
- The client renders whatever the list contains. If the list is empty there is nothing
to show, but see `pitfalls.md` P5 on **how not** to empty it: never make a shared
request fail.
- Sponsored cards that look like content (a VPN promo, a network-accelerator card, a
third-party product) are usually exactly this.
**A note on what "no SDK found" means.** If you have grepped the dex, enumerated the
loaded classes at runtime, and counted the SDK log tags in a capture, and all three
are zero (see Step 1), then there is no SDK, and continuing to search for one is a
dead end rather than a thorough approach. Re-classify against this list instead. A
zero result on all three signals is a strong positive finding about where the
behaviour lives, and it should move you to the server-config path immediately.
**Legit content that looks like an ad.** Verify before acting. A `/adverts?position=banner` response containing anime titles and poster images is the home-page carousel, not an advertisement. Removing it removes real functionality.
## Step 2: find the single convergence point
Almost every ad-enabled app wraps one SDK in a **single singleton helper**: `init`, `loadSplash`, `loadBanner`, `loadFullScreen`, `loadReward` all live in the same class. Find that class, and patch nothing else if you can.
```
helper.init(Application) -> Sdk.init(appId, appKey); Sdk.start() // plus plugin download
helper.showSplash(Activity, ViewGroup, onClose: () -> Unit)
helper.showInterstitial(Activity)
helper.showReward(Activity, onStart: () -> Unit, onFinish: (Boolean) -> Unit)
helper.preload*(Activity)
helper.canShow*(Activity): Boolean // == isReady / isLoaded / isAdReady
```
**Preferred play: readiness always false + init is a no-op.**
1. Make the readiness predicate constant `false` (`isReady` / `canShow*` / `isLoaded` / `isAdReady`).
2. Make `init` an empty method (`return-void`) so the SDK never builds a network stack and never requests a plugin.
3. Only then touch show/load methods, and only for slots that bypass the readiness predicate.
Why this order works:
- Callers of a readiness predicate **already own a "no ad available" branch**, and the app ships and tests it. Returning `false` routes every slot through that existing path: no crash, no hang, no new code path.
- Killing init removes the SDK's network, cache, and download activity entirely. That is the difference between "the ad is hidden" and "the ad subsystem never started", and only the second claim is provable (Step 4).
- Per-slot patching (splash this week, interstitial next) leaves the SDK alive and downloading. Avoid it unless a slot ignores the predicate.
Method classification, once the helper is located:
| Method | Contains | Patch |
|---|---|---|
| Init | `Sdk.init(...)` + `Sdk.start()`, plugin download | body -> `return-void` |
| Readiness / gate | `isAdReady`, `canShow`, `isLoaded` | return `false` / `0` |
| Show splash / interstitial | constructs an ad object, `loadAd()` / `show()` | `return-void`, **but preserve the completion callback** |
| Show reward | same, plus success/failure lambdas | `return-void`; decide deliberately whether to grant the reward |
| Preload / warm-up | `RewardVideoAutoAd.init(...)`, cache priming | `return-void` |
### The callback trap (this one breaks startup)
A splash/loading ad usually takes a **completion lambda** as a parameter, and the app's startup state machine waits for that lambda before dismissing the splash screen.
**If you replace the method with a bare `return-void`, the lambda never fires and the app hangs on the splash screen forever.** A patch that "removed the ad" and produced a frozen splash is this bug, not a repackaging bug.
Correct shape, skip the ad and still complete:
```
.method public final showSplash(Landroid/app/Activity;Landroid/view/ViewGroup;Lkotlin/jvm/functions/Function0;)V
.registers N
invoke-interface {p3}, Lkotlin/jvm/functions/Function0;->invoke()Ljava/lang/Object;
return-void
.end method
```
**Before patching, prove the contract.** Read the listener class the method constructs and find which callback triggers the completion lambda (`onAdDismiss`? `onAdError`? `onAdLoadTimeout`?). That tells you whether to call it on success, on failure, or unconditionally.
For a reward ad whose lambdas are `(onStart, (Boolean) -> Unit)`, granting the reward client-side is a choice: `onFinish(true)` makes the app treat the reward as earned. Check whether the reward is validated server-side before promising it.
### Global timestamp gates (one write, many slots)
Some "free reading + ads" apps do not gate ads per slot. They store one local timestamp and every slot, cooldown, and interval check reads it: splash, chapter-break interstitial, unlock prompt, "next ad in N minutes". Patching call sites one by one always misses some.
- **Find it:** dump the app's preference keys and filter for ad-ish names (`ad`, `free`, `cooldown`, `interval`, `end_time`, `expire`, `next_`). Then hook `SharedPreferences.getLong/getInt/getString` and watch which key is read immediately before a slot appears. The key read across several screens is the gate.
- **Patch it:** in `Application.onCreate()`, write a far-future value as early as possible (for example `4102444800000L`). Avoid `Long.MAX_VALUE`: implementations often compute `end - now` and can misjudge an extreme value. One write disables every reader at once, which is far more stable than editing each consumer.
- **If the app overwrites it later:** observe what it writes back under different start sequences. A single startup write is not automatically permanent for keys the app re-asserts; then patch the writer or the reader instead.
- **Same technique, two key semantics:** "ad-free until `<date>`" keys and "cooldown/interval" keys both respond to this, but the value differs. For a cooldown key, a far-future value means "next ad is never due", which is what you want, while a progress/check-in flow reading the same key may behave oddly.
- **Side effects are mandatory in the delivery notes.** The same key is often reused as a business field (the anchor for a new-user ad-free window, a remaining-quota counter). Forcing it to a far-future value changes those business judgements. Usually harmless for a de-ad goal, but state it explicitly and confirm the related screens do not show a broken state.
- With DataStore instead of SharedPreferences, a write inside `Application.onCreate` needs a coroutine or a blocking write; see `runtime-data.md`, and never block the main thread on persistence.
### "Ads" vs ad-gating UI
Some blocking dialogs contain no ad slot at all ("watch an ad to unlock", skip/continue prompts, cooldown notices). If the goal is "the app is usable", they count as ads and must be handled too. Handle them by letting the gate pass, not by deleting the dialog view.
- Where the gate reads the timestamp key above, the timestamp patch already covers it.
- Where it reads a boolean or a `canUnlock()` method, patch that predicate.
- **Business-coupled entrances need a check first.** "Watch a video to earn benefits" may be a real feature. Decide with evidence: find the callers (`scripts/find_refs.py`) and see whether the entrance leads to a **server request** or a **local entitlement write**. A server-issued entitlement cannot be created client-side, so releasing the gate only fools the UI while the server still refuses; an entrance that changes purely local UI state is safe to release.
## Step 3: choose the patch layer
From safest to riskiest (full technique table in `dex-patching.md`):
1. **Kill SDK init** (Step 2) — the SDK never starts; its networks, caches, and plugin downloads never happen. Highest-value single patch. Verify nothing else silently depended on init.
2. **Neutralize the readiness gate** — `isReady`/`canShow` return false; routes existing "no ad" branches.
3. **No-op the show/preload methods** — only when 1 and 2 are insufficient, always preserving callbacks.
4. **Global gate state** — one startup write covers every reader (timestamp / preference keys above).
5. **Filter server-issued ad data at the consumption layer** — drop entries for the target position before they reach UI state.
6. *(avoid)* **Renderer-level suppression** — only if the component is genuinely ad-exclusive. Check `pitfalls.md` P6 first.
7. *(avoid)* **Transport-level blocking** — see `pitfalls.md` P5.
## Step 4: verify — three independent kinds of evidence
Logcat silence alone is weak evidence. A credible "ads are gone" claim needs all three, plus the neighbours:
1. **UI, screen by screen** — splash, home banner, detail page, reader/player, reward button, unlock prompt. Capture before/after screenshots (`verification.md` §screenshot discipline).
2. **Logs** — the SDK's characteristic tags are **entirely absent**, not merely quiet: `anythink`, `ATSDK`, `Pangle`, `TTAd`, `GDT`, `ksad`, and so on.
Make this a **count, not an impression**. Aggregators log a recognisable burst while they probe which networks are wired in (a "SDK id N not integrated" style line, once per network). Capture the same fixed window on the original and on your build and compare numbers: `8 -> 0` is a measurement; "I did not see it" is not. A tag that goes quiet without reaching zero usually means the SDK still starts and simply failed to load an ad this run — a different, weaker result than "the subsystem never started".
3. **Filesystem, the strongest signal** — the app's private directory contains **no ad SDK working directory at all**:
```bash
adb shell "su -c 'ls -la /data/data/<app.package>/files/'"
```
For a dynamic-plugin SDK, an absent `<sdk>_p` tree proves the init gate never ran. It is not an artifact you can fake by hiding a view. If the tree exists, something still initializes the SDK: the init patch did not take effect, or a second init path exists.
Additional cheap evidence:
4. **DNS / socket level** — a runtime `InetAddress` hook. Before: a burst of ad/tracker domains. After: none. Many ad SDKs bypass the system proxy, so a proxy log can miss them while this does not.
5. **Exercise the screens that had ads** and confirm nothing unrelated regressed: images, playback, lists, pagination, downloads, login.
6. **Clean install** — uninstall first, then install. Data left from a previous run can hide a failed init patch (`pitfalls.md` P11).
Frame the result at the right strength: *"the ad was hidden"* vs *"the ad subsystem never started"*. Only evidence 3 and 4 support the second (`verification.md` §the claim ladder).
## Step 5: what is usually NOT removable
Be honest about residual ads rather than breaking the app to chase them:
- **Content promos embedded deep in a feature's own data payload**, where the screen's
load depends on the same request. Distinguish this from a *dedicated config block*:
if the promo arrives in its own `splash` / `banner` / `popup` field with its own
`enabled` flag, it is removable (Step 1, server-driven). It is only genuinely hard
when the same list is both the content and the ad, with no marker separating them.
- **Ads delivered as content** (a sponsored "article" or a native card with no SDK marker)
— indistinguishable from real content without runtime tracing.
- **Ads whose SDK init also enables other features.** Removing init can break
functionality that silently depended on it. Verify before shipping; a working app with
one residual ad beats a broken app.
- **Server-issued entitlements gated behind watching an ad.** The gate can be opened
client-side, the entitlement cannot be created client-side.
- **A purely local brand launch screen.** If all you see is the app's own logo, no
third-party image and no network-sourced content, that is a splash screen, not an ad.
Removing it is a judgement call about the user's stated goal — say which one you
removed rather than silently treating "startup screen" as "ad".
If a residual ad cannot be removed without breaking something, say so, and say exactly
which coupling caused it. That is a better deliverable than a broken APK.

View File

@@ -0,0 +1,593 @@
# Advanced Unpacking — Extraction Shells, Active Invocation, and the VMP Boundary
Load this when a memory dump of a packed app has already landed on your desk and the
dump itself is the problem: the image parses, the classes are there, and the method
bodies are empty. The neighbouring files each own one stage — `packers.md` owns
*identifying* a shell and mapping what it validates, `recon.md` §Unpacking a dex-level
packer owns the whole-image memory dump and its first-pass filtering, and
`dynamic-frida.md` owns the runtime environment those dumps need. This file owns what
comes after: recognizing that a "successful" dump is only a skeleton, recovering the
missing code, and knowing where that recovery honestly stops.
**Strength note, read this first:** the repository's verification target carried no
extraction shell and no VMP, so nothing here was exercised end to end against a live
hardened sample by this skill's own verification pass. The FART/Youpk mechanisms and
the Android 12-16 failure analysis are **inferred** from public work (FART/Youpk
release notes and a 2026-08 Kanxue thread, `thread-292312`, on why classic
active-invocation hooks die on modern ART). The detection metric has since been
**measured against a purpose-built extraction-shell fixture set**, and that pass
invalidated the threshold this file used to state — see the calibration table in
§Measuring extraction instead of guessing and the commands behind it in
`references/evidence-summary.md` §The capability matrix. The older surgical fixture
that first exercised the script is in `references/evidence-summary.md` §The capability matrix.
Label your own results the same way.
Two parts of this file have since been exercised against live hardened targets on a
rooted device. The **root-side memory route** (§Dumping when frida is refused) was run
end to end: 17 ART dex mappings exported from `/proc/<pid>/mem`, page-alignment trim
included, validated with `scripts/dex_dump_validate.py`; the evidence, the device-shell
traps and the throughput numbers are recorded in
`references/evidence-summary.md` §The capability matrix. The `frida-dexdump` refusal that
motivates that section is in `references/evidence-summary.md` §The capability matrix. A later
pass added the route's **negative boundary** — what it yields nothing against, and how
to tell before spending a process window — plus the reproducibility check between read
paths and the layered-descent table; those are measured on the clean MASTG targets and
on a hardened sample, with commands and hashes in
`references/evidence-summary.md` §The capability matrix. The
FART/active-invocation half of the file is still **inferred** — no extraction-shell
target has been spliced end to end here, and the two candidate samples shipped in
`repos/CyReverse` turned out not to be extraction shells at all (their two "shell"
assets differ by six bytes; row B3 in the bench evidence file).
## The four shapes a dump can be
Diagnose before choosing a route. Run `scripts/dex_dump_validate.py` over the dump
directory and read the `stub%` column — the four shapes separate on it.
| The dumped image | `stub%` | Diagnosis | Route |
|---|---|---|---|
| Parses, many classes, real bodies | near-baseline | First-generation landing shell; the whole-dex dump **is** the original | Done — filter (`recon.md` §Unpacking a dex-level packer) and proceed to patching |
| Parses, many classes, most bodies are `return-void` stubs or nop fills | high | **Extraction shell skeleton** — bodies are decrypted only on invocation | FART loop, this file |
| Whole classes appear as bare `native` declarations, and the matching `Java_*` export exists | n/a | **JNI sinking** — the code left the dex for the `.so` | `java2c-and-jni-sinking.md` — read the function in the library, **not** this file |
| Method bodies look like a thin forwarder to a native routine, or a single native island holds the app's own logic | n/a | **Java2C / native island** — the body is a compiled `J`/`.so` function, so re-reading the dex finds only the call | `java2c-and-jni-sinking.md` — the dex is the wrong instrument |
| Whole classes appear as bare `native` declarations, and **no** `Java_*` export exists | n/a | Dynamic-registration native sink — the binding table is built at runtime | `java2c-and-jni-sinking.md` §the symbol search fails structurally |
| Parses, bodies present but instruction streams are nonsense to a dalvik disassembler | baseline | **Real Dex VMP** — private opcodes behind a native interpreter | Last section of this file, and be honest about the cost |
The two `native`-looking rows are **not** one row. They are separated because the
correct next action differs: JNI sinking keeps the logic in a named, exported ARM64
function that you read from the library, while a dynamic-registration sink has no
symbol to find and needs the binding table read instead. Lumping them together sends an
analyst to dump memory for code that is sitting in an ordinary `.so` — the misdiagnosis
`java2c-and-jni-sinking.md` was written to stop.
Two supporting signals worth recording while you are here (the second is measured on
this repo's hardened sample): a shipped shell `classes.dex` can be megabytes in size
yet define only a handful of classes — the bytes are payload, not code, so class count
and file size move independently under a packer. And a dump whose signature field
disagrees with its own contents while the checksum agrees is not necessarily corrupt;
a shell that never verifies its own header can ship it that way, so treat
checksum/signature results from `verify_dex_header` as evidence about *who touched the
image*, not as a pass/fail gate on usability.
## Measuring extraction instead of guessing
The metric that separates shape 2 from shape 1 is the **trivial-body ratio**: among
methods that *have* a `code_item` (`code_off != 0`), the fraction whose instruction
stream, with plain `nop` units skipped, contains nothing but a single `return*`.
`code_off == 0` methods are abstract or native declarations — that absence is normal
and is excluded from the ratio. A method with a `code_item`, real `insns_size`, and a
lone `return-void` inside is what an extraction shell leaves behind.
```bash
python scripts/dex_dump_validate.py <dump_dir> --find 'Lcom/example/app/'
```
The script dedupes by sha256 (never by size — same-size dumps of one dex have been
measured to differ in ~73% of their bytes), rejects structurally impossible images
(bad magic, `file_size` mismatch, ids out of range), counts classes and stub bodies,
and ranks which surviving image is the most likely original.
**Calibration — measured, and it is not a threshold.** Do not look for a `stub%` number
to compare against. On a real 982 KB application dex (5,061 bodies) every emptying
shape the shell family uses was built into a fixture and measured against a
zero-change control (`references/evidence-summary.md` §The capability matrix,
which carries the commands and the full matrix):
| What the shell left behind | `stub%` | vs. control (1.9 %) |
|---|---|---|
| bare `return-void` in every body | 100.0 % | sighted |
| body cut to `const/4 v0,#0; return v0` | 93.2 % | sighted |
| nop fill, no return — **the slot-clearing shape** | 0.0 % | **blind, and scores below the untouched original** |
| `new RuntimeException; throw` stub — **the 360/legu shape** | 1.9 % | **blind, indistinguishable from the control** |
| 25 / 50 / 75 % of bodies emptied | 1.7 / 1.7 / 2.1 % | blind |
The distribution is **bimodal**: `stub%` lands either at the app's own baseline
(ordinary constructors and interface stubs, measured at 1.9 % here) or at ~100 %.
Nothing sits in between, so there is no band a threshold could live in, and **partial
extraction is invisible** — a dex with three quarters of its bodies removed reported
0.2 points above its untouched control.
Two consequences the metric's name hides:
- **`stub%` high is dispositive; `stub%` low is not evidence of anything.** A low
reading is compatible with a genuine dex, a fully nop-wiped skeleton, and a
`throw`-stubbed skeleton alike.
- **Read `emptied%`, not `stub%`, for the skeleton call.** `emptied%` unions the
`return*`-stub and the nop-wiped classes, which is what lifts the nop-filled
skeleton from 0.0 % to 100 %. A `throw`-stubbed skeleton still reads low; for that
shape the ranking cannot help you and only per-body inspection can.
The trivial-body shapes `dex_dump_validate.py` counts, and which of them are skeleton
evidence, are documented in the script itself (`classify_body`); the split between
`stub` / `erased` / `minimal` exists because collapsing them produced a ranking that
named a fully-wiped image the most likely original.
### The ranking's failure conditions — read these before trusting its winner
The `ranking` block names a "best-supported candidate for the original", and it is
wrong in two measured situations. Both were reproduced, and both are the *opposite* of
the obvious reading:
- **A partially extracted image outranks a heavily stubbed one.** Ranking on the
emptied share alone put a dex with 25 % of its bodies removed *ahead of* its own
untouched control — 1.7 % against 1.9 %, because replacing a body with
`const/4 + return` lowers the `return-void` count. Measured before it was fixed;
the current key uses the count of skeleton-shaped bodies first, which is monotone,
and evicts anything over 50 % emptied.
- **A `throw`-stub skeleton is invisible and ranks at the control's own baseline.**
Every body replaced by `new RuntimeException; throw` measured 1.9 % — identical to
the untouched image — and it still sorts third in the fixture set. No body statistic
in this kit sees that shape. If the target family is 360/legu-like, **do not use the
ranking to pick a baseline**; diff candidate images against each other by body hash
instead.
The reason to state this in the reference file rather than only in the script: the
ranking's output is a *sentence with a winner in it*, and a wrong winner is worse than
no winner — an agent that adopts a modified image as its baseline builds every
subsequent diff on the wrong artifact. When `emptied%` is at the app's own baseline and
you cannot name a signal that separates the candidates, treat the ranking as
uninformative for that set. Commands and full matrix:
`references/evidence-summary.md` §The capability matrix.
## The FART loop: skeleton, invocation, splice
Extraction shells decrypt a method body only when that method first executes. A dump
taken at any fixed moment therefore holds whatever the app happened to invoke before
you dumped — mostly stubs. Waiting for the UI to wander into every method is not a
plan (thousands of methods, many on paths a user never triggers). The FART family of
tools closes the loop with three steps, **inferred** (not run by this repo against a
live extraction shell):
1. **Dump the whole image.** Class definitions, field and method signatures, string
tables — the skeleton is complete even when every body is a stub. This is the
ordinary `recon.md` dump; it gives you the address map for step 3.
2. **Actively invoke every method.** Walk the dumped class list reflectively and call
each method (dummy arguments; most will throw, which is fine — the point is that
the *shell* decrypts and installs the body as the method is entered). Hook inside
ART at the interpreter's entry so that, as each method begins executing, the
method's `code_item` — now holding the decrypted body — is written to disk,
tagged with its `method_idx`. These per-method records are the "bin" files of a
FART dump.
3. **Splice the bodies back into the skeleton.** For each record, overwrite the
stub body in the whole-dex image at the offset the skeleton gives you, fix the
header (`scripts/dexutil.py` `fix_dex_header` — signature first, checksum last),
and **accept nothing until jadx or baksmali parses the result** and the method
count matches the skeleton. "It assembled" is not "it repaired".
## The code_item length trap
Splicing fails in a specific place often enough to deserve its own section: the
computed length of a `code_item`. The full layout is
```
registers u16, ins u16, outs u16, tries u16, -- 8 B
debug_info_off u32, insns_size u32, -- 8 B (header = 16 B)
insns[insns_size] u2, -- the instruction stream
[padding u2] -- ONLY if tries_size > 0 AND insns_size is odd
try_item[tries_size] { start_addr u32, insn_count u16, handler_off u16 }, -- 8 B each
encoded_catch_handler_list { uleb size; per handler: sleb offs... } -- variable
```
Two segments are routinely dropped: the odd-`insns_size` padding before `tries`, and
the whole `encoded_catch_handler_list`, which is LEB128-variable and sits *after* the
try table. Drop either and the splice boundary slides — the rebuilt method swallows
bytes from its neighbour or leaves the handler table pointing past the image, and the
failure surfaces later as a dex that half-parses. Walk the handler list with
`read_uleb` to its true end (`scripts/dexutil.py`); do not estimate it. This trap is
**inferred** from repair-tool post-mortems, and it is the first place to look when a
spliced dex parses worse than the skeleton did.
## Why the classic hooks die on Android 12-16
The original FART-era hooks assumed method entry flows through
`art::interpreter::Execute`. On Android 12-16 that assumption fails for five
independent reasons (**inferred**, from the Kanxue `thread-292312` analysis, 2026-08).
Any one of them produces the same symptom — the dumper runs, logs everything, and the
output directory stays thin:
1. **Invoke dispatch splits away from `Execute`.** A quick-entry call goes to Nterp,
AOT or JIT code, or a bridge — none of them pass through the old `Execute` hook.
Forcing the interpreter is not a fix either: the entry point then switches to
Nterp, which *also* bypasses it. *Repair:* move the dump point up to
`EnterInterpreterFromInvoke`, which sits ahead of the Nterp/interpreter split, and
keep fallback points (special invoke branches, ClassLinker load paths, DexFile
open, Frida ClassLoader enumeration) for the methods that still slip through.
2. **`ArtMethod` layout drift.** Field offsets and pointer-sized members changed
across releases; a hardcoded offset reads the wrong field and the dump silently
mislabels everything after it. *Repair:* derive offsets at runtime (probe
`access_flags` / `quickCode` / `jniCode` on a known method) instead of compiling
them in.
3. **Scoped Storage breaks the output quietly.** Writes to `/sdcard` fail, the log
reads like a completed run, and the directory is empty. *Repair:* write under
`/sdcard/Android/data/<pkg>/files/` (or app-private storage) and **check the
`open()` return value** — the silent variant of this failure wastes a whole round.
4. **The shell fights back.** Junk classes that exit inside their initializer,
detection of reflective enumeration, deliberate dex-header corruption in memory,
decryption deferred to the true execution point, fingerprinting of known dumper
thread names and paths. *Repair:* randomize your thread names and output paths,
filter junk classes (few methods, throwaway names) before invoking, and expect the
counter-measure list to differ per vendor.
5. **Compilation policy shifts the entry under you.** A just-installed app and one
that has run for a while present different entry mixes (profile-guided
compilation, Mainline module updates). *Repair:* re-probe the entry type every
run; never cache yesterday's hook decision.
The debugging order that isolates all five in the fewest rounds: print the entry and
flags for a known method → force the interpreter → observe what the entry became →
place the dump point accordingly → force only the target package (`ignore` lists for
`androidx.*`, `com.google.*`, `kotlin.*` — forcing the framework too freezes or kills
the app) → compare a method's `code_item` before and after invocation → splice →
verify with jadx/baksmali.
## Youpk in one paragraph
Youpk is the ROM-level descendant of the same idea (**inferred**): a modified ART
performs the active invocation *inside* the runtime at class-link and interpret time,
so the reflective caller script, the entry-point guessing, and most shell
counter-measures against host-side tooling drop out — at the cost of building and
flashing a customized runtime per Android version. Choose it when the target's
anti-instrumentation defeats host-side loops entirely, and you control the device.
## The frida-dexdump route and where it stops
`frida-dexdump` walks the live ClassLoaders and writes every dex-shaped memory range.
Environment setup and its two time sinks (the `-o` directory must pre-exist, and
`-D` does not take remote devices) are recorded in `recon.md` §Unpacking a dex-level
packer; the Frida environment itself is `dynamic-frida.md`. What it hands you is a
pile of images of the four shapes above — dedupe, reject and rank them with
`scripts/dex_dump_validate.py` before touching any by hand. Its hard limit: against
an extraction shell it can only ever produce the **skeleton**, because the missing
bodies exist nowhere in memory until invoked. No amount of re-dumping fixes that;
only the invocation loop recovers them.
## Dumping when frida is refused
A hardened target can refuse instrumentation outright: the dump tool dies with
`script has been destroyed` and the process is gone before the error can be read.
That is a *protocol* refusal, not a timeout, and re-running the same attach with a
different flag is the wrong response — twice-identical failure on one variable is a
stop signal, not a hint to try a third flag. What remains is the route that never
touches the process's instrumentation interface at all: **root reads the target's own
memory** through `/proc/<pid>/maps` and `/proc/<pid>/mem`. No agent, no ptrace
attach, no server process for the shell to find.
**Read the signals carefully, because one of them lies.** The refusal presents as
(a) the dump tool's attach failing with `script has been destroyed` while the app is
otherwise healthy, and (b) pid churn. But **pid churn alone is not evidence that
frida was detected.** A 360-jiagu sample on this repository's test device was
measured exiting every 7-18 s and being restarted by the platform, with *no*
instrumentation process anywhere on the device, logging
`Process <pkg> (pid N) has died: fg TOP` and no crash, tombstone or ANR record — a
clean self-exit, i.e. the packer's own environment check. Attribution needs the
control run, and the control run needs a window longer than one restart period: a
12-second observation lands inside a 17-second cycle and reports "stable" about a
process that is not. If the sample self-exits on that cadence, every step below must
fit inside a single lifetime.
### Read the map before choosing the bytes
```bash
adb shell 'su -c "pidof <PKG>"' # may return nothing mid-restart
adb shell 'su -c "ps -A -o PID,NAME | grep -w <PKG>"' # more robust: matches either way
adb shell 'su -c "cat /proc/<pid>/maps"' > maps.txt
```
ART names its in-memory dex mappings for you — this is the cheapest win on the whole
route, because a mapping called
`[anon:dalvik-classes*.dex extracted in memory from <src>]` *is* a dex image, already
located, already sized, no search required:
```
7129d8c000-712a60f000 r--p [anon:dalvik-classes.dex extracted in memory from /data/app/~~…/base.apk]
```
Copy those ranges first. Everything else — anonymous `rw-p`, `libc_malloc`, `.bss` —
is a *search* target, and searching is the expensive half.
### Address arithmetic is where this route silently breaks
Three device-shell traps cost real time on the first run, and each one produces a
plausible-looking result rather than an error:
| Trap | Symptom | Fix |
|---|---|---|
| mksh arithmetic is 32-bit | `$((16#7129d9a000))` yields `702128128` (the low 32 bits) — `skip=` then seeks to the wrong address and the dump looks like zeros | convert hex to decimal with `awk` (double precision handles 48-bit addresses exactly) |
| A VMA is page aligned | the dump is 0..4095 bytes longer than the image; a validator rejects it with `file_size=8923752 actual=8925184` (17 of 17 files on the measured run) | trim to the `file_size` at dex header offset 32 before validating |
| `grep` is line oriented | the magic contains a `0A`, so no line-oriented pattern can ever match `dex\n035\0`; `-F` with a real newline in the pattern fails too | `grep -z -a -o -b -E 'dex.035'` — with `-z`, records are NUL separated and `.` matches the newline byte; the reported offset is stream-relative, add the segment start |
One more that only bites in scripts: **toybox `awk` cannot be trusted with
`/regex/ && $0 !~ /…/`** — a filter written that way selected all 808 map lines,
including `r--p` system libraries. Filter the map with `grep` and keep `awk` for
arithmetic only.
### Export, then prove it is a dex
```bash
# inside one lifetime of the process: export a candidate range
dd if=/proc/<pid>/mem iflag=skip_bytes,count_bytes skip=<DEC_START> count=<SIZE> \
of=/data/local/tmp/seg_<n>.bin bs=256k
adb pull /data/local/tmp/seg_<n>.bin <dumpdir>/<n>.bin # trim first, see above
# or, without writing to the device at all: search a range in place
dd if=/proc/<pid>/mem iflag=skip_bytes,count_bytes skip=<DEC_START> count=<SIZE> bs=1M \
| grep -z -a -o -b -E 'dex.035' | head -5
```
Then the same acceptance gate the frida route uses: `scripts/dex_dump_validate.py`
over the dump directory — sha256 dedupe, header and `file_size` validation, class
counts, `stub%`, ranking. Rename to `*.dex` first; the validator selects on extension.
Pass `--trim` when the captures come straight from VMAs: a page-aligned region is always
longer than the image it holds, so without it every file is rejected on
`file_size=… actual=…` — 17 of 17 on the measured run, all of them recoverable.
When you do **not** already know which region holds the payload —
`scripts/dex_mem_scan.py <region_dir> --dump <out_dir>` does the search and the cut in one
step: it scans the captures for the magic in bounded chunks, reads each hit's own header,
and writes exactly `file_size` bytes from there. That is the anonymous-mapping case, where
no `maps` entry names the buffer. The division of labour matches the rest of this kit:
that script **finds and cuts**, `dex_dump_validate.py` **judges**. On the measured device
the scan of 142 anonymous regions returned zero hits while the named ART mappings returned
17 images — a negative from a cheap scan is worth having before committing to a heap sweep.
Measured on the test device, this route **works**: 17 ART dex mappings exported in
one pass, all 17 parsing after the page-alignment trim, `stub%` 0.9-3.2 % (real
bodies, SDK plugins), and one of them — the mapping that came from the app's own
`base.apk` — defining exactly **4 classes** on 8.9 MB. That last image is the landing
shell skeleton, and the class count is what says so; the size says nothing. Note what
the route did *not* produce: no second application dex ever appeared in the map
across 22 s of sampling, so the packer's real payload was not sitting in memory as a
loaded ART dex while the observer watched — which is the honest boundary of every
`/proc/<pid>/mem` dump, not a defect of the tooling.
### Limits to budget for
- **The process must stay alive and keep the same pid.** A VMA map and a page table
both belong to a live process, and every `dd` after the restart reads the wrong
address space or nothing. On a sample that self-exits every 7-18 s, a dump routine
must be atomic within one window and re-entrant (re-read `maps` after every
restart) rather than a long batch.
- **Throughput is the binding constraint, not correctness.** Reading
`/proc/<pid>/mem` through `grep -z -E` measured about **10 MB/s** on this device.
A 1 GB ART heap is therefore a ~100 s scan — several lifetimes — so scanning the
Java heap for a decrypted buffer is the *last* resort, not the first. Named ART dex
mappings are free; a heap sweep is not.
- **`/proc/<pid>/map_files/` is not a shortcut on this ROM.** The directory exists
and is root-owned, but its entries are absent/unreadable, so `dd
if=/proc/<pid>/map_files/<start>-<end>` returns a few dozen bytes of error text.
Use the `mem` + decimal-offset form.
- **Memcpy dumps of `.so` files are not the `.so` files.** A range lifted from
`/proc/<pid>/mem` usually carries `PT_LOAD` contents and no section header table;
a real ELF on disk has both. That difference is how to tell a *disk copy* of a
library from a *memory-extracted* one — and it is why a leftover `.so` in a work
directory must be matched against the APK it claims to come from before it is used
as evidence.
- **In-memory dex bodies can be non-contiguous.** A decrypted image stitched across
two VMAs (or a payload the packer splits) will not be recovered by dumping ranges
independently; the `code_off` cross-check and the class-count sanity check in
§Measuring extraction are what catch that, not the magic search.
### What this route cannot do, and how to tell before you spend the window
The sections above are the how. This is the boundary, and it is worth reading *before*
the first export, because the failure is silent: you export a region, it validates as
something else, and the conclusion "the payload is not resident" is drawn from a wrong
capture. Two measured obstacles and one measured non-obstacle.
**A dex that is still compressed never materialises as an image.** `dexopt` can only
map a dex directly when the archive stores it uncompressed, so for an APK whose
`classes.dex` is stored with **deflate** the method never applies — and nothing in
memory is a whole dex. Measured on the clean MASTG targets (`owasp.mstg.uncrackable1`,
`owasp.mstg.uncrackable3`, both with `classes.dex` at `compress_type=8` in the
*on-device* APK): **zero** `[anon:dalvik-classes.dex extracted in memory from …]`
mappings across ~2,400 map lines each, i.e. `named_dex=0`. There was nothing to
export, and the route's negative says nothing about those targets being protected.
**An `r--s` view of `base.apk` is not a source of dex bytes.** Both targets showed
exactly that shape — a read-only shared mapping of the APK — and it is the shape that
looks like an answer and is not one. Exported and compared byte-for-byte with the same
offset in the file:
| View | Compared with | Result |
|---|---|---|
| `76dfb14000-76dfb15000` (L1, file offset `0x11000`, 4 KiB) | the APK at `0x11000` | **identical** (sha256 `74846b8c…` both) — it is the APK's own bytes, and at that offset they are the ZIP *central directory* (`PK\x01\x02`), not a dex |
| `765a89c000-765a8de000` (L3, file offset `0x6000`, 264 KiB) | the APK at `0x6000` | **identical** (sha256 `2eafd7cc…` both) |
| `76dc28e000-76dc299000` (L3, file offset `0x15a000`, 44 KiB) | the APK at `0x15a000` | **different** — and the reason is not tampering: the mapping range runs *past the end of the file* (offset `0x15a000` > 1,460,555 B), so the file-side read is short and the two hashes describe different byte counts |
Two things follow, and the second one generalises. First, **the two read paths agree
exactly whenever they cover the same bytes** — `/proc/<pid>/mem` and a straight file
read returned identical sha256 on both full-length comparisons, which is the
reproducibility check worth running before treating any capture as evidence. Second,
**when they disagree, compute the length before suspecting the target**: a VMA can
legitimately extend beyond its backing file, and "the bytes differ" then means "you
compared different amounts of data", not "something rewrote the file".
**The non-obstacle, measured:** when the instrumentation route is refused, nothing here
needs fixing — root reads `/proc/<pid>/mem` with no `ptrace` and no agent, and on the
test device that produced 17 real, parseable dex images from a hardened process
(`references/evidence-summary.md` §The capability matrix). The correction this pass adds is
narrower than that claim: **the route's yield is a property of the target, not of the
route.** It yields everything the packer leaves as a whole image; it yields nothing
against a target whose dex never becomes one.
### Is this dex image real? Two independent readers, or a claim you have not earned
The route above produces candidate images; something else has to judge them, and the
judgement has to come from a reader you did not write. A single reading of a hard problem
is not evidence, and this file already carries the lesson in the opposite direction —
`§Establish "the bodies do not decode" with a decoder you did not write` is the case where
a hand-written decoder *invented* a finding.
Three checks, in increasing cost:
1. **Two read paths over the same bytes must agree byte-for-byte.** `/proc/<pid>/mem` at
`(start, start+size)` versus the file or a second export. Measured above: identical
sha256 on every comparison that covered the same length. Do this **before**
interpretation — a capture that cannot be reproduced is not a capture.
2. **A producer you did not write must parse it.** `dexdump`
(`E:\tools\android-14\dexdump.exe`), `baksmali` or `jadx` — and quote *its* failure
count, not your own parser's. `scripts/dex_dump_validate.py` ranks and screens
candidates; it does not decide whether a body is real dalvik.
3. **Only then interpret** `stub%` / `emptied%` / class counts, per
§Measuring extraction instead of guessing.
**Cross-checking two dumpers is only meaningful when both produce the same artifact.** A
byte-identity comparison is a real check between two implementations reading the same
address space (`/proc/<pid>/mem` versus `process_vm_readv(2)`, or two independent export
tools); it is an empty check between a whole-image dump and a per-method `code_item`
harvest, because those are different artifacts and a byte difference says nothing about
either. State which kind of comparison you ran. A *cross-tool* agreement also does not
prove recovery: on a shell that stubs bodies, two agreeing dumps are two copies of the
same skeleton — which is why check 2's parse count and §Measuring extraction's ratios
both have to pass.
## Layered descent: Java → JNI → Native → libc → syscall
**Read this when an upper layer has gone silent.** "My hook produced no events" is not a
finding about the target until you know which layer is capable of seeing the code that
runs. The upper layers are cheaper and more informative, so the order is deliberate; the
mistake is skipping down for reassurance instead of on a signal.
| Layer | What it sees | What it cannot see | The signal to descend |
|---|---|---|---|
| **Java** (framework hooks) | methods, objects, arguments — the algorithm in its readable form | anything the app does in a library; anything the framework compiled out of reach | no invocation ever fires, or only the first call and then nothing |
| **JNI boundary** | argument and return values of a *named* export; the boundary itself | argument *meaning*; any logic past the call | outcomes are correct but you cannot see why |
| **Native, named exports** (`Module.findExportByName`) | entries of exported functions; the call site that reached them | the function body's control flow (that is `native-dbi-and-deobfuscation.md`) | correct answers, opaque body; a `.so` that never calls its own exports |
| **Native, internal** (module base + offset) | code with no symbol at all | how you found the offset in the first place | a library whose exports are a thin shell over internal work |
| **libc** (`open`, `read`, `mmap`, `strstr`, `pthread_create`, `exit`, …) | every call that *goes through* libc, with caller attribution | calls that do not; anything above libc | an event count that contradicts observed effects |
| **`svc #0` sites** | nothing at runtime until you patch the site — it is a *static* layer | kernel-side: unreachable here, see the gate below | libc shows no such syscall while the effect happens |
Three rules that keep the descent honest:
- **Descending is cheap to state and expensive to trust.** A lower layer has less
context, so an event there needs *more* support to mean something: `open()` fired says
nothing on its own, while `open()` with a caller of `libX.so+0x1cef8` names a detector.
Always carry the caller offset down, or you have traded meaning for volume.
- **The bottom is static, not dynamic.** Below libc there is nothing to hook — a
`svc #0` site is found by scanning the library (`scripts/svc_scan.py`) and changed by
patching bytes. Measured: the ROM's `libc.so` carries exactly **4** termination sites
(`exit`, `exit_group`, `kill`, `tgkill`) and those are libc's own exported
implementations, which is *why* a libc hook sees callers at all; the same scan on a
shell library returned 21 byte-scan "sites" that were all data (no syscall-number load
near any of them). Read the neighbours before believing a count.
- **"Down" does not mean "more capable".** Measured on a real target, the decisive
detection ran at the **libc** layer — `strstr("frida")` at ~300 ms of process life,
followed within milliseconds by a clean self-exit with no tombstone — while the code
that recovered the caller chain sat in **JIT-compiled** runtime output, i.e. a
*higher* layer that has no file offset. The layers are not a ladder of access; they are
different observation points, and the useful one is wherever the target's check
happens to be.
**The kernel layer is not reachable on this device, and the gate is a kernel version.**
eBPF tracing (`stackplz`, kprobes, `seccomp` filters pushed from userspace) requires
kernel **5.10+**; the measured device is **4.14.186**, so that entire layer is closed
here regardless of tooling. `kernel-and-environment-hardening.md` maps the route and its
version gate; do not spend a session on the layer before checking `adb shell uname -r`.
The consequence for this file: the descent above stops at the `svc` boundary, and a check
that runs *inside* the kernel is out of reach of both dynamic and static userspace work.
## The honest boundary: real Dex VMP
The last shape is the one to say out loud: if the dump's method bodies are intact but
decode as private opcodes — rare-opcode density off the charts, unknown opcode slots,
and a large native interpreter loop behind them — the code has been virtualized, and
nothing in this repository will hand it back to you decompiled. Distinguish it from
the JNI sink first (`code-virtualization-and-custom-linkers.md`,
`java2c-and-jni-sinking.md`): there the methods are `native` stubs and the logic lives
as native code; here the bodies are present and *re-encoded*.
### Establish "the bodies do not decode" with a decoder you did not write
This is the one measurement in this file that has already been got wrong here, so it
gets its own rule. A hand-written opcode-width table silently desynchronises on large
dex files — one arm64 branch opcode missing from the table is enough — and the
desynchronisation is indistinguishable from the finding it fakes. Measured on the
`ezAndroid` sample: a workbench table reported **1,393 of 22,424 bodies as
non-decodable** and I read it as private opcodes; the platform decoder returned
**943,223 lines, 34,566 instruction decodes, and zero structural errors** on the same
file (`references/evidence-summary.md` §The capability matrix). The bodies
were ordinary dalvik the whole time, and one of the "desync" methods disassembles into
five clean instructions under `dexdump`.
So: **before any VMP verdict, decode the same file with `dexdump`,
`baksmali`/`smali`, or `jadx`, and quote the failure count that came from *it*.** A
rare-opcode histogram is a lead, not evidence. Never let a private table's desync
inform a classification — "my decoder stopped" is a fact about the decoder.
### What a static dex probe can and cannot decide
Two different questions live in the same dump, and only the first one is answerable
from the bytes on disk:
| Question | Statically decidable? | Why |
|---|---|---|
| Are the bodies real dalvik, or an encoded program? | **yes** | the platform decoder decodes them or reports a structural error |
| Did the code leave the dex for native? | **yes** | `native` access flags, then `Java_*` exports / `JNI_OnLoad` in the matching `.so` |
| Does a native island check signature, root or debugger? | **no** | the logic is ARM64 behind a vtable; the check may exist in no dex at all |
| Are unlabelled `assets/` blobs live payload? | **no** | an unreferenced blob is dead until a runtime path constructs its name — measured: six such files in `ezAndroid`'s `assets/`, **zero** dex strings referencing them |
The trap is the asymmetry. A clean static reading licenses exactly one sentence —
*"this is not dex-VMP"* — and it does **not** license *"this is not hardened"*. Those
are different claims: the second one is the misdiagnosis that sends an analyst looking
for an unpacker when the problem is a native island, or for a private disassembler when
the code is ordinary ARM64 in a 206 KB `.so`. A static probe's silence about hardening
is silence, not a clean bill of health.
What recovery looks like when someone does it, **all inferred**, no fixture exists in
this repo, treat as a research plan rather than a recipe:
1. **Differential hardening as a black-box oracle.** Build sample APKs containing
every dalvik opcode in known, labeled sequences; submit them to the *same*
hardening platform as the target; dump the returned dexes; align each original
method with its hardened counterpart and read off the opcode substitution table.
2. **Handler map recovery.** Locate the native interpreter's dispatch table; each
handler implements one virtual opcode; the differential table names them.
3. **A private disassembler.** Feed the substitution table into an existing dalvik
decode layer (`scripts/dexutil.py`'s format table is the shape of the thing) and
re-disassemble the bodies; decompilation is then ordinary tooling on the output.
Budget honestly: this is weeks, not an afternoon, and it redone per vendor per
version. The cheaper question is usually whether the behaviour you need survives
outside the virtualized island — a hook at the method's Java boundary
(`dynamic-frida.md`) or a server-side answer often reaches the goal without paying
for the island itself.
## Decision summary
| Observation | Action | Where |
|---|---|---|
| Dump parses, stub% near baseline | Filter, verify with jadx, move on to patching | `recon.md` |
| stub% high, image parses | FART loop: skeleton + active invocation + splice | this file |
| `emptied%` high but `stub%` low | nop-wiped skeleton; the old `stub%`-only reading called it the original | §Measuring extraction instead of guessing |
| The ranking names a winner but `emptied%` sits at the app's baseline | Treat the ranking as uninformative for this set; do not adopt that image as a patch baseline | §The ranking's failure conditions |
| Splice produces a half-parsing dex | Re-check code_item length (padding, handler list) | §The code_item length trap |
| Dumper runs, output dir empty | Storage path + `open()` check, then entry-type probe | §Why the classic hooks die on Android 12-16 |
| App dies under the invoker | Shell counter-measures; narrow the force list | §Why the classic hooks die on Android 12-16, and `detection-and-anti-analysis.md` |
| frida attach refused (`script has been destroyed`) | Root-side `/proc/<pid>/mem` dump, atomic inside one process lifetime | §Dumping when frida is refused |
| Target has no `[anon:dalvik-classes…]` mapping at all (`named_dex=0`) | Its dex is compressed and never becomes a whole image — the route has nothing to export here; this is not evidence of protection | §What this route cannot do, and how to tell before you spend the window |
| An `r--s` view of `base.apk` looks like a dex region | Compare it with the file at the same offset and check the length: it is the APK's own bytes (often the ZIP central directory), and a range may exceed the file | §What this route cannot do, and how to tell before you spend the window |
| Two captures of the same address differ | Compare byte *lengths* first; a VMA can extend past its backing file | §What this route cannot do, and how to tell before you spend the window |
| A hook on the upper layer stops firing | Descend one layer on a named signal, not for reassurance, and carry the caller offset down | §Layered descent: Java → JNI → Native → libc → syscall |
| The effect happens but libc shows no such syscall | Static: scan the library for an inline `svc` site before assuming evasion | §Layered descent: Java → JNI → Native → libc → syscall |
| Sample self-exits and restarts with no instrumenter present | The packer's own check, not your hook; shorten every step to one lifetime | §Dumping when frida is refused |
| Methods are `native` stubs with a `Java_*` export | JNI sinking — read the function in the `.so` | `java2c-and-jni-sinking.md` |
| Bodies present, decode as nonsense | Real VMP — **but confirm with `dexdump` first**, then differential oracle or walk away | §Establish "the bodies do not decode" with a decoder you did not write |

View File

@@ -0,0 +1,194 @@
# Byte-level dex patching — equal-length edits, and the traps that make them fail
`dex-patching.md` covers rewriting a **method body** with dexlib2. This file covers the other
technique: rewriting **a few bytes in place**. Reach for it whenever the change can be expressed as
"make this branch do the other thing" or "make this constant a different constant", because it is
strictly safer than rebuilding a method — but only if you respect the constraints below, which are
not optional and are not obvious.
**Load this when:** the change fits in an existing instruction slot or constant, and you want nothing to move (no offset, try/catch or debug pointer invalidated). It gives the equal-length edit, its legality rules, and where it silently fails.
## Why equal-length, in numbers
A method-level rebuild is not free. Measured on one R8-processed sample (4.32 MB single dex):
| | original | after a 2-method dexlib2 rewrite |
|---|---|---|
| dex size | 4.32 MB | **7.73 MB** |
| `debug_info` table | 924 bytes | **22,828 bytes** |
| class set / access flags | — | identical (zero drift) |
The class table is unchanged, so the rebuild is *correct* — but dexlib2 does not reuse R8's shared
`debug_info` entries and the file grows ~80%. That is the real cost of "surgical" method rewriting,
and it matters because it changes the byte layout of everything after the edited methods.
An equal-length edit changes **only the bytes you chose**. No offset moves, so no try/catch block,
no debug-info pointer, and no branch displacement anywhere in the file can be invalidated by
construction. That property is worth more than the convenience of writing smali.
**Decision rule:** if the change fits in an existing instruction slot or a constant, patch bytes. If
it genuinely needs new instructions or a different register allocation, rebuild the method — and do
it in a copy so you can diff the tables.
## Locating the exact byte offset of an instruction
This is the part that eats hours if you improvise. Three approaches, worst to best.
**Do not reconstruct offsets from a `.line` listing.** baksmali emits one `.line` per *source* line,
and one source line can be attributed to several consecutive instructions. The `.line` values
therefore repeat and do not map 1:1 to offsets. A listing walker built on `.line` silently produces
zero candidates for a branch you can see with your eyes.
**Do not match on a guessed byte sequence.** Instruction encodings vary with register numbers and
operand widths; a pattern that looks right (`38 xx`) can be a different instruction than you think
(`0x38` is `if-test`, 22t and 4 bytes — **not** `if-testz`).
**Do this instead: walk the dex structure to the method, then decode.** Resolve class → `class_data_item`
→ method (`code_off`) → `code_item` → instruction stream, and decode forward with a complete format
table. Then match on *decoded semantics* ("an `iget-boolean p1` immediately followed by `if-nez p1`
whose target reads field `X`") rather than on bytes. `scripts/dex_find_insn.py` does this; the
decoder in `scripts/dexutil.py` is the reusable part.
Three details in the walk that silently produce wrong answers:
- **`class_data_item` member indices are *diffs*.** `field_idx`, `method_idx` are encoded as deltas
against the previous entry in the same list and must be accumulated. Reading a raw uleb as an
absolute index gives you real-looking wrong members — you will "find" a method that is not the one
you asked for and not notice.
- **Payload `nop`s are not `nop`s.** dalvik encodes switch/array payloads as `00 <ident> <size>` with
`ident` in 1..3. A plain `00 00` is an ordinary one-unit `nop`. Treating every `00` as a payload
swallows the next instruction and desynchronises the whole rest of the method.
- **The header field order is not what you remember.** After `magic(8) + checksum(4) + signature(20)`,
the offsets are `file_size 0x20, header_size 0x24, endian 0x28, link_size 0x2C, link_off 0x30,
map_off 0x34, string_ids_size 0x38, string_ids_off 0x3C, type_ids 0x40/0x44, proto_ids 0x48/0x4C,
field_ids 0x50/0x54, method_ids 0x58/0x5C, class_defs 0x60/0x64, data 0x68/0x6C`.
Print the parsed sizes and compare against the file before trusting anything downstream.
## Instruction format table (the part that must be right)
An operand width wrong by one byte desynchronises the decode from that point on, and the failure looks
like "the instruction I want does not exist". These are the groups that matter in practice:
| opcode range | format | code units |
|---|---|---|
| `01 04 07 0A 0B 0C 0D 0E 0F 10 11 12` | 12x / 11x / 11n / 10x | 1 |
| `13 15 16 17 19 1B 20 21 23 24` | 21s/21h/21c/22c/35c/3rc | 2 |
| `14 18 1A 22 25 27 28 29 2A 2B` | 31i/31c/31t/30t/20t/switch | 3 |
| `02 05 08` | 22x | 2 |
| `03 06 09` | 32x | 3 |
| `2C`–`31` | 23x | 2 |
| **`32`–`37`** | **22t if-test** (2 regs + int16) | **2** |
| **`38`–`3D`** | **21t if-testz** (1 reg + int16) | **2** |
| `44`–`51` | 23x aget/aput | 2 |
| `52`–`5F` | 22c iget/iput | 2 |
| `60`–`6D` | 21c sget/sput | 2 |
| `6E`–`72` / `74`–`78` | 35c / 3rc invoke | 3 |
| `7B`–`8F` | 12x unop | 1 |
| `90`–`AF` | 23x binop | 2 |
| `B0`–`CF` | 12x binop/2addr | 1 |
| `D0`–`D7` | 22s binop/lit16 | 2 |
| `D8`–`E2` | 22b binop/lit8 | 2 |
**Self-check that costs nothing:** decode the whole method and assert you land **exactly** on
`insns_off + insns_size*2`. If you overshoot or undershoot, the table is wrong somewhere and every
offset you derived is suspect. Also scan the decoded register numbers: a method whose `registers`
count is 12 cannot use `v13`, so any `v13+` in the output is proof of desync.
**Long conditional branches are two instructions.** A conditional jump has a 16-bit displacement, but
when you see `if-nez vX, :label` in smali and `:label` is far away, the assembler may have emitted
`if-*` followed by a separate `goto`. When hunting a branch, do not assume the pattern is
"branch → target"; confirm by decoding.
## Neutralise a branch, do not redirect it
Once you have decided which side of a condition you want, the safest edit is to **remove the branch**,
not to point it somewhere else.
| Edit | When it is right | Risk |
|---|---|---|
| `if-*` → `nop` pair (**preferred**) | You want the **fall-through** path | None. No new control-flow edge is created |
| `if-*` → `goto` (+ same displacement) | You want the **branch-taken** path | Introduces an edge. It may land on a `move-result*`, which the verifier rejects |
| flip the sense (`if-nez` → `if-eqz`) | Polarity is the only problem | Same edge as above, different destination |
The `move-result` rule: `move-result*` must be **immediately preceded by its producing invoke** in the
instruction stream. A branch whose target is a `move-result` bypasses the producer and the class fails
to load with `VerifyError: ... copyRes vN <- result0 type=Undefined`. A linear "is the previous
instruction the producer" check does **not** catch this — you need the CFG view: *does any branch
target this `move-result`?* `scripts/dex_check_verifier.py` answers that question directly.
A `nop` pair cannot create that problem, which is why it is the default choice.
**Polarity is the other silent killer.** Field names lie about intent often enough that you must read
the *branch structure*, not the name. In one real case the config field was `enabled`, the smali was
`if-nez v, +6`, and the fall-through was `startActivity(main)` while the branch target was the
countdown block. So `enabled == true` was the value that *skipped* the promo — the opposite of the
literal reading. **Always decode both sides of the branch and name what each one does before editing.**
Getting this backwards produces a build that does the exact opposite of the goal and still starts
cleanly, so it survives casual testing.
## The dex header has two integrity fields, and order matters
Any edit to a dex body invalidates both header integrity fields. Recompute them **in this order**:
```
bytes 12..32 = sha1(data[32:]) # signature first
bytes 8..12 = adler32(data[12:]) # checksum last — it covers the signature bytes
```
Writing them in the reverse order produces a header that looks plausible and never verifies, because
the adler32 was taken while the signature field was still zeroed.
**Why this is dangerous rather than merely broken:** Android logs
`Failure to verify dex file ...: Bad checksum (computed, expected)` — note that the *real* adler32
appears as the "expected" value, which reads backwards — and then falls back to interpreting the dex.
The process may still start, but the class loader can fail to resolve ordinary classes
(`ClassNotFoundException: <your Application class>`), so the symptom is "the APK I built is broken"
rather than "two header fields are stale".
**Aggravating detail:** some producers ship a dex whose `signature` field is **all zeros** (a known
R8/optimiser artifact). On such a file the wrong order is self-consistent until the first patch, so
the bug is invisible until you edit something — and then looks like your edit caused it.
`scripts/dex_patch_bytes.py` recomputes both fields in the correct order and refuses to write if the
result does not self-verify.
## Worked shape of a patch script
Keep these properties; they are what make the edit auditable a month later:
1. **Locate by structure + decoded semantics**, never by a hard-coded offset alone.
2. **Assert the old bytes** before replacing, and abort on mismatch. A silent mismatch means the
sample changed or your understanding did.
3. **Equal length in, equal length out.** Assert `len(new) == len(old)`.
4. **Recompute both header fields**, then assert `adler32(data[12:]) == header_checksum` and
`sha1(data[32:]) == header_signature` on the bytes you are about to write.
5. **Re-decode the patched method** and print the instruction you changed, plus confirm the
instruction that should follow it still does. Two instructions of output is enough to catch the
"landed on the wrong site" class.
6. **Report the size delta** (should be 0) and before/after hashes of both the dex and the APK.
## Verification specific to byte patches
`patch-audit.md` covers the general audit. Two checks matter more here:
- **Assert the instruction count and every branch target are unchanged.** Compare a decoded listing of
the whole method before and after: same instruction count, same offsets, same targets. If the count
changed, you replaced a 4-byte instruction with a 4-byte *pair* and the extra decode boundary is
expected — but say so explicitly rather than letting a diff tool surprise the next reader.
- **`VerifyError` is a class-load failure, not a startup failure.** If the app starts and one screen
is dead, or a secondary class is missing, suspect a verifier violation in the method you edited.
`logcat` shows it as `E/dalvikvm` or `E/art` with the class name; it does not always surface as a
crash dialog.
## When not to use this technique
- The change needs new instructions, a new register, or a different call — go to method rewriting.
- The change is a **short string constant** whose new value is a different length — you cannot grow the
string in place. Equal-length string swaps work (`pitfalls.md` P2 on the string-id ordering guard).
**A different-length value is not a byte patch at all**: `scripts/dex_strpatch.py` enforces equal
length and will refuse it, so the work belongs to method rewriting (`references/dex-patching.md`)
or a full rebuild — do not look for a "different-length byte patcher" in this kit, there isn't one.
- The target is a **multi-dex** app and you intend to move code between dexes. Byte patching stays
inside one dex by definition.

View File

@@ -0,0 +1,220 @@
# Code Virtualization & Custom Linkers
Load this when recon says **there is no packer** (the manifest's `application` is the app's own
class, dex is readable) and yet a re-signed build dies before your code runs — especially when the
death is `SIGSEGV fault addr 0x0` with all registers zero.
This is the layer between "packed" and "clean". `packers.md` covers a shell that owns
`Application`; this file covers protection that leaves `Application` alone and instead
**turns individual methods into native code** and hosts a private loader next to them. It is now
common enough that treating it as "no packer, therefore easy" is the most expensive recon error
in this skill's history.
## Identification — three cheap tells
1. **Whole classes whose methods are `native` declarations with no implementation.**
Read the smali for the entry Activity / engine host class. Under code virtualization the
*entire class* is converted — lifecycle callbacks, getters, everything — each becoming a bare
`.method public native ...` with no body. A framework class you *know* ships a Java
implementation (a Flutter/Unity/RN host class is the usual victim) sitting there fully
`native` is the signal.
Two `native` methods is normal (JNI). **Sixty is virtualization.**
2. **A registration call in the class's static initializer.** Someone binds those native
declarations to an implementation at class-load time:
```
.method static constructor <clinit>()V
invoke-static {v0, v1}, L<somewhere>/DtcLoader;->registerNativesForClass(ILjava/lang/Class;)V
invoke-static {v1}, L<somewhere>/hidden/Hidden0;->special_clinit_0_00(Ljava/lang/Class;)V
```
The `Hidden0.special_clinit_*` family is the virtualizer's hook for running its own version of
the class initializer. When you see this, the Java in that class **is not what executes**.
3. **A shipping library whose SONAME does not match its filename.** A private loader is often
named for its role while sitting under an innocuous filename. Read `DT_SONAME` from
`.dynamic`, not the file name — `probe_deps`-style parsing of `PT_DYNAMIC` (see
`native-tamper-and-suicide.md` §Forged section headers) gives you the truth. A `lib<something>.so` claiming
`soname = liblinkerloader.so` is a private ELF loader.
Supporting tells: `assets/` carrying a small text descriptor naming the protector and its version;
native libraries woven into `DT_NEEDED` chains of other libraries (the virtualized code has to be
reachable); log lines tagged with the loader's own name appearing during startup.
## The deadlock that eats hours — and the way out
A virtualizer that also does integrity checking produces this shape:
| Action | Result |
|---|---|
| Keep the private loader in the APK | its embedded validation runs → deliberate crash on a re-signed build |
| Delete the private loader | the virtualized class has **no implementation at all** → `UnsatisfiedLinkError: dlopen failed: library "…" not found` |
Both routes die, and the two failures look nothing alike, so it is easy to conclude the target is
impossible. It is not. The way out is one question:
> **Is that library the checker, or the implementation — and who asks for it?**
## Separate the checker from the implementation
Two distinct roles hide behind "a protected library":
- **The implementation** — the native code that *is* the virtualized class. It must be present and
loaded, or the class cannot work.
- **The checker** — a validation payload (a signature/integrity payload, a second-stage
self-decrypting image) that something *explicitly loads*.
They are frequently **different libraries**, and the load of the checker is frequently a
**single, findable call site** — not a link-time dependency.
Test which is which before designing a fix:
```
# 1. Does anything name it in DT_NEEDED? Parse PT_DYNAMIC of every shipped .so.
# No DT_NEEDED referrer => it is dlopen()ed, i.e. named at some call site.
# 2. Delete it and read the exact error: which class/method was executing when the
# load failed? That is the caller.
```
In the observed case the error was not "the class is missing" but:
```
java.lang.UnsatisfiedLinkError: dlopen failed: library "lib<checker>.so" not found
at java.lang.System.loadLibrary(System.java)
at <framework host class>.onCreate(Native Method)
```
Read that stack carefully: the **virtualized** `onCreate` — the very code you feared deleting the
library would break — is running fine. It is the *checker* that is absent. The implementation lives
elsewhere and was never the problem.
**That single stack frame converts an impossible target into a trivial one.**
## Technique: redirect the load by rewriting a string constant
Once you know the checker is named at a call site, you do not need to patch code, hook anything, or
neutralize a crash. **Change what that call site loads.**
JNI-heavy native code keeps its class/method/string operands in a constant pool inside the library.
A `loadLibrary("X")` becomes three facts sitting in `.rodata`/`.data`:
```
… onCreate (Landroid/os/Bundle;)V loadLibrary (Ljava/lang/String;)Ljava/lang/String; …
… <SomeUnrelatedClassName> X <unrelated constant> …
```
Find the name as an **isolated NUL-terminated string** and rewrite it **in place, same length** to
the name of a library that is *guaranteed already loaded*. On Android, `android` (`libandroid.so`)
is reliably mapped — the protected libraries themselves depend on it.
```
apkhuan -> android (7 chars -> 7 chars)
```
Why this is disproportionately effective:
- **Equal length means zero structural change.** No offsets move, no section resizes, the ELF stays
exactly as valid as it was. Nothing for an integrity check to notice about *layout*.
- **The load succeeds**, so no `UnsatisfiedLinkError` and no error branch anywhere.
- **The checker never gets mapped**, so none of its validation runs — the signature payload, the
deliberate-crash payload, all of it becomes dead weight in the APK.
- **You never patch a death path**, so the whole `native-tamper-and-suicide.md` rule set (make it
*return*, don't make it *not return*) never comes into play. There is nothing to neutralize.
Requirements and hazards:
1. **Same byte length is not optional.** Replacing with a shorter/longer name shifts everything
after it. Pad only within the same NUL-terminated slot; never spill into the next constant.
2. **Verify the string is an isolated constant** — the byte before and after must be `\x00`.
A substring of a longer identifier will corrupt that identifier.
3. **The replacement must be loadable and already-loaded.** A nonexistent name throws and puts you
back in the deadlock; a heavy library changes startup cost.
4. **Scan the whole file** for the name, then confirm which occurrence sits in the constant pool.
Report the offset in the patch record.
`scripts/so_constpatch.py` implements this: finds isolated constants, enforces equal length, writes
a patched copy, and prints the byte-diff.
This is one member of a general family — **redirect a reference instead of defeating a check** —
and the same shape recurs for class names, method names and file paths in a constant pool.
## Expected log shapes
A private loader announces itself. These strings come from the loader, not from the app, and their
presence/absence across builds is a usable signal:
```
I/<Loader>: Loading embedded protected image from carrier (self)
I/<Loader>: Found embedded protected image at offset 0x…, logical size 0x…
E/<Loader>: Invalid ELF magic <-- appears ONLY on a modified build
D/<Loader>: ELF Header: type=3, machine=183, entry=0x0, phnum=N
D/<Loader>: Starting PrelinkImage for embedded_protected
```
A custom container magic at the tail of the library (a four-byte tag, not `\x7fELF`) marks the
embedded image; `entry=0x0` plus `SIGSEGV fault addr 0x0` is the loader failing to decrypt its
payload and jumping to a zero entry. Treat "`Invalid ELF magic` appears on my build and not on the
original" as **proof the loader is validating the build**, which is exactly what makes the
string-redirect route worth trying.
## What the native check actually reads — measure it, don't guess
A validation payload that reads "the signature" can be reading several different things, and the
answer decides whether *any* repack route exists:
| It reads | Consequence |
|---|---|
| the certificate material **inside the APK** | keeping the original signature block while swapping code can pass |
| the signature **PackageManager reports** | that trick fails; you must remove the check (this file's technique) or rebuild |
| an entry **hash** of the whole file | only the string-redirect / check-removal route survives |
**Single-variable experiment to tell them apart.** Same APK content both times; flip only the
PMS-recorded signature:
1. Install a build **you signed** normally, so PMS records *your* certificate.
2. Root-overwrite that installed `base.apk` with your candidate (one whose *internal* signature
block is the original), leaving PMS's record untouched.
3. Launch.
Dies ⇒ the check reads the PMS-reported signature, and preserving in-APK certificate material
cannot save you. Survives ⇒ it reads in-APK material, and a "keep the original signature block"
route is worth investing in.
Do this **before** investing in elaborate signature-block plumbing. It is two installs.
## A Java-layer "signature killer" is a decoy
A protection bundle may ship a Java class that hooks `PackageManager` so the framework reports a
hardcoded certificate, and log `Signature consistency ok` forever — including on your modified
build. This is an **anti-tamper feature aimed at other patchers**, not a gate you must satisfy.
Its corollary is what matters operationally: **`Signature consistency ok` in the log proves
nothing.** Do not let a green Java-layer message talk you out of the fact that the process is
dying, or send you hunting for a Java signature check. If the death is `SIGSEGV` with a zero
program counter, the check that killed you is native and the Java one is irrelevant.
Two practical notes:
- **Do not delete such a class to "clean up".** It usually participates in the `Application`
inheritance chain; removing it breaks startup and trips unrelated class-hierarchy checks.
- Its presence is a strong hint that **the code that matters is virtualized**, because it exists to
survive exactly the kind of repack you are attempting.
## Verification
A string-redirect fix is verified by the **absence of the loader**, not by the absence of an error:
1. Loader's log tag count in a full cold-start capture = **0** (previously non-zero).
2. `Invalid ELF magic` = 0.
3. `SIGSEGV` = 0 **and** `SIGSYS`/seccomp kills = 0 — the secondary kill paths the payload carried
disappear with it. A drop from 1 to 0 here is a real result worth recording.
4. Process survives past the previously measured time-to-death, on this machine, with nothing
attached; then survives real interaction.
5. The features that class implements still work — the virtualized code is the *implementation*, so
exercise it (start playback, navigate, whatever it owns). A build that starts but whose
virtualized feature is dead is not a result.
Record the original and patched bytes, the offset, and the loader log counts before/after in the
patch record — the same audit standard as any other byte-level edit.

View File

@@ -0,0 +1,220 @@
# Coverage and limits — the evidence behind each claim
`SKILL.md` §Coverage states **what this skill covers and what it does not**, because the failure
worth preventing is not ignorance but *a confident wrong answer produced by applying the nearest
available procedure to a target it was never written for*. That statement has to stay short: it is
read on every task, before any routing decision.
This file carries the part that is read **only when you need to weigh a claim** — the evidence
behind each covered item, the exact footing of the unverified ones, and the historical record of
what was and was not exercised. Load it when a claim's strength decides whether you trust it.
**Load this when:** a claim's strength decides whether you trust it, or you are about to quote this skill's coverage. It gives the evidence behind each covered item, the dependencies this skill does not ship, and the routes nobody has run.
## Strength labels
- **observed** — a command was run and its output is recorded in the evidence record condensed in `references/evidence-summary.md` §Where the full record lives.
- **inferred** — follows from documented mechanism or from a neighbouring measurement; the step
itself was not executed.
- **unverified** — assumed, or reported by someone else, and not reproduced in this repository.
Reserve `observed` strictly (`references/long-task-discipline.md` does). "I reasoned it out" is
`inferred`. The distinction is load-bearing, not cosmetic: an `inferred` route may still be right,
but nobody here has paid for the counters yet.
## Covered, by verified mechanisms — the evidence
- **Client-side ads, promos and splash/popup/tab configuration**, including the server-issued UI
config that has no SDK to find (`ad-removal.md`, `server-config-and-updates.md`).
- **Deciding whether a membership, paywall or feature gate is client-enforceable at all**, and
saying so plainly when it is not (`membership-and-limits.md`, `account-gates.md`).
- **dex-level surgical patching**: equal-length byte edits and dexlib2 method rewrites, plus the
header, verifier and alignment rules that decide whether the build loads (`dex-patching.md`,
`byte-level-patching.md`, `patch-audit.md`).
- **Repacking, signing, installing**, and the install refusals that look like a broken build
(`repack-and-sign.md`).
- **Packers, custom loaders and code virtualization**: identifying them, measuring the validation
boundary, and the routes that survive it (`packers.md`,
`code-virtualization-and-custom-linkers.md`).
- **The native layer**: `.so` hosts, tamper-triggered self-termination, forged ELF structure, and
neutralising a terminate path without freezing the process (`native-and-so.md`,
`native-tamper-and-suicide.md`).
- **Flutter / Dart AOT**: analysing and patching `libapp.so` **given a snapshot dump** —
pool-reference counting, disassembly windows, caller indexing, patch-site choice (`dart-aot.md`).
Measured on a real Dart 3.6.0 build: `dart_disasm.py` decoded identically to capstone (32/32 and
96/96), the caller index was re-derived independently with a symmetric difference of 0, and a
specific business logic site was located end to end. **The snapshot dump is a dependency, not a
detail** — see the next section.
- **Runtime analysis with Frida, server-side API probing, feature-scoped TLS failures, update and
forced-upgrade neutralisation**, and the verification discipline everything above rests on.
### Added by the benchmark pass, and measured against public targets
Each of these has a row in the benchmark matrix (`references/evidence-summary.md` §The capability matrix) naming its target, its strength and the evidence
file behind it.
- **Java2C versus JNI sinking versus an extraction shell.** Native-declaration density separates the
shapes by roughly 2000x: **0.03–0.04%** on three real JNI samples against **82.76%** on a
Java2C-shaped fixture, with `Java_*` symbols matching dex native-method counts 1:1 per ABI. A
symbol search fails *silently* on real Java2C — `dcc` emits `-fvisibility=hidden`, and C++
`jni.h` inlines `RegisterNatives` so it leaves no symbol at all
(`java2c-and-jni-sinking.md`, `scripts/java2c_probe.py`). **No Dex-to-C compiler output was ever
built here** (no NDK, no host clang, no device clang, WSL unavailable), so every Java2C-specific
criterion is `inferred`; the JNI-sinking side and the discrimination itself are `observed`.
- **Split APK / App Bundle sets.** Inventory, unified re-signing for `pm install-multiple`, and a
merge of code/native members into one APK — the merge **refuses by design** when a member carries
its own `resources.arsc` (`split-apk.md`, `scripts/repack.py`). Measured on two real sets pulled
from a device; `adb install-multiple` and launch confirmation were **not** executed.
- **Schema-free protobuf decoding**, cross-checked against the official runtime
(`scripts/protobuf_decode_raw.py`, `protocol-reverse.md`). 26/26 built-in fixtures, 21/21 against
the official runtime, and a real DataStore container round-tripped byte-exact.
- **Differential hardening for a real Dex VMP** — a labelled opcode-coverage fixture, a closed-loop
verification of the derived map, and an explicit cost judgement that the upload link cannot be
automated (`vmp-differential-analysis.md`, `scripts/vmp_diff_harness.py`).
- **Kernel-module templates with their version gates**
(`kernel-and-environment-hardening.md`, `scripts/kernelsu_syscall_mask.py`). The generator is
measured; **no kernel-side artefact was compiled or loaded** — see the record below.
## Covered by documented routes — inferred rather than measured
Written from public work and from whatever the extension pass could exercise; each document carries
its own strength note at the top.
- **Module-side delivery instead of a repack** — what to do when the client-side logic is reachable
but a rebuilt APK is refused (`lsposed-and-modules.md`).
- **Extraction shells and the VMP boundary** — how to *measure* that the bodies are empty instead of
guessing, why the classic active-invocation hooks stopped working on Android 12–16, and where
recovery honestly stops (`advanced-unpacking.md`, `scripts/dex_dump_validate.py`).
- **Calling the target instead of reading it** — emulated execution and live Frida-RPC
service-ification (`emulation-and-rpc.md`).
- **Instruction-level tracing and de-obfuscation** — the OLLVM shapes, a Stalker trace, the
trace-to-CFG route, and the measured boundaries in `native-dbi-and-deobfuscation.md`.
- **Protocol reversing beyond REST** — protobuf without a schema, gRPC frame capture, the QUIC/HTTP3
limit, native-side certificate pinning (`protocol-reverse.md`).
- **What to do when userspace hooking provably cannot reach the check** — raw `svc` syscalls,
`init_array`-early detection, and the kernel-route map with its version gate
(`kernel-and-environment-hardening.md`).
- **Working from the phone itself** — MT Manager, its APK MCP surface, LSPosed Manager, on-device
data inspection (`on-device-tooling.md`, `scripts/mt_mcp_probe.py`).
## Dependencies this skill does not ship — name them before the workflow starts
- **Dart AOT analysis needs a snapshot dump.** `dart-aot.md`'s workflow begins at `pp.txt`;
producing it requires a snapshot container resolver this skill does not contain and cannot
synthesize. `dart_pool_strings.py` reports **file** offsets while `pp.txt` and `dart_pprefs.py`
speak in **pool** offsets, and the mapping between the two spaces is not a constant: over the
4,241 strings present in both, a measured run found 4,237 distinct deltas. Ship a pinned front end
(aotopsy — pure Go, no toolchain) or build blutter (~80 s, needs a C++ toolchain). **Say which one
you are using and why, because the two report different Dart version labels for the same binary.**
Do not describe the object pool as something this skill decodes on its own.
## Not covered — say so rather than improvise
- **Unity / IL2CPP logic recovery.** `framework-runtimes.md` identifies the runtime and establishes
that the dex is not the battlefield; it does not carry the IL2CPP equivalent of `dart-aot.md`.
There is no verified recipe here for locating a method inside `libil2cpp.so` plus
`global-metadata.dat`.
- **React Native / Hermes bytecode** and Cordova/hybrid internals, beyond runtime identification and
the generic "find the string, then find what references it" approach.
- **iOS / `.ipa` of any kind.** Every device, signing and packaging instruction here is Android.
- **Defeating a server-side authority.** `server-api.md` determines *who owns a gate*, not how to
break an authorization the server performs.
- **An off-the-shelf unpacker, and an anti-detection arms race.** `advanced-unpacking.md` routes the
problem — measure the ratio, name the recovery mechanism, stop when the target is a real VMP — but
ships no modified ART runtime, no private-bytecode decompiler and no opcode-mapping derivation,
and says so rather than presenting a memory dump as a recovery. `detection-and-anti-analysis.md`
decides by cost and often concludes "switch to static"; it is not a catalogue of evasion. It now
also carries an order of search, plus a measurable environment self-report and an observer-only
probe — and the boundary that keeps those from becoming an evasion project is stated in the same
file (its Step 2B, two boundaries this file adopts): observers and interceptors stay separate
modules, and the cost of each added module is what decides when to leave for static.
- **Kernel development.** `kernel-and-environment-hardening.md` maps the kernel-side route and names
the version gate that decides whether it exists on your device; building and shipping a kernel
module is outside this skill.
- **A working Stalker trace on every device.** Two boundaries are measured and one of them is now
better understood: exclusion keeps the target alive but does **not** restore event delivery
(`references/evidence-summary.md` §The capability matrix). Treat the zero-event case as a boundary to
identify, not a recipe to follow.
## Historical verification record
This is the part that grows every pass, which is why it does not live in `SKILL.md`. Read it before
trusting any "this was measured" statement above.
**The first verification pass measured, against a real target:** a mid-size Flutter AOT application
with no packer — the Dart work above, library mapping, and the environment facts. It **did not
exercise** the packer, code-virtualization, custom-linker, integrity-check-redirection or
tamper-suicide scenarios: that target has none of those features and its unmodified build already
fails to start, which removes the repack-and-regress loop those scenarios need. Nothing in
the evidence record condensed in `references/evidence-summary.md` §Where the full record lives is evidence either way about them.
**Scripts the first pass did not run** — they carry no measurement from that pass, and any
conclusion drawn from them should be labelled accordingly: `native_crash.py`, `apk_diff.py`,
`snap.py`, `grab_crash.py`, `install_test.py`, `repack.py`, `dex_patch_bytes.py`,
`dex_find_insn.py`, `dex_check_verifier.py`, `dex_classdiff.py`, `dex_strpatch.py`,
`patch_smali.py`, `smtool.py`, `datastore_inject.py`, `probe_api.py`, `run_probe.py`,
`tls_check.py`, `usb_net_proxy.py`, `devsh.py`, and the `dexpatch/` java rewriter. Absence from
that record is not a verdict on them. Note that `grab_crash.py` claims to recover stacks hidden by
a crash-reporter SDK — the exact situation that target presented — and was not tried, so that claim
remains **unverified**. **The benchmark pass has since run some of these**: `repack.py`,
`dex_patch_bytes.py`, `dex_dump_validate.py`, `dex_mem_scan.py`, `spawn_patch_detach.py` and
`stalker_trace.js` now have measurements, with the failures recorded alongside the successes in
the benchmark matrix (`references/evidence-summary.md` §The capability matrix).
**The extension pass shipped its own scripts, each with its own status** — read the matching
the per-topic files named in `references/evidence-summary.md` §Where the full record lives before relying on one: `dex_dump_validate.py` (measured
against a fixture derived from a real hardened sample, and against that sample's own shell dex),
`lsposed_scaffold.py` (its generated project was built end to end, toolchain timings recorded),
`frida_rpc_serve.py` (the `rpc.exports` bridge was exercised on a live device),
`mt_mcp_probe.py` (the "service is down" path is measured; the connected path needs the service
started by hand), and `stalker_trace.js` / `stalker_report.py` (two boundary results, no successful
trace of a real target).
**The extension pass's claims are mostly `inferred`.** Its evidence lives in
the per-topic files named in `references/evidence-summary.md` §Where the full record lives, one file per topic, each with its own strength note. The
common shape there is *the tool was measured, the route was not* — read those files before treating
any of the newer documents as a verified path.
**The detection pass added two tools and three boundaries, and its evidence is mixed.** Recorded in
`references/evidence-summary.md` §The capability matrix:
- `svc_scan.py` — **measured**, and its whole claim is cross-checked: two independent decoders
(a hand-written word scan and the capstone-based scan) returned an identical 214-site set on a
device `linker64`, set difference empty. That agreement is what makes its output usable as
evidence about a *target's* library rather than about the scanner.
- `anti_detect_probe.js` — **measured, observer-only by contract** (it patches nothing, so a run
that uses it still describes the target it was pointed at). On a public MASTG challenge target it
recorded `strstr("frida")` at ~300 ms of process life followed by a clean self-exit, and it
reported the target's own view of the environment (`TracerPid=0` while **four** frida-named
mappings were visible in `/proc/self/maps`). The same arm captured the full sequence **once out of
three runs** — on a target that dies in ~300 ms the window is sub-second and not arm-to-arm
reproducible, which is why the reference labels it that way rather than as a pipeline.
- `scan_leaks.py` — **measured against a planted corpus and against this repository**. On the corpus
every rule fired (30 findings across 6 categories) and the exemption list produced zero false
positives on identifiers that must stay (tool names, SDK packages, dex constant identifiers, CVEs,
hardening products, public crackme names, placeholders). On this repository it found **26 strong
hits on its own evidence file**, which is the strongest evidence available that the class of leak it
targets is not visible by hand. `observed`.
- **The Dart AOT string-table formats** — the arm64 packed scheme is confirmed at the byte level
(tag byte equal to `0x80|(len<<1)` at every literal checked, 4,980 chained pool entries), and the
previously documented armv7 form is **refuted**: the 32-bit record is
`[header u32][byte-count u32le][UTF-8 payload]`, and the extractor's zero for that ABI is a format
mismatch rather than an empty table. `observed` for the formats; `unverified` for an end-to-end
patched build, because none was repacked and installed in that pass.
- **`observed`:** for the `ptrace-free` route, byte-identity between two *read paths*
(`/proc/<pid>/mem` versus the backing file) on the same byte range, and the route's negative
boundary — a target whose dex is deflate-compressed inside its APK has **no** named dex mapping to
export (`named_dex=0` on both MASTG targets), so the route yields nothing there for reasons that
say nothing about protection.
- **`unverified`:** byte-identity between two independent *dumpers*. The second producer
(`frida-dexdump`) is refused by this repository's hardened sample, whose usable lifetime collapsed
to ~2 s this pass, and the clean targets have no whole-image dex for any dumper to find. The
weaker read-path check is what was run instead, and the distinction is stated in the evidence file
rather than papered over.
**Where the record lives.** The full record is `docs/tool-verification/` at the **repository root, not
shipped** with the skill; `references/evidence-summary.md` is the condensation that does travel, and
`references/routing.md` is the inventory. Neither the per-topic records nor the public-target
regression matrix ships inside an installed skill, so an installed copy carries the claim but not its
evidence file — which is why the strength label is stated here rather than left to be looked up.

View File

@@ -0,0 +1,70 @@
# Coverage inventory — the per-route list behind `SKILL.md`'s Coverage section
`SKILL.md` states the rule: **do not apply the nearest available procedure to a target it was never
written for**, and label every claim `observed` / `inferred` / `unverified`. This file is the list that
rule needs — which routes are covered, by what, and how strong the evidence actually is.
Load it when the question is "**can this skill actually do X**", or before quoting a coverage claim
as if it were verified. `references/coverage-and-limits.md` carries the same material as a claim
ledger with the per-item evidence; this file is the inventory you can read in one pass.
## Covered, by verified mechanisms
These have a command and an output behind them somewhere in the evidence record (repository root,
the evidence record condensed in `references/evidence-summary.md` §Where the full record lives, **not shipped with the skill**).
| Area | Route, in one line | Where it lives |
|---|---|---|
| Client-side ads, promos, splash/popup/tab config | includes server-issued UI config that has no SDK to find | `ad-removal.md`, `server-config-and-updates.md` |
| Membership / paywall / feature gates | decide whether it is *client-enforceable* at all, and say so when it is not | `membership-and-limits.md`, `account-gates.md` |
| dex-level surgical patching | equal-length byte edits, dexlib2 method rewrite, header/verifier/alignment rules | `dex-patching.md`, `byte-level-patching.md`, `patch-audit.md` |
| Repack, sign, install | including the install refusals that look like a broken build | `repack-and-sign.md` |
| Packers, custom loaders, code virtualization | identification, the validation boundary, the routes that survive it | `packers.md`, `code-virtualization-and-custom-linkers.md` |
| Java2C vs extraction shell vs JNI sinking | the misdiagnosis that sends you hunting a decrypted DEX that never exists | `java2c-and-jni-sinking.md`, `scripts/java2c_probe.py` |
| Native layer | `.so` hosts, tamper-triggered self-termination, forged ELF, neutralising a terminate path | `native-and-so.md`, `native-tamper-and-suicide.md` |
| 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` |
## Also covered, by documented routes — mostly *inferred* rather than measured
Read the qualification, not just the name: for these the **mechanism is documented and the end-to-end
route was not measured on this repository's evidence**. Treat them as leads with a stated boundary.
| Area | What it gives you |
|---|---|
| Module-side delivery | an LSPosed/Xposed module when a repack is refused — `lsposed-and-modules.md` |
| Extraction shells and the VMP boundary | how to *measure* that the bodies are empty, and where recovery stops — `advanced-unpacking.md` |
| Calling a routine instead of reading it | emulation (Unidbg/Unicorn) versus service-ifying the live function over Frida RPC — `emulation-and-rpc.md` |
| Instruction-level tracing | Stalker traces and the trace-to-CFG route against OLLVM, with its measured boundaries — `native-dbi-and-deobfuscation.md` |
| Protocols beyond REST | schema-free protobuf, gRPC frames, QUIC/HTTP3 limits, native-side pinning — `protocol-reverse.md` |
| When userspace hooking cannot reach the check | raw `svc`, `init_array`-early detection, the kernel-route map and its version gate — `kernel-and-environment-hardening.md` |
| Split APK / App Bundle sets | reading a set, merge versus unified re-signing, the install refusal each mistake produces — `split-apk.md` |
| Dex-VMP differential analysis | the known-plaintext route, what it can and cannot automate — `vmp-differential-analysis.md` |
| Kernel-module templates | a scaffold **and** its version gate; a userspace module cannot change a syscall return value — `kernelsu_syscall_mask.py` |
| Working from the phone itself | MT Manager edit/repack/sign and its APK MCP, LSPosed Manager, Termux — `on-device-tooling.md` |
| Publishing without publishing the target | the leak scanner, the do-not-anonymize list, and the graded precedent library — `desensitization-and-leak-scans.md`, `scripts/scan_leaks.py`, `precedents/` |
## Dependencies this skill does not ship
Each of these is a **fence**, not a caveat: the route above it stops here unless you obtain the
dependency yourself.
- **Dart AOT analysis needs a snapshot dump**, and which front end produced it matters (`dart-aot.md` §1–2).
- **`smali` round-trip needs baksmali/smali/dexlib2 jars** — not bundled; `APKREV_JARS` points at them.
- **Repack-and-sign needs `zipalign` + `apksigner`** (or `uber-apk-signer`); a JVM alone is not enough,
which is exactly what `scripts/doctor.py` now refuses to report as OK.
- **Device work needs `adb` plus a device the app does not object to**; dynamic work additionally needs
a `frida-server` matching the host version.
- **A kernel-side answer needs a module and a kernel that accepts it** — the version gate is in
`kernel-and-environment-hardening.md`.
## Not covered — say so rather than improvise
Unity / IL2CPP logic recovery · React Native, Hermes bytecode and Cordova internals · iOS of any kind ·
defeating a server-side authority · an off-the-shelf unpacker · an anti-detection arms race · building
and shipping a kernel module · a Stalker trace guaranteed on every device.
Each has a reason and, where one exists, an evidence boundary — in `references/coverage-and-limits.md`.
**Silence in that record is not support**, and a route that is only "documented" is not a route that
was measured.

View File

@@ -0,0 +1,393 @@
# Dart AOT (Flutter) — reaching the layer that actually owns the logic
Load this when: the target is a Flutter app (`libflutter.so` + `libapp.so`) and the behaviour you
must change is decided **inside** the Dart snapshot, not in the dex. The Java side of a Flutter app
is a thin plugin shell; a dex-only plan will stall (`framework-runtimes.md` §the layer trap).
Everything below assumes you already confirmed the runtime is Flutter and that Java hooks fire
**zero times** while the UI plainly works.
---
## 1. Pin the Dart version before choosing any tool
The snapshot format is version-specific. A decompiler built for another Dart version may run and
produce plausible-but-wrong output, which is worse than failing.
```python
import re
blob = open('libflutter.so', 'rb').read() # the engine, not libapp.so
m = re.search(rb'(\d+\.\d+\.\d+ \(stable\)[^\x00]{0,60})', blob)
print(m.group(1).decode() if m else 'not found') # e.g. b'3.9.0 (stable) (Mon ...) on "android_arm64"'
```
If the string is absent, fall back to the snapshot hash the loader reports at runtime, or to the
engine build id in `libflutter.so`'s version string.
**The regex above can return a version-shaped lie — assert the shape, do not just take the digits.**
Measured exception, on a real `libflutter.so`: the full banner string is
`0.0.1 on "android_arm64"` — a 66-byte engine version
record whose numeric field is `0.0.1` and whose **64-character build-id field is blank**, with no
`(stable)` marker and no `(Mon …)` timestamp at all. Anchoring only on `\d+\.\d+\.\d+` matches the
truncated prefix of a banner that was never populated. The discriminator that works: find the
` on "<arch>"` suffix, take the version string from the preceding NUL, and check that a
`(stable) (…)` tail is actually present **before** feeding the number to a VM-compiling tool
(§2's Route B). If it is missing, you cannot pin the version from this file — say so and use the
snapshot hash or a structural probe (§2's Route A), rather than building a decompiler for `0.0.1`.
## 2. Get a decompiler that matches that version
You need a tool that resolves the snapshot container — something that turns `libapp.so` into named
functions, class layouts and, above all, a **pool listing with `pp+0x…` offsets**. Everything in
onward consumes that listing; without it the workflow cannot start, and no script in this skill can
produce it (see §4 for why the mapping is not recoverable from the binary alone).
Two routes exist. Pick deliberately, because their version reporting differs.
**Route A — aotopsy: no toolchain, run it now.** A single static binary (pure Go) that parses the
snapshot binary format directly, with no Dart VM and no SDK compile. On a real Dart 3.6.0
`libapp.so` the full pipeline ran out of the box: 30,586 functions, 6,133 class layouts, 39,202 pool
entries (38,274 resolved), 175,120 call edges with 95.7% of indirect sites annotated, plus
`functions.jsonl`, `call_edges.jsonl`, `classes.jsonl`, `string_refs.jsonl`,
`dispatch_table.jsonl` and an annotated `asm/` tree. It also supports x86_64, which blutter does not.
Its `doctor` subcommand reports a Dart version that is a **structural profile label, not the SDK
version**: on the sample above it printed `3.6.2` while the engine banner, the snapshot hash and a
byte search all said `3.6.0`, and no `3.6.2` byte sequence existed in either `.so`. That matters only
if you feed the number to a VM-compiling tool — use §1's banner for that. Its own README states the
detection is structure-based, so expect the label to name the newest matching profile rather than the
compiler's version.
**Route B — blutter: full fidelity, needs a build.** It embeds a matching Dart VM and deserializes
through the VM's own code paths, which yields the canonical `pp.txt` in the `pp+0x…` space. It is a
*source tree you build*, not a binary you download: its `bin/` is gitignored and it publishes no
release artifacts, so there is nothing prebuilt to fetch for a given Dart version.
- blutter's git HEAD typically supports newer Dart than its shipped `dartsdk/` directory.
- When the target's version is missing, blutter fetches it itself: it sparse-clones
`dart-lang/sdk` at that version tag (only `runtime`, `tools`, `third_party/double-conversion`),
generates a source list, and builds a `dartvm` static library for the target ABI.
- That build needs `cmake` (>=3.20 is fine, including 4.x), `ninja`, `git`, and a C++ compiler with
C++20 `<format>` support. The documented VS 2022 requirement is **over-strict** — MSVC 19.34
(VS 17.4.3, Nov 2022) compiles and runs the `std::format` path, measured. A `vcvars64`-style
environment must be active, and `CMakeLists` pins `cmake_minimum_required(3.20)`.
- Budget a **couple of minutes**, not "tens of minutes": the whole pipeline (clone, `init_env_win.py`,
sparse SDK clone, cmake, nja, link) measured **≈78 s** wall clock. It uses a unity build via
`dartvm_create_srclist.py`, which is why it is fast. The compiler log is enormous (per-file include
trees), so read only its tail.
- **If the freshly built binary faults immediately** with `0xC0000005` and no output at all, do not
conclude anything about the sample. Relink it (`ninja` in
`build/blutter_dartvm<ver>_<os>_<arch>`) and try again; on the sample above the fault never
reproduced after a relink — including for the unchanged binary — and three subsequent runs each
completed in ~5.9 s. **One retry, then a relink, before you blame the target.**
- **Two Windows build failures that are not your target's fault, both measured `~0.2 h` each.**
*The compiler is not found even though `vcvars64` ran*: `%PATH%` expands when `cmd` parses the
line, so a `set PATH=...;%PATH%` in the same command overwrites the environment `vcvars` just
installed. Use delayed expansion (`cmd /V:ON` with `set PATH=...;!PATH!`) or run `vcvars64` in a
separate `cmd` invocation. *`string(REPLACE "/EHsc" ...)` aborts with "not enough arguments"*:
the `REPLACE` call is unguarded, so it breaks when `CMAKE_CXX_FLAGS` is empty — add an
`if(CMAKE_CXX_FLAGS)` guard, and patch **both** the template and the generated copy, or the next
configure regenerates the broken one. A prebuilt binary offered by a mirror is not a shortcut if
it is built for `aarch64`-Linux (the Termux target): check its ELF machine before spending time on
it — that mismatch cost `1 h` on the precedent this section is derived from, against `0.2 h` for
either compile fix above.
Outputs of interest (blutter):
| Output | What it gives you | What it does *not* give |
|---|---|---|
| `pp.txt` | every object-pool entry with its `pp+0x…` offset: strings, types, closures, fields, stubs | who references what |
| `objs.txt` | reconstructed object shapes with type annotations | code |
| `asm/` | class layouts **and function bodies with full instruction listings**, pool annotations and resolved call targets | a pseudocode-level view |
| `ida_script/`, `blutter_frida.js` | symbol/annotation helpers | a finished analysis |
> `asm/` is the richest artifact in the chain. It is one file per **library URI** (mirroring the
> package path), not per class as its name suggests, and it carries annotated disassembly such as:
>
> ```
> // 0x923b3c: r0 = LinkedHashMap.from()
> // 0x923b3c: bl #0x60165c ; [dart:collection] LinkedHashMap::LinkedHashMap.from
> ```
>
> An earlier revision of this file claimed `asm/` contained no instructions and told the reader to
> ignore it. That was wrong and cost real time — plan around `asm/` as your primary annotated
> disassembly source.
**`product` builds carry no debug info.** Expect `no-code_comments`, no function names, and
obfuscated identifiers. That is normal; the strings still survive (see). Note this is a *format*
floor, not a tooling gap: the Dart compiler drops field names outside debug builds, so roughly
97-99% of instance field names are simply absent, and local/captured variable names are gone
entirely. Accessor-based recovery (`get:`/`set:` still carry the name) recovers part of the field
picture; the rest should be rendered as unknown rather than guessed.
## 3. The object pool is the whole game
Dart AOT uses **compressed pointers**: heap references are 32-bit offsets from a base held in a
dedicated register. Constants, strings, types, closures and field metadata live in one contiguous
**object pool**, reached through that register.
Two offset spaces exist and they are **not the same number** — mixing them wastes hours:
- **PP offsets** (`pp+0x…`) — what `pp.txt` and every pool-load in disassembly use.
- **file offsets** — where a byte actually sits in `libapp.so` on disk.
Keep the mapping explicit in your notes. When a tool reports "this string is at X", state which
space X is in.
**There is no constant between the two spaces — do not go looking for one.** The relationship is a
property of the *reconstructed* pool, not of the file, so it cannot be computed from `libapp.so`
alone. Measured over the 4,241 strings that appear in both the string table and `pp.txt`, there were
**4,237 distinct deltas** (range −1,361,429 to +277,654). A string recovered by file offset therefore
does not lead to its pool offset, which is exactly why §2 insists on a snapshot-decoding front end:
it is what gives you the `pp+0x…` space in the first place.
## 4. Build your own reference index (blutter will not give you this)
`pp.txt` tells you what is in the pool. To *find the code that uses it* you need pool-offset →
referencing-instruction-address. Generate it once, then query it constantly:
```
python dart_pprefs.py libapp.so pp_refs.json
python dart_pprefs.py --lookup pp_refs.json 0x1d1a8 0xc9d0
```
**Do not build this index with a full-capstone pass.** Decoding every instruction of a multi-MB
`.text` with `detail=True` and querying `regs_access()` per instruction costs minutes of CPU and
1–2 GB of peak memory, and on a 16 GB host it presents as a hang with no progress output. Only a
handful of encodings can read the pool, so decode them from the raw 32-bit words instead — the
shipped script does exactly that and finishes in seconds.
**But do not fall back to a linear capstone sweep either.** A Dart AOT `.text` begins with snapshot
metadata, not instructions — on a real `libapp.so` the section started at `0x4a0000` while the first
Dart function prologue (`stp x29, x30, [sp, #-imm]!`) was at `0x4b143c`. A linear sweep starting at
offset 0 dies on that metadata and returns **zero instructions**, which looks exactly like "this
library has no code". If you need capstone here, start at a known function address, or use
`skipdata=True`, and treat an implausibly small count as a decoding problem before you treat it as a
property of the file.
The same mask-based argument applies to building a call graph (): decode `B`/`BL` arithmetically
rather than by sweeping.
**A live example of why the index's completeness matters:** after this file's advice to "count the
references before editing a shared constant", one pool offset measured `[0 refs]` with the shipped
script and `[79 refs]` once a missing load family was added to the scanner. A one-sided quiet gap in
the scan reads as "nothing uses this", which is the answer that gets a shared constant edited
carelessly. If a count looks surprisingly low, suspect the scanner before believing the pool.
## 5. What the registers mean
Stable across Dart 3.x arm64 AOT. Verify once on a known function, then rely on it.
| Register | Role |
|---|---|
| `x27` | **object pool base (PP)** — every constant/string/type load goes through it |
| `x26` | current thread (`[x26,#0x38]` stack limit, `[x26,#0x68]` isolate group) |
| `x28` | heap base — how compressed pointers are decompressed |
| `x22` | **null/base for booleans** (see) |
| `x15` | Dart's own stack pointer (not the system SP) |
| `x21` | class dispatch table (virtual calls load a target from it) |
## 6. Booleans are not 0/1
`true` and `false` are objects immediately adjacent to null, so they appear as small fixed offsets
from `x22`:
```asm
add x0, x22, #0x20 ; construct TRUE
add x0, x22, #0x30 ; construct FALSE
tbnz w0, #4, <label> ; test bit 4 of the value
tbz w0, #4, <label>
```
The bit-4 test is the standard "is this the false object" check, and it is the cheapest place to
force a decision: replacing the conditional branch with an unconditional one, or replacing a
constructed constant, flips the outcome without changing structure.
## 7. String encoding — get this exactly right
Pool string entries are serialized as:
```
[ tag byte ][ payload ]
tag = 0x80 | (len << 1) | two_byte_bit
```
- **one-byte strings** (ASCII / Latin-1): `payload` is the raw bytes.
- **two-byte strings**: `payload` is **UTF-16LE**.
Consequences that decide whether your search works:
- An **ASCII** literal (`/api/foo`, `isVip`, a URL) is found by a **UTF-8 byte search**. It is a
one-byte string — searching UTF-16 for it will miss.
- A **CJK / non-Latin** literal is a two-byte string: a UTF-8 search returns **zero**, a UTF-16LE
search hits. This is the case that makes people wrongly conclude "the strings were stripped".
- Therefore: **search ASCII as UTF-8 and non-ASCII as UTF-16LE**, rather than forcing one encoding
on everything. (This is the concrete form of `pitfalls.md` P25.)
- A raw byte scan for the tag pattern finds many false candidates. Accept a candidate only when
entries **chain** — the previous entry's `tag+payload` must end exactly where the next candidate
begins. Without that constraint you get order-of-magnitude more garbage than strings; a
per-byte scan of a multi-MB snapshot once produced ~880k "strings" of which essentially all were
misaligned noise, and a "no such feature present" conclusion was drawn from it.
```
python dart_pool_strings.py libapp.so strings.tsv --min 3
```
### The 32-bit ABI does not use this format — and the tool will say so
**On a 32-bit (`armeabi-v7a`) snapshot the packed tag scheme above does not exist, and the string
table is not laid out as a walkable chain at all.** `dart_pool_strings.py` enforces the chaining
constraint, so on armv7 it reports `kept (run >= 3): 0` — every candidate is isolated and discarded.
That zero is a **format mismatch, not an empty string table**: the same extractor over the arm64
snapshot of the same app kept 4,980 chained entries. Do not read the armv7 zero as "the strings were
stripped".
Measured on one app shipped with both ABIs (arm64 16,352,152 B / armv7 17,629,776 B):
| ABI | Where a literal sits | How to find it |
|---|---|---|
| arm64 | inside the packed string table, `[0x80\|(len<<1)][payload]` | `dart_pool_strings.py`; the chain constraint makes it reliable |
| armv7 | **inline in the read-only data**, as its own record | a plain ASCII search for the literal — it is *not* UTF-16, so a byte search is the whole method |
For armv7, the record a literal sits in is **4-byte aligned** and reads
`[header u32][length u32le][SAFE payload]`, where `length` is the **byte** count of the payload
(measured: an ASCII literal 12 chars long carried `12`, and a 70-char literal carried `70`). The
header u32 is a class/tags word that varies between snapshots — on the sample above it took the
values `0x00550238` and `0x00560238`, both 4-byte aligned, 13,397 occurrences. Treat the header as
a **validator, not a constant**: assert `length == payload length` and that the payload is
all-printable, then read the header word from the file rather than hard-coding it. Scanning the whole
17.6 MB image at 4-byte stride costs **0.94 s**, so validating every candidate is affordable.
Consequence for patching: because the armv7 payload is **UTF-8 and length-explicit**, an equal-length
ASCII replacement is legal on both ABIs without recomputing the tag — the one-byte tag encodes the
length only on arm64, which is why the same patch script can serve both if it asserts the prefix it
found instead of assuming which encoding it is looking at.
**Patch every ABI the app ships, or the device may run the one you left alone.** A phone that
prefers `arm64-v8a` still has the `armeabi-v7a` snapshot on disk, and a build that patches only one
of them is a controlled experiment, not a deliverable.
## 8. Reading AOT code: three signatures that carry most of the weight
Once you can disassemble a window with pool annotations, most business logic resolves into these
shapes.
**A — reading a map / JSON object (`map[key]`)**
```asm
ldur x3, [x29, #-8] ; the Map
ldur x0, [x3, #-1] ; compressed class id sits at offset -1
ubfx x0, x0, #0xc, #0x14 ; decode class id
mov x1, x3
... x2 = <pool string> ; <- the key, annotated by your pool map
add x30, x0, #0x342
ldr x30, [x21, x30, lsl #3]
blr x30 ; this is map[key]
```
Remember the quartet: `ldur [..,#-1]` + `ubfx #0xc,#0x14` + `add x30,x0,...` + `blr`.
**B — writing a map (`map[key] = value`)**
```asm
... x16 = <pool string> ; the key
stur w16, [x0, #0xf] ; key stored into the freshly built map
... ; then the value at the next slot
```
`stur` writes, `blr` reads. Confusing the two means patching a serializer that never touches the UI.
**C — function entry**
```asm
stp x29, x30, [x15, #-0x10]!
mov x29, x15
sub x15, x15, #0x20
ldr x16, [x26, #0x38]
cmp x15, x16
b.ls <stack-overflow handler>
```
Use this to find function boundaries when you need to delimit one.
## 9. The locating workflow
1. **Anchor on a string.** Search the pool for the shortest distinctive token — a field name, an
endpoint path, a label. Prefer identifiers over sentences (a sentence may be assembled from
fragments).
2. **Find its referencing instructions** via your index ().
3. **Disassemble a window** around each reference with pool annotations:
```
python dart_disasm.py libapp.so --pp pp.txt --refs pp_refs.json 0x26e390
python dart_disasm.py libapp.so --pp pp.txt --refs pp_refs.json --range 0x64f310 0x64f3e0
```
4. **Classify the site**: read (A) or write (B)? For writes, check whether the value is a constant
from the pool — that is a "send this flag" site, not a "decide locally" site.
5. **Walk outward.** Find the enclosing function entry (§8C), then find its callers:
```
python dart_disasm.py libapp.so --index callers.json --build-index
python dart_disasm.py libapp.so --index callers.json 0x26e230
```
6. **Cross-validate by clustering.** Related strings usually sit **adjacent in the pool** and are
referenced from **adjacent code**. If two field names are a few bytes apart in the pool and their
reference sites are a few instructions apart, you are looking at one coherent piece of logic —
strong evidence you are in the right place.
## 10. Patching this layer
- **Prefer changing the data source over the decision point.** Locate the chain
"read field from map → type conversion → store to stack slot" and replace the middle with a load
of a pool constant. One edit then feeds every consumer, instead of chasing each comparison. This
has produced clean results where per-condition patches did not.
- **Boolean sites** are the cheapest: `tbnz`/`tbz` → `nop` (always fall through) or → an
unconditional branch; `add xD, x22, #0x20` ↔ `#0x30` to flip a constructed constant.
- **A pool string may be shared.** Before editing one, count its references. If the same entry is
read by several sites, changing it changes all of them — self-consistent and therefore useless
for splitting behaviour. Prefer editing code over shared data.
- **Assert your addresses.** Patch with the original bytes asserted first and re-read the result
afterwards; an address that is off by one instruction will happily corrupt a snapshot into
something that still loads.
- **`libapp.so` inside an APK is usually deflate-compressed**, so in-place byte patching of the zip
entry is not possible — patch the extracted file, then replace the whole entry
(`repack-and-sign.md`).
- **Replacing a string in place is safe; replacing the *wrong* string is not.** The highest-value
equal-length target on a server-driven app is a **data key** — a JSON field name, a slot key, a
reporting label. Rename it to equal-length noise and the client looks up a key the server never
sends, so the feature goes quiet while every other request keeps working. This is the cheapest
surgical form of the "do not make an API fail" constraint at the top level of `SKILL.md`: the
request still succeeds, only the client's interpretation of it changes.
- **Never replace an API path or URL string.** It looks like the same kind of string and it is not.
Measured failure, on a real build: replacing the ad-fetch path produced a **404 whose error body is
not valid JSON**, the app's startup flow called `jsonDecode` on it, `FormatException` propagated
out of the Future that builds the home screen, and the app **stayed on the launch logo forever** —
`logcat` showed only `E flutter`. The ad did disappear; so did the app. Isolate it the way that
case did: a **re-sign-only control** (no string edits) plus `adb logcat -d | grep "E flutter"`, so
a startup failure is attributable to the patch rather than to the packer. Full precedent:
`references/precedents/`.
- **Longest string first when the targets are substrings of each other.** In a byte search
`welfare_ad` also matches inside `welfare_ad_top`, and `ad_click:` inside `ad_click:exp`; replacing
the short one first corrupts the longer entry. Sort candidates by descending length, and assert the
prefix byte you expect **immediately before** each hit — on arm64 that byte is `0x80|(len<<1)`, on
armv7 the length is the u32 immediately before the payload — so a hit that lands mid-string is
rejected instead of patched.
## 11. Traps specific to this layer
- **Identifiers are ambiguous across layers.** A pool string that reads like a domain concept can
belong to a standard library or a wire protocol instead. A near-miss example worth remembering:
the token `expires` resolved to HTTP **response-header** parsing (its siblings were `date`,
`host`, `connection`), not to a subscription expiry field. **Read the surrounding strings and the
code shape before assigning meaning** — one misleading identifier can send a whole analysis down
the wrong branch.
- **Obfuscated names are not identities.** Symbol names are mangled (`_abc@12345` shapes are
common) and change between builds. String literals do survive, so anchor on those.
- **Clusters reveal structure.** Adjacent pool entries plus adjacent reference sites indicate one
subsystem; use it to avoid guessing what a function is for.
- **A write-only flag is usually an outbound parameter, not a local switch.** If a string is
referenced exactly once, at a site that stores a constant into a map that is then sent, the client
is *reporting* a value — changing it does not change local behaviour. Confirm whether the value is
ever read before treating it as a gate.
- **Full-file disassembly is not a debugging tool** (). Windowed disassembly is.
## 12. Verification
Static: asserted patch bytes, re-read from the built artifact, plus structural checks
(`verification.md`). Runtime: the changed behaviour must be observed in the app's own UI — for this
layer that means an actual launch, because nothing about Dart AOT code proves itself statically.
If the snapshot drives a decision that a server also enforces, see `membership-and-limits.md`:
getting the client side right does not make the outcome reachable.

View File

@@ -0,0 +1,225 @@
# Desensitization and leak scans — what must leave, and what must stay
A skill repository is published, and the material feeding it is real work: device transcripts,
packet captures, `ps` listings, log excerpts, an hour of shell history. Target identity leaks
into that material one line at a time — a bundle id in the middle of a `pm path` output, a
device serial echoed before a `dumpsys`, an SDK key pasted while debugging a download — and
**no structural check can see it.** `check_repo.py` validates layout, `check_refs.py` validates
anchors; both pass on a file that names a live app and a real phone.
The failure this file prevents is two-sided, and the second side is the one that gets
forgotten:
- **Under-redaction**: a published repository carries a target's identity, a device serial, or
a credential, and nobody notices until someone greps for it a year later. This is what
happened here once, by hand, after a pass had already been committed.
- **Over-redaction**: the same pass strips the *reusable* material along with it — the tool
names, the hardening-product names, the public crackme names, the protocol field names — and
the document stops being able to teach anything. A repository that has been scrubbed into
uselessness has failed at the same job from the other side.
A rule that only handles the first side produces "a document about nothing". A rule that only
handles the second produces a leak. Both get fixed by the same artifact: an explicit list of
what is *exempt*, in code, that can be read and argued with.
**Load this when:** you are about to publish anything derived from real work -- an evidence file, a transcript, a README -- or a leak scan reports a hit. It gives the must-leave/must-stay split and the scanner's exit semantics.
## Strength labels
- **observed** — a command was run here and its output is quoted in the text or recorded in
the evidence record condensed in `references/evidence-summary.md` §Where the full record lives.
- **inferred** — follows from an observed fact or from documented mechanism; the step itself
was not executed.
- **unverified** — assumed, or reported elsewhere, and not reproduced in this repository.
Claim strength in this file means *how well the statement about desensitization was measured*,
not how confident the writing sounds.
## The two rules, stated so they can be argued with
**1. Redact what identifies the target; keep everything that identifies the technique.** A
bundle id, a device serial, a host user path, an appkey, a live token, a non-loopback endpoint
are identity. A tool name, a library name, a function name, a protocol field name, a CVE id, a
hardening product name, a public crackme name and its URL are *technique* — they are how the
document transfers skill, and they are exactly what a naive redaction pass deletes first.
The test that separates them: **would removing this string make the sentence less able to
teach?** `frida`, `apktool`, `libjiagu`, `UnCrackable-Level1`, `com.stub.StubApp`,
`RegisterNatives`, `proto3` all fail the test — removing them costs knowledge and protects
nothing, because they name a product, a tool or a public fixture. `<PKG>`, `<DEVICE>`,
`<APPKEY>` pass — they carry the sentence's structure and none of its identity. And
`com.<redacted>.app` also passes the test, which is why a scanner must *report* the reverse
domain shape and let a human see what it is; it cannot decide.
**2. A finding is reported with limited context, not a line number.** A report that says
`foo.md:412` makes the fixer reopen the file, read around the line, and decide what to delete —
per finding. A report that quotes ~48 characters either side means the decision is made from
the report itself. This is the same argument `verification.md` §Reporting makes for logs: an
artifact that does not contain the decisive information sends its reader back to the source.
## What must be kept — the do-not-anonymize list
Every entry here has been observed in this repository's own documents, and every one of them
is a wrong answer from a shape-only scanner. The list is materialized in
`scripts/scan_leaks.py` as `BENIGN_PACKAGE_PREFIXES`, `PUBLIC_TARGET_PREFIXES`,
`BENIGN_IP_PREFIXES` and the `CODE_IDENTIFIER_HINTS` table, so it can be extended with a
one-line edit and audited with `--show-exempt`.
| Category | Examples | Why it is exempt |
|---|---|---|
| Tool and library names | `frida`, `apktool`, `jadx`, `libart.so`, `libfoo.so` | naming a tool is the content; the name is public and identical for every reader |
| Function and symbol names | `RegisterNatives`, `findExportByName`, `pthread_mutex_lock`, `JNI_OnLoad` | API vocabulary, not identity — and a large fraction of the reverse-engineering technique is exactly these names |
| DEX/ELF constant identifiers | `findExportByName`, `ImmutableDexFile`, `installPackageLI`, `LoadPackageParam` | 16-character camelCase runs that a serial-shaped regex matches; they are code constants |
| Protocol field names | `proto3`, `varint`, `packed repeated`, `length-delimited` | wire-format vocabulary |
| CVE identifiers | `CVE-2024-31317`, `CVE-2021-44228` | public advisory ids; redacting them destroys the reference |
| Hardening product names | `com.stub.StubApp`, `libjiagu.so`, `libDexHelper.so`, `com.secneo` | a *product* signature. It is what lets a reader recognise a shell, and it names no target |
| Public crackme / benchmark targets | `UnCrackable-Level1.apk`, `sg.vantagepoint.uncrackable3`, MASTG and its URLs | deliberately published fixtures; their package names are part of the published exercise |
| Platform and SDK packages | `com.android.settings`, `com.google.android.gms.ads`, `com.qq.e.ads.PortraitADActivity`, `androidx.work.WorkManager` | inventory. "Which SDK does this app carry" is a finding; the SDK's own name is not a secret |
| This repository's own fixtures | `com.example.*`, `probe.synthetic.*`, a probe module's package | synthetic by construction |
| Placeholders | `<PKG>`, `<DEVICE>`, `<serial>`, `<hash>`, `<work>`, `<user>`, `C:\Users\<user>\...` | the redaction mechanism itself; a scanner that reports these is unusable |
| Loopback / unspecified / emulator addresses | `127.0.0.1:27042`, `0.0.0.0:8080`, `10.0.2.2:8080`, `169.254.169.254` (as a documented endpoint) | the frida and ADB idioms are these addresses; they identify nothing |
**Deliberately *not* exempt, and observed as a finding here**: the RFC 5737 documentation
ranges (`192.0.2.0/24`, `198.51.100.0/24`, `203.0.113.0/24`). They are the correct way to write
a synthetic address, so a hit there is a signal rather than noise. Suppressing the range outright
would also hide the case where someone pastes a real address that happens to look synthetic.
This repository pays for that decision with the *only* findings it produces, and the price is
recorded rather than argued away: **four `endpoint/weak` hits and nothing else**, measured on the
tracked surface with file count 129. Three of the four are the three blocks named in the sentence
above — this file stating the decision is itself the hit — and the fourth is
`scripts/tls_check.py`'s own usage example, a benign context the report makes obvious in one line.
Every other category is at zero. That ratio is why the gate's pass condition lives in
`--fail-on` and not in an exemption table: the documentation of the decision *is* the residue, so
a gate that failed on it would be failing on its own rationale. The transcript is in
`references/evidence-summary.md` §The capability matrix.
**What is a leak here, in contrast** — the shapes worth reporting: `package="…"` in a manifest
line, a bundle id after `pm path` / `pidof` / `ps -A` / `component=`, a 16-character
`[A-Z0-9]` run standing alone or beside a device word, `github_pat_…`/`ghp_…`, an inline
`APPKEY`/`appSecretKey`/`SECRET_KEY` assignment with a literal value, a non-loopback `IP:port`,
and `/home/<name>/`, `/Users/<name>/`, `C:\Users\<name>\`.
## What a scan can and cannot decide
Decide in code: whether *this shape* is present, and whether a known-benign pattern explains
it. Never decide in code: whether the surrounding document is identity-bearing.
Three consequences, each observed by writing the scanner for this repository:
- **A range of four digits separated by dots is an IP address only if nothing nearby says
"version".** `JDK 17.0.4.1`, `build-tools 34.0.0` and `frida 16.7.19` are the shapes the first
version of the scanner reported twenty times across the evidence record condensed in `references/evidence-summary.md` §Where the full record lives. Context
(`jdk`, `python`, `frida`, … immediately before the match) separates them.
- **A 16-character uppercase token is a device serial only if it carries a letter.** Stack
addresses from a tombstone (`0000000040001000`) and hex protocol fixtures
(`0807060504030201`) are all-digit and were reported until that rule was added.
- **A reverse-domain string preceded by `args.` is a property lookup.** `args.package.split(".")`
in a script — observed in `scripts/spawn_patch_detach.py` — is not a bundle id, and the
exemption needs the *line*, which is why the exemption function takes it.
Each of those is a **false positive that a reader would have had to investigate**. Three of them
were found by running the scanner against this repository, not by reasoning about it — which is
the argument for making the scan a gate rather than a document.
## The scanner, and where it belongs in the pipeline
`scripts/scan_leaks.py` — standard library only, `--root` explicit or defaulting to its own
repository, categories `package` / `device` / `token` / `appkey` / `endpoint` / `path`, text and
JSON output, and a fixed exit-code contract:
| Exit | Token | Meaning | What a script does |
|---|---|---|---|
| 0 | `RESULT=clean` | nothing outside the exemption list | continue |
| 0 | `RESULT=leaks_found_strong_only` | findings exist, but every one of them is `weak` (an address from the documented range) | continue, and read the report — it is the publish-time checklist |
| 1 | `RESULT=leaks_found` | at least one `strong`/`certain` finding, or any finding under `--fail-on any` | fail the job and print the report |
| 2 | `RESULT=error` | bad usage, unreadable root, or a read error with no findings | fail the job as an infrastructure fault, not a content fault |
The token is the machine-readable line; the exit code is the interface. Keeping them separate
matters because a wrapper that only greps stdout cannot distinguish "clean" from "the scanner
never ran", and a wrapper that only checks `$?` cannot explain itself in a log.
Which findings fail the gate is chosen by `--fail-on`, and the two settings are two different
questions:
- `--fail-on strong` (default) — only `strong`/`certain` findings fail. This is the maintenance
habit's setting, and it exists because of the RFC 5737 decision above: if the documented ranges
are reported on purpose, this repository is never `clean`, and a gate that is red on its own
rationale is a gate people learn to disable.
- `--fail-on any` — every finding fails, `weak` ones included. This is the publish-time setting,
and the one to run against the file you are about to publish rather than the tree you are
working in.
Neither setting changes what is *reported*: weak findings are printed under both, and only the
exit code differs. The strict pass is therefore a decision recorded in a command, not a surprise.
Placement, and the reasoning is about *when* the wrong answer is cheap:
- **Not in the pre-commit hook alone.** A commit-time gate is the cheapest place to catch a
leak, but this repository's evidence files are written by long passes that commit once at the
end; a hook would fire on a tree nobody has finished writing.
- **In the maintenance gate, next to the other two checkers.** The habit that already exists is
`python check_repo.py && python check_refs.py` before a commit; a third line costs nothing and
runs at the moment the material is about to become permanent. Its exit 1 is a *content*
finding to read, not an error to retry — unlike the two checkers, whose failures are
mechanical.
- **Before publishing an evidence file**, once, by hand, with `--show-exempt`. The exemption
audit is where the interesting mistakes are: a value suppressed for the wrong reason looks
exactly like a value that was never there.
- **On the report itself, after it is written and before it is published.** A report that quotes
the hits in order to be concrete is a *copy* of the leak, in a file that is tracked and read.
This is not hypothetical here: the first version of the desensitization extension record did
exactly that, and the scanner flagged its own evidence file on the next run. Run the scan on
the artifact you are about to ship, which for an evidence file means the file itself.
- **In CI, on the published tree only.** A scanner run over a git-ignored work area will report
the work area — which is where the target identity legitimately lives — and a gate that cries
wolf on every run is a gate that gets disabled. `tools/` is excluded by default for exactly
this reason; a caller who points `--root` at it means it, and gets the findings.
**What the scan does not replace.** It cannot see identity that is *described* rather than
*quoted* ("a mid-size Flutter app with a 360 shell"), and it cannot see a value that has been
partially masked on purpose. Neither is a defect to fix by adding patterns: the first is why
this repository's evidence files open with a statement that target identity is absent, and the
second is a judgement call a human makes once and writes down.
## Failure modes
| Symptom | Mechanism | What to do |
|---|---|---|
| The scanner reports a hundred hits, none of them a leak | shape-only rules against a document full of legitimate identifiers (versions, hashes, symbol names) | Do not widen the exemption list one hit at a time. Run `--show-exempt`, group the reasons, and fix the *rule* — a context-free regex is the bug |
| The scanner reports nothing and you believe it | a root that excluded the directory the leak was in; a text file with an extension the walker skips; a value inside a fenced block the reader never scrolled to | Read the header line — it prints the file count and the root. Then point `--root` at one directory and confirm the count is plausible |
| A real leak was suppressed | an exemption matched a substring (`<pkg>` inside something else), or the benign-prefix list is too broad (`com.`-prefixed platform list catching a target that starts with the same two segments) | `--show-exempt` prints the *reason* per suppressed hit. An empty or vague reason is the defect |
| Same hit reported twice on one line | two rules whose scopes overlap (`SECRET_KEY=` is both a token and an appkey) | Expected, and left in deliberately: two independent rules naming one value is corroboration. Exact duplicates (same rule, same value, same line) are collapsed |
| The gate is green in CI and the leak is in the tarball | CI scans the tree; the artifact was built from a work area, or from a branch that was never scanned | Scan the directory the artifact is built from, with the same exclusions the build uses |
| A `<PKG>`-style placeholder is reported | the placeholder uses a different bracket, or no brackets at all | Add the token to `PLACEHOLDER_TOKENS`. This is the one exemption class that should grow freely — placeholders cannot leak |
| The published report contains the leak it was written about | the report quotes each hit value in order to be concrete, and the report is the published artifact — a report is a copy of what it found | Never quote a hit value in a published document. Keep rule, category, strength, position and count, and describe the value by shape (`<reverse-domain>.<n>`, `16×[A-Z0-9]`, `24×[0-9a-f]`) or with a placeholder. Scan the file you are about to publish, not the file you started from |
## The entry-point surface
A published skill is not only read by an agent; its **entry files are read as instructions**.
`SKILL.md`, a `README.md`, and the front-matter of any reference are the parts a host process
loads *before* it decides anything, and a repository assembled from third-party material — a
borrowed reference file, a contributed script — carries whatever those files say. This
repository's convention, observed in its own history: content reproduced from elsewhere is
summarised as prose with the source named, never quoted verbatim in a way that would land in an
instruction position, and a contributed file is read before it is linked.
The practical consequence for redaction is the same as for the rest of this file: **the
redaction must not be reversible by asking the file.** A placeholder that is defined next to
its own value, a "sample transcript" that still carries the serial in a comment, or a finding
report pasted into a document that later becomes an entry point all reintroduce the identity
that a scanner would have caught — because the scanner ran before the report was written. Run
the scan on the file you are about to publish, not on the file you started from.
## Cross-references
- `references/long-task-discipline.md` §Keep a live record, not a log — the record is written
during the work, so it is where identity lands first, and the file most likely to be published
by accident.
- `references/verification.md` §Reporting — the evidence/report split this file mirrors: what
ships is neutral and mechanical, what identifies the target stays in the analysis.
- `references/precedents/README.md` — the case library; its "write back to the repository"
checklist is where a desensitization finding becomes an index line.
- `references/third-party-builds.md` — the same do-not-anonymize reasoning applied to a sample
you did not produce, where the identity is someone else's.

View File

@@ -0,0 +1,349 @@
# Detection, anti-analysis, and when to stop fighting it
Load this when the app fights back: it dies or misbehaves after you attach, refuses to run on your
device, detects root/emulator/hook/debugger, or when your dynamic tool simply will not work on the
environment you have.
This file is a **decision** file, not a bypass catalogue. The expensive mistake it exists to prevent is
spending hours escalating against a detection layer when a cheaper route — usually static — was
available the whole time.
## Step 0: the cheapest decision in this file
**Dynamic analysis is a convenience, not a prerequisite.** Everything that ends in an installable
artifact is decided statically: you edit a file and repackage. Dynamic work is how you *find* things,
and it is often the fastest way — but when it is blocked or unavailable, the correct move is usually to
switch to static, not to escalate.
So before doing anything clever, ask:
1. Is the thing I still need to learn actually discoverable statically? (Strings, code structure,
call sites, comparisons — usually yes.)
2. Do I need the process to run *to verify*, or only to *find*? Verification can often be done by
observing the shipped artifact's behaviour, which a hostile process still exposes.
If either answer allows a static path, take it and stop reading this file.
## Step 1: is it detection, or is it your environment?
Detection has a distinctive shape: **it is reproducible, tied to your instrumentation, and absent in the
same app run without it.** Everything else that looks like detection is usually one of these:
| Looks like detection | Usually is | How to tell |
|---|---|---|
| app dies right after attach | hook timing, a bad script, or a genuine RASP layer | run the same app with **no** hooks: alive? then it is your instrumentation or a hooking detector |
| app refuses to start at all | ABI mismatch, missing native lib for this device, install problem | check the live mapping (`scripts/lib_map.py`) and that a clean unmodified build starts |
| app exits on this device only | emulator/root detection, **or** a device-state problem | run preflight; try the same build on a different device class |
| app hangs forever | a frozen thread from a bad patch, or a real ANR | `pitfalls.md` P7 and the "never make it not return" rule |
| dynamic tool "cannot attach" | **the tool cannot work on this environment at all** | see Step 4 — this is the one people waste days on |
**Rule: reproduce with the control first.** Instrumentation-free run, then instrumented run, same
build. That single comparison separates "the app detects me" from "my tooling is broken", and it takes
one round.
## Step 2: if it *is* detection, decide by cost, not by pride
Three legitimate outcomes. Pick one deliberately and say which you picked.
**A. Work around it — only when the workaround is small and stable.**
Worth it when the detector is a simple, localised check you can neutralise in one place, and the
workaround lives in *your* environment rather than in the shipped artifact. Examples of the class:
running on a device the app does not object to, using a build that satisfies the check, or a single
one-line gate in a helper. Also legitimate: naming the artefact differently, using a different device
profile, or simply doing the work on a device that the app accepts.
**B. Change route.** When the workaround is a moving target, switch to static analysis and stay there.
This is the right answer more often than it feels like. Hooking-detection especially tends to escalate:
each hide provokes a stronger check, and you end up maintaining a cat-and-mouse setup that is worth
nothing at delivery time.
**C. Accept and report.** If the detection blocks the *deliverable* rather than your analysis — for
example, the app verifies something the user's device will not have — then the honest output is a
statement of what is blocked, with the evidence. Do not ship a build whose only purpose is to defeat a
detector you were not asked about.
**Signal to stop escalating:** you have spent more effort on the analysis environment than on the
change the user asked for. That is the drift this skill treats as most expensive, in its runtime form
(`long-task-discipline.md` §the most expensive drift).
## Step 3: locating the check — the order of search
Step 2 decides *whether* to fight. This step is the part the two steps around it do not provide:
**once you know a check fired, in what order do you look for it?** The order matters because the
cheap probes answer most cases and the expensive ones only pay off after a cheap probe has already
narrowed the field. Every stage below names its input signal, the smallest action that answers it,
the evidence that makes it true rather than plausible, and where to go when it does not.
Two shaping notes before the stages.
**Order is the whole content here.** Reading this as a menu and starting at the middle is how a
session becomes an arms race: each probe you add changes the process further, so the later probes are
measuring a target you already modified. Do them in order and stop as soon as one answers.
- **Stage order is a funnel, not a checklist.** Stage 1 is five minutes and rules out whole classes of
problem; Stage 5 requires the offsets Stage 2 produced. Skipping forward costs more than it saves.
- **Every stage ends in `observed` or in a stated negative.** "No hook fired" is only a fact once you
have shown the hook can fire on this target (`pitfalls.md` P25/P26 in tool form).
**Strength note.** The stages are a restatement, for judgement purposes, of a six-phase
anti-instrumentation pipeline published in `index-login/MobileRE-Skill`
(`https://github.com/index-login/MobileRE-Skill`, retrieved 2026-09). That project's phase *modules*
are not vendored here and none of its scripts are used; what is taken is the **order** and the
branch conditions, which are the parts that transfer. Stages 2, 3, 5 and 6 are **observed** as
mechanisms on this repository's own device (evidence and exact commands:
`references/evidence-summary.md` §The capability matrix). Stage 4's kill path was reached once and
is unstable across runs, so treat its detail there as `observed`-once, not reproducible.
Stage 1 is `observed` and is the one that most often ends the investigation without an escalation.
### Stage 0: rule out the two non-detection causes first
Before hooking anything: is the process alive at all, in what state, and does it die the same way
with nothing attached?
```bash
adb shell 'cat /proc/<pid>/status | head -4' # state matters: D is uninterruptible sleep
adb shell 'cat /proc/<pid>/stat | cut -d" " -f3'
```
**Observed failure that looks exactly like detection:** a target in **`D` (uninterruptible disk
sleep)** makes an attach hang and then fail, and makes a second attach report *"process not found"* —
while the process is still in `ps`. No detector is involved; a userspace attach needs the process to
run. On the measured run this was the actual cause, and it was read as "the app refuses
instrumentation" for one round. Check state before you blame the target, and check **whether attach
works on any other process on the same device** — that one-line control separates a broken toolchain
from a hostile target in a single command.
### Stage 1: a benign probe, to establish that your pipeline can fire at all
**Input signal:** nothing yet. **Action:** load an *observer-only* probe
(`scripts/anti_detect_probe.js`) that patches nothing. **Evidence it worked:** the probe's own
`armed` line and its environment self-report — `TracerPid`, which of your artefacts are visible in
the process's own maps, whether it can read `/proc/net/tcp` at all. **If nothing arrives:** the
problem is plumbing or liveness, not detection. Re-read Stage 0; do not escalate.
**The self-report is the cheapest half of this file's whole subject, and it is usually skipped.**
Before asking "does it detect frida", ask "is frida visible to this process". Measured on the test
device, a probe spawned into a target read 4 frida-named mappings in the target's own
`/proc/self/maps` before any check ran — so "was it detectable" was never in doubt, and the
remaining question was only which check reads it.
**Stream, do not batch, on a target that self-destructs.** A probe that reports only at the end of a
window reports *nothing* when the target dies inside the window — precisely the run that mattered.
Measured: three spawn-and-probe arms of one target; one delivered a full event sequence ending in a
self-destruct, the next two lost the script before its first timer fired. Emit a heartbeat and stream
each first hit. The heartbeat's absence at time *T* is itself the datum: it separates "the target was
quiet" from "the script was gone before *T*".
### Stage 2: name the detector, by caller offset
**Input signal:** Stage 1's environment events. **Action:** hook the path-access and process-control
surface (`open`/`openat`/`fopen`/`access`/`stat`/`readlink`, `dlopen`/`dlsym`, `pthread_create`,
`kill`/`tgkill`/`exit*`) and report, for each call, the **caller module + offset**. **Evidence:**
`libX.so+0x1cef8` — a named module and an address. **If no caller resolves:** you are on a runtime
that cannot unwind here; record the module and move to Stage 6's static path instead of adding a
third unwinding strategy.
This stage's product is a **name**, and everything after it is cheaper. Two branch conditions decide
which Stage 4 branch you take, so they are worth reading off explicitly:
| What Stage 2 shows | Branch | Why |
|---|---|---|
| the check runs on a thread the detector created itself (`pthread_create`/`clone` caller is the detector module) | **block at creation** | the polling loop never exists to fire |
| the check runs inline inside a library constructor, and the calls you can see are only libc wrappers | **Stage 3** | under that ordering a libc hook is the wrong layer |
| the detector is a *shell* library that also decrypts | **do not patch broadly** | the same module carries load-bearing work; scope to named offsets |
### Stage 3: if it fires before your hooks exist, take the constructor order
**Input signal:** the death happens before or during first library load, or Stage 2's caller offsets
all sit in a constructor path. **Action:** find the constructor dispatcher and hook it *before*
constructors run. **Evidence:** the module's `init_array` entries are visible and their addresses
map to the checks. **If the device's linker does not expose the symbol:** switch to the static route
(`scripts/svc_scan.py` on that library) rather than guessing an address.
```bash
# the symbol name is mangled; grep for the substring, not the bare name
readelf -sW <linker64> | grep call_constructors
```
**Measured:** on this repository's device the symbol exists as
`__dl__ZN6soinfo17call_constructorsEv` at `0x4a258` — a **mangled** name. A search for a bare
`call_constructors` export reports "absent" and sends the analyst to an offset-guessing path that
was never necessary. Confirm presence/absence before building either route.
**The measured cost of getting this stage wrong:** do not clear a module's whole `init_array`. A
shell library's constructor does payload decryption as well as detection; emptying it converts a
detectable app into a non-running one, which is a worse result than the detection was. Scope to
functions you have a reason to suspect — a per-function static scan for the syscall form in Stage 6
is how you get that reason.
### Stage 4: keep the process alive — deliberately, and with a stop condition
**Input signal:** every attempt shows events up to a point and then the process is gone. **Action:**
make the termination observable (record path and caller) and, if necessary, prevent it. **Evidence:**
the death is attributed to a module and an offset, not to "the platform". **If attributing it costs
more than three arms, stop attributing and switch to static.**
Two measured shapes, and they need opposite treatment:
- **A libc-visible kill.** Measured on a clean MASTI-style target: a probe saw
`strstr("frida")` at ~300 ms and then the process was gone, with **no** tombstone, no crash and no
ANR record — a clean self-exit. The abort path itself surfaced once as a libc `raise`/signal call;
it did not surface on the later arms that died sooner. That instability is the finding: **on a
fast detector the observable window is sub-second and not reproducible**, so a single successful
attribution is not a pipeline.
- **A libc-invisible kill.** If a module issues `svc #0` itself, no libc-level hook can see it, and
the absence of an event beside a death is the signal to go static (Stage 6).
**`exit_blocker`-style keepalive has a failure mode that looks like success.** Blocking the exit
turns a clean death into a spinner: the process stays alive, stops responding, and you can no longer
tell "the check is neutralised" from "the app is wedged". Keep the blocked-call count, a timeout, and
one behavioural check — does the UI still reach the state you care about — or the keepalive is
measuring itself.
### Stage 5: shellcode and runtime-generated code
**Input signal:** Stage 2's loader stream shows executable mappings appear that were never loaded
from a file. **Action:** watch allocation with execute permission and read-only-executable
transitions; disassemble around the allocation's caller. **Evidence:** an `mmap`/`mprotect` call with
execute permission whose caller offset is in the detector module. **If the region is unreadable:**
stop; the offsets from Stage 2 are all a static session needs.
A target can make itself harder to look at without doing anything exotic: on one measured run the
recovered caller chain sat inside `jit-cache`, i.e. **Java code the runtime had JIT-compiled**, not a
shipped library. A crash frame in JIT output cannot be mapped to a file offset. Expect it on any
API where the framework compiles reflection at runtime, and treat "the frame has no file" as a fact
about compilation, not as a hidden module.
### Stage 6: the precise stop, and the static alternative to it
**Input signal:** a named module + offset from Stage 2, Stage 3 or Stage 5. **Action:** neutralise
that site — or decide not to. **Evidence:** a fresh control run with the site left alone dies where
it died before, and the patched run does not. **If the offset came from a trace you could not
reproduce:** do not patch it; go to the static route below.
The static route is often strictly better than the dynamic one and needs no live process:
```bash
python scripts/svc_scan.py /data/local/tmp/libDetect.so
```
**Measured discriminator.** A module that contains **no** inline `svc` site for a termination syscall
cannot be bypassing libc for its kill — so a missing exit event is then a finding about your hook,
not about the target. Measured on this device: the ROM's `libc.so` carries exactly 4 termination
sites (`exit`, `exit_group`, `kill`, `tgkill`), and those are libc's *own* exported implementations —
so hooking `exit`/`kill` really does see callers that go through libc. A shell library on the other
hand showed 21 byte-scan "svc sites" that were all **data**, with no call-number load anywhere near
them: a byte scan for `svc` matches inside data, so read the neighbours before believing a count.
That distinction — structural absence vs. observed absence — is what Stage 6 is for.
### The routing chain, on one page
```
Stage 0 process state + does attach work on ANY process? -> D-state / broken toolchain, not detection
Stage 1 observer-only probe + environment self-report -> what the target can see
Stage 2 path/loader/thread/kill hooks with caller offsets -> a module+offset name
|-- caller is a created thread -> block at creation (Stage 4, branch A)
|-- death precedes hooks, or the checks are in a loader ctor -> Stage 3, or static (Stage 6)
Stage 3 constructor order / mangled symbol check -> the check is reachable at all
Stage 4 attribute the kill, then decide whether to block it -> attributed death, or "unreproducible"
Stage 5 executable mappings not backed by a file -> shellcode / JIT, often a dead end
Stage 6 NOP one site, or prove statically that libc is enough -> observed before/after
```
## Step 2B: two boundaries this file adopts
**(a) Observers and interceptors are separate modules and stay separate.** An observation module must
not change behaviour, and an intervention module must not double as a monitor. This is the discipline
the pipeline above is built to survive: Stage 1's value is that its output describes the *original*
process, and the moment the same script also blocks an exit or NOPs a function, every later
observation belongs to a target that no longer exists. The practical version: a probe that watches
and a patch that neutralises are two files, loaded in two runs, and the runs are the experiment.
`scripts/anti_detect_probe.js` is the observer half and patches nothing by contract.
Be honest about the leak in that contract: **hooking is itself observable.** Attaching changes
timing, and on a hardened target the first event the probe recorded was a `dlopen` of the target's
own code followed within ~50 ms by a check — i.e. the act of being watched is part of the
environment the target is reacting to. Separation makes runs comparable to each other; it does not
make them a description of the untouched process.
**(b) Name the cost before you add another module, and know when to leave.** Each avoidance module
is another thing the target can notice and another reason a later result is unattributable, and the
detection is not standing still: a check that was a string compare becomes an inline syscall becomes
a native check in a module you have not looked at. Track three numbers, and stop when two of them
are true: how many bypass modules are loaded, whether the run needs a keepalive to stay up at all,
and whether the target only behaves under the full stack of modules. **When they are true, switch to
static** — that is the same conclusion as Step 2B, reached with a threshold instead of a judgement
call, and it is the exit this whole step needs so that Stage 6 does not become a permanent home.
## Step 4: when the tool cannot work here at all
Some environments cannot run a given dynamic tool, and no amount of trying will change that. Recognise
it and move on — this is a *finding about the environment*, and recording it prevents the next person
from repeating the attempt.
**The instructive class: an emulator that executes a different architecture than it reports.** A device
may advertise one ABI while the process runs libraries of another through a translation layer. This
shows up as a tool that reports the device is fine, then fails to attach, attach-and-die, or attach and
see nothing. Symptoms worth treating as a hard signal:
- the tool's own attach path fails with an error that mentions tracer, ptrace, or a debugger — on an
environment where translation is in play;
- attaching kills the process immediately, repeatedly, with no Java stack;
- the tool attaches but **no** hook ever fires, including hooks on code you know runs.
**Do not spend the session proving it is impossible.** Establish it once, with one clean reproduction,
write the environment fact down, and switch to static.
**What to do instead:** the entire static toolchain is architecture-agnostic — string tables, code
structure, call-site counts, byte-level patching. None of it needs the app to run. If your verification
also needed the app to run, use the device-level observables instead: does the UI change, do the logs
fall silent, does a file appear or stop appearing. Those are properties of the shipped artifact, and they
do not care that you could not attach.
## Step 5: what a detection layer means for the deliverable
Two distinct questions — keep them separate, because conflating them produces either a broken artifact
or an unnecessary retreat.
1. **Does it block your analysis?** → handle with Step 2/3/4. This is your problem, and it is temporary.
2. **Will it block the patched build on the user's device?** → this is a property of the artifact. A
root check that merely requires an unrooted device is usually irrelevant to a repackaged APK. A
**self-integrity or signature check** is a different matter entirely, and it belongs to
`references/native-tamper-and-suicide.md` and `references/signature-derived-keys.md`.
Ask explicitly: *does this check fire because of how the app is built, or because of where I am running
it?* Only the first kind follows your build into delivery.
**Root is an environment fact, not a defeat.** "Runs only on a rooted device" is a legitimate analysis
environment and an illegitimate deliverable when the request was an installable build for a normal phone.
Say which one you actually have.
## Step 6: keep a one-line environment fact
When you conclude that something cannot be done in this environment, write one line in the task record:
```
cannot: <tool/technique> in <environment> because: <observed failure> route taken: <alternative>
```
That single line is what saves a future round. It is also the difference between "we could not attach,
so we did X" and an unqualified "dynamic analysis is impossible", which is a claim you have not earned
and which will mislead the next reader.
## Checklist
- [ ] **Located before escalating:** Stage 0 state check, an attach control on another process, then the
stages in order — and stopped at the first stage that answered
- [ ] **Observer and interceptor are not in the same script**, and the observation run was kept free of
any patch
- [ ] Detector named as **module+offset**, or the negative stated ("no caller resolvable on this runtime")
- [ ] **Cost measured, not felt:** bypass-module count, whether a keepalive is required, whether the
target only runs under the full stack — two of three true means switch to static
- [ ] Controlled the comparison: same build with and without instrumentation
- [ ] Preflight run, so device state is excluded before blaming a detector
- [ ] Classified: detection vs environment vs my own tooling
- [ ] Chose A/B/C deliberately and can say which, and why
- [ ] If the tool cannot work here: established it **once**, recorded the fact, switched route
- [ ] Separated "blocks my analysis" from "blocks the deliverable"
- [ ] Nothing in the shipped artifact exists solely to fool a detector
- [ ] Recorded the one-line environment fact if a route was closed

View File

@@ -0,0 +1,204 @@
# Dex patching — choosing and executing the surgical edit
The single most important decision in a patch task. Get this wrong and you produce an APK that assembles perfectly and dies at runtime.
**Load this when:** you have located the class and method to change and must choose the edit technique. It gives the least-destructive-first order and the signal that says a byte patch is not enough.
## Pick the least destructive technique that can express your change
Ordered from safest to most dangerous.
| # | Technique | Expresses | Structure risk | Use when |
|---|---|---|---|---|
| 1 | **dexlib2 method-level rewrite** | Replace a method's implementation (return a constant, no-op, emit a fixed value) | **Lowest** — only the target `code_item` changes | Default choice. Any behavior change that can be expressed as "this method now does X instead" |
| 2 | **Byte-level string constant patch** | Change a string literal to another of **equal length** | Medium — must preserve `string_ids` ordering (see P2) | Renaming a path/key/protocol token |
| 3 | **Resource / asset edit** | Change a config file, JSON, image, or bundled jar | Low (no dex change) | The behavior is data-driven |
| 4 | **Manifest edit** | Disable a component, drop a permission, flip a flag | Low, but re-signing required | Turning off a service/receiver/activity |
| 5 | **Whole-tree smali round-trip** | Arbitrary code edits | **High** — breaks R8 output (see P3) | Last resort, and verify by running |
**Do not reach for #5 because it is familiar.** It is the most likely to produce a broken build.
## Where to patch: pick the layer, not the symptom
Ads and gates can be suppressed at several layers. Higher in this list = smaller blast radius.
| Layer | What it looks like | Example | Risk |
|---|---|---|---|
| **SDK initialization** | A single helper that calls `Sdk.init()` | `AdHelper.k(Application)` | Low — SDK never starts, nothing else depends on it |
| **Feature entry point** | The app method that shows the thing | `AdHelper.showSplash()`, `showReward()` | Low–medium — must preserve callbacks the caller awaits |
| **Data consumption** | Where a server list is filtered/rendered | filter out a `position` before it reaches UI state | Medium — must not break sibling data |
| **Renderer** | The composable/view for a **specific** element | a dedicated ad card composable | **High** — generic components are shared (see P6) |
| **Transport** | Blocking/repointing an endpoint | `/app/adverts` → dead path | **Very high** — shared endpoints take screens down (see P5) |
**Rule:** prefer the highest layer (closest to the SDK/feature) that produces the required effect. Never go to the transport layer to hide a UI element.
## Before you patch a method: blast-radius check
```bash
python scripts/find_refs.py <smali_tree|dex|dir|apk> 'Lcom/pkg/Helper;->methodName(args)RetType'
```
Read the `[scanned]` line before the count. It reports how many inputs were actually opened, and
`[scanned] 0 file(s)` exits 2 with the reason: an unsupported path, a typo, or a directory of archives
is **not** the same answer as "read 2 dex files and found nothing", and the two once printed
identically — which put a false zero on exactly the decision this check exists to protect. A dex input
is decoded directly (`dexutil.py`), so baksmali is not required.
- **1–3 callers, all in the same feature area** → safe to patch.
- **Many callers, or callers across unrelated packages** → it is a general utility. Do not patch it. Go one level up and patch the specific caller instead.
- **Look at the parameters too.** If the signature mentions `Modifier`, `ContentScale`, `ColorFormat`, `Shape`, `View`, or a content-generic model type, it is not specific to your target.
Real example: a composable `(AdModel, ColorScheme, Modifier, Function0, ContentScale, Shape, Composer, II)V` looked ad-specific because of its first parameter. It was actually the shared image-card renderer; neutering it removed all cover art and broke playback.
## Technique 1: dexlib2 method-level rewrite (preferred)
Concept: load the dex, find the target class, replace **only** the target method's implementation, write a new dex. Nothing else is touched.
See `scripts/dexpatch/` for a working Java implementation and build instructions.
Shape of the code:
```java
DexFile dex = DexFileFactory.loadDexFile(new File(in), Opcodes.forApi(API));
List<ClassDef> out = new ArrayList<>();
for (ClassDef cd : dex.getClasses()) {
List<Method> direct = new ArrayList<>(); for (Method m : cd.getDirectMethods()) direct.add(m);
List<Method> virtual = new ArrayList<>(); for (Method m : cd.getVirtualMethods()) virtual.add(m);
boolean touched = false;
// ... for the target class: find the method, build new instructions, list.set(i, newMethod)
out.add(touched
? new ImmutableClassDef(cd.getType(), cd.getAccessFlags(), cd.getSuperclass(), cd.getInterfaces(),
cd.getSourceFile(), cd.getAnnotations(), cd.getStaticFields(),
cd.getInstanceFields(), direct, virtual)
: cd);
}
DexFileFactory.writeDexFile(out, new ImmutableDexFile(Opcodes.forApi(API), out));
```
### Practical details that decide success
- **`direct` vs `virtual`.** `static`, `private`, and constructors live in `directMethods`; everything else (including `public final`) lives in `virtualMethods`. **Scan both** when searching. A "method not found" that is actually "in the other list" wastes a lot of time.
- **Preserve the register count.** Keep `m.getImplementation().getRegisterCount()`. `registers` must be `>= parameterRegisters`, and parameter registers are the **highest-numbered** ones: with `registers = N` and `k` parameters (including `this` for non-static), `p0 = N - k`.
- **Method parameters** come from the prototype. For `(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;` on an instance method, `k = 3` → `p0=3, p1=4, p2=5` when `registers = 6`.
- **`const-wide` needs the 64-bit instruction form.** `ImmutableInstruction51l`, not `31i`.
- **Instruction register operands in `35c` form** (invoke) are `(opcode, registerCount, regC, regD, regE, regF, regG, ref)`. `invoke-static {v1, v2}, ...` → `registerCount=2, regC=1, regD=2`.
- **Every method body must end with a return of the right type.**
- **One dex, one write pass.** If you need two edits to the same dex, do them in the same program (see P4).
- **Null `tryBlocks` / `debugItems` on the rewritten method is fine** — you are replacing the whole body.
### Common rewrites
**Return a boolean constant** (`Z`):
```
const/4 v0, 0x1
return v0
```
`registers` = original count is fine; `const/4` needs a 4-bit register (`v0`–`v15`).
**No-op** (`V`):
```
return-void
```
**Return an object/empty collection**: build the constant, then `return-object`. For `java.lang.Long`, `const-wide` + `Long->valueOf(J)` + `move-result-object`.
**Coerce a suspend/Flow lambda to always yield a fixed value** — this is what "make a local flag permanently true" usually looks like in practice. See the worked example below.
### Worked example: permanently "not expired" without touching a shared helper
**Situation.** A local preference (`<key>_expires_at`) gates a promo popup. Writing a large value into the datastore works on the current device but is **runtime data**, so a fresh install loses it (see P11). The read path is a Flow built at construction time, so the getter cannot be patched meaningfully, and the generic `Long.valueOf` wrapper has 30+ callers (see P6).
**Target.** The dedicated map lambda, e.g. `feature/data/SomeStore$currentX$$inlined$map$1$2`:
```
.method public final emit(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
iget-object v0, p0, L<same class>;->b:Lkw2; # downstream collector
const-wide v1, <big value>
invoke-static {v1, v2}, Ljava/lang/Long;->valueOf(J)Ljava/lang/Long;
move-result-object v1
invoke-interface {v0, v1, p2}, Lkw2;->emit(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
move-result-object v0
return-object v0
.end method
```
Note the shape: the lambda class holds the downstream collector in a field (here `b:Lkw2;`). Always read that field name from the actual smali — do not assume.
This class has exactly one purpose, so patching it is safe.
## Technique 2: byte-level string patch
Only for **equal-length** replacements. Must preserve `string_ids` ordering.
```bash
python scripts/dex_strpatch.py <in.dex> <out.dex> "<old>" "<new>"
```
The script:
1. requires `len(new) == len(old)`,
2. requires exactly one occurrence,
3. looks up the entry's neighbours in `string_ids` and **rejects** the replacement unless `prev < new < next`,
4. recomputes `signature` (SHA-1 over bytes from offset 32) and `checksum` (adler32 over bytes from offset 12).
If a candidate is rejected, try another string that sorts inside the same interval. `/app/noadver` was rejected (`n > c` against neighbour `/app/configs/`); `/app/blocked` was accepted.
**Do not** use this technique to change behavior. Strings are renamed, not logic.
## Technique 3: smali tree editing (only when you must)
Use when the change cannot be expressed as a single-method rewrite (adding a field, restructuring control flow).
Tools: `scripts/smtool.py` (baksmali/smali with a bundled classpath) and `scripts/patch_smali.py` (method-body replacement by signature).
```bash
python scripts/smtool.py d <in.dex> <out_tree>
python scripts/patch_smali.py <tree> <patch.json> [--dry-run]
python scripts/smtool.py a <tree> <out.dex>
```
Then **must** verify (P3 will bite otherwise):
```bash
python scripts/dex_classdiff.py <original.dex> <rebuilt.dex>
```
Expect: same class count, `only_in_A=0`, `only_in_B=0`, `ACC_INTERFACE mismatch=0`.
If the class table is clean but the app crashes with `IncompatibleClassChangeError` / `VerifyError`, the round-trip damaged code items that table comparison cannot see. Fall back to technique 1.
Patch spec format for `patch_smali.py`:
```json
[
{
"file": "com/pkg/Helper.smali",
"method": ".method public final showAd(Landroid/app/Activity;)V",
"registers": 3,
"body": ["return-void"],
"note": "why this is safe"
}
]
```
## Finding the call site
Reverse-lookup from a smali tree or a set of disassembled dex:
```bash
python scripts/find_refs.py <tree> 'Lcom/pkg/AdHelper;->showSplash(...)V'
```
Search strategies that work:
- Search the **string table** first (`scripts/dex_strings.py`). API paths, keys, and SDK class names are usually string literals, and they are unique.
- Search for the **SDK's entry class** (`Sdk.init`, `Sdk.show`). The app's wrapper is almost always the only caller.
- When the decompiler is confusing, trust the **byte offsets** — `grep` the dex for the literal and note where it sits relative to other strings of the same family.
## Order of operations for a real patch task
1. Identify every place the behavior is produced (`recon` + `find_refs`).
2. Pick the highest safe layer per the table above.
3. Write all edits for a given dex into **one** dexlib2 program.
4. Patch each dex **once**.
5. `dex_classdiff` every patched dex against the original → expect zero structural drift.
6. Repack, sign, install, launch, exercise, read logcat.
7. If anything is off, revert that dex to the original and re-verify the control build.

View File

@@ -0,0 +1,502 @@
# Dynamic analysis with Frida
Static analysis tells you what code exists. Frida tells you what code **runs**. When they disagree, runtime wins (`references/pitfalls.md` P10).
Use Frida when you need to answer:
- Which method actually renders this element? (static decompilation routinely misleads)
- What is the real call chain at the moment of interest?
- What does the app actually do on startup, in order?
- Which domains does it resolve, and when?
- Is the app detecting my instrumentation?
**Load this when:** static analysis cannot answer the question (which method renders this, what value reaches that call), or Frida will not install, attach, or stay alive. It gives setup, the four-layer probe, hook strategy, and the ROMs that hunt instrumentation.
## Setup — version alignment is a hard gate
**Do this first.** Nearly every "Frida is broken on this device" report is a version mismatch, and the error text rarely says so. The host `frida` package and the device-side `frida-server` must be the **same version**, and that version must actually support the device's Android release. Align, then debug everything else.
| Symptom | Cause | Fix |
|---|---|---|
| `unable to locate Android dynamic linker` | host/server too new for this Android release | drop to an older frida line (16.x is a safe baseline for older ROMs) |
| `Java is not defined` / `Java.perform is not a function` | that build ships no bundled Java bridge | align to a build that has it, or inline the bridge (see *17+ gotchas*) |
| `Failed to connect to remote frida-server` / `unexpected message` / `invalid message` | host package and device server differ | download both from the same release tag |
| `Java.choose` / `Java.use` throws immediately | bridge present but the VM is not ready | wrap everything in `Java.perform` |
| Attached, but hooks fire in the wrong process | USB auto-selection grabbed the emulator | use an explicit remote device (below) |
| `HOOK-OK` prints and nothing ever fires | not a version problem | go to *Hook never fires* |
### Prefer a remote device over USB
With a physical device *and* an emulator attached, `frida.get_usb_device()` can silently pick the emulator — you then attach to the wrong process and chase phantom failures for a long time. Address the device explicitly:
```bash
adb devices -l # get <serial>
adb -s <serial> forward tcp:27042 tcp:27042
frida -H 127.0.0.1:27042 -f <app.package> -l probe.js --no-pause
```
```python
device = frida.get_device_manager().add_remote_device('127.0.0.1:27042')
```
Keep `-s <serial>` on every other adb call too, or you will drift between targets mid-session.
### Attach by PID when the process list is incomplete
`device.enumerate_processes()` can return a list that simply does not contain the app you are
targeting, while `adb shell ps -A | grep <pkg>` shows it running. This is an enumeration gap,
not a permission problem, and no amount of retrying fixes it. Resolve the pid through adb and
attach to it directly:
```python
import subprocess
def find_pid(serial, pkg):
out = subprocess.run(['adb', '-s', serial, 'shell', 'ps -A -o PID,NAME'],
capture_output=True, text=True).stdout
for line in out.splitlines():
parts = line.split()
if len(parts) >= 2 and parts[1] == pkg:
return int(parts[0]) # last match wins; use the main process
return None
pid = find_pid(serial, pkg)
session = device.attach(pid) # works where attach(pkg) raises ProcessNotFound
```
Two related behaviours worth knowing:
- `attach(<package name>)` raises `ProcessNotFoundError` in exactly this situation, which is
easy to misread as "the app is not running".
- A pid captured earlier goes stale the moment the process is recycled. Re-resolve it
immediately before each injection rather than reusing a value from an earlier step.
### Verify the runtime before blaming the script
A script that reports nothing at all is usually not seeing the Java layer. Two failure modes
look identical from the host — test for both in one go:
```javascript
// send({ runtime: Script.runtime, java: typeof Java, objc: typeof ObjC });
```
- `Java` is `undefined` ⇒ the script runtime has no Java bridge. Pass `runtime='v8'`
explicitly when creating the script; some builds default to a runtime without it.
- The bridge exists but every hook misses ⇒ you attached to the wrong process, or the layer
below is native (go to the four-layer probe / `references/native-and-so.md`).
### Start the device server so it survives
Run it as root, give it a non-obvious name out of obvious paths, and **detach it from the shell's session**. A process started with `nohup ... &` from an `adb shell` that then exits gets reaped mid-run — the hooks work for a minute and then stop.
```bash
adb push frida-server-<ver>-android-<abi> /data/local/tmp/
adb shell "su -c 'mkdir -p /data/local/tmp/.svc'"
adb shell "su -c 'cp -f /data/local/tmp/frida-server-<ver>-android-<abi> /data/local/tmp/.svc/kwork'"
adb shell "su -c 'chmod 755 /data/local/tmp/.svc/kwork'"
adb shell "su -c 'setsid /data/local/tmp/.svc/kwork >/dev/null 2>&1 </dev/null &'"
adb shell "su -c 'pgrep -f kwork'" # must still be alive after the shell returned
```
If `setsid` is unavailable, hold the adb connection open instead: run `adb shell "su -c '/data/local/tmp/.svc/kwork'"` as a host-side background job and leave it running for the whole session.
### Spawn, don't attach, when timing matters
ABI must match the device (`getprop ro.product.cpu.abi`). Attaching usually misses startup — init, the first network calls, and splash logic all happen before you get a session.
```python
device = frida.get_device_manager().add_remote_device('127.0.0.1:27042')
pid = device.spawn([pkg])
session = device.attach(pid)
# ... load the script, wait until it reports that hooks are installed ...
device.resume(pid) # resume ONLY after hooks are ready
```
**Common bug:** resuming before the script has finished loading loses the first ~100–300 ms, which is exactly where init happens. Have the script `send()` a ready signal (the probe template below sends `PROBE-READY`) and resume only after receiving it.
### Spawn keeps the Activity stack down — patch, detach, then launch
Spawn mode is the right way to catch startup, and it carries a trap that costs a whole
observation round when it is not expected: **on some targets the Activity stack never comes
up while you are attached.** `dumpsys window | grep mCurrentFocus` stays `null`, screenshots
come back blank or a few kilobytes, and the process is alive with no UI — which reads as
"the app is broken" when it is only unrendered.
The app is not broken: launch it normally, with no session attached, and it renders. So the
ordering is the fix, not a different hook.
1. spawn and attach, as above;
2. let the probe write what it needs into memory (`Memory.patchCode`);
3. **detach** — a memory write is a plain write and survives, while every `Interceptor`
hook goes away with the session. For an observation run that is usually what you want,
because it removes your instrumentation from the picture entirely;
4. start the Activity normally and capture.
`scripts/spawn_patch_detach.py` implements exactly this, with
`scripts/hook_patch_only.js` as the minimal "neutralise one death site and report `PATCHED`"
probe.
Two consequences worth stating:
- **A memory patch is enough to observe a build that cannot run on its own.** If a build dies
at startup you do not have to ship a patched file to look at its UI — write the fix in
memory, detach, launch. This is also the cleanest way to run the **unmodified original** as
a control while still getting past its death site (`verification.md` §the control build rule).
- **Do not conclude "no UI" from a blank capture taken while attached.** Check
`mCurrentFocus`: if it is `null` under an attached session and non-null without one, you
have this problem, not a finding about the target.
## Frida 17+ gotchas
The preferred fix for every symptom in the table above is **version alignment**. Use these only when you genuinely must run a 17+ build.
- The **built-in Java bridge was removed**. `Java.perform(...)` is not available unless you inline the bridge yourself:
```python
bridge = open(<site-packages>/frida_tools/bridges/java.js, encoding='utf-8').read()
script = bridge + "\nObject.defineProperty(globalThis,'Java',{value:bridge,configurable:true});\n" + my_js
```
Pass the whole thing through a small loader script (below).
- **Export lookup renamed.** Use a compatibility helper rather than a single API:
```javascript
function resolveExport(name) {
try { if (Module.getGlobalExportByName) return Module.getGlobalExportByName(name); } catch (e) {}
try { if (Module.findExportByName) return Module.findExportByName(null, name); } catch (e) {}
try { const l = Process.findModuleByName('libc.so'); if (l) return l.getExportByName(name); } catch (e) {}
return null;
}
```
- **Large script injection times out.** A multi-hundred-KB bridge + script can exceed the transport timeout. Use a tiny loader and post the real payload:
```python
loader = "recv('go', m => { eval(m.payload.code); }); send('loader-ready');"
sc = session.create_script(loader)
sc.load()
sc.on('message', on_message) # wait for 'loader-ready'
sc.post({'type': 'go', 'payload': {'code': BIG_JS}})
```
## Hook strategy
### Java layer (Kotlin/Java app)
```javascript
Java.perform(() => {
const Cls = Java.use('com.example.Helper');
Cls.showAd.overload('android.app.Activity').implementation = function (a) {
send({ tag: 'Helper.showAd', stack: Java.use('android.util.Log').getStackTraceString(
Java.use('java.lang.Throwable').$new()) });
return this.showAd(a); // observe, then delegate
};
});
```
- **Use `overload(...)`** — obfuscated classes often have several methods with the same name.
- To find obfuscated names, enumerate at runtime rather than guessing from decompiled short names:
```javascript
Java.enumerateLoadedClasses({
onMatch: n => { if (n.indexOf('example') >= 0) send(n); },
onComplete: () => send('done')
});
```
- **Call stacks are the highest-value signal.** `Log.getStackTraceString(new Throwable())` or `Java.use('android.util.Log').getStackTraceString(...)` gives you the real chain from the framework down to the method — far more reliable than reading smali.
### Native layer
```javascript
const f = resolveExport('open');
Interceptor.attach(f, {
onEnter(args) { this.p = args[0].readCString(); },
onLeave(ret) { if (this.p) send({ tag: 'open', path: this.p }); }
});
```
### Network / domain observation (cheap and very informative)
```javascript
const Inet = Java.use('java.net.InetAddress');
Inet.getAllByName.overload('java.lang.String').implementation = function (h) {
send({ tag: 'dns', host: h });
return this.getAllByName(h);
};
```
This is often the **decisive evidence** for an ad-removal claim: if the ad SDK's domains are *never resolved*, the subsystem never started — a much stronger statement than "logcat was quiet". It also works for SDKs that bypass the system HTTP proxy.
## The four-layer probe template
When the question is "why did this request fail / where did it go", do not hook one class and hope. Ship **one long-lived script that hooks four layers at once**; whichever layer fires first localizes the problem immediately. This is the single most productive artifact of a runtime investigation — reuse it verbatim.
The layers, and why each one is there:
1. **The app's own network wrapper** — enumerate `getDeclaredMethods` and wrap *every* overload, so you do not have to guess the entry point.
2. **OkHttp end to end** — `newCall`, `Request$Builder.build`, `RealCall.execute`, `AsyncCall.run`, `RealInterceptorChain.proceed`. The last two are the ones that fire for asynchronous calls.
3. **`java.net.URL.openConnection`** — plenty of login/register paths never touch OkHttp; they use `HttpsURLConnection` and a completely different trust configuration.
4. **`Throwable.getMessage`** — pulls out the original text of exceptions an upper layer caught and swallowed. Without it, a caught failure looks like "nothing happened".
Rules that make the difference between a usable probe and a wasted session:
- **Write to a file as well as to `send()`.** Stdout is lossy and the CLI drops messages; the file is the evidence.
- **Let it stay resident.** Hook before the app touches the network, then leave it running while you drive the UI. A one-shot script misses everything that happens after its first second.
- **Wrap every hook in its own `try/catch`.** One missing class must not take down the other three layers.
- **Never capture an overload in a `var`.** Inside a loop, `var ov = ...` leaves every hook pointing at the *last* overload: they install cleanly and then mis-report forever. Use `let` or `.forEach()`.
`scripts/frida_probe.js` is the same probe in a fuller form (per-arity dispatch, DNS layer, dedup and hard caps). The script below is the minimal portable one — paste it into any loader and it adapts to the target.
```javascript
// probe.js — four-layer network + swallowed-exception probe.
const APP_PKG = '<app.package>';
const APP_NET_CLASS = 'com.example.app.net.HttpHelper'; // the app's own wrapper, once you have found it
const LOGFILE = '/data/user/0/' + APP_PKG + '/files/probe.log'; // app-private: always writable by the app
const MAX_THROW = 400; // Throwable.getMessage is hot — cap it
const INTERESTING = /(network|http|ssl|cert|fail|timeout|refused|unable|error|exception)/i; // widen for the app's UI language
let out = null;
try { out = new File(LOGFILE, 'a'); } catch (e) {}
function log(ev, data) {
const line = JSON.stringify(Object.assign({ t: Date.now(), ev: ev }, data));
try { send(line); } catch (e) {}
try { if (out !== null) { out.write(line + '\n'); out.flush(); } } catch (e) {}
}
function stack(n) {
try {
const t = Java.use('java.lang.Throwable').$new();
return Java.use('android.util.Log').getStackTraceString(t).split('\n').slice(1, (n || 6) + 1).join(' | ');
} catch (e) { return '<no stack>'; }
}
function sa(args) { // a hook must never die inside its own logging
try {
const o = {};
for (let i = 0; i < args.length; i++) o['a' + i] = args[i] === null ? 'null' : '' + args[i];
return o;
} catch (e) { return { err: '' + e }; }
}
// 1) the app's own wrapper: every declared method, every overload, separately wrapped
function hookAppNet(cls) {
try {
const C = Java.use(cls);
const ms = C.class.getDeclaredMethods();
for (let i = 0; i < ms.length; i++) {
try {
const name = ms[i].getName();
const ps = ms[i].getParameterTypes();
const sig = [];
for (let j = 0; j < ps.length; j++) sig.push(ps[j].getName());
const ov = C[name].overload.apply(C[name], sig);
ov.implementation = function () {
try { log('APP-NET', { cls: cls, m: name, args: sa(arguments), stack: stack(5) }); }
catch (e) { log('PROBE-ERR', { at: 'app-net', msg: '' + e }); }
return ov.apply(this, arguments);
};
} catch (e) { log('HOOK-SKIP', { cls: cls, m: ms[i].getName(), msg: '' + e }); }
}
log('HOOK-OK', { layer: 'app-net', cls: cls, n: ms.length });
} catch (e) { log('HOOK-FAIL', { layer: 'app-net', cls: cls, msg: '' + e }); }
}
// 2) OkHttp end to end — internal class names moved between major versions, so try every location
function hookOkHttp() {
const targets = [
['okhttp3.OkHttpClient', 'newCall'],
['okhttp3.Request$Builder', 'build'],
['okhttp3.RealCall', 'execute'],
['okhttp3.RealCall$AsyncCall', 'run'],
['okhttp3.internal.http.RealInterceptorChain', 'proceed'], // okhttp 3.x
['okhttp3.internal.connection.RealInterceptorChain', 'proceed'] // okhttp 4.x / 5.x
];
for (let i = 0; i < targets.length; i++) {
const cn = targets[i][0], mn = targets[i][1];
try {
const ovs = Java.use(cn)[mn].overloads; // every overload of that name; use .overload('<sig>') if this is undefined
for (let j = 0; j < ovs.length; j++) {
const ov = ovs[j]; // block-scoped: every hook keeps its own overload
ov.implementation = function () {
try { log('OKHTTP', { cls: cn, m: mn, args: sa(arguments), stack: stack(6) }); }
catch (e) { log('PROBE-ERR', { at: cn, msg: '' + e }); }
return ov.apply(this, arguments);
};
}
log('HOOK-OK', { layer: 'okhttp', cls: cn, m: mn, overloads: ovs.length });
} catch (e) { log('HOOK-SKIP', { layer: 'okhttp', cls: cn, m: mn, msg: '' + e }); }
}
}
// 3) java.net.URL — login/register frequently bypasses OkHttp entirely
function hookRawUrl() {
try {
const U = Java.use('java.net.URL');
const o0 = U.openConnection.overload();
o0.implementation = function () {
try { log('RAW-URL', { url: '' + this.toString(), stack: stack(6) }); } catch (e) {}
return o0.apply(this, arguments);
};
try {
const o1 = U.openConnection.overload('java.net.Proxy');
o1.implementation = function () {
try { log('RAW-URL', { url: '' + this.toString(), proxy: true, stack: stack(6) }); } catch (e) {}
return o1.apply(this, arguments);
};
} catch (e) {}
log('HOOK-OK', { layer: 'url' });
} catch (e) { log('HOOK-FAIL', { layer: 'url', msg: '' + e }); }
}
// 4) Throwable.getMessage — the original text of an exception an upper layer caught and swallowed
function hookThrowable() {
try {
const T = Java.use('java.lang.Throwable');
const seen = {};
let n = 0;
T.getMessage.implementation = function () {
const msg = this.getMessage(); // self-call inside the replacement is fine (Frida guards it)
try {
if (msg !== null && msg !== undefined && n < MAX_THROW) {
const cls = '' + this.getClass().getName();
const interesting = cls.indexOf('Exception') >= 0 || cls.indexOf('Error') >= 0 || INTERESTING.test('' + msg);
const key = cls + '|' + msg;
if (interesting && seen[key] === undefined) { // dedup: this method is on a very hot path
seen[key] = 1; n++;
log('THROW', { cls: cls, msg: '' + msg, stack: stack(4) });
}
}
} catch (e) {}
return msg;
};
log('HOOK-OK', { layer: 'throwable' });
} catch (e) { log('HOOK-FAIL', { layer: 'throwable', msg: '' + e }); }
}
Java.perform(function () {
hookAppNet(APP_NET_CLASS);
hookOkHttp();
hookRawUrl();
hookThrowable();
log('PROBE-READY', { pkg: APP_PKG, logfile: LOGFILE }); // the host waits for this before resuming
});
```
Read the log after driving the UI. `RAW-URL` (with no `OKHTTP` events) is how you learn the request went over `HttpsURLConnection` — a different trust path entirely (`references/tls-and-cert.md` §If OkHttp is the failing path). `THROW` is how you learn an upper layer swallowed the real error.
```bash
# pull the record (app-private path needs root to read)
adb -s <serial> shell "su -c 'cat /data/user/0/<app.package>/files/probe.log'" > probe.log
```
## Hook never fires — debug in this order
`HOOK-SKIP` / `HOOK-FAIL` lines answer this before you start guessing. When every layer reports `HOOK-OK` and still no event arrives:
1. **Does the class / method / overload actually exist?** A misspelled obfuscated name, a renamed okhttp internal class, or an overload taking `Object` instead of `String` all produce a wrapper that is simply never invoked.
2. **Is the ClassLoader the right one?** Multi-dex, plugin-loaded and packed apps can hold several loaders; `Java.use` uses the app loader by default and throws `ClassNotFoundException` for a class that is plainly in the APK. Enumerate and switch:
```javascript
Java.enumerateClassLoaders({
onMatch: function (l) { try { if (l.findClass('<app.net.Class>')) Java.classFactory.loader = l; } catch (e) {} },
onComplete: function () {}
});
```
3. **Is that code path reached at all?** Layer 4 settles it: if `THROW` events show the app failing earlier, your hook's call site is never executed and no amount of hooking will help.
4. **Did your UI action trigger business logic?** A tap that looks fine but fails a local form check returns before any network call. Verify the input actually reached the field (`references/environment.md` §Driving the UI from adb), not just that the button animated.
## `Java.choose` also matches dead instances
`Java.choose('com.example.app.MainActivity', ...)` returns **every** instance the VM still tracks, including finished and destroyed ones. Walking their view tree then yields an empty list — that is normal, not a bug in your script. Filter first, and prefer a direct reflective call over simulating a tap:
```javascript
Java.choose('com.example.app.MainActivity', {
onMatch: function (a) {
try {
if (a.isFinishing() || a.isDestroyed()) return; // dead instance: skip it
send({ tag: 'live', view: '' + a.findViewById(<viewId>) });
} catch (e) {}
},
onComplete: function () {}
});
```
## Reading obfuscated code at runtime
When names are meaningless (`a7`, `x6`, `uf0`), do not patch from names. Instead:
1. Find a **unique anchor**: an API path, a preference key, or an SDK class name from the string table (`scripts/dex_strings.py`).
2. Locate the small class/method that references it (byte search in the dex + a targeted disassembly).
3. Hook that anchor, capture the **stack**, and read off the real chain.
4. Hook the classes the stack reveals — that is where semantics become visible.
## Anti-instrumentation
Signs: the app exits or freezes shortly after attach; `logcat` shows a generic process death with no Java stack; strings like `frida`, `xposed`, `magisk`, `substrate` are referenced by **app code** (not just by an SDK's string list).
**Rule this out first — it is not always a detector.** A process that dies right after you
attach is frequently the ROM rather than the app: on aggressive OEM ROMs, merely backgrounding
the app (pressing HOME, switching away) triggers a freeze, and the logcat signature is a state
transition such as `state: R -> F` while hooks stop with no Java stack. That looks identical to
a detector killing you. Check `references/environment.md` for that signature, and keep the app
in the foreground, before building an anti-anti plan.
Countermeasures, in order of least disruption:
1. Spawn + late resume, so hooks are installed before the check runs.
2. Rename `frida-server` and move it out of obvious paths.
3. Hook the detector itself and neuter it — find it by hooking the suspicious API (e.g. `/proc/self/maps` reads, `File.exists`, `Runtime.exec`) and inspecting the stack.
4. Only then consider a gadget-based approach.
**Remember:** most `frida`/`root` strings in a decompiled APK belong to third-party SDKs' own detection lists, not to the app. Verify that **app code** references them before doing anti-anti work.
### When the ROM hunts your instrumentation
Some vendor ROMs treat a device-side `frida-server` as hostile and kill it on a timer. The
symptom is not an error message — it is a working session that disappears. A later
`attach`/`spawn` fails with `unable to connect to remote frida-server`, or with
`TransportError: connection closed` **during `spawn`, before `resume`**. That second shape is
easy to misread as the target crashing during startup. It is not the target.
Measured on one vendor build: the server was killed repeatedly across a work session, and on
one occasion the whole device restarted, briefly taking `system_server` with it
(`Can't find service: activity`, `No service published for: input`). Both symptoms are
environment, not evidence about the app.
- **Check the server is alive immediately before every experiment**, not once per session:
`adb shell "su -c 'pidof <server-name>'"`. Restart it if it is gone.
- **Distinguish the two failure shapes.** `connection closed` at `spawn` = server died or was
killed (environment). `connection closed` just after `resume` = the target exited — *that*
one is about the app.
- **Rename the server binary and move it off the obvious path.** It defeats simple filename
sweeps. It does not hide from a target that lists the directory, so do not treat the rename
as a fix for detection — only as protection from cleanup.
- **Budget a restart, not a re-analysis.** A killed server costs seconds; a round spent
re-deriving the previous conclusion costs far more.
- **If a device reboot interrupts the run, re-establish state before measuring**: server up,
`adb forward` re-created, screen awake and unlocked, installed-build hash re-read. A
measurement taken across a reboot is a measurement of two states
(`long-task-discipline.md` §keep the observation window clean).
### When live attach cannot work at all
Sometimes nothing short of "do not inject at runtime" is viable — the check runs
before your hooks can, it watches its own process, or the environment forbids
`ptrace`. Do not keep escalating on the same axis. There are non-live ways to get
runtime-grade information, and they are often enough to finish the job:
| Alternative | What it gives you | Cost |
|---|---|---|
| **Static instrumentation** — load a gadget/small agent by modifying the APK (e.g. an injected `Application` wrapper, a gadget `.so` added to `lib/<abi>/` and wired from the manifest) | Hooks run from process start with no external attach, no `ptrace`, no server process on the device to be found | Must repack and re-sign; the build you observe is no longer byte-identical to the original, so **it cannot serve as the unmodified control** (`verification.md` §the control build rule) |
| **Static read of the answer** — the check's inputs are usually visible: the field it reads, the string it compares, the response field it trusts | Often enough to plan a patch without ever observing | You do not learn the live values; combine with `runtime-data.md` for local state |
| **Self-recorded evidence** — the app's own cached responses, logs, or on-disk state | Real runtime data with no injection at all (a HTTP cache, a pref, a JSON snapshot left behind by a normal launch) | Only what the app happened to persist; needs one clean launch and then a filesystem read |
The last one is worth remembering because it is the cheapest and it is easy to
forget: a normally-launched app often leaves the exact runtime data you wanted —
server responses in its HTTP cache, config in a preferences file, feature state in
a snapshot. Read those **before** concluding that dynamic analysis is blocked.
**Two cautions that apply to every alternative above:**
- **Changed bytes change behaviour.** Any injected build is a different artifact,
and on a target that fingerprints its own layout that difference is the finding,
not the app's real behaviour. Always keep the unmodified run as the control.
- **Evidence from a different environment is a different claim.** A result obtained
with the app patched-for-injection, or on a device with different privilege state,
supports "verified under condition X". Say so; do not present it as the app's
unmodified behaviour (`long-task-discipline.md` §the most expensive drift).
## What to capture for the record
- spawn time, hook-ready time, resume time
- every hook hit with a stack (bounded — cap output)
- DNS hosts in order, with timestamps
- the exact build under test (hash) and the control build's results

View File

@@ -0,0 +1,340 @@
# Emulation and RPC — running the target's code on your terms
There is a class of target where neither reading nor patching is the bottleneck: the code you need
is *inside* a hardened `.so`, statically unreadable (OLLVM, string encryption, VMP) or only
decrypted in memory, and what you actually want is its **output** — the signature it computes, the
blob it decrypts, the token it mints — not its source. Two routes give you that without winning the
unpacking war first:
- **Unidbg** — emulate the `.so` on your PC, feed it inputs, read outputs. Offline, batch, no
device, no detector. You pay in *environment*: everything the library touches must be faked.
- **Frida RPC** — let the real process run its own hardened code, and call it from Python through a
resident session. High fidelity (it is the real environment), no faking required. You pay in
*stability*: the process must tolerate injection, and the session must survive.
This file is about choosing between them and driving both. The unpacking itself is
`references/packers.md` and `references/advanced-unpacking.md`; the hooking mechanics live in
`references/dynamic-frida.md`. What is new here is treating the target's own crypto as a **callable
function** instead of a thing to understand.
**Load this when:** you need the *output* of a routine rather than a change to the app -- a signature, a token, a cipher. It gives emulation with its environment-filling cost, against service-ifying the live function over Frida RPC.
## When emulation or RPC is the right move
Reach for this file when any of these holds:
- The algorithm you need lives in a hardened `.so` and reverse-engineering it (weeks against OLLVM
or VMP) costs more than *calling* it (hours of environment work).
- You need **many** calls — fuzzing an input format, brute-forcing a key schedule, generating
signed request batches — where hand-driving a device per call is hopeless.
- The function's output depends on environment state (files, system properties, other libraries)
that a static read cannot evaluate.
- The gate you care about is a *computed value* (sign header, encrypted body) rather than a branch
— `references/server-api.md` told you the server checks it, and the client computes it in native
code (`references/signature-derived-keys.md`).
Do **not** reach for it when a two-byte dex patch or an LSPosed module solves the problem —
`references/dex-patching.md` and `references/lsposed-and-modules.md` are cheaper and more durable.
Emulation and RPC are for when the code that matters must keep *running*.
## The decision table
| | Unidbg (offline emulation) | Frida RPC (live process) |
|---|---|---|
| Environment fidelity | fake — you build it | real — the device provides it |
| Works without a device | yes | no (device or emulator required) |
| Works against anti-injection | **yes** — the code never knows | **no** — injection is the entry ticket |
| Batch throughput | thousands of calls/min | tens of calls/sec, process-bound |
| Setup cost | Java + maven + per-library env work | frida already installed, minutes |
| Maintenance cost | every missing JNI/syscall is a new stub | reconnect logic, process lifecycle |
| Reproducibility | deterministic (you control time/random) | real time, real randomness |
| Best for | signing algorithms, crypto, parsers | anything entangled with live app state |
| **Priority when either could work** | second — the environment bill below is real and routinely underestimated | **first** — the device already owns the environment |
The two-strike rule applies across the table: if a library refuses to run under unidbg after two
focused rounds of `DalvikVM` patching, stop emulating and move to RPC (or the reverse) instead of a
third round — a library that checks its own loading path or decrypts itself against device state
may simply not be worth emulating, and that is a finding, not a failure (`SKILL.md`
§Stop conditions).
### The environment bill — budget days, not hours
"Filling the environment" reads like a checklist and behaves like a project. The mistake this
section prevents is starting an emulation because the table says *works against anti-injection*, and
then discovering that this library's environment includes everything emulation cannot fake: an
Android `Context` backed by a real package manager, Binder round-trips into another process, a
`KeyStore` attestation that only succeeds on real hardware, or a self-check against device state
captured at install time. For a commercial native algorithm of that shape, "stub it until it runs"
is measured in **days, not hours** — each missing piece is discovered one fault at a time, and the
rounds do not get shorter.
Weigh it before starting:
| Question | If yes |
|---|---|
| Does it call through `Context` (package name, files dir, signature, `PackageManager`)? | a stub is often enough — cheap, keep going |
| Does it talk to another process (Binder service, bound SDK, remote provider)? | the stub surface grows fast; **prefer RPC** |
| Does it verify hardware (KeyStore, TEE, an attestation chain)? | emulation is likely a dead end — the value it wants cannot be produced here |
| Does it check its own loading environment (paths, maps, root, debugger, installer)? | readable and patchable *inside* the emulator, but budget one round per check |
| Must it be called thousands of times a minute? | the RPC throughput ceiling is real — this is the case that justifies the bill |
Default to **RPC first, emulation second**: the device already provides the environment, and
per-call cost only starts to matter once you need volume. The two claims in this subsection
(the day-scale cost of a Context/Binder/KeyStore-shaped library, and the RPC throughput ceiling)
are **inferred** from the mechanism and from community practice — this repository has not emulated a
commercial sample end to end, as `references/evidence-summary.md` §The capability matrix states.
## Part A — Unidbg
### What it is
Unidbg ([zhkl0228/unidbg](https://github.com/zhkl0228/unidbg), Java, Apache-2.0) emulates an
Android process on your PC:
- **CPU** — a `backend` layer over Unicorn (default) or Dynarmic/Hypervisor, executing the real
arm/arm64 code of your `.so`.
- **Memory & linker** — it maps the ELF, resolves `DT_NEEDED` dependencies from a bundled Android
system-library set (`AndroidResolver(apiLevel)`), and runs relocations.
- **JNI environment** — a Dalvik VM emulation (`emulator.createDalvikVM(apk)`) that answers the
library's `FindClass`/`GetMethodInfo`/`CallObjectMethod` calls against either real classes from
the APK's dex or **stubs you write in Java**.
- **Syscalls** — file IO, `mmap`, `pthread` primitives, system properties, `/proc` reads are
emulated or redirected.
You write a Java main that wires those together, calls `JNI_OnLoad` (many hardened libraries do
their initialization there), then calls the target function with your inputs. The library's real
arm code executes; its output comes back as a Java value.
**This repository does not bundle unidbg** — it is a Java/maven project, not a script. To use it:
```bash
git clone --depth 1 https://github.com/zhkl0228/unidbg.git
cd unidbg
# Windows + JDK 17 (measured): the shipped pom pins -source/-target 8, and on JDK 9+
# the compiler resolves against the current API where `Module` is ambiguous
# (java.lang.Module vs com.github.unidbg.Module) — unidbg-api fails to compile.
# <release>8</release> selects the JDK 8 API and fixes it; the bundled Maven wrapper
# is 3.5.4, which rejects maven-compiler-plugin 3.13, so 3.8.1 is the version that
# both supports <release> and runs on that wrapper.
set JAVA_HOME=C:\Program Files\Java\jdk-17.0.4.1
mvnw.cmd -B -pl unidbg-android -am -DskipTests compile # BUILD SUCCESS, ~48 s
# tests are skipped by <maven.test.skip>true</maven.test.skip> in the pom — override it:
mvnw.cmd -B -pl unidbg-android -am -Dmaven.test.skip=false \
-Dtest=MemoryTrackerTest -DfailIfNoTests=false test # Tests run: 3, Failures: 0
# first run downloads maven itself plus dependencies — minutes
```
Then pick the demo closest to your target under `unidbg-android/src/test/java` and adapt it; the
bundled demos (`TTEncrypt`, `SignUtil`, `QDReaderJni`, …) each need the `.so`/APK they reference
placed where the test expects it.
A library project that already uses maven/gradle can instead depend on the published artifacts
(group `com.github.zhkl0228`, artifacts `unidbg-android` + `unidbg-api`, on Maven Central) — the
clone-above route avoids version drift and gives you the test-suite demos to copy from.
### The minimal call template
Adapt this to your target — it is the shape every unidbg driver has (measured against the
0.9.x API; class names move rarely but do move, check a current test under
`unidbg-android/src/test/java` when something fails to resolve):
```java
import com.github.unidbg.AndroidEmulator;
import com.github.unidbg.LibraryLoader;
import com.github.unidbg.linux.android.AndroidEmulatorBuilder;
import com.github.unidbg.linux.android.AndroidResolver;
import com.github.unidbg.linux.android.dvm.*;
import com.github.unidbg.memory.Memory;
import java.io.File;
public class CallTarget {
public static void main(String[] args) {
// 64-bit or 32-bit must match the .so, not the PC.
AndroidEmulator emulator = AndroidEmulatorBuilder.for64Bit()
.setProcessName("com.example.app") // some libraries check this
.build();
Memory memory = emulator.getMemory();
memory.setLibraryResolver(new AndroidResolver(23)); // bundled system .so set
// The APK gives the VM real classes for JNI calls; null is allowed if
// you intend to stub everything yourself.
VM vm = emulator.createDalvikVM(new File("target.apk"));
vm.setVerbose(true);
vm.setJni(new AbstractJni() { // your JNI callbacks — see below
// override methods as the target demands them
});
DalvikModule dm = vm.loadLibrary(new File("libtarget.so"), false);
dm.callJNI_OnLoad(emulator); // init: decrypts, registers natives
// Calling a *statically exported* symbol:
// DvmObject<?> ret = dm.callFunction(...)
// Calling a *JNI-registered* method by its Java name (most common for hardened SDKs):
DvmClass<?> c = vm.resolveClass("com/example/NativeApi");
DvmObject<?> result = c.callStaticJniMethodObject(emulator,
"sign(Ljava/lang/String;)Ljava/lang/String;", "input");
System.out.println(result.getValue());
}
}
```
### Filling the environment — "supply what is missing"
A hardened library does not just compute; it *checks*. Every unidbg session converges on the same
loop: run, read the first unsupported callback or mapping fault, implement exactly that, repeat.
In order of frequency:
| The library wants | You supply |
|---|---|
| `JNI_OnLoad` → `FindClass`/`GetMethodID` on app classes | `AbstractJni` overrides (`callStaticObjectMethodV`, `getStaticObjectField`, …) returning canned values; or real classes from the APK via `createDalvikVM(apk)` |
| `Context` methods (`getPackageName`, `getFilesDir`, `getPackageManager` → signature!) | stubs — `vm.resolveClass("android/content/Context")` + your `AbstractJni` answers; a signature check wants the *original* APK's signature bytes, which `createDalvikVM(apk)` serves from that very file |
| file reads (`/proc/self/maps`, `/data/data/...`, config files) | `emulator.getSyscallHandler().addIOResolver(...)` redirecting to host files, or `virtualFileSystem` mounts; a `/proc/self/maps` check that looks for frida/xposed is satisfied by a clean canned maps file |
| other `.so` (cocos, tls, crypto) | `vm.loadLibrary` them first, in dependency order, or drop them beside the target so the resolver finds them |
| time / randomness | `emulator.getSyscallHandler` clock override; unidbg's deterministic RNG unless the target seeds from `/dev/urandom` (redirect it) |
Two structural rules that save hours:
- **Load order is init order.** `JNI_OnLoad` of the shell often registers natives and decrypts;
calling a function before the init that populates it yields a null deref that looks like a bug
in unidbg. Always `callJNI_OnLoad` first, and if the library has an explicit init/exported
setup entry, call that next.
- **32 vs 64 must match the `.so`,** not your PC and not the device's preference
(`references/native-and-so.md` §Cross-architecture applies here too: `for32Bit()`/`for64Bit()`
mirror the ABI you extracted from `lib/`).
### Failure modes you will meet
| Symptom | Meaning | Move |
|---|---|---|
| `unsupported syscall` / `signal 11 (SIGSEGV)` at a stable address | the library touched something unidbg does not fake yet | read the unidbg log line above the fault — it names the syscall/JNI call; stub it |
| `JNI_OnLoad` returns non-zero or throws | self-check failed inside init | enable `vm.setVerbose(true)`, read which check it ran (often signature, path, or a system property), satisfy it via stub |
| works, but output differs from device | environment input differs (time, random, files, another lib's state) | diff the inputs: redirect and pin them; on-device RPC (Part B) is the ground truth to compare against |
| crashes deep in the library, no unsupported call logged | the code path is genuinely wrong (wrong calling convention, wrong args) — or the library decrypted itself against device state it cannot have here | recheck the prototype (arg types/order) against the dex `native` declaration; then consider RPC instead |
| library refuses to load: dependency not found | a `DT_NEEDED` `.so` you did not provide | `readelf -d` the target, load missing libs first |
Know when to stop: a library that validates its own decrypt key against server data, or that
refuses any environment it did not boot in, is **not worth emulating** — that is exactly the shape
where the decision table sends you to Part B. (Which anti-emulation checks exist and how common
each is: inferred from community experience, not measured here.)
### Measured here
See `references/evidence-summary.md` §The capability matrix for the exact commands and outputs behind
every label in this section. Measured in that pass: the repository builds on Windows/JDK 17 once
the compiler plugin is pointed at the JDK 8 API, the emulator boots, and one bundled test suite
passes on the Dynarmic backend. **No target `.so` was emulated yet** — every per-library claim above
(which JNI callbacks a hardened library asks for, which syscalls it hits, how many rounds of stubbing
it costs) is **inferred** from the library's shape and from community practice, not from a run
against this sample.
## Part B — Frida RPC
### The mechanism
A Frida script can export functions to its host: assign to `rpc.exports`, and every property
becomes callable from Python (`script.exports_sync.name(...)` after frida 16) with JSON-serializable
arguments and return values, synchronously, from outside the process. That is the whole trick:
```javascript
rpc.exports = {
add: function (a, b) { return a + b; },
};
```
```python
script = session.create_script(code); script.load()
print(script.exports_sync.add(40, 2)) # -> 42
```
Everything else is packaging: keeping the session alive, finding the function address, converting
types across the NativeFunction boundary. The pattern for the case that matters — a hardened
library's signing function, exported or `RegisterNatives`-bound:
```javascript
// 1) exported symbol:
const addr = Module.findExportByName('libtarget.so', 'Java_..._sign');
// 2) dynamically registered (no export — the usual shape for hardened SDKs):
// hook RegisterNatives once at startup, note the fnPtr for the name you care about
const sign = new NativeFunction(addr, 'pointer', ['pointer', 'pointer']);
rpc.exports = { sign: function (s) { return sign(env, jstring(s)); } };
```
Java-side functions are equally callable (`Java.perform` wraps the call), which is how the
harmless connectivity probe — `getPackageName` over RPC — works in the template below.
### The kit's tooling
`scripts/frida_rpc_serve.py` + `scripts/rpc_template.js` implement the full loop:
- three front ends: one-shot `--mode call`, interactive `--mode repl`, and a resident
`--mode http` endpoint (`POST /call {"export": "...", "args": [...]}`, `GET /exports`,
`POST /reload`);
- spawn or attach (by name or by pid — attach-by-name fails on incomplete process enumeration,
`references/dynamic-frida.md` §Attach by PID);
- automatic reconnect: a detached session (app restart, killed server, ROM reaping the server)
re-attaches, reloads the script and retries the call, with per-request retry cycles and
exponential backoff; a killed-and-restarted device server is recovered by dropping the cached
remote device before reconnecting;
- the template exports `add` (transport sanity), `getpackagename` (Java probe),
`callnative(module, export, retType, argTypes, args)` and `callnativeaddr(module, offset, ...)`
for wrapping any native symbol, with a frida-16/17-compatible export resolver.
Typical session (device server already running and forwarded, `references/dynamic-frida.md`
§Setup):
```bash
adb -s <serial> forward tcp:27043 tcp:27043
python scripts/frida_rpc_serve.py --remote 127.0.0.1:27043 --package com.example.app \
--script scripts/rpc_template.js --mode repl
rpc> list
rpc> getpackagename
rpc> callnative ["libtarget.so","Java_com_example_Native_sign","pointer",["pointer","pointer"],[...]]
```
Then feed the recovered function into request generation the way `references/server-api.md`
describes — the RPC service turns "the server checks a signed header" into "I can compute that
header on demand", which is the whole reason this route exists.
### What the live route costs — measured failure modes
Every row below was hit against a real device during the verification pass behind this file
(exact outputs in `references/evidence-summary.md` §The capability matrix):
| Failure | What it looks like | Handling |
|---|---|---|
| **Hardened target kills injection** | attach: `process either refused to load frida-agent, or terminated during injection`, logcat `CRASH ... IterateRegisters found fp`; spawn: script loads, then every call dies with `script has been destroyed` | the shell's anti-instrumentation won; do not retry the same attach — switch route (unidbg for the crypto, or `references/lsposed-and-modules.md` / static patching), or fight the detector deliberately (`references/detection-and-anti-analysis.md`) |
| **ROM reaps the server** | `connection closed` mid-session, sometimes repeatedly | budget restarts, not re-analysis: the bridge reconnects once the server is back; check `pidof <server>` before every experiment block (`references/dynamic-frida.md`) |
| **Attach by name fails for processes the device can see** | `unable to find process with name 'com.example.app'` while `frida-ps -Uai` and `ps -A` both list it alive; reproduced for a system app and for a Magisk-manager process, for every package name tried | resolve the pid yourself and attach with `--pid`: measured on the same ROM, `device.attach(pid)` loaded the script and returned RPC results for the very processes whose *name* lookup failed (`--pid 19938` → `com.android.settings`, `--pid 21493` → `com.android.email`). Take the pid from `frida-ps -U`, `ps -A`, or `adb shell pidof <pkg>` |
| **The server comes back on a non-default port** | `frida.ServerNotRunningError: unable to connect to remote frida-server: closed` on attach even though `frida-ps -U` still works | the ROM or a watchdog reaped the visible server and a disguised copy returned elsewhere (measured: only `*.svc16` left alive, listening on 27043, nothing on 27042). Check the listening port, `adb forward tcp:27043 tcp:27043`, then `--remote 127.0.0.1:27043` — do not assume the default port |
| **Attach by pid times out on a busy process** | `frida.TimedOutError: unexpectedly timed out while waiting for signal from process with PID X` during attach (raised by an ordinary third-party app); it is not a `TransportError` subclass and escaped every handler | nothing is wrong with the tooling — the process did not answer the agent handshake; use another process for the connectivity probe, or retry once the app settles (the bridge now treats `TimedOutError` as retryable and fails with one line instead of a traceback) |
| **Short-lived processes** | the settings main activity process exits seconds after `am start` | pick a persistent process for connectivity tests; the business target only when it actually stays up |
The strategic point those rows make together: **on a hardened target, RPC is not guaranteed to be
available at all** — the injection ticket can be revoked by the target itself. That is the single
strongest argument for keeping the unidbg route warm, despite its environment cost: it is the one
route that works when the process refuses to be instrumented.
### Combining the routes
The routes compose rather than compete:
1. **RPC as oracle, unidbg as student.** When an emulated function's output must be trusted, diff
it against the live process's output for identical inputs; disagreement localizes the missing
environment piece far faster than reading the library (`Part A` §failure modes, "output
differs").
2. **Dump via RPC, emulate after.** A live call that returns the decrypted key/table the library
uses (`references/runtime-data.md`) can be replayed into the emulated run as a fixed stub.
3. **Batch offload.** Use the device session to discover the calling convention and to capture
sample inputs/outputs (few calls), then run the bulk of the work offline under unidbg.
## What to record
- which route was chosen and why (one line, against the decision table)
- for unidbg: the API level resolver, the list of JNI stubs you had to write, what the library
checked before it would compute (that list *is* the target's defense inventory)
- for RPC: server version vs host version, attach vs spawn, reconnect behaviour observed, and —
decisive for a hardened target — whether injection survived at all
- inputs/outputs for at least one call verified against an independent source (device-side log,
server response, or a known-answer test)

View File

@@ -0,0 +1,306 @@
# Environment — device, emulator, tooling, networking
**Load this when:** before the first experiment on a device, and again whenever a failure surprises you. It gives device/emulator selection, root, ADB and networking, the preflight check, and how to look at the screen instead of driving blind.
## Pick your target
| Option | Pros | Cons |
|---|---|---|
| **Rooted physical device, native ABI** | Truest behavior; native libs load natively; Frida works well | One at a time; USB flakiness |
| **Emulator with root** | Disposable; snapshots; easy reset | Different ABI; some apps detect it or refuse to run; native ARM libs may need translation |
| **Static only** | No device needed | Cannot verify anything. Never claim success from static analysis alone. |
**Rule:** the artifact must be verified where the user will run it. An emulator that runs the app is **not** evidence about a physical ARM device, and vice versa.
**Important:** if the app ships only `arm64-v8a` native libraries and your emulator is x86_64, it may still run via ARM translation — but translation changes timing, and some native checks misbehave. Prefer a real ARM device for the final verification pass.
## ADB basics worth pinning down
```bash
adb devices -l
adb -s <serial> shell getprop ro.product.cpu.abi
adb -s <serial> shell getprop ro.build.version.release
adb -s <serial> shell getprop ro.build.version.sdk
```
Root:
```bash
adb -s <serial> shell "su -c id"
```
`pm grant` and `pm install` frequently require root on OEM builds; the `shell` user gets `SecurityException`.
**Always specify `-s <serial>` when more than one device is attached** — otherwise adb errors out or picks the wrong one.
## Shell quoting (a real time sink)
The host shell expands `$`, `|`, `>`, and quotes **before** adb sees them. Windows PowerShell additionally mangles `$var:`, `[^"...`, and `$(...)`.
Symptom: "Could not find a part of the path", "Missing type name after '['", "no closing quote".
**Fix:** never inline device commands in the host shell. Use a small helper that calls adb from a script file and passes the command as a single argument:
```python
subprocess.run([ADB, '-s', SERIAL, 'shell', 'su -c "%s"' % cmd])
```
See `scripts/devsh.py`.
Same class of bug: `javac` reads sources using the platform default encoding. Pass `-encoding UTF-8` or non-ASCII comments break the build.
## Giving an offline device network over USB
Use when the device has no usable network (broken Wi-Fi, no SIM, restricted network) but your host does. **`adb reverse` runs on the device's loopback, so it needs no device-side network interface at all.**
```bash
# 1) host HTTP/HTTPS proxy (CONNECT-capable)
python scripts/usb_net_proxy.py 8080 proxy.log
# 2) forward device-localhost:8080 to host:8080
adb -s <serial> reverse tcp:8080 tcp:8080
# 3) point the device at it
adb -s <serial> shell "su -c 'settings put global http_proxy 127.0.0.1:8080'"
```
Notes:
- `adb reverse` **does not survive a device reboot** — recreate it after any restart.
- Keep the host proxy process alive; if it dies, the device goes offline again.
- Some SDKs bypass the system proxy entirely (many ad SDKs do). Proxy logs therefore **undercount** traffic — do not conclude "no ad traffic" from proxy logs alone; confirm with a runtime DNS hook.
- Clean up when done: `settings put global http_proxy :0`.
## Preflight — run this before every experiment block
`scripts/preflight.py` checks, read-only, everything that silently fakes a failure: device
reachability, whether more than one device is attached without an explicit serial, root, the
runtime translation layer, device/host clock drift, a leftover device-wide proxy setting, stale
port forwards, a dead device server, the ABI the package manager actually chose, and the free
space left for installs.
```bash
python scripts/preflight.py --pkg <app.package> --expect-root
python scripts/preflight.py --cleanup # also clears a leftover proxy and stale forwards
```
**The rule it exists to enforce:** *do not attribute a failure to your patch while preflight is
dirty.* A surprising fraction of "my change broke it" is a device that was already in a bad state.
Costing thirty seconds here is cheaper than costing several rounds to a wrong conclusion, and the
wrong conclusion is the one that gets written down and trusted later.
## Emulator notes
Emulators are the right place to iterate and the wrong place to conclude.
- **Verify the app can install at all.** INSTALL failures on emulators are common (ABI, min SDK,
vendor checks, a stale copy with a different signature) and are usually **not** related to your
patch. Get a clean install of the *unmodified* build working before you change anything.
- **Snapshot/rollback is the main advantage** — use it to A/B two builds quickly, and to get back
to a known-good state after a destructive experiment.
- **Never quote emulator behavior as proof for a device-only question** (and vice versa). An
emulator that runs the app is not evidence about a physical ARM device.
- **Most vendors ship a console binary that is far more reliable than the GUI.** Learn yours early;
it is what you will need when the GUI is unresponsive and adb is already down.
| Vendor | Typical console binary | Useful verbs |
|---|---|---|
| LDPlayer | `dnconsole.exe` (next to the player exe) | `list2`, `launch --index N`, `reboot --index N`, `quitall` |
| MuMu | `MuMuManager.exe` | `info -v all`, `control -v N launch`, `control -v N shutdown` |
| AVD / Android Emulator | `emulator.exe` | `-list-avds`, `@<avd>`, `-no-window` |
| Genymotion | `gmtool` | `admin list`, `admin start` |
- **Recovery order when `adb devices` goes empty.** Do these in order; the first two are the ones
people skip:
1. `adb kill-server && adb start-server` — clears a wedged host daemon.
2. Re-run `adb devices`. If still empty, check whether the **emulator process is actually alive**
(`tasklist` / `ps`). A running process with **no listening port** means the VM never finished
booting its adb bridge — restarting the *device* is the only fix.
3. Restart the instance through the vendor console (`reboot --index N`). Restarting via the
player binary's "launch" verb frequently leaves you with a process and still no adb.
4. Only then consider that something is wrong with the host.
Symptom worth memorising: **the emulator process exists but nothing is listening on the adb
port.** Distinguishing "not running" from "running but not booted" is what makes step 2 worth
doing — they have different fixes and only one of them needs the GUI.
- **Root is a per-vendor setting, not a given.** Most emulators expose it as a toggle in their
settings ("root permission" / "ROOT 权限"); some ship a separate rooted image. Always verify
rather than assume:
```bash
adb -s <serial> shell "su -c id" # want: uid=0(root)
```
A rooted emulator is usually the fastest environment for everything in this skill, which makes it
easy to forget that it is also the *least representative* one.
- **Running two instances.** Different serials make two emulators genuinely disjoint, which is
useful for holding a clean control (original build, untouched state) alongside your workbench.
Keep them labelled, always pass `--serial`, and **do not run the same experiment on both** — the
value of a second device is independence, and duplicating work destroys it. Operate one at a
time; the other is a reference point, not extra throughput.
- **Apps can detect the emulator** and change behaviour or refuse to run. If the app behaves
differently here than on hardware, that is a finding about the emulator, not about the app. Note
it and move the question to a real device.
## Which architecture is actually executing
Device selection and architecture are the same question. `getprop ro.product.cpu.abi` reports what
the device claims; it does **not** report what is executing. Read `native-and-so.md` §Cross-architecture
before you choose which library to patch, and `scripts/lib_map.py` to see the live truth.
## Install / reinstall
```bash
adb -s <serial> shell "su -c 'pm uninstall <pkg>'"
adb push out.apk /data/local/tmp/x.apk
adb -s <serial> shell "su -c 'pm install -r -t -d /data/local/tmp/x.apk'"
```
- `-r` reinstall (keep data, same signing key) · `-t` allow test-only · `-d` allow downgrade
- Different signing key than the installed app → must uninstall first (data is lost)
- Vendor installers may reject `adb install`; pushing + `su -c pm install` usually works
**After reinstalling:** if you restored a data directory, fix ownership, or the app crashes in a DB-init path:
```bash
su -c "chown -R <uid>:<uid> /data/user/0/<pkg>"
su -c "restorecon -R /data/user/0/<pkg>"
```
`<uid>` from `dumpsys package <pkg> | grep userId=`.
## Driving the UI from adb
Automated UI interaction is where verification loops usually break, and most of the breakage looks like a bug in your script. Most of it is not.
### `input tap` may simply not work on a given control
On some OEM ROMs `input tap <x> <y>` is silently ignored for certain controls while working fine for others at neighboring coordinates. **Do not conclude that your automation is wrong.** Before doubting the script:
```bash
adb -s <serial> shell "su -c 'uiautomator dump /sdcard/ui.xml'"
adb -s <serial> pull /sdcard/ui.xml
# bounds="[x1,y1][x2,y2]" -> tap the center: ((x1+x2)/2, (y1+y2)/2)
adb -s <serial> shell "su -c 'input tap <cx> <cy>'"
```
If the control still does not react, bypass the UI path entirely — `am start -n <app.package>/<activity>`, or invoke the logic from Frida. A finished task needs the *code path*, not the tap.
`input` requires `INJECT_EVENTS`, which the plain `shell` user does not have. An unprivileged `input` **fails silently** — no error, no effect. Always wrap it: `su -c 'input tap ...'`.
`input text` additionally mangles or drops non-ASCII input. For CJK text, either install an ADB-driven helper IME or set the field from a runtime call rather than typing.
### Look at the screen — do not drive and wait blind
The most expensive habit in device work is: tap a coordinate, sleep, tap again, sleep, conclude
something about the app. A screen that is *looked at* answers in one step what coordinate-guessing
cannot answer in five — the layout shifted, a different dialog came up, a countdown is frozen, the
button is disabled, the text on screen says exactly why.
**Treat a look as a routine step, not a debugging last resort.** Capture:
- **immediately before** anything time-dependent, so you know the starting state;
- **during** a wait, at intervals — state changes are the information, and a single sample at the end
cannot distinguish "it progressed" from "it never moved";
- **at every decision point**, before choosing the next action;
- **on any surprise**, before forming a theory about it.
`scripts/snap.py` does this with sane bounds, and tells you which kind of evidence you actually got:
```bash
python scripts/snap.py --out shots --tag before
python scripts/snap.py --out shots --tag waiting --count 6 --interval 3
```
**Use the stall detector.** If consecutive samples are byte-identical, nothing is happening and more
waiting cannot help. Stop, and go find out why — that is a different investigation from waiting
longer.
### Two kinds of screen evidence, and which one is trustworthy here
| Evidence | What it gives you | When it is the right one |
|---|---|---|
| **the image** | exactly what is rendered: layout, which dialog, disabled states, drawn text | always available; the **only** evidence for runtime-drawn UI |
| **the control tree** (`uiautomator dump`) | precise `bounds`, exact text, diffable | only when it actually has content |
The tree is more convenient *when it exists* — it gives you tap coordinates and text you can diff. But
**verify it has content before planning around it**:
```bash
adb -s <serial> shell "su -c 'uiautomator dump /sdcard/ui.xml'"
adb -s <serial> pull /sdcard/ui.xml
grep -c '<node' ui.xml # 0 nodes -> the tree is useless for this screen
```
**A runtime-rendered UI frequently exposes no real controls at all.** Cross-platform runtimes, web
views and canvas-drawn surfaces often produce an empty (or text-free) tree, which is why a plan built
on "read the bounds out of the XML" stalls on exactly those apps — and also why tapping a control that
"should be there" silently does nothing. When the tree is empty, the image is the **primary** evidence,
not a fallback, and you read it directly rather than trying to derive coordinates from a tree that does
not exist.
`screencap` itself returns a 0-byte file on some ROMs. Write on-device and pull instead:
```bash
adb -s <serial> shell "su -c 'screencap -p /sdcard/x.png'" && adb -s <serial> pull /sdcard/x.png
```
If that is also empty, do not fight it — but note that **a 0-byte capture is not evidence the screen is
blank** (`pitfalls.md` P20). And if `uiautomator dump` fails with `could not get idle state`, retry once;
a paused animation is usually the cause.
### Verify form input by reading it back
Filling a form and tapping submit is **not** a verified interaction. Read the actual field contents and lengths back first:
```bash
adb -s <serial> shell "su -c 'input text <value>'" # no literal spaces; %s encodes one
adb -s <serial> shell "su -c 'uiautomator dump /sdcard/ui.xml'"
adb -s <serial> pull /sdcard/ui.xml
grep -o 'text="[^"]*"' ui.xml # every populated field
```
A whole class of "the button does nothing" is really a local validation rejecting the input — two password fields of different length, a required field empty, a format check. The app returns **before** issuing any request, so a network probe stays silent and the tap looks broken. Compare the lengths you read back, fix the input, retry.
### ROM background freezing kills your hooks
Aggressive ROMs freeze backgrounded apps; the logcat signature is a process state transition `state: R -> F` (running to frozen). Once frozen, hooks stop firing and network calls stop, which looks exactly like a broken probe.
```bash
adb -s <serial> shell "su -c 'dumpsys deviceidle whitelist +<app.package>'"
adb -s <serial> shell "su -c 'cmd appops set <app.package> RUN_IN_BACKGROUND allow'"
```
While debugging, **do not press HOME** — backgrounding the app is what triggers the freeze. Return to it with `am start -n <app.package>/<activity>` and keep it in the foreground for the whole session.
## Signal extraction (what to actually read)
```bash
adb -s <serial> logcat -c # clear before the run
adb -s <serial> shell "am start -n <pkg>/<activity>"
adb -s <serial> logcat -d -v brief | grep -E '<pkg>|FATAL|VerifyError|IncompatibleClassChange|uncaughtException'
```
Failure signatures worth memorizing:
| Log line | Likely cause |
|---|---|
| `FATAL EXCEPTION` + Java stack | App-level crash — read the stack |
| `VerifyError` / `IncompatibleClassChangeError` | Damaged dex (round-trip or bad rewrite) → `pitfalls.md` P3 |
| `Failure starting process` (no stack, by `ActivityManager`) | ART refused the dex, or device state is broken → `pitfalls.md` P4, P9 |
| `ClassNotFoundException: <App.Application>` | Dex rejected wholesale (ordering / structure) → `pitfalls.md` P2 |
| `uncaughtException` with **no stack**, right after launch | A crash-reporter SDK swallowed it. Check whether the process is gone, and look for the SDK's own log file under the app's data dir |
| App alive but nothing rendered | A swallowed exception in the UI path; hunt the crash-reporter's log file |
**Crash-reporter SDKs hide your stack traces.** When an app installs a global uncaught-exception handler (友盟/UCrash/Bugly etc.), the Java stack never reaches `logcat` — only a line like `uncaughtException time: ...`. Three ways to get the stack:
1. **Frida**, hooking `Thread.setDefaultUncaughtExceptionHandler` or the handler class (most reliable).
2. **Race the reporter's log file**: it writes a file under `<app data>/<sdk>/...` then uploads and deletes it. Poll it every ~0.2 s from the device shell and copy on sight:
```bash
while [ $i -lt 400 ]; do
for f in <dir>/*.log; do [ -f "$f" ] && cp -f "$f" /data/local/tmp/capture.log; done
i=$((i+1)); sleep 0.2
done
```
3. Run a build with the reporter disabled (not always possible).
## Determinism
Before concluding anything from a failure: **reboot the device and retry**, and **run a control build with zero patches**. A surprising share of "my patch broke it" turns out to be device state (`pitfalls.md` P9).

View File

@@ -0,0 +1,102 @@
# Evidence summary — what is proven, how strongly, and where to look
Load this when a claim's strength decides whether you trust it, and the command that produced it is
not in front of you. It is the condensation that travels with the skill: `npx skills add` installs
`skills/apk-reverse/` only, so the run records, the tool verdicts and the public-target benchmark
matrix stay behind at the repository root. Every row below therefore answers four questions inside an
installed copy — **is it proven, how strongly, which capability is blocked, and which files to open
next** — and points at the one section that says "the full record is elsewhere, and here is its name".
## How to read this file
- **Status** is one of `ok` (route carried out on a real target, result recorded), `partial` (a
decisive step — usually install, launch, or a second independent producer — was not run), or
`blocked` (the route depends on something this skill does not ship, or nothing here is evidence
either way).
- **Strength** is the repository's three-tier label: `observed` (a command was run and its output
exists behind the claim), `inferred` (follows from a documented mechanism or a neighbouring
measurement), `unverified` (assumed or reported elsewhere, not reproduced here). `observed` is
reserved strictly.
- **Evidence** names files that exist in your install. When a claim's full record does not ship, this
file says so once, in one place, instead of scattering dead paths through the table.
- Machine-readable companions: `evidence/capability-matrix.json` (the same rows, with more fields),
`evidence/tested-tool-versions.json` (versions and the probe behind each), and
`evidence/known-limitations.md` (the installer-facing limit list).
## The capability matrix
| Capability | What is proven, in one line | Status | Strength | Evidence in this install |
|---|---|---|---|---|
| Equal-length dex surgical patch | 2 bytes rewritten in a 5,528-byte dex; whole-file diff is those 4 bytes plus header bytes 8..32; the written file reproduces both header integrity fields | ok | observed | `references/byte-level-patching.md`, `references/patch-audit.md`, `scripts/dex_patch_bytes.py` |
| Dex method-level rewrite (dexlib2) | Documented, not measured: the smali round-trip blind spot is real and a tree round trip can pass every table check and still fail at load | partial | unverified | `references/dex-patching.md`, `references/patch-audit.md` |
| Dex header integrity and verifier legality | Recompute order proven on one written file; `dex_classdiff` passing is necessary, not sufficient — it cannot see code-item damage | ok | observed | `references/patch-audit.md`, `scripts/dexutil.py`, `scripts/dex_check_verifier.py` |
| Dex string-constant patch | Equal-length only; the shipped script refuses unequal lengths, so a different-length edit is a different, unmeasured route | partial | unverified | `references/byte-level-patching.md`, `scripts/dex_strpatch.py` |
| Repack, sign, install (single APK) | End to end: STORED and 4-byte-aligned `resources.arsc`, v1+v2+v3 true, install succeeded, on-device hash matched the local build, control build still fails the old way | ok | observed | `references/repack-and-sign.md`, `references/verification.md`, `scripts/repack.py` |
| Split APK / App Bundle sets | Analyze, unified re-sign and merge measured on two real sets; merge correctly refuses a member carrying its own `resources.arsc` | partial | observed | `references/split-apk.md`, `scripts/repack.py` |
| Third-party build audit | Not measured: the APK differ is on the list of scripts no pass has run | partial | unverified | `references/third-party-builds.md`, `scripts/apk_diff.py` |
| Extraction-shell detection (trivial-body ratio) | The ratio is bimodal, not thresholded; the old "tens of percent" rule was wrong and was deleted | ok | observed | `references/advanced-unpacking.md`, `scripts/dex_dump_validate.py` |
| Dex-VMP declaration boundary | Static criteria can rule a VMP out, never in; a hand-written opcode table produced a VMP verdict on ordinary dalvik with zero structural errors | partial | observed | `references/advanced-unpacking.md`, `references/code-virtualization-and-custom-linkers.md` |
| VMP differential opcode map | Closed loop re-derived 218 of 218 emitted opcodes with zero fabrications; no hardening platform was ever contacted | partial | observed | `references/vmp-differential-analysis.md`, `scripts/vmp_diff_harness.py` |
| Java2C versus JNI sinking | Native-declaration density separates the shapes by about 2000x and `Java_*` symbols matched dex counts 1:1; no Dex-to-C compiler output was ever built here | ok | observed | `references/java2c-and-jni-sinking.md`, `scripts/java2c_probe.py` |
| Neutralising a native terminate path | Tooling works, patch does not: writes land and survive detach, and the target still dies at the same site | partial | observed | `references/native-tamper-and-suicide.md`, `scripts/spawn_patch_detach.py`, `scripts/hook_patch_only.js` |
| Instruction-level tracing (Stalker) | Exclusion keeps the target alive; it does not restore event delivery, which stayed at zero | partial | observed | `references/native-dbi-and-deobfuscation.md`, `scripts/stalker_trace.js`, `scripts/stalker_report.py` |
| Library mapping and PLT resolution | Mapping verified on a real target; the PLT script was broken and fixed after a false negative on a symbol that exists and is called | ok | observed | `references/native-and-so.md`, `scripts/lib_map.py`, `scripts/elf_plt.py` |
| Native crash triage and swallowed stacks | Unmeasured, including on the one target whose crash reporter hid exactly the stack the tool claims to recover | partial | unverified | `references/native-tamper-and-suicide.md`, `scripts/native_crash.py`, `scripts/grab_crash.py` |
| Runtime analysis with Frida | Instrumentation is a variable: attaching is what kills some targets, and exclusion is what keeps others alive | ok | observed | `references/dynamic-frida.md`, `references/environment.md`, `scripts/run_probe.py` |
| Local runtime data (DataStore and friends) | One real container round-tripped byte-exact and an edited value was read back; SharedPreferences and SQLite edits have no equivalent measurement | partial | observed | `references/runtime-data.md`, `scripts/datastore_inject.py` |
| Anti-instrumentation triage | The check was named and timed, and the `TracerPid=0` versus four frida-named mappings asymmetry was recorded; reproducibility is bounded to a sub-second window | ok | observed | `references/detection-and-anti-analysis.md`, `scripts/anti_detect_probe.js` |
| Emulation and Frida-RPC | The RPC bridge ran end to end on a live device, but the route itself is classified inferred: no target library was emulated | partial | inferred | `references/emulation-and-rpc.md`, `scripts/frida_rpc_serve.py` |
| Schema-free protobuf decode | 26/26 built-in fixtures, 21/21 against the official runtime, 8/8 framing checks, one real container byte-exact | ok | observed | `references/protocol-reverse.md`, `scripts/protobuf_decode_raw.py` |
| Server API probing and TLS scope | Determines who owns a gate, not how to break it; the native-pinning row was never run because it needs a toolchain this host lacks | partial | inferred | `references/server-api.md`, `references/tls-and-cert.md`, `scripts/probe_api.py` |
| Dart AOT analysis (given a dump) | Decoded identically to an independent disassembler (32/32, 96/96) and re-derived a caller index with a symmetric difference of 0 | partial | observed | `references/dart-aot.md`, `scripts/dart_disasm.py`, `scripts/dart_pprefs.py` |
| Dart AOT string-table format | arm64 packed form confirmed at the byte level; the documented armv7 UTF-16 form is refuted, and the extractor's zero is a format mismatch | partial | observed | `references/dart-aot.md`, `scripts/dart_pool_strings.py` |
| Producing a Dart AOT snapshot dump | Blocked: no resolver ships with this skill and none can be synthesized here | blocked | unverified | `references/dart-aot.md`, `references/coverage-and-limits.md` |
| Packer and custom-loader identification | Never exercised end to end; the target that was measured has no packer, so the tooling was recorded as not applicable | partial | unverified | `references/packers.md`, `references/code-virtualization-and-custom-linkers.md` |
| Module delivery instead of a repack | The scaffold builds end to end, but the delivery route has no public-target measurement and needs a device with the framework active | partial | inferred | `references/lsposed-and-modules.md`, `scripts/lsposed_scaffold.py` |
| Kernel-side syscall answer forging | Blocked on the kernel side: the generator is measured, no kernel artefact was compiled or loaded, and a userspace module cannot change a return value | blocked | unverified | `references/kernel-and-environment-hardening.md`, `scripts/kernelsu_syscall_mask.py` |
| `svc` site scanning | Two independent decoders agreed on an identical 214-site set; a byte scan also matches data, so neighbour context decides | ok | observed | `references/kernel-and-environment-hardening.md`, `scripts/svc_scan.py` |
| On-device tooling (MT Manager MCP) | The service-down path is measured; the connected path needs the service started by hand | partial | observed | `references/on-device-tooling.md`, `scripts/mt_mcp_probe.py` |
| Client-side ads and server-issued UI config | Measured on a real mid-size target's ad chain, with a repacked build's on-screen change confirmed | ok | observed | `references/ad-removal.md`, `references/server-config-and-updates.md` |
| Is a membership or paywall gate client-enforceable? | The decision framework is documented; no gate-shaped public target was put through it here | ok | inferred | `references/membership-and-limits.md`, `references/account-gates.md` |
| Update and forced-upgrade neutralisation | Documented, no measurement; the failure shape is a build that installs, runs and fails every signed request | partial | inferred | `references/updates-and-forced-upgrade.md`, `references/signature-derived-keys.md` |
| Signature-derived keys | Documented only: neither the offline candidate path nor the live read has a public-target measurement here | partial | unverified | `references/signature-derived-keys.md`, `scripts/sig_probe.py` |
| Publishing sanitisation and leak scanning | Every rule fired on a planted corpus with zero false positives on the do-not-anonymize list, and the scan found 26 strong hits in its own evidence file | ok | observed | `references/desensitization-and-leak-scans.md`, `scripts/scan_leaks.py` |
| Unity / IL2CPP, React Native / Hermes, iOS | Outside this skill: the runtime can be identified, the logic recovery is not covered | blocked | unverified | `references/framework-runtimes.md`, `references/coverage-and-limits.md` |
| Defeating a server-side authority | Out of scope by design; report it as a residual rather than patching harder | blocked | unverified | `references/server-api.md`, `references/handoff-boundaries.md` |
## Where the full record lives — repository root, not shipped
Six entries, deliberately: everything the table needs beyond your install is here, and nothing else
in this skill should point outside itself. **All six sit at the repository root and are not installed
by `npx skills add`; their paths are names, not openable files, in your copy.**
- `docs/tool-verification/README.md` — the index of the evidence record (repository root, not shipped)
- `docs/tool-verification/TOOL-VERDICTS.md` — one verdict per script and external toolchain, with the independent cross-check behind it (repository root, not shipped)
- `docs/tool-verification/FINDINGS.md` — defects, contradictions and boundary evidence about the skill itself (repository root, not shipped)
- `docs/tool-verification/REPO-DECISIONS.md` — what changed as a result, and what deliberately did not (repository root, not shipped)
- `docs/tool-verification/EXTENSION-*.md` — one file per topic, each with its own strength note and the exact commands (repository root, not shipped)
- `tests/benchmark.md` — the B1–B13 public-target regression matrix: what happened, including the negative results (repository root, not shipped)
The reference documents still cite those paths where the detail matters, because a repository reader
can open them. The labels, the one-line verdicts and the blocked list are all here, so an installed
copy never has to fail silently at a path it cannot open.
## When the evidence does not reach your case
| Your situation | Do this |
|---|---|
| The row you need says `observed`, but for a different target shape | Treat it as a reason to try the route first, not as proof it will work. Keep your own control build. |
| The row says `inferred` | The mechanism is documented or borrowed from a neighbouring measurement. Budget one cheap experiment that would make it `observed` before building on it. |
| The row says `unverified` | Nobody here has paid for the counters. Do not cite this skill as support; run the step and label your own result. |
| The row says `blocked` | Do not improvise a route. State the missing dependency, and name the cheapest experiment that would identify whether it is the real blocker. |
| The step needs install or launch, and the row stops before it | The route is not end-to-end. Install it, launch it, and exercise the feature before calling it done. |
## Failure modes
- Reading "the tool ran" as "the tool is right". One script here produced a false negative on a symbol
that exists and is called; another answers "no references" for input it cannot read at all.
- Reading a confident paragraph as `observed`. The label decides, not the prose.
- Reading `unverified` as a refutation. It is an absence of evidence here, not evidence of absence.
- Treating silence in `evidence/known-limitations.md`'s never-exercised list as support for a route.
- Presenting a route as verified when the artifact that was verified is not the artifact being handed
over — the claim ladder in `references/verification.md` exists for exactly this gap.

View File

@@ -0,0 +1,120 @@
# Cross-Platform Runtimes
Load this when the app's UI is not native, or when Java-layer hooks produce no hits at all.
Many apps ship a native shell plus a cross-platform runtime. Their business logic, their UI, and their
feature gates live **inside the runtime**, not in the dex. Patching the dex then does nothing — the Java
code you can see is mostly glue. Determine the runtime before choosing where to patch.
## Identify the runtime early
Signals in the APK:
| Runtime | Giveaways |
|---|---|
| Flutter | `libflutter.so`, `libapp.so`, `assets/flutter_assets/`, Dart snapshot |
| React Native | `libhermes.so` or `libjsc.so`, `index.android.bundle`, `assets/index.android.bundle` |
| Unity | `libunity.so`, `libil2cpp.so`, `assets/bin/Data/`, `globalgamemanagers` |
| Cordova / Ionic | `assets/www/`, `cordova.js` |
| Xamarin / .NET | `libmonodroid.so`, `assemblies/` in the APK |
A `classes.dex` that is small relative to the total payload is another strong hint that the real code
lives elsewhere.
## The layer trap
The expensive mistake: seeing a UI element (dialog, paywall, gate) and assuming it is a native control,
then spending a long time looking for it in the dex.
If it is drawn by the runtime, the dex contains only the bridge. Symptoms of being on the wrong layer:
- hooking the obvious Java dialog/Activity classes produces **zero** hits while the UI clearly appears;
- the classes that do appear in stacks are the runtime's own, with obfuscated names;
- hooking a Java method changes nothing about the visible behaviour.
Establish layer ownership **before** investing in a patch direction.
### How to tell which layer drew a given UI
1. Enumerate the Java dialog and presentation classes and hook their show/creation paths.
2. Reproduce the UI. If any hook fires, the caller stack names the detection and you are on the Java
layer.
3. If nothing fires while the UI is on screen, the UI is runtime-drawn or native-drawn. Switch axis.
4. Expect runtime-owned windows to appear as ordinary system `Dialog`/`Presentation` objects used as
**containers** for the runtime's surface. A hit on such a container is not evidence that the visible
element is a native dialog — read the class name carefully before drawing conclusions.
## Flutter specifics
- Business logic is compiled ahead-of-time into **`libapp.so`**; UI is rendered by `libflutter.so` with
no per-widget Java objects.
- **Widgets, dialogs, and paywalls are not Java views.** They will never appear in Java stack traces,
and no Java hook can intercept them.
- The Java side is thin: one Activity, plus plugin classes. Calls cross the boundary through the
platform-channel mechanism, whose class names are frequently obfuscated.
- Because channel classes may be renamed, locate them **by method signature and call shape**, not by
class name.
### Where to intervene in a Flutter app
| Goal | Layer | Notes |
|---|---|---|
| Stop the app being killed / refused at startup | host native library | earliest point, see `native-and-so.md` |
| Observe what the runtime is told | platform channel | shows the messages that drive the UI |
| Change a decision permanently | `libapp.so` | most direct, hardest to locate |
| Change startup behaviour only | host native library constructor | survives runtime restarts |
Editing `libapp.so` is the most durable but locating Dart AOT code is genuinely hard. This subject is
large enough to have its own reference: **`references/dart-aot.md`** covers pinning the Dart version,
building a decompiler for exactly that version, the object-pool model and how to index it, the register
and boolean conventions, the three assembly signatures that identify most business logic, the locating
workflow, and how to patch this layer safely.
## Locating logic without symbols
- **Strings first.** User-visible text (dialog bodies, feature labels) is the cheapest anchor in any
runtime. Find it, then find what references it.
- **Search them in the encoding the runtime actually uses, or you will conclude they do not exist.**
This is the single most common false negative in cross-platform work. A runtime snapshot does not
necessarily store text as UTF-8; UTF-16 (little-endian on these targets) is common and entirely
valid. A UTF-8 search of such a snapshot returns **zero hits**, which reads as "the strings were
stripped / encrypted, this route is dead" — and that conclusion is wrong.
```python
blob = open('libapp.so', 'rb').read() # or whichever artifact holds the snapshot
for phrase in ('<feature label>', '<dialog title>'):
print(phrase, 'utf-8:', blob.find(phrase.encode('utf-8')),
'utf-16le:', blob.find(phrase.encode('utf-16-le')))
```
Try both, and try a short distinctive substring rather than a long phrase. Which encoding applies
is predictable rather than random: in a Dart AOT snapshot ASCII literals are stored one-byte (a
UTF-8 search finds them) while CJK / non-Latin literals are UTF-16LE (a UTF-8 search returns zero).
`dart-aot.md` §7 gives the exact framing, and `scripts/dart_pool_strings.py --find` locates either
encoding by file offset.
- **A phrase that is absent may simply never exist as one literal.** UI text is often assembled from
fragments or templates, so "the whole sentence" can be missing while both halves are present.
Search the shortest distinctive token, and expect the interesting anchor to be a *label* rather
than a sentence.
- **Landing on a string tells you where the text lives, not which code decided to show it.** Treat the
hit as an anchor for reference-hunting, not as the answer.
- **Compare two builds.** The same feature in a slightly different version often reveals the code path.
- **Watch the boundary, not the interior.** For cross-platform apps it is usually far cheaper to observe
what crosses between layers than to reverse the interior of the runtime.
- **Do not assume the obfuscated names are stable or meaningful.** They are not, and treating them as
identities leads to conclusions that break on the next build.
- **Obfuscated identifiers can be non-ASCII and invisible.** Renaming passes can replace class and
member names with characters outside ASCII (combining marks, variant selectors and similar). If a
class exists at runtime but "cannot be found" by the name you read from a decompiler, suspect
encoding or normalisation in your tooling rather than a missing class. Resolve such members by
**shape** — parameter counts, types, interface implementation — instead of by name, and prefer
runtime enumeration of loaded classes over guessing identifiers.
## What this changes about your plan
- Budget for the runtime layer **up front**. If the app is Flutter/RN/Unity, the dex is not the main
battlefield and a dex-only plan will stall.
- Any behavioural claim must be validated **through the runtime's own UI**, because that is what the
user sees. A patch that changes internal state without changing the UI is not a fix.
- Keep the layer you are working in explicit in your notes. "Patched the paywall" is meaningless without
saying which layer owned it.

View File

@@ -0,0 +1,70 @@
# Hand-off boundaries — where this skill ends and another view begins
Four boundaries that are easy to walk into without noticing. Each names what the other side owns
rather than restating it, because two copies of the same advice drift apart. `SKILL.md` keeps the
one-line version of each so a routing decision can be made without loading this file; this file
carries the detail.
## 1. JNI — a Java `native` declaration and its implementation are two different views of one function
This skill reads the Java side (dex) and the native side (`.so`) with different tools, so the *join*
is where analyses go wrong.
| Form | What you see | How to find it |
|---|---|---|
| Static linkage | symbol `Java_<pkg>_<Class>_<method>` in `.dynsym` | search the dynamic symbol table. Under R8 the class name is a short name, so the symbol deforms with it and a search for the readable original finds nothing |
| Dynamic registration | **nothing** in the symbol table — binding happens at runtime | find `RegisterNatives` call sites, or hook it to read the binding table. Obfuscated targets prefer this, and a symbol search fails **silently** on it |
| Native → Java callbacks | native code pulling data back through Java | follow `FindClass` / `GetMethodID` / `CallObjectMethod` |
`FindClass`/`RegisterNatives` in a `.so` tell you a JNI boundary exists even when no `Java_*` symbol
does. **Strength note:** the three rows above are documented behaviour, not results from the first
verification pass, which did not trace a JNI boundary end to end; `FlutterJNI.loadLibrary` appearing
in a dex is the closest it came. Treat them as a map, not as a measurement.
**The benchmark pass then measured the failure the middle row predicts:** a real dynamically
registering library contains **zero `RegisterNatives` symbols**, because the C++ `jni.h` inlines the
call — so a symbol-based search fails structurally, not by bad luck. The combination that did fire
on a real library was *"exports `JNI_OnLoad` and zero `Java_*`"*. The authoritative treatment is
`references/java2c-and-jni-sinking.md` §The JNI boundary — why a symbol search fails silently; this
section is the pointer, not the source.
## 2. Hardening — a dex-side packer observation is a native-side implementation question
If the dex turns out to be a shell, the logic is behind a loader and the analysis moves to the `.so`
that performs the unpacking. `packers.md` owns the dex-side identification; the native deep dive
belongs on the other side of this boundary. **Not exercised by the first verification pass** — that
target had no packer, so this pointer carries no measurement from it. The benchmark pass measured the
*shape discrimination* on public samples but still did not run a live extraction shell
(the benchmark matrix (`references/evidence-summary.md` §The capability matrix) row B3).
## 3. The existing native boundaries — read native anomalies from the APK side, not from inside
`native-and-so.md` and `native-tamper-and-suicide.md` are deliberately scoped to what you can
conclude *from the APK side*: a repacked build that dies instantly with a null-looking fault, a
Java-layer check that reports success while the process dies, a terminate path you made not-return.
That judgement belongs here, because it is about deciding whether your *patch* caused the death. Deep
native work — restoring a symbol, rebuilding a call graph, reversing an OLLVM function — is a
different activity with a different toolchain. Point across rather than duplicating: if you need the
latter, say so instead of extending those two files into it.
## 4. When the deliverable stops being an APK, the verification question changes with it
`SKILL.md` §What "done" means is written for a rebuilt, installable artifact, and every word of it
assumes one. The other three forms in G1 each move the evidence somewhere else, and the failure mode
is quiet: **a privileged result gets reported in the language of a finished build.**
| Form | What "verified" now means | What is *not* evidence |
|---|---|---|
| **LSPosed / Xposed module** | The module was loaded into the target and its hook produced an observable effect **in the target's own log**, on a named build of the target | The module installed; `pm path` returned a path; the package was enabled in the manager. All three are true of a module whose entry class does not exist in its own dex |
| **Local RPC / emulation service** | A call returned the value the app itself would produce, from a named target build and a named device or emulated environment — and the harness survives a reconnect | "The script loaded"; a call that returned *something* without a reference value to compare against |
| **Analysis report with a stated boundary** | The evidence chain (commands, outputs, and the layer each conclusion belongs to) plus the boundary — what was **not** determined and why | Any implication that a route was exhausted when it was only abandoned |
The privileged-form drift R1 warns about lives here. `references/lsposed-and-modules.md`,
`references/emulation-and-rpc.md` and `references/verification.md` each carry the specific check; this
table is the reminder that changing the deliverable's form is a decision that must be re-stated out
loud, not a quiet downgrade of what counts as done.
**A fifth boundary, added by the benchmark pass:** when the deliverable is a **tool or a document in
this repository**, "verified" means an independent check exists — a second implementation, an
official disassembler, a byte-exact round trip. A tool's own self-test passing is not that check.
the benchmark matrix (`references/evidence-summary.md` §The capability matrix) records which rows have one and which do not.

View File

@@ -0,0 +1,149 @@
# Java2C and JNI Sinking — the dex is not where the code is
Load this when the dex shows method bodies missing or replaced by `native` declarations, and
you are about to go looking for a decrypted DEX in memory. **Decide which of the two shapes you
have before you spend anything**, because one of them has no DEX to find at any point in the
process lifetime, and the search for it is unbounded.
Two neighbouring routes have historically folded this shape into one: `advanced-unpacking.md`'s
dump-shape table and the SKILL.md symptom index both point "whole classes are bare `native`
declarations" at `code-virtualization-and-custom-linkers.md`. That file is about protection which
leaves the code **reachable** — a private container, a loader, a private opcode interpreter, all of
which you can dump and measure. **Java2C is not reachable, because it was never bytecode.** This
file splits that row and owns both halves of it; the virtualization file stays what it is.
**Strength note, read this first.** The measurements below are **observed** — every number was
produced by `scripts/java2c_probe.py` against a real sample during this pass, recorded in
`references/evidence-summary.md` §The capability matrix. But no Java2C library exists in this repository and
none could be built here (no NDK, no clang, WSL unavailable — see that record), so **the
Java2C-specific identification criteria are inferred, not observed end to end**. What *is*
observed is the discriminating measurement that separates Java2C from JNI sinking, and the JNI
boundary behaviour. Treat the type table as a map; label your own conclusions the same way.
## The five shapes — one table, because the wrong row costs days
| Shape | Static shape in dex | What is in memory at runtime | Route | Cost of the wrong row |
|---|---|---|---|---|
| **Landing shell** | `Application` is a third-party class; dex is a small stub | A full, decrypted DEX | Dump memory, filter, patch | Cheap if you notice the stub `Application` |
| **Extraction shell** | Reads as a normal dex until you measure it; bodies are stubs | A DEX whose bodies are filled in **on invocation** | Dump, measure `stub%`, FART-style active invocation | Medium — you keep dumping, and every dump is a skeleton |
| **VMP** | Dex parses; bodies are *present* but decode as nonsense | A DEX containing **private opcodes** interpreted by a native VM | Measure, then usually stop and report | Medium — recovery cost usually exceeds task value |
| **Java2C** | Whole classes are `native`; **no code_item at all** for them | **Never a DEX.** The translated methods exist only as compiled C in a `.so` | Read the `.so`; rebuild the JNI call chain | **Highest in this table** — you hunt a decrypted DEX that is never produced, then blame your dump tooling, then your anti-detection assumptions |
| **JNI sinking** | A handful of `native` declarations; the rest of the dex is ordinary Java | A normal DEX | Locate the Java call site; reverse one or a few native functions | Low — but you may widen the job to the whole app if you read it as Java2C |
The one question that decides the table: **does the artifact ever hold dex bytecode for the
methods you care about?**
- Landing shell, extraction shell, VMP: **yes** — the bytecode is there, in some state, and the
work is recovering or interpreting it.
- Java2C: **no** — a translated method is a `native` declaration plus an entry in a registration
table. Nothing is decrypted, so nothing can be dumped. `[inferred]` as a runtime claim: it
follows from the translation mechanism, and this pass did not observe a live Java2C app.
- JNI sinking: **yes** — the dex was never damaged.
## Identification
### The discriminating measurement is native density
This is the single most useful number and it separates the two shapes by roughly three orders of
magnitude. Measured on real samples with `scripts/java2c_probe.py` `[observed]`:
| Sample | dex methods | `native` methods | native ratio |
|---|---|---|---|
| JNI-sinking target A (MASTG L2) | 5081 | 2 | **0.04 %** |
| JNI-sinking target B (MASTG L3) | 11182 | 3 | **0.03 %** |
| App carrying a real JNI library | 23603 | 7 | **0.03 %** |
| Java2C-shaped fixture | 29 | 24 | **82.76 %** |
A JNI *sink* is by definition surgical: a few hot methods are moved out and the rest of the app
stays in Java. **A JNI sink is not "the app is native".** When the density is in the tens of
percent, and whole classes have zero Java methods left, you are looking at a translation pass.
`[observed]` in the same runs: two of the fixture's three classes were native-dominated (>= 70 %
of >= 3 named methods), against **zero** such classes in any of the three real targets. Note that
constructors are excluded from that test — a translation pass leaves `<init>`/`<clinit>` in Java
in practice, so "every method native" is an essentially unreachable test and should not be used.
### Native-layer signatures
| Evidence | Strength | Why |
|---|---|---|
| `.so` exports `Java_*` symbols whose count is roughly 1:1 with the dex native-method count | **strong** | Measured 2:2 (sample A) and 3:3 (sample B) `[observed]`. It is the signature of a *static-linkage* translation or sink. Note it does **not** distinguish Java2C from JNI sinking — only the density does |
| The `.so` contains one C function per Java method, each beginning `JNIEnv *env, jobject thiz, ...` | **strong** | Observed in generated, uncompiled output: every translated method became exactly one function with those leading parameters |
| Toolchain strings: `Dex2C`, `dynamic_register_compile_methods`, `ScopedLocalRef`, `well_known_classes` | **strong** | These are the runtime header and entry-point names a Dex-to-C toolchain emits. `[observed]` in generated sources; `[inferred]` that they survive compilation into the shipped `.so` |
| `JNI_OnLoad` present | **weak** | Present in nearly every JNI library. Measured true on a system library with zero `Java_*` symbols `[observed]` — it told us registration was dynamic, not that anything was hardened |
| `RegisterNatives` **string** anywhere in the `.so` | **weak** | See the boundary section: NDK's C++ headers do not emit this symbol at all |
| `libc++_shared.so` / `c++_static` | **weak** | An ordinary NDK C++ setting. The Dex-to-C toolchain read here does ship `APP_STL := c++_static`, but so do countless unrelated libraries `[observed]` |
| `classes.dex` byte-count per class far below the app's own pre-hardening build | **weak, uncalibrated** | Bodies leaving the dex should shrink it. Measured 584 B/class on the fixture versus 1152–1386 B/class on the real Java targets `[observed]` — but there is no population baseline for "normal", so this only compares an app against **itself** before and after |
`java2c_probe.py` prints every one of these with its strength attached, and prints the weak ones
as an explicit warning block. A weak hit is not a verdict and must not be reported as one.
## The route: read the .so, not memory
For Java2C the analyst's instinct — dump memory and find the dex — is not merely expensive, it is
**unbounded**, because the success condition can never occur.
1. **Do not dump.** There is no target artifact. If you dump anyway, you will recover a dex that
still shows the same `native` declarations you started with, and the natural (wrong) reading is
"the dump failed".
2. **List the translated functions.** With static linkage, `Java_<PKG>_<Class>_<method>__<proto>`
names map the `.so` back onto the dex one for one. Use the dex as the index and the `.so` as
the library.
3. **Rebuild the chain backwards.** A translated function calls *out* to Java constantly. The
strings `FindClass` / `GetMethodID` / `GetStaticMethodID` / `Call*Method` / `NewObject` /
`GetFieldID` inside it are the reverse edges. Following them reconstructs what the original
Java method did, which is the only way the logic comes back.
4. **Expect a register-machine shape.** Generated code declares a slot per dex register up front
and assigns them in sequence. Reading it as if a human wrote it wastes hours; read it as a
linearizer output, matching each call site against the JNI signature it passes.
The decompiler choice matters less here than for obfuscated code: the generated functions are
long but flat and unoptimized, which is precisely the shape a decompiler handles well.
## The JNI boundary — why a symbol search fails silently
Two independent mechanisms erase the symbol that a reader would search for. Both are real; the
second was measured this pass.
**1. Dynamic registration.** Nothing named `Java_*` is exported. Binding is performed at runtime
by a registration table built in a JNI entry point. A grep over the symbol table returns nothing,
exits zero, and looks like a legitimate answer.
**2. `-fvisibility=hidden`, and an inline `RegisterNatives`.** The Dex-to-C toolchain read here
ships `APP_CPPFLAGS += -fvisibility=hidden` in its `Application.mk` `[observed]`, which hides the
generated functions from the dynamic symbol table — so even a statically-named translation can
present as "no `Java_*` symbols". Compounding it: NDK's C++ `jni.h` implements `RegisterNatives`
as an **inline member that calls through the function table**, so calling it emits *no*
`RegisterNatives` symbol either `[observed]`: a real system library exporting `JNI_OnLoad` and
zero `Java_*` symbols contains zero literal `RegisterNatives` symbols. A rule that greps for that
name is structurally incapable of firing.
**The check that does work** `[observed]`: a library that **exports a JNI entry point and exports
zero `Java_*` symbols** is registering dynamically. That combination is affirmative evidence, and
it fired on a real library in this pass.
**Confirming it at runtime.** Hook the registration entry point (`JNI_OnLoad`) and read the table
it builds, or hook the class-load path and enumerate the class's methods' native backing. Do not
conclude "no JNI boundary" from an empty symbol table; conclude "the binding is not visible in the
symbol table" and go find where it happens.
## Failure modes
| What you see | What it is not | What it is |
|---|---|---|
| Whole classes are `native`, you dump memory and find the same `native` declarations | "The dump failed" / "the tool is broken" | The translation moved the code out of dex permanently. Re-read the dex shape and open the `.so` |
| `grep Java_ .dynsym` returns nothing | "There is no native implementation" | Dynamic registration, or hidden visibility — see the boundary section |
| A library exports `JNI_OnLoad` and nothing else you recognise | "It is a loader" | It may be an ordinary dynamically-registering JNI library. A loader normally also has an `init_array` entry and a payload to map |
| Native density is high but every class still has Java methods | Java2C | Could be a JNI sink applied widely, or an R8-stripped app. Check whether the `native` methods have matching `Java_*` symbols before concluding |
| The dex looks completely unremarkable on a sample you were told is hardened | "Nothing is hardened" | A VMP whose payload is decrypted at runtime has an ordinary static dex. Measured this pass: a VMP-labelled sample had 7 native methods out of 23603 and a 4 % stub ratio `[observed]`. **Static dex metrics cannot see runtime-only hardening** — that needs a runtime check, not more reading |
## Where this file stops
- **It does not decompile the generated C.** That is ordinary native reversing; `native-and-so.md`
and `native-dbi-and-deobfuscation.md` own it.
- **It does not recover a VMP.** Private-opcode interpretation is a different mechanism with a
different cost model (`advanced-unpacking.md`).
- **The Java2C runtime claim is inferred.** That no dex bytecode exists in memory for a translated
method follows from the mechanism, not from a measurement made here. If you get a live Java2C
sample, that is the first thing to verify, and it is cheap: attach, force the class to load, and
ask whether the method resolves to Java bytecode or to a registered native entry.

View File

@@ -0,0 +1,299 @@
# Kernel-level and environment hardening — where to go when userspace hooking is not enough
Load this when you have already concluded, via `detection-and-anti-analysis.md`, that the target
genuinely detects your instrumentation — and that the check cannot be neutralised from userspace
because it runs before your hook, or bypasses the layers a userspace hook can reach. This file is
the **escalation map**: what the next layer down actually is, what it costs, and when it is the
wrong answer.
Division of labour, so the two files never duplicate:
- `detection-and-anti-analysis.md` owns **identifying** detection and the **cost decision**
(work around / change route / accept and report). Read it first; its Step 0 still applies.
- This file owns **the routes below userspace** — root-implementation hiding properties, kernel
interception options, and the syscall-level realities that decide whether "just hook it" was
ever going to work.
**This repository does not do kernel development.** Everything in the kernel section is a map of
externally documented mechanisms with sources, labelled `inferred` — none of it was executed here.
That is deliberate: the purpose is to stop you from spending a day rediscovering the version
gate () or the conflict (), not to teach kernel hacking.
**Extension pass, read the next paragraph before relying on it.** This file now has a companion
generator — `scripts/kernelsu_syscall_mask.py` — which emits a loadable userspace module skeleton
plus kernel-side **templates** for the three routes in. The generator, its userspace output and
its own consistency check are `measured`; **the kernel-side code has never been compiled or loaded
anywhere**, and every generated kernel file says so in its own header. A template is a starting
point that removes the blank page, not a weapon that has been fired.
## 1. The attack-surface timeline — why "just hook it" fails
An app process initializes in a fixed order, and every anti-analysis check you will meet lives at
one of these stations:
```
linker (ld-android/inker) -> relocations resolved, DT_INIT run
.init_array of each loaded .so -> constructors: earliest native code that runs
JNI_OnLoad -> packers and RASP SDKs hook here
Application.onCreate -> first Java-level check
first frame -> UI-visible checks (and the end of "early")
```
The later your tool attaches, the more of this timeline has already executed and taken its
decision. Frida in attach mode arrives after all of it; spawn mode arrives at zygote fork, which
is why `dynamic-frida.md` prefers it — but a constructor in `.init_array` can still run before
the Frida agent finishes injecting, and a check placed there fires once and stores its verdict.
What those checks actually look for, and why each one is out of reach of a naive userspace hook
(the identification side belongs to `detection-and-anti-analysis.md`; this is the *mechanics* of
why the hook does not land):
| Detection mechanism | Why a userspace hook misses it |
|---|---|
| Inline `svc #0` syscalls (bypassing libc) | PLT/GOT hooks only intercept calls that go through the PLT. Hand-written `svc` instructions in the target's own `.so` never touch libc, so there is no PLT entry to redirect. |
| `/proc/self/maps` scan for foreign mappings | The read can be done with raw `openat`+`read` syscalls (same svc trick), and the thing being detected — your injected agent's mapping — exists in the process regardless of what you hook. |
| Named pipes / thread-name probes for known agents | Frida's runtime creates identifiable threads (`gum-js-loop` etc.) and (older versions) a linjector pipe. Renaming helps only if you control the runtime's identity, not the probe. |
| Code-page checksums of the target's own `.so` | An inline hook *is* a modification of the pages being checksummed. The check catches the hook by construction; the only userspace answer is to also neutralise the checksum, which recursively has the same problem if *that* code is checksummed too. |
| `/proc/self/status` `TracerPid` read | The field is written by the kernel. A userspace hook on the reader works only until the reader uses raw syscalls (row 1). See. |
The structural lesson: **userspace hooking and userspace anti-hooking are the same layer.** A
detection routine that lives in the same process as your hook, and that is willing to use raw
syscalls and self-verification, can always in principle race you. The routes below win by moving
to a layer the check cannot see from userspace — or by not being in the process at all.
## 2. Root implementations and what each one exposes
The root method you run decides which artefacts a detector can find *outside* the target process
(manager app, daemon, mount layout, SELinux contexts). Facts below are from the projects' own
documentation (sources at the end of the section); none were re-measured here — `inferred`.
| | **Magisk** | **KernelSU** | **APatch** |
|---|---|---|---|
| How su works | Patches the ramdisk/init; a `magiskd` runs in userspace; the manager app requests su through it | `su` handled **inside the kernel**; a manager app talks to the kernel via a driver/interface. Kernel modification. | Kernel modification via **KernelPatch**: patches the existing `boot.img` kernel — no kernel source needed (the difference from KernelSU) |
| Kernel version gate | None (works on old kernels — this is why the 4.14 test device runs it) | Official support = GKI 2.0, kernel **5.10+** (in practice: shipped with Android 12). Backported to 4.14 but you must build the kernel yourself | Same GKI-era expectation; needs only the stock `boot.img` to patch |
| Access control | Manager grants per-app; denylist for hiding | Manager + **App Profile** (constrain what a rooted app can do) | **SuperKey**: KPatch installs a new syscall (SuperCall); callers must present the SuperKey credential |
| SELinux | Modifies contexts (magiskpolicy) | Kernel-side handling | Hooks/bypasses SELinux rather than rewriting contexts — threads can be rooted in app context without `libsu`/IPC |
| Module format | Magisk modules (magic mount) | Magisk-compatible modules; `/system` modification needs a metamodule (overlayfs) | APM (Magisk-compatible) plus **KPM** — kernel-space modules with inline-hook and syscall-table-hook capability |
| Zygisk | Built-in | None built-in; add ZygiskNext | None built-in; add ZygiskNext / ReZygisk / NeoZygisk |
**What this means for hiding** (`inferred`):
- **Manager identity is the shared weak point.** All three ship a manager app the detector can
enumerate by package or by scanning for its UI. All three support "randomise/repackage the
manager" — use it, and do not assume the default package name is safe.
- **KernelSU/APatch remove the userspace su daemon** the detector could otherwise find as a
process or a socket. But they add a kernel-level surface of their own; a detector cannot see it
from an unprivileged app — which is the point.
- **Magisk on an old kernel is not a disadvantage for hiding**; it is a constraint of the device.
Do not switch root schemes to win a detection race — that is §7's stop signal.
- **Module ecosystems interoperate but do not merge**: KernelSU's module system conflicts with
Magisk magic mount (per KernelSU FAQ, running both means one stops working); su-only coexistence
is possible because KernelSU patches the kernel while Magisk patches the ramdisk.
Sources: KernelSU README + FAQ (github.com/tiann/KernelSU), APatch FAQ (apatch.dev, project
originally at github.com/bmax121/APatch), fetched 2026-09. Device facts (Magisk alpha on kernel
4.14.186) are `observed` on the reference device used for this pass.
## 3. Environment-hiding modules: Zygisk-Assistant vs Shamiko
Both are Zygisk modules that hide root + Zygisk from target apps. **Do not install both** — they
overlap in function (mount-namespace handling, trace wiping) and interfere; this is documented in
the module ecosystems and was the deciding fact on the reference device (`observed`: Shamiko kept as a backup zip and uninstalled there).
The practical difference is their **denylist dependency** (from each project's own README,
`inferred`):
| | **Shamiko** | **Zygisk-Assistant** |
|---|---|---|
| Reads the Magisk denylist | Yes — but **requires DenyList enforcement to be OFF** (it takes over the handling itself) | Magisk: add the target to the denylist, **Enforce DenyList OFF**. KernelSU/APatch: enable the manager's "Umount modules/Exclude modifications" for the target app |
| Extra modes | Whitelist mode via `/data/adb/shamiko/whitelist` (empty file); docs warn of performance/memory cost, test-only | — |
| On APatch | Officially unsupported ("Shamiko is proprietary software, we cannot adapt it" — APatch FAQ) | Works via ZygiskNext |
| Version gates | Newer Shamiko requires recent Magisk/KernelSU bases | Current |
Selection rule (`inferred`): on Magisk, either works alone — pick one, configure its denylist
dependency correctly, and verify on the actual target. On KernelSU/APatch, Zygisk-Assistant (+
ZygiskNext) is the supported path. The denylist/enforcement settings are the part people get
wrong: **enforcement off, list populated** is the working combination for both.
Verification status: neither module's hiding effect was tested against a real detector on the
reference device — treat every
"this hides X" claim above as documented-but-unproven here.
## 4. Kernel-level interception — the map, and the version gate
When you need to observe or alter what a target does *at syscall level* — e.g. it reads
`/proc/self/status` via raw `svc`, and you want that read to return a spoofed page — userspace is
structurally the wrong layer. The options below exist; **none of them were executed in this
repository** (`unverified`), and each has a hard prerequisite worth checking before you read any
further:
```
uname -r # on the device. The gate for most of this section is 5.10+
```
- **Why 5.10**: Android's GKI (Generic Kernel Image) programme starts at android12-5.10; the
kernel-BPF ecosystem for tracing (bpftrace-class tools, kprobe/uprobe attachment as the tooling
expects it) targets GKI kernels. Source: the Android kernel architecture documentation on
source.android.com (GKI and eBPF pages). On the reference
device (kernel **4.14.186**) this entire row is **closed** — `observed` (the version), with the
consequence that eBPF-based tooling
(stackplz, mcp-termux's tracing half) is unavailable there. Write the one-line environment fact
per `detection-and-anti-analysis.md` Step 5 and do not revisit it.
| Route | What it gives you | Prereqs | Strength |
|---|---|---|---|
| **eBPF kprobes/uprobes** | Fire a BPF program on kernel or userspace function entry; observe syscall arguments/results system-wide, invisible to the target process | GKI 5.10+ kernel with the BTF/tracing config; root; toolchain (bpftrace or a custom loader) | `inferred` — documented upstream, not run here |
| **seccomp-BPF filter** | Per-process syscall allow/deny — can make a syscall **fail**, and seccomp can be installed by the app on itself (no root needed for self-filtering) | Any modern kernel; but a filter you install into the target requires ptrace/zygisk injection first | `inferred`. **Key limitation**: seccomp can reject or error a syscall (SECCOMP_RET_ERRNO/TRAP); it cannot rewrite the *content* of what a successful read returns. It closes doors; it does not paint them. |
| **Kernel module hooking** (KPM on APatch; out-of-tree LKM elsewhere) | Inline hooks and syscall-table hooks in kernel space — the layer that *can* rewrite what a `/proc` read returns | APatch (KPM) or a self-built kernel/LKM load path; kernel-dev skills | `inferred`. This is genuine kernel development — out of scope for this skill () |
| **Zygisk injection** (not kernel, but below the target's defences) | Run code in the target's process from zygote fork, before `.init_array` of the app's own libs | Magisk/KernelSU+ZygiskNext + a Zygisk module; no ptrace involved | `measured` as a framework (LSPosed runs this way on the reference device); a purpose-built module for a given target is `inferred` here |
The row that matters most in practice: **Zygisk is the cheap "below userspace" route** — it is in
the process earlier than any userspace tool can be, needs no ptrace (so it does not trip
`TracerPid`), and its ecosystem is maintained by other people. A custom kernel module that
rewrites one `/proc` read is a research project; a Zygisk module that hooks the target's
constructor is a build task.
### 4a. From "no weapon" to "template + gate": what the extension pass added
The gap this closes is specific. §4 above was a map with no artefact behind it: a reader told
"Kernel module hooking — inline hooks and syscall-table hooks in kernel space — the layer that *can*
rewrite what a `/proc` read returns" had nowhere to go next. `scripts/kernelsu_syscall_mask.py`
produces the artifact; this subsection states its boundary, because the boundary is the part that
matters.
**The correction worth internalising first: a KernelSU module cannot do any of this.** A KernelSU
(or Magisk, or APatch-userspace) module is a *userspace* module whose scripts run as root in the
normal world. `module.prop`, `post-fs-data.sh` and `service.sh` cannot change what `openat`
returns — not because of a version gate, but because nothing in that format is ever in the kernel's
return path. Detectors of the kind §2 and §3 discuss are not defeated by a module of that shape.
The generator emits that skeleton anyway, because it is the right carrier for the configuration and
the metadata, and it says in its own README what the skeleton cannot do.
What *can* reach the return path is one of three artifacts, and each carries a gate that has to be
checked on the actual device:
| Artifact | What it is | Gate | Status of the shipped template |
|---|---|---|---|
| **KPM** (KernelPatch / APatch) | A relocatable `.kpm` loaded by kpimg injected into the kernel image; replaces syscall-table pointers (`fp_hook_syscalln`) or rewrites prologues (`hook_wrapN`) | A KernelPatch-patched boot image, plus a **bare-metal** ARM64 toolchain (`aarch64-none-elf-gcc`) — not the NDK | `unverified` — template only, never compiled |
| **Out-of-tree LKM** | An ordinary kernel module that reaches `sys_call_table` and swaps a pointer | Kernel source matching the device's exact vermagic, and on ≥5.7 a way around `kallsyms_lookup_name` no longer being exported | `unverified` — template only; rated last of the three on cost |
| **eBPF probe** | A BPF program on a syscall tracepoint or kprobe | GKI 5.10+ with the tracing/BTF machinery | `unverified` — template only; the version gate alone closes it on most older devices |
The eBPF row carries a capability ceiling that is easy to miss and is stated in the generated file
as well: **a tracepoint can observe a syscall, not rewrite its result.** `bpf_override_return()`
only applies to functions flagged `ALLOW_ERROR_INJECTION`, which raw syscall entries are not —
so "eBPF to spoof a `/proc` read" is, on most kernels, an observation plan wearing a rewrite
plan's clothes. Observation is genuinely useful (it tells you which syscall the check uses, which
`§5` requires you to know anyway); it is not spoofing.
**Field notes on the KPM route, from a public KPM development write-up** (`inferred` here — the
author's measurements, not this repository's; source: blackr0ck, *APatch KPM 开发*,
[bbs.kanxue.com/thread-291665.htm](https://bbs.kanxue.com/thread-291665.htm), 2026-06). These are
recorded because each one is a day of somebody's time:
- **An inline hook can install cleanly and never fire.** On a GKI kernel with LTO, the exported
symbol is frequently not the call site — the callee was inlined into its only caller, so
replacing instructions at the symbol's address intercepts nothing. The reported symptom is
"module loaded, callback never ran". Pointer replacement in the syscall table does not have this
failure mode, because the syscall entry path must go through the table.
- **The instruction-sequence length in a module header is a trap of its own.** The reported cause
of a hard-to-diagnose load failure: declaring a resolved kernel function with `extern` makes the
compiler emit an undefined reference (`*UND*`) instead of allocating the slot, and the loader
refuses the module with `unknown symbol`. Letting it be a tentative definition allocates `.bss`
and the loader fills it in. The generated template states this rule in the code, and
`verify` checks for it.
- **Per-syscall callbacks run on every syscall of that number, system-wide.** The reported
incident: a `write` hook that did not first reject `fd <= 2` intercepted the framework's own log
output tens of thousands of times and the device rebooted. **Performance is correctness here** —
the first statement in every callback is a filter that rejects the common case.
- **Which hook point survives is kernel-specific, and the cheap way to find out is to start at the
syscall layer.** The same write-up reports that modifying the SELinux internal function they
first targeted crashed the kernel under every combination tried (before/after, argument,
return value, skip-origin), while a syscall-table hook on `write` proved stable and sufficient.
Treat the syscall layer as the default and move inward only with evidence.
**Measured on the reference device, and why every template here is labelled the way it is.**
`<DEVICE>` runs kernel **4.14.186+** (`adb shell 'uname -r; cat /proc/version'` →
`4.14.186+`, `Linux version 4.14.186+ (nobody@android-build) (Android (6443078 based on r383902)
clang version 11.0.1 ...) #1 SMP PREEMPT Wed Mar 30 23:32:42 CST 2022`). On that device the two
remaining gates answer themselves:
- **eBPF**: 4.14 is far below the 5.10 gate. Closed, as §4 already recorded.
- **KPM**: no KernelPatch-patched image is present, and the host has no
`aarch64-none-elf-gcc` (`kernelsu_syscall_mask.py gates` reports `NOT FOUND`, along with no
`ndk-build` and no `make`). Installing APatch means patching the boot image — a bricking risk
that this repository does not take on an APK task.
- **LKM**: no kernel source for the device, so no matching vermagic is possible.
So on this machine the honest answer to "do you have a kernel-level weapon" is: **you have a
template, a gate table and a measured statement that the gate is closed here.** That is a route
decision, not a failure — and it is the same shape of answer §6 gives for the whole escalation
ladder. Do not let the existence of the template change what you claim about it: `unverified` is
still `unverified`.
## 5. The syscall-level realities that survive every hook
Two facts decide a lot of "unexplainable" behaviour, and both belong to the kernel's design
rather than to any tool:
1. **`TracerPid` is written by the kernel, not by a library.** Anything that ptraces the target
(classic Frida attach, debuggers) makes `/proc/self/status` show it, and the only readers that
miss it are the ones you broke. Ways out, in escalating cost:
- attach-free operation: **Zygisk** (injection at fork; no ptrace) or Frida **spawn** (still
ptrace-based but only during injection — verify for your version) or a memory patch applied
before detach (`scripts/spawn_patch_detach.py`, `measured`);
- kernel-level hiding of the field (KPM/eBPF rewrite of the proc read) —, `unverified`;
- **self-ptrace**: the target ptraces itself so nothing else can (classic anti-anti-debug);
works because only one tracer is allowed — but it constrains your tooling to non-ptrace
routes anyway. `inferred`.
2. **A raw `svc` cannot be intercepted from userspace at all.** No PLT, no libc, no hookable
symbol. Every plan that says "hook open/read and spoof the maps" silently assumes the target
calls libc; a hardened target does not. Verify which one you have before building the spoof:
disassemble the check's `.so` and look for `svc #0` (AArch64) / `int 0x80`-era equivalents in
the check path (`inferred`; `scripts/native_crash.py`-style capstone disassembly applies).
## 6. What the reference environment chose (and why it generalises)
On the 4.14-kernel reference device, the escalation ladder terminates early — and that is the
honest outcome (`observed` on that device):
| Layer | Status on a 4.14 Magisk device |
|---|---|
| LSPosed (Zygisk) module hooking | available — framework activation verified by log (`welcome to LSPosed!` lines, `lspd` daemon process) |
| MT Manager on-device editing/repack/sign | available (`on-device-tooling.md`) |
| eBPF tracing | closed (kernel gate) |
| KPM / kernel module route | closed on this device — no KernelPatch image, no kernel source, no bare-metal ARM64 toolchain; the **template and gate table** are shipped (§4a) |
| Frida with disguised server | available (a renamed server binary was present on the device from prior work) |
The generalisable rule: **enumerate the ladder for *your* device once, write the one-line
environment facts, and stop re-deriving them mid-task.** A closed rung is a route decision, not a
failure.
## 7. When to stop escalating — the decision table
This extends `detection-and-anti-analysis.md` Step 2 with the kernel rung. The repo's stance:
R3 (never ship or claim an unverified artifact) outranks winning the arms race; a detector you
defeated with a custom kernel module is not a deliverable anyone can install.
| Situation | Do this | Not this |
|---|---|---|
| Check is userspace, hookable, single-site | Neutralise at the site (`native-tamper-and-suicide.md` rules) | Building a hide stack |
| Check runs before your userspace tool (init_array) | Zygisk-route module, or static patch of the check itself | Earlier userspace attach racing the constructor |
| Check uses raw syscalls | Static patch of the check; or LSPosed/Zygisk module hooking the consuming code | "Hook libc open/read" (does nothing) |
| Check detects the *environment* (root/emulator), not your patch | `detection-and-anti-analysis.md` A/B/C — usually a different device or route | Kernel work |
| You are about to write a kernel module / patch a kernel | Stop. State what is blocked and the evidence; propose the static/module route | Kernel development (out of scope for this skill) |
| You have the kernel template and want to load it on a 4.14/Magisk device | Read `§4a`'s gate table, confirm the gate is closed, and say so | Patching a boot image to obtain a kernel route, on an APK task |
| A kernel-side template exists, so it feels like a weapon | It is `unverified` until a device with the matching gate compiles and loads it | Presenting a template as a working capability |
| Escalation effort exceeds the user's actual ask | `detection-and-anti-analysis.md` stop signal — switch to static | One more layer |
## Checklist
- [ ] `detection-and-anti-analysis.md` read first; A/B/C chosen deliberately
- [ ] Check located on the timeline () before choosing a counter-layer
- [ ] Root scheme's artefacts enumerated (manager/daemon/mounts) before blaming the target
- [ ] Hiding module: exactly one of Shamiko / Zygisk-Assistant; denylist configured to its spec
- [ ] Kernel rung checked against `uname -r` — one line recorded if closed
- [ ] Kernel-side template treated as `unverified` until a device with the matching gate
actually compiles and loads it (`§4a`); the userspace module skeleton is a carrier for
configuration, not a kernel capability
- [ ] Raw-syscall vs libc-call distinction verified by disassembly before building any spoof
- [ ] No kernel development undertaken as part of an APK deliverable

View File

@@ -0,0 +1,370 @@
# Long-Task Discipline
Load this when a task is likely to run long: many rounds, many experiments, or a conversation that will
exceed what can be held in context. Also load it before resuming a task someone else (or a past you)
started.
The failure mode this file prevents is not tooling — it is **judgement drift**. Over a long task, three
things reliably go wrong, and each is expensive in a way that is invisible while it happens:
1. **You re-derive what you already proved.** Rounds get spent rediscovering a boundary or a mechanism
that was established earlier and then lost.
2. **You re-walk a route that was already excluded.** The exclusion is real, but the reason has been
forgotten, so the route looks promising again.
3. **A wrong conclusion keeps steering.** An early mis-attribution is written into your mental model and
silently removes good options, or props up bad ones, for a long time afterwards.
All three come from the same cause: **conclusions living in context instead of on disk**.
## Keep a live record, not a log
Maintain one artifact — a single file — that is the task's authoritative state. Update it as things are
learned, not at the end. What matters is not size; it is that every entry is **actionable later**.
Keep these sections:
| Section | Contents | Why |
|---|---|---|
| **Operating rules** | things that must be true every run (how to install, how to launch, how to capture) | prevents repeating a mechanical mistake |
| **Confirmed facts** | each with the run that proved it | the basis everything else builds on |
| **Refuted conclusions** | what you believed, why it was wrong, what replaced it | stops a dead idea from coming back |
| **Dead routes** | ruled out, with the evidence | the single biggest time saver |
| **Open questions** | what is still unknown | keeps the next step honest |
| **Environment** | device, ports, tool paths, credentials locations | avoids re-discovery |
Two rules make this file worth having:
- **Every claim carries its evidence.** "The shell rejects this" must include the run that showed it,
including the exact failure text. An unattributed claim is how a false conclusion gets adopted later.
- **Refutations are first-class entries.** When you find that an earlier conclusion was wrong, record
*both* the wrong conclusion and why it was wrong. Deleting the mistake loses the most valuable thing
you learned.
## Grade your own conclusions
Label every non-obvious statement with its strength, and never let a weaker label inherit the authority
of a stronger one:
| Grade | Meaning | May it justify a decision? |
|---|---|---|
| **Observed** | reproduced it, with the exact command and output | yes |
| **Inferred** | follows from an observation, but the step is reasoned | provisionally |
| **Hypothesis** | plausible, untested | only as something to test |
| **Refuted** | tested and found false | never — keep it only as a warning |
Most long-task damage comes from hypotheses drifting upward into "facts" simply by being repeated. If
you catch yourself referring to something as established, check the grade in the record. If it is not
observed, go observe it or label it again.
## Every script answers with a token, not with prose
A long task is a chain of decisions, and each decision reads the *result* of the step before it. When
that result is human-readable prose, the reading step becomes an interpretation — and the
interpretation is where a wrong conclusion enters with no error message attached. This repository has
already paid for it once: a `logcat`-only verdict concluded that a hooking module never ran, and the
module had run nine times.
So a script in this kit ends with a machine-decidable answer, and the exit code carries the same
meaning:
- **A final `RESULT=<token>` line**, from a small closed vocabulary (`clean`, `patched`, `success`,
`crash`, `timeout`, `unavailable`, …). The token is the answer; everything before it is evidence for
a human.
- **Exit codes with fixed meaning**: `0` the token names success, `1` a real negative finding, `2` the
script could not do its job (usage, unreadable input, missing dependency). **A `2` is never a
finding about the target** — that distinction is the whole point, and conflating it turns a broken
harness into a property of the app.
- **Counts as tokens too** where a count is the measurement (`PROBE_COUNT=`, `VULN_COUNT=`,
`events=0`), so "nothing happened" is a value you can branch on rather than an absence you have to
notice.
The rule for the reader is the other half: **decide from the token and the exit code, and only then
read the prose.** When the two disagree, the token is what the script's author defined and the prose
is what they were thinking that day.
## Single-variable discipline across the whole task
The most common source of a wrong *and durable* conclusion is a compound experiment: two changes, one
failure, one invented explanation. Because the failure is real, the explanation feels earned.
Consequences to enforce:
- When a route is about to be abandoned, **re-read why it was abandoned**. If the evidence was compound,
the abandonment is not yet justified — re-run it single-variable before writing it off.
- Keep a semantic control in your pipeline: a build with **no** changes, run through the same
install-and-launch path. If the control fails, nothing else you measure is meaningful.
## Guard against drift at natural checkpoints
Do these cheaply, and only when they can change a decision — not as ceremony.
- **Before starting a new experiment**: read the refuted-conclusions and dead-routes sections. If your
plan appears there, stop and read why.
- **Before declaring progress**: ask what the *user-visible* outcome is right now. An internal signal
improving is not progress (see P19). Re-state the actual target.
- **After any surprise**: write it down immediately with evidence, before you have a theory. Theories
written after the fact are hard to distinguish from observations.
- **When context feels long**: prefer writing to the record over re-reading conversation. The record is
what survives; the conversation is what gets truncated.
- **When resuming**: read the record first, and treat anything not in it as unknown, even if it feels
familiar.
## The most expensive drift: solving it in an environment the deliverable will never see
There is one drift in this domain that costs more than all the others, because it produces a
result that looks like success and is not one.
**A runtime-only result is easy to obtain and easy to mistake for a finished artifact.**
Editing a data file, hooking a live process, blocking a hostname, holding a proxy open — each of
these can make the app behave correctly *on the machine where you did it*, with no repackaging
required. It is often the fastest path to a visible win. It is also frequently **not the
deliverable at all**, because the requirement was never "make it work here".
The constraint axes that decide this — check every one against the original request, and write
down the answer as a testable sentence before you start:
| Axis | The question | Why it silently invalidates work |
|---|---|---|
| **Privilege** | must it run **unrooted**? | a rooted-only result cannot be given to a normal user at all |
| **Modification form** | must it be a **rebuilt/installable artifact**, or is a live-instrumentation result acceptable? | hooks and data edits do not ship inside an APK |
| **ABI / device class** | which ABI, which device family? | an emulator-only or x86-only result is not evidence for a physical ARM device |
| **Network** | must it work **online**? | a fix that depends on being offline or on a host proxy fails the moment it is used normally |
| **Persistence** | must it survive restart, upgrade, and a fresh install? | in-memory and session-scoped state evaporates |
| **Distribution** | does the *shipped file* have to be self-contained? | anything that needs a helper on the machine is not a shippable artifact |
**Checkpoint question, asked at the same moments as the drift checks above:**
> *If I handed over exactly what exists right now, would it satisfy the constraint sentence I wrote
> at the start?*
If the answer is no, you have made real progress on a **component**, and that is worth stating as
such — but it is not the task. Do not let "it works" stand in for "it works under the constraint".
**What to do instead of over-claiming.** Split the result into two explicitly labelled parts:
1. **What is achievable inside the constraint**, and how far along it is.
2. **The unconstrained workaround** (needs root / needs a host / needs a proxy), stated as a
deliberate fallback, with its requirements made obvious to the reader.
A privileged workaround is genuinely useful — it can be the difference between using the app and
not. It becomes a problem only when it is presented as the deliverable. Report it as a fallback,
keep the constrained goal open, and say plainly which one you have.
**Also watch the inverse**: if the constraint is "must be a rebuilt artifact", do not let a working
runtime result quietly close the investigation. Use it for what it is good for — it proves the
mechanism and identifies the exact code or data to change — then port that finding into the
artifact. The runtime result is the map, not the destination.
## Bound every wait
A long task stalls in ways that produce no information and consume the most valuable resource you
have: wall-clock time and the next person's patience. Three habits prevent almost all of it.
**1. Every command has a timeout, and its absence is the bug.** A helper that shells out without a
timeout can hang forever, and the failure presents as "the task stopped making progress" rather
than "this call blocked". Pass an explicit timeout on every subprocess, every HTTP request, every
device call. When one expires, the result is **unknown**, not failed — record it that way and
re-check state before retrying.
**2. Calibrate the expected duration from measurement, not from a guess.** A timeout only means
something if it is set relative to how long the operation *should* take, because that is what makes
"slow" and "hung" distinguishable. Guessed budgets are either so short that healthy work gets killed,
or so long that a stall looks like patience.
So: **measure once, write it down, then derive the bound.**
| Operation class | How to bound it |
|---|---|
| host-side tool (`baksmali`, `apksigner`, a compiler) | seconds to low minutes; time it once, then set roughly 3–5× observed |
| device shell call (`adb shell …`) | seconds — **except** the first `su` after a reboot, which can prompt and block |
| install / push of a large artifact | minutes; scales with size, not with the app's complexity |
| app cold start to first frame | tens of seconds — **longer with a packer**, which does real work before your code runs |
| a step needing a human or an external service | **unbounded in principle** — do not wait at all (see P22) |
| waiting for an on-screen state change | bound it *and* sample it (point 3) |
Record the observed durations in the live record. They are exactly the kind of mechanical fact that
gets re-derived painfully after a context loss, and having them turns a later timeout into an
interpretable signal instead of a mystery.
**Exceeding the expected window is itself the finding.** It tells you something about state — the
device is wedged, the app never reached that phase, the action never happened — and that is a
different investigation from waiting longer. Re-check state instead of extending the deadline.
**3. Every wait has a deadline, an observable to sample, and a look.** "Wait for the operation to
finish" is not a plan. Name the observable, how often you will sample it, how long you will sample
before giving up, and what you conclude on expiry:
```
waiting for: <observable> e.g. the overlay is gone / the counter incremented
sample: <interval + how> e.g. every 15 s: screenshot + current window focus
give up at: <deadline> e.g. 150 s
on expiry: <what I conclude> e.g. "did not complete within the window" -- not "cannot work"
```
**Sample with your eyes rather than sleeping blind.** When the step is gated on something visible — a
screen, a dialog, a progress indicator — capture it and **look at it** instead of sleeping through the
interval. A fixed sleep either wastes time or measures the wrong instant; a look tells you what
actually happened, and byte-identical consecutive samples tell you nothing is going to change
(`environment.md` §look at the screen). This is the cheapest way to avoid spending rounds on a state
that was never going to arrive.
Three rules that follow:
- **A deadline that passes is a measurement, not a verdict.** "Still not finished after N seconds"
tells you about latency and reliability. It does not tell you the approach is impossible, and
writing it down as impossible is how a viable route gets discarded.
- **Never busy-poll a long job while you have independent work.** Start it, do the other thing, and
collect it when it settles. Polling wastes the same resource the task is already spending.
- **Prefer a bounded observable over a fixed sleep.** Poll the state you actually care about, with a
cap — and prefer a sample you can inspect over a duration you hope is right.
## Keep the observation window clean
Every conclusion in this skill rests on "the build I am looking at is the build I made". That
assumption is silently false more often than any other, and a contaminated window does not look
contaminated — it looks like a result.
Three ways it happens, all of which have produced confidently wrong findings:
- **Something else touched the target while you were observing.** Another process, another agent,
another window of your own work installed, reverted, restored or cleared the app. Your screenshots
and logs are then a mixture of two states and describe neither.
- **You are looking at a stale process.** The app was never actually restarted, so the "before" and
"after" captures come from the same run.
- **The artifact on the device is not the artifact on disk.** An install that reported success, or was
skipped because the version matched, leaves the previous build running.
Guards, in order of cost:
1. **Pin the identity, not the filename.** Hash the artifact you built, hash the artifact you intend
to test, and hash what is actually installed (pull the installed APK or read its digest) — the
filename proves nothing. Do this immediately before the observation window, not hours earlier.
2. **Take the window deliberately.** Before a capture sequence that a conclusion will rest on, state
(to yourself or in the record) that the next N seconds are for this observation only, and do not
run another install/uninstall/clear inside it.
3. **Timestamp the window.** Record the wall-clock start and end. It costs one line and it is the only
way to later notice that a teammate, a background job or your own earlier command landed inside it.
4. **Prefer one continuous capture over several short ones.** A restarted capture invites a restarted
state; a single sequence cannot straddle an install it did not perform.
When you discover a window was contaminated, **the correct action is to discard it and re-capture**,
not to salvage it. A re-run costs minutes; a wrong conclusion costs the rest of the task, and it will
be re-derived from the same bad evidence because the record says it was observed.
### Captures you never looked at are not evidence
This is a distinct failure from a contaminated window, and it survives every other guard here. The
frames are real, the timestamps are honest, the hashes are right — and nobody looked at them.
Two shapes it takes, both of which produce a confident wrong answer:
- **You took the screenshots and moved on.** The images exist; the conclusion was drawn from logcat
or from the patch itself. A burst of uninspected frames reads in a report exactly like a burst of
inspected ones.
- **You did look, but at the wrong thing.** Every frame shows a window that is not your app — a
vendor installer confirmation left over from an earlier install, a system dialog, the launcher —
and because the frames are consistent with each other, conviction grows. Consistency is not
corroboration when all the frames share the same blind spot.
Guards:
1. **Check what is on screen before trusting any capture.** One command settles it:
`dumpsys activity activities | grep -m1 ResumedActivity`. If the component is not your package,
the frames describe something else. Do this at the start of a capture sequence and again at the
end — the foreground can change mid-sequence.
2. **Inspect immediately, not "later".** Look at the first frame, the middle one, and any frame at a
moment you care about (the second the splash would have shown, the moment a dialog would have
appeared). If you cannot describe in one sentence what a frame shows, you have not looked at it.
3. **Treat identical consecutive frames as a signal.** Byte-identical frames mean the screen is
static. That is a finding — a hang, a dialog waiting for input, an activity that never changed —
not a capture artefact to be skipped over.
4. **Name what you saw, per frame, in the record.** One line each. "f03 at 2.1 s: target main screen,
list populated" is evidence; "captured 12 frames" is a file listing.
5. **A screenshot of a clean-looking screen is not proof that a dialog is absent** unless you captured
continuously across the moment it would have shown. Sampling is not observation: a modal that
appears and is then occluded can fall entirely between samples, and every frame you happened to
take shows something else.
`scripts/coldstart.py` exists to make points 1 and 5 automatic: it takes a timed burst, prints the
foreground component, and refuses to present the run as meaningful if the foreground is not your
app. It still cannot look at the frames for you — that part is on you.
## Long-context decay: the same mistake, twice
A long task has a failure mode that has nothing to do with the target: **as the
working context fills, settled conclusions lose their force.** Something that was
established and verified two hours ago becomes, by the end, just another plausible
belief — and the cheapest way to get from a stuck point to a feeling of progress is
to re-try the thing that already failed.
The symptoms, which are recognisable if you watch for them:
- You are about to run a command whose result you already recorded earlier.
- You are about to re-derive a fact (package name, an offset, a version, which
patch landed) that is in your own notes.
- You are about to re-attempt an approach that failed — and the failure is not
obviously connected in your mind to this attempt.
- A conclusion you are treating as solid was actually never verified, only
assumed early on and repeated since.
- You are rewriting a summary of the task from memory rather than reading the
record.
**The antidote is a record written for the second half of the task, not for a
human at the end.** Keep it structured so a weakened-context read still gets the
decision-relevant content:
```markdown
## Settled (verified — do not re-derive)
- <fact> [how it was verified, one clause]
## Refuted (do NOT retry — each cost time)
- <approach> — fails because <mechanism>
## Unverified assumptions currently in play
- <assumption> — becomes a problem if <condition>
## Next action, and how it will be judged
- <one step> — success looks like <observable>
```
Three rules that make the difference:
1. **Record the mechanism, not just the outcome.** "Traversal via that endpoint
broke the home screen because the child request shares the parent load" survives
context decay; "tried the endpoint thing, did not work" does not — and the
second version invites a third attempt.
2. **Re-read the record at every checkpoint, and before starting any new
experiment.** Not at the end. The cost is seconds and it is the only thing
standing between you and a loop.
3. **Two failures of the same shape means the model is wrong, not the
parameters.** Do not run a third variation. That is the same rule as the
two-strike rule in `SKILL.md`, and long-context decay is exactly what makes
people violate it.
A useful asymmetry: **re-verifying is cheap, re-deciding is not.** Reading a value
back out of the artifact takes a second and is fine. Re-running a whole experiment
because you forgot its conclusion is what costs the round.
## Handover
A handover is the record plus three things, written for someone with **no** memory of the task:
1. **Operating rules first** — anything mechanical that silently wastes time if unknown.
2. **Honest current state** — including what is *not* achieved. An optimistic summary costs the next
person far more than an accurate one, because they will build on it.
3. **The next best step, and why** — plus the dead routes, so they do not start there.
Two things belong in every handover, because they are the hardest to recover:
- **What you got wrong**, with the evidence that corrected it.
- **What you never actually verified**, stated plainly. "Never confirmed" is more useful than silence.
## Avoiding the opposite failure
Discipline can itself become a burden. Guard against that too:
- **Do not log everything.** Record only what would change a future decision or prevent a repeated
mistake. A record nobody reads is worse than no record, because it looks like coverage.
- **Do not treat the record as authority.** It is a summary; the environment is ground truth. If the
record and a fresh observation disagree, re-test — do not defend the record.
- **Do not let grades calcify.** A hypothesis that becomes testable should be tested, not archived.
- **Do not let the record's structure dictate the work.** If a section is empty because it is not
relevant to this task, leave it empty.

View File

@@ -0,0 +1,353 @@
# LSPosed modules — when to deliver a hook instead of a patched APK
Load this when the honest answer to "patch the APK" is *no*: the target checks its own bytes, derives
keys from its signature, re-downloads what you removed, or is simply cheaper to hook than to rebuild.
A module is a different **deliverable**, not a different way to analyse — it changes what you ship, how
it survives upgrades, and who has to install it.
This file covers the module route end to end: choosing it, the smallest project that works, the
Gradle-free build chain, deployment and verification, what hides it from the target, and what it
cannot reach at all.
**Route honesty first.** A module does not modify the APK, so it cannot be "the patched build". If the
user asked for an installable standalone APK, a module is not that artifact, and this file will not
make it one.
## Step 0 — pick the route deliberately
Three routes ship three different things. Choose on **who runs it and for how long**, not on which one
you enjoy more.
| Route | Deliverable | Runs when | Survives target update | Cost |
|---|---|---|---|---|
| **Frida** (attach/spawn) | a script, driven by you | while a server process is alive on the device | it does not; you re-run it | lowest to start, highest per-session |
| **LSPosed module** | an installable APK the user keeps | every process start, automatically, after the module is enabled and scoped | usually yes — the hook targets classes/methods, not offsets | mid: one project, no repackaging of the target |
| **Repackaged APK** | a modified, re-signed APK | whenever the app runs | no — every update needs rework | highest: integrity, signature, packing, repack |
Reach for a module when **any** of these is true:
- the target verifies its own signature or APK digest, so a repackaged build dies on arrival
(`references/signature-derived-keys.md`, `references/native-tamper-and-suicide.md`);
- the change must persist across restarts without your laptop being attached;
- attaching a debugger/injector is itself detected, but a startup-time hook is not
(`references/detection-and-anti-analysis.md`);
- you need a Java-level behaviour change only, and the target's own code is reachable from Java.
Do **not** reach for a module when the logic you need lives in native code or in an AOT-compiled
runtime: see *What this route cannot reach* below.
## What the module can and cannot change
| Layer | Module can reach it? | Where to go instead |
|---|---|---|
| Java/Kotlin app code (incl. classes loaded late by a packer's ClassLoader) | yes, once you have the right `ClassLoader` | — |
| `Application` / `Activity` / framework lifecycle | yes | — |
| Java SDK internals (OkHttp, ad SDKs, HTTP clients) | yes | — |
| Pure native logic inside a `.so` | no — Java hooks see only the JNI boundary | `references/native-and-so.md`, `references/native-dbi-and-deobfuscation.md` |
| Extracted / virtualised dex (instruction-抽取, dex VMP) | no — the original method bodies may not exist as dex at all | `references/advanced-unpacking.md` |
| Flutter / Dart AOT business logic | no — Dart is not Java; the Java layer only hosts the shell | `references/dart-aot.md` |
| Behaviour that is *decided* server-side | irrelevant — a hook cannot remove a server rule | `references/server-api.md`, `references/server-config-and-updates.md` |
The Flutter row is the one that most often surprises: hooking every Java entry point in a Flutter app
still leaves all business logic behind `libapp.so`. Confirm which layer owns your target before
building a module for it (`references/dart-aot.md`).
## How injection actually works
The mechanism matters because every failure mode below is a consequence of it.
- LSPosed ships as a **Zygisk module** (`zygisk_lsposed`). Zygisk loads into `zygote`, so every app
process forked from it is a candidate for injection. [inferred: standard Magisk/Zygisk design]
- A module is **not** injected everywhere. For each forked process LSPosed asks its daemon (`lspd`)
whether this package is in the module's **scope**, and only then loads the module. Scope is therefore
the real gate; anything inside the module that filters on package name is a second, weaker gate.
- Module code is loaded **from memory**, not from a file mapping — the module's dex is read out of the
installed APK and handed to an in-memory ClassLoader. This is why a target scanning `/proc/self/maps`
for path names finds nothing (`references` below, *What detects you anyway*).
- The entry point is the class named in `assets/xposed_init`, and it receives
`handleLoadPackage(XC_LoadPackage.LoadPackageParam)` **before the application's own `Application`
object is attached** — the earliest Java-visible moment in the process. [measured: the injection
point is why `Application.attach` and `Activity.onCreate` hooks installed from here fire even under a
packer]
- `LoadPackageParam.classLoader` is the **application's** loader (the packer's stub loader when the APK
is packed). It is the handle you use to find classes the app loads later, and it is also the object
anti-Xposed code counts — see *What detects you anyway*.
## Minimal module anatomy
Four files. Nothing else is required — no `res/`, no launcher Activity, no permissions.
| File | Why it is required |
|---|---|
| `AndroidManifest.xml` with `<meta-data android:name="xposedmodule" android:value="true"/>` | without this key the APK installs as an ordinary app and never appears in the module list |
| same manifest: `xposeddescription`, `xposedminversion` (`82`) | shown in the manager UI; `82` is the classic XposedBridge API level and is accepted by LSPosed |
| `assets/xposed_init` | one line: the fully qualified entry class. A typo here produces a module that loads and silently does nothing |
| the entry class implementing `IXposedHookLoadPackage` | the only class LSPosed instantiates for you |
`scripts/lsposed_scaffold.py` writes exactly this set, plus a README containing the build chain below,
so the project is reproducible without Gradle:
```sh
lsposed_scaffold.py --package com.example.probe --name "Example probe" \
--hook-target com.example.target --out ./module
```
The generated entry class logs on **every** scoped process load before anything else, and hooks
`Application.attach` / `Activity.onCreate`. Both are diagnostics first: the first line proves injection,
the second names the real Activity classes (under a packer, these are the *unpacked* classes, not the
manifest's stub).
## Building without Gradle
There is no resource to compile and no dependency resolution to do, so the whole toolchain is five
commands. [measured on a Windows bench, JDK 17, build-tools 34.0.0, Android API 28 android.jar,
Xposed api-82 stub jar; end-to-end 5.3 s for a one-class module]
```sh
BT=/path/to/build-tools/34.0.0
AJ=/path/to/android.jar # platforms/android-28/android.jar
API=/path/to/api-82.jar # de.robv.android.xposed:api:82
OUT=build
javac -encoding UTF-8 -source 8 -target 8 -nowarn -cp "$AJ:$API" \
-d "$OUT/classes" src/com/example/probe/MainHook.java # 850 ms
jar cf "$OUT/classes.jar" -C "$OUT/classes" . # 259 ms
"$BT/d8" --min-api 24 --lib "$AJ" --output "$OUT" "$OUT/classes.jar" # 1143 ms -> classes.dex
"$BT/aapt2" link -o "$OUT/base.apk" -I "$AJ" --manifest AndroidManifest.xml \
--min-sdk-version 24 --target-sdk-version 28 -A assets # 126 ms
cp "$OUT/base.apk" "$OUT/unsigned.apk"
cp "$OUT/classes.dex" . # must sit next to the apk
(cd "$OUT" && "$BT/aapt" add unsigned.apk classes.dex) # 97 ms
"$BT/zipalign" -f -p 4 "$OUT/unsigned.apk" "$OUT/aligned.apk" # 76 ms
"$BT/apksigner" sign --ks mod.keystore --ks-pass pass:android --key-pass pass:android \
--v2-signing-enabled true --out "$OUT/module.apk" "$OUT/aligned.apk" # 1794 ms
"$BT/apksigner" verify --print-certs "$OUT/module.apk" # 725 ms -> v2/v3 OK
```
Four traps, all measured rather than guessed:
- **`d8` rejects a directory.** Pointing it at `build/classes/` fails with *Unsupported source file
type*. Jar the classes first and pass the jar.
- **`aapt add` resolves its argument relative to the current directory and stores the same name.** Run
it from the output directory with `classes.dex` present there; a path like `dex/classes.dex` lands
inside the APK as `dex/classes.dex` and the module never loads.
- **`jar` and `keytool` are frequently not on `PATH` even when `javac` is.** Oracle's `javapath` shim
exposes only `java`/`javac`; call the JDK's `bin` directory explicitly.
- **`-bootclasspath` no longer exists on JDK 9+**, so the Android API comes from `-cp` together with the
Xposed stub jar. `-source 8 -target 8` keeps the class-file version low enough for `d8`.
The generated README carries the same chain in copy-paste form, including the Windows variants
(`.bat` wrappers, `;` classpath separator).
## Deploy, enable, and verify
**There are two independent switches.** Getting one right and not the other is the single most common
"I built a module and nothing happens" report.
| Switch | Where | What it actually controls |
|---|---|---|
| package enabled state | `pm enable <module>` / `pm disable <module>` | whether Android allows the package's components to run at all |
| module enabled | LSPosed Manager (module list) | whether LSPosed loads the module into scoped processes |
| scope | LSPosed Manager (module → scope) | *which* packages the module is injected into |
[measured: a freshly installed module can be in the module database yet disabled — the package
manager reported `enabled=0` for the module and `pm enable` flipped it to `enabled=1`, while the
LSPosed-side enable flag was still 0 in its own database]
A third, device-level gate sits on vendor ROMs: the *install* itself can be intercepted by the ROM's
security centre. The shape is a shell-identity `pm install` returning a bare `Failure [-99]` while the
vendor security app takes the foreground, and the **same APK installing cleanly as root**
(`su -c 'pm install -r <apk>'`). If a module APK "will not install" on a phone that is otherwise
healthy, try root before suspecting the APK. [measured; the extension record has the transcript]
Verification has **two channels**, and on a device with a broken platform log only one of them works:
```sh
# 1. the platform log — where the module's line normally appears
adb logcat -s <TAG> # then start the target app; do NOT clear the buffer first
# 2. LSPosed's own module log — where it appears when logd is broken
adb shell "su -c 'cat /data/adb/lspd/log/modules_<timestamp>.log'"
```
The expected first line in either channel is the one the scaffold writes on entry — it fires before
any app code:
```
<TAG> injected: <target.package>/<process> cl=<loader>@<hash>
```
[measured] On a device whose `logd` route is broken, channel 1 is **completely empty** — not merely
`logcat -s <TAG>`, but `logcat -s LSPosed-Bridge` and a full-buffer search for the tag as well — while
channel 2 shows a complete, successful injection for every process start. A ROM whose LSPosed log
opens with `Logd maybe crashed (err=Socket operation on non-socket)` is exactly this case, and it had
nine successful injections in it while logcat showed nothing at all. Debugging with `logcat` alone
there produces the confident and wrong conclusion "the module never ran".
Decision order when that line is missing — stop at the first hit:
1. module enabled in LSPosed Manager?
2. target package present in the module's **scope**?
3. module APK still installed (`pm path <module>`)?
4. **was the target process started after the scope change?** Scope is read for newly forked
processes; a process that was already running keeps the decision it got at fork time.
[external, matches LSPosed behaviour reported independently: a scope change takes effect on the
target's next start, no device reboot required — 看雪 thread-291750]
The LSPosed-side state is inspectable from a root shell:
| Path | Contents |
|---|---|
| `/data/adb/lspd/config/modules_config.db` | SQLite: `modules(mid, module_pkg_name, apk_path, enabled)`, `scope(mid, app_pkg_name, user_id)`, `configs(...)` [measured] |
| `/data/adb/lspd/log/` | `modules_<timestamp>.log`, `verbose_<timestamp>.log`, `kmsg.log`, `props.txt` [measured] |
| process `lspd` | the daemon that serves scope decisions; it is a separate process from the target [measured] |
Two measured caveats about that log directory, because both waste time:
- `modules_*.log` **is** the module log: it is the file LSPosed appends your `XposedBridge.log()` lines
to. It may *also* contain `Logd maybe crashed (err=Socket operation on non-socket), retrying in 1s...`,
which means the platform log channel is unavailable — not that your hook failed. Check this file
**and** `logcat -s <TAG>`; a device can deliver one and not the other. [measured: nine successful
injections were in this file while logcat showed nothing]
- `verbose_*.log` is a *logcat snapshot* that includes the whole system, so it is useful for
correlating a system-side event with your run, and useless as a module log.
**Do not kill `lspd` to force a configuration reload.** [measured] On the bench it is started by
`/data/adb/modules/zygisk_lsposed/service.sh`, which runs `unshare -m sh -c "$MODDIR/daemon --from-service &"`;
the `daemon` script ends in `exec /system/bin/app_process ... org.lsposed.lspd.Main`. Magisk runs
`service.sh` once per boot, so an externally killed `lspd` is not restarted by anything you can see
from a root shell. The consequence is worse than a dead daemon: newly forked processes stop getting
scope decisions. Configure scope through the manager, which talks to the running daemon.
The same reasoning applies to editing `modules_config.db` by hand: the file is the daemon's
**persisted** state, while the decisions actually served to `zygote` come from the daemon's live copy.
A hand-edited row is not wrong — it is simply not read until the daemon reloads it, and there is no
supported way to make it reload. [inferred from the storage layout and the daemon lifecycle; the
manager path is the one that updates both]
## Hiding the framework from the target
Injecting a Java hook is trivial to detect **if the target is allowed to see the root/Zygisk
environment at all**. On a rooted device the hiding layer is a separate module, and the two common
choices differ in exactly one operational detail: how they learn which apps to hide from.
| | Zygisk Assistant | Shamiko |
|---|---|---|
| source | open | closed-source, LSPosed project |
| target selection | Magisk **DenyList** entries (or KernelSU profiles); no UI of its own | also reads Magisk's DenyList, **but requires DenyList enforcement to be OFF** |
| extra mode | — | creating `/data/adb/shamiko/whitelist` switches to whitelist mode without a reboot; documented as costly in memory/performance and test-only |
| mechanism | unmounts root-related paths and masks Zygisk presence for the selected process | same family of process-level hiding, plus its own mount/`maps` handling |
[external: Zygisk Assistant module description and Shamiko README]
**The DenyList/enforcement distinction is where people lose afternoons.** Enforcement is Magisk's own
feature that unmounts Magisk-provided files for listed processes; the *list* is shared, the *enforcement
toggle* is not the same thing. Shamiko wants the list without enforcement; Zygisk Assistant is
documented to work with the list as configured.
[measured on the bench: the DenyList was **enforced but empty** — `magisk --denylist status` reported
`Denylist is enforced`, `magisk --denylist ls` printed nothing, `/data/adb/shamiko` did not exist, and
Zygisk Assistant v2.1.4 was installed. So on that device nothing was being hidden from any app, while
LSPosed and `lspd` were running normally. If a target seems to know it is on a rooted device, check
this before blaming the module.]
Practical rules:
- **Keep the scope minimal.** Every extra scoped package is both an extra place your hook can break
something and an extra ClassLoader in that process (next section).
- Hiding and hooking are separate problems. A module that is perfectly hidden can still be detected by
the ClassLoader count; a target that never checks cannot see either.
- If the target is the *only* app you care about, scoping to it alone is both the smallest blast radius
and the least observable configuration.
## What detects you anyway
Traditional Java-level anti-Xposed checks — `Class.forName("de.robv.android.xposed.XposedBridge")`,
exception-stack scanning for Xposed frames, reflection on `findAndHookMethod`, enumerating
in-memory-ClassLoader instances — are all **query interfaces**, and hiding layers exist precisely to
falsify their answers. They are no longer a reliable signal that a module is present or absent.
The check that does not depend on a query interface is the **ClassLoader count**. [external: 看雪
thread-291750, which reports a clean baseline of 3 versus 12 with a module injected, threshold ≥9; the
original idea is traced there to thread-289567]
The reason it works, in one paragraph: ART's `ClassLinker` keeps a `class_loaders_` list of every live
ClassLoader, and that list is part of GC reachability. A module's loader must stay on that list or the
GC will collect the module's code and the hook dies. So a module cannot both remain alive and be absent
from a structure the runtime itself walks. Hiding layers can fake lookups; they cannot remove a node
from a GC-traversal list without unloading the module.
The same source reports two further native-side traces of the same injection, which are worth knowing
because they are independent of ClassLoader counting: the module's dex shows up as **anonymous
executable memory** (it is loaded from memory, so no file mapping exists), and Zygisk's patching of
`libart` leaves **private-dirty pages** in the library's executable segments.
Engineering consequences for anyone choosing this route:
- **Scope discipline is detection hygiene.** Fewer scoped packages means fewer injected processes; a
multi-module stack inflates the same count. The number is per-process, so a clean device elsewhere
does not help this process.
- **A count check needs a calibrated baseline.** On complex hosts (WebView, split APKs, plugin
frameworks, other modules) the clean count is already high, and the threshold has to be measured per
host, never copied. [external: same source]
- **Detection of the framework is not the same as detection of your change.** If the target reacts to
the count by refusing to run, that is an environment finding to report
(`references/detection-and-anti-analysis.md`), not something to escalate against indefinitely.
- If the target's check is native and inline-hooks *your* detector's counterpart, you are in DBI
territory: `references/native-dbi-and-deobfuscation.md`.
## LSPosed vs Frida — the division of labour
They are not competitors; they are the two answers to "how long must this hook exist".
| Need | Frida | LSPosed module |
|---|---|---|
| Understand a flow once, now | **yes** — interactive, no build step | no: build + install + restart per iteration |
| Ship something that works every launch, unattended | no: needs a server process | **yes** |
| Hook a `.so` function directly | **yes** (`Interceptor`, Stalker) | no: Java hooks stop at JNI |
| Survive a target that kills external injectors | weak: `ptrace`-based attach is the thing being detected | **stronger: no external process, no attach, no port** |
| Replace a method's return value in app code | yes | yes |
| Be re-runnable after a target update | yes (re-attach) | usually yes (class/method names auto-update far better than byte offsets) |
| Need no root on the target device | no | no |
The productive pattern is to use **both in sequence**: Frida to discover which class and method matter
and what the real call chain is (`references/dynamic-frida.md`), then a module to make that finding
permanent without touching the APK. Rebuilding a module for every question is the expensive way to
explore; exploring with an interactive script and only then writing the module is the cheap way.
A third option exists when the goal is *calling* target code rather than changing it — exporting a
native function over RPC: `references/emulation-and-rpc.md`.
## Failure modes
| Symptom | Most likely cause | Check |
|---|---|---|
| module never appears in the manager | `xposedmodule` meta-data missing/mistyped after manifest merge | `aapt2 dump xmltree` the built APK, or re-read the manifest |
| module listed, no `injected:` line in either channel | module disabled in manager, package not in scope, **target process not restarted** after the scope change, or you looked at only one channel | the four-step order above, then the two-channel check |
| the platform log is empty but the module log is not | the ROM's `logd` route is broken; the module is working | the tell is `Logd maybe crashed (err=Socket operation on non-socket)` at the top of the module log |
| `injected:` line present, but `TARGETS` gate skips the process | in-module gate, not the framework | read the module's own log line, then its gate |
| hook installed, method never fires | wrong class/loader: packers and plugin frameworks hold several ClassLoaders | resolve the loader at runtime; enumerate `/proc/<pid>/maps`-independent evidence via `references/dex-patching.md` |
| target dies right after injection | target side: ClassLoader counting or anonymous-memory heuristics (above); device side: ROM behaviour | instrument-free control run first (`references/detection-and-anti-analysis.md`) |
| `pm enable` had no effect on injection | `pm` state and LSPosed state are different switches | the two-switch table above |
| `d8` fails on a directory / `aapt add` cannot find the dex / `keytool` not found | build-chain traps, all three measured | the four traps above |
## Verification for the record
- which module APK, its hash, and what it was built from (scaffold output or hand-written);
- the build commands and whether `apksigner verify` passed;
- `pm path`/`dumpsys package` output for the module (installed, enabled state);
- the scope list actually configured;
- the target process start time relative to the scope change;
- the module's logcat lines, in order, showing injection and any hook installs;
- whether the target misbehaved, and the instrument-free control result for the same build.
## See also
- `references/dynamic-frida.md` — the exploration half of the workflow above
- `references/detection-and-anti-analysis.md` — when the target fights back and when to stop
- `references/native-and-so.md`, `references/native-dbi-and-deobfuscation.md` — the layer a Java hook stops at
- `references/dart-aot.md` — the layer that owns a Flutter app's logic
- `references/advanced-unpacking.md` — extracted dex and the VMP boundary
- `references/repack-and-sign.md`, `references/signature-derived-keys.md` — what you are avoiding, and why it is sometimes unavoidable
- `references/on-device-tooling.md` — MT Manager and the rest of the on-device kit
- `references/verification.md` — what "done" means, including the control-build rule
- `scripts/lsposed_scaffold.py` — generates the project skeleton described here

View File

@@ -0,0 +1,90 @@
# Membership, paywalls, and the honest limits of client-side patching
Read this **before** investing hours. Most "unlock VIP / remove the paywall" requests cannot be satisfied by patching the client, and knowing that early is the most valuable output you can produce.
## First question: who decides?
| Signal | Owner | Client patch can... |
|---|---|---|
| A local boolean/int/string field in an app model, computed locally | Client | Fully determine the outcome |
| A field that comes from an API response and is merely *read* by the UI | Server | Change display only — not access |
| Download/stream/play URLs issued by an API | Server | Nothing. You cannot mint a valid URL. |
| Entitlement checked on every protected action with a server round-trip | Server | Nothing |
| A local cache of a server value | Server (cached) | Temporarily fool the UI until it refreshes |
**Decision rule:** if the thing you want (a media URL, a decrypted payload, premium data) is *delivered by the server*, the client is not the gate. The gate is server-side and is not patchable.
## How to prove it in minutes
Do not guess. Test the API directly (`references/server-api.md`).
```
1. Call the protected endpoint with no credentials. -> 401/403 means auth-gated
2. Call it with an obviously forged token. -> another 401 confirms it is validated
3. Call the metadata endpoint. -> often 200, giving you the shape,
but deliberately omitting the asset URL
4. Compare: is the valuable field simply absent? -> then no client change can invent it
```
A concrete, real result pattern worth remembering:
- `GET /videos/{id}` → **200**, full metadata, cast list, source list — **but no playback URL at all**
- `GET /v2/sections/{id}/play-url` → **401** with no token
- same, with `Authorization: Bearer <forged>` → **401**
- `GET /user/me` with forged token → **401**
That combination is conclusive: metadata is public, the asset URL is server-minted, and the token is verified. **Client-side patching cannot unlock it.**
## What *is* legitimately achievable client-side
Don't overclaim, and don't underclaim either. Genuinely client-side wins:
- **Remove ads / SDKs.** Ads are a client behavior (`references/ad-removal.md`).
- **Remove a UI gate** where the underlying data is already delivered and only a local check hides it (a "locked" tab whose content is already fetched, a disabled button).
- **Skip promotional interstitials and self-promo popups** driven by local flags.
- **Unlock client-only features**: debug menus, extra player settings, higher local bitrate selection, gesture options.
- **Grant a client-side reward** only if the reward is neither validated nor authoritative server-side (check first — a reward that later fails server validation is worse than no reward).
- **Bypass a client-side trial timer** whose enforcement is purely local (but expect the server to re-assert on next sync).
## What is NOT achievable, and how to say so
- **Paid content whose URL comes from the server.** State it plainly and show the 401 evidence.
- **Server-validated rewards / purchases / credits.**
- **Anything the server re-checks on the next request.** The patch may look like it works for one screen and then silently revert.
Wording that is both honest and useful:
> The client cannot unlock this. Metadata is public, but the asset URL is issued by the server, and the endpoint returns 401 for both anonymous and forged credentials — so no local change can produce a valid URL. What *is* removable client-side is X, Y, Z. To actually get access you need a real account with the required entitlement.
## The fake-VIP trap
Forcing a local `isVip()`-style method to return `true` is the classic mistake. Two failure modes:
1. **Cosmetic only** — UI shows VIP styling, but protected actions still fail server-side.
2. **Actively harmful** — the client now believes it holds entitlements it does not, so it enters code paths that expect server data it never receives. Observed result: **blank screen** (process alive, nothing rendered, only a swallowed uncaught exception in the crash buffer).
If you patch such a method, patch it in a **conservatively correct** way, and verify all screens, not just the one you cared about:
- Do not fabricate entitlements that gate data fetching.
- Prefer patching *presentation* consumers of the flag over the flag's computation, if the flag is also used for access decisions.
## Local state vs code — the deliverable question
A value can be "fixed" in two places:
| Where | Survives fresh install? | Fits an APK deliverable? |
|---|---|---|
| Code (patched dex) | Yes | Yes |
| App data (datastore/prefs/db) | **No** | No — unless you also patch the read path |
If a fix works on your device but a user reports it gone after reinstall, this is why (`references/pitfalls.md` P11). Re-implement the fix by patching the **read path** so it always yields the desired value.
## Reporting template
```
Goal: <what the user asked for>
Gate owner: client | server | mixed (evidence: ...)
Achievable: <list, with evidence each>
Not achievable: <list, with the 401/absent-field evidence>
Residual: <what remains and the exact coupling that prevents removing it>
Verified on: <device, build hash, what was exercised>
```

View File

@@ -0,0 +1,195 @@
# Native & SO Layer
Load this when the Java/dex layer is blocked or unsuitable, when you need code to run **before** the app's
own code, or when a native library is the only editable place left.
If instead the problem is that the app **dies on its own** — hangs, restarts, or crashes natively at a
roughly constant time after launch — that is a terminate mechanism, not a hooking problem, and the
methods differ: read `native-tamper-and-suicide.md` before this file.
The Java layer is usually the right choice. Go native when you need one of these:
- execution **earlier than any app code** (before `Application.onCreate`, before static initialisers),
- a change that survives the app re-loading or re-initialising its Java state,
- a host the integrity checks do not cover while the dex is covered.
## Picking a host library
A library is usable only if all three hold. Verify each; do not assume.
1. **Loaded in your scenario** — confirm in `/proc/<pid>/maps`, not by inspecting the APK.
2. **Editable** — per the boundary map you built in `packers.md`.
3. **Has a call the runtime makes for you** — an exported `JNI_OnLoad` is ideal, because the runtime
invokes it automatically when the library is loaded by the app, with no trigger of your own.
Prefer a host whose exported JNI entry point exists *and* which appears in `maps`.
## The auto-load trap: `DT_NEEDED` does not call `JNI_OnLoad`
Adding a `DT_NEEDED` entry so one library drags in another is a natural idea. It maps the second library,
but **`JNI_OnLoad` is not invoked for it** — that callback only fires when the runtime loads a library
through the normal library-load API. A dependency loaded purely by the dynamic linker is mapped, not
initialised.
If you rely on `DT_NEEDED`, your entry point must be an **ELF constructor** or a `DT_INIT` entry, not
`JNI_OnLoad`. Conversely, if your host exports `JNI_OnLoad` and you control when the app loads it, that is
the simplest reliable trigger.
Related: a constructor/`DT_INIT` runs so early that the VM may not exist yet. Retry, and treat failure as
non-fatal — a library that logs nothing must never take the process down.
## Relocations and page permissions
This is where most hand-built native payloads fail, and the failure looks like a crash **inside the
dynamic linker**, not inside your code.
Rules that follow from how Android maps libraries:
- **A relocation target must be writable at load time.** The linker writes the computed address into the
target slot. If the slot lives in a section that is mapped read-only, the write faults
(`SEGV_ACCERR` in `plain_relocate_impl`).
- **Modern Android refuses W+X segments.** Adding a read/write/execute segment to get both properties is
rejected; the loader will not give you one.
- Together these mean: relative relocations cannot heal pointer slots that sit in an executable-only page.
### Bootstrapping when relocations cannot work
If your payload's internal pointers all live in the same page as the code that reads them, and that page
must be executable, you cannot use relocations. Do the fixup at runtime instead:
1. derive the runtime page base (a PC-relative instruction gives you this without any relocation),
2. make the page writable/executable via a direct `mprotect` **syscall** (do not depend on the libc
symbol being imported; a syscall needs no linkage),
3. write the pointer slots yourself, computed from the runtime base,
4. restore the page to read+execute,
5. then branch into your entry point.
Every pointer you need is `runtime_base + fixed_offset`. No relocation is added, so the segment
properties never have to change.
Do **not** try to make a page writable by re-mapping it over itself with an anonymous fixed mapping:
that severs the file mapping, and any other thread executing code on that page faults immediately. Use a
permission change, not a re-map.
## Replacing a Java method from native code
You can redirect a Java method's implementation without touching dex:
- Look up the method id, walk to the `ArtMethod` structure, and rewrite its **quick entry point**.
- Emit a short stub: load a target address from a literal pool and branch to it; return values follow the
normal calling convention (arguments are already in place, return in the usual register).
- **Always preserve the original code you overwrote.** If the method can be entered through its own
entry point, a naive redirect makes it jump into your stub, which jumps back into the same entry —
infinite recursion. Copy the overwritten instructions into a trampoline and branch back after them.
- **Do not patch a method whose entry still points at a shared interpreter bridge.** Methods that have
not been compiled by JIT/AOT share an entry; rewriting it breaks every method that shares it.
A practical guard: if two unrelated target methods report the identical entry address, both are still
on the bridge — refuse to patch rather than corrupting the process.
- Also refuse when the first instruction at the entry is PC-relative (address computed from the current
program counter): copying it elsewhere silently changes what it points to.
Make installation **fail-safe**: if any precondition is not met, skip the patch and let the app run
unmodified. A hook that silently does not apply is survivable; a hook that corrupts a shared entry is not.
## Finding call sites without symbols
Stripped and obfuscated libraries still expose structure:
- Scan the code section for the pattern that reaches your target (a PC-relative address computation
followed by an indirect branch through a table slot). A method reached through a **cached table slot**
will ignore a rewrite of its own entry point, because callers never read that field — another reason
the entry-point rewrite must be validated per call site.
- Dump the executable range and disassemble around candidate offsets instead of trusting symbol names.
- When a payload was produced by a just-in-time compiler on the target, the displacement between its
pages is often zero, which is exactly why relocation-free bootstrapping is required (above).
## Cross-architecture notes
Architecture is not background information here — it decides **which artifact you must edit** and
**whether your hook can observe anything at all**. Get it from the runtime, not from the manifest.
### What the device claims vs what is executing
Three different answers, and they disagree more often than people expect:
| Source | Answers | Command |
|---|---|---|
| The device | which ABIs the system supports | `getprop ro.product.cpu.abi` and `ro.product.cpu.abilist` |
| The package manager | **which ABI it chose for this app on this device** | `dumpsys package <pkg> \| grep primaryCpuAbi` |
| The live process | **what is actually mapped right now** | `scripts/lib_map.py --pkg <pkg>` |
Only the third is ground truth. The first two are predictions, and they are wrong exactly when it
matters: an emulator whose primary ABI is `x86_64` can be running an `arm64-v8a`-only app through a
translator, and a fat APK can have the package manager pick an ABI you did not assume.
**Consequence: if the library you patched does not appear in the live mapping, your change cannot
matter.** That is a plan problem, and no amount of re-patching will fix it.
### Translation layers change what you are observing
When an ARM-only app runs on an x86 host, a translator (Intel Houdini, `libndk_translation`, a
`native_bridge` in general) is executing the guest code. Detect it from the maps — look for
translator marker libraries and for app libraries whose architecture differs from the host's
primary ABI (`lib_map.py` reports both).
What it changes:
- **Timing.** Translation is slower and less predictable. Anything you measured about latency is
about the translator, not the code.
- **Native integrity and anti-tamper checks can behave differently** under translation, in either
direction — a check that fails on hardware may pass here, and vice versa. A pass under translation
is not evidence of a pass on hardware.
- **Hook behaviour.** Intercepting translated code is not the same as intercepting native code:
address spaces, calling conventions, and what the host sees at a syscall boundary all differ. If a
native hook reports nothing while the feature plainly runs, suspect translation before suspecting
your script.
- **Syscall-level observation shows the host's view.** A file or network call made from the guest
may look different on the host side than it would natively.
**Rule: verify the artifact on the ABI the user will actually run.** An emulator-only result is a
mid-task checkpoint, never the final claim (`verification.md`).
### Fat APKs: patching the ABI that never loads
An APK can ship `lib/arm64-v8a/`, `lib/armeabi-v7a/`, `lib/x86_64/`… The package manager extracts
**one** of them into the install-time native library directory, and only that one is loaded.
- Check `primaryCpuAbi` and the live maps before editing a `.so`. Editing `arm64-v8a` while the
device loads `armeabi-v7a` produces a build that is byte-different and behaviorally identical.
- If the deliverable must work for an unknown user, remember their device may select a different
ABI than yours. Either patch every ABI present, or state which ABI your artifact targets.
- If the app's own libraries are written at *runtime* rather than extracted from the APK, the live
paths will not be inside the APK at all (`lib_map.py` marks these `materialized`). Those belong to
whatever produced them, and a repacked APK will not carry them in that location.
### Payloads and tooling are per-ABI too
- A native payload, an injected library, or a shellcode blob built for one ABI **will not load** on
another. The failure usually surfaces as a *missing library* or a generic linker error rather than
a clean "wrong architecture" message — do not read it as "my payload is broken".
- **Pointer size differs** between 32- and 64-bit targets. A script or struct layout that assumes
8-byte words misbehaves silently on a 32-bit target.
- Keep build artifacts per-ABI and label them with the ABI in the filename. Mixing them up is a
reliable source of "it crashed for no reason" when the same APK behaves differently on an emulator
and on a device.
### Quick decision list
1. `scripts/lib_map.py --pkg <pkg> --app-only` → which app libraries are loaded, from where, and at
what architecture.
2. If a translator is present and the task needs reliable native behaviour, move to a matching
device.
3. If the library you meant to patch is absent, stop — pick the right library first.
4. Only then start editing.
## Verification
A native patch earns no credit until the behaviour changes **and** the app stays healthy:
- log a marker from your entry point so you know it ran at all;
- confirm the library is in `maps`;
- confirm the app reaches its normal UI afterwards;
- keep the original library so you can produce an unmodified control build.
Never conclude "the hook works" from the absence of a crash. Absence of effect is the normal outcome of a
hook that never installed.

View File

@@ -0,0 +1,318 @@
# Native DBI and deobfuscation
**Load this when static disassembly of a `.so` has stopped producing information** — an exported
function decompiles into a dispatcher loop and arithmetic soup, a symbol you need has no name, or you
must answer "what actually ran" instead of "what could run". This file covers instruction-level
dynamic tracing: what a trace can and cannot decide, how to record one with the scripts in
`scripts/`, and where it hands off to emulation and symbolic execution.
**Hand-off.** `native-and-so.md` owns the APK-side conclusions about a library — which `.so` is
loaded, which ABI is executing, whether a hook can observe anything, how to neutralise a terminate
path by returning. This file owns execution-level evidence. If the question is "did my patch cause
the death", that is `native-and-so.md` and `native-tamper-and-suicide.md`; if it is "which blocks did
this function really execute, and how often", it is here.
**Claim strength.** `measured` = an exact command and its output exist in
`references/evidence-summary.md` §The capability matrix. `inferred` = it follows from measured behaviour or
from documented behaviour of the tool. `unverified` = reported by someone else or reasoned from
first principles without a run. The distinction matters more here than usual, because the honest
result of this pass is that **the trace pipeline is unverified on the test device** — see.
## 1. Which obfuscation are you looking at
Obfuscator-LLVM ships three transforms, and one target usually carries all three. Identify by
**which one made static reading fail**, because each is undone by a different observation.
| Transform | Static shape | Dynamic signature | What removes it |
|---|---|---|---|
| **Control-flow flattening** | One dominator block at the top of the function, reached from everywhere; a state variable (register or stack slot) assigned a constant before each jump back; a `switch`/jump table dispatch; the original block order is gone | The dispatcher block dominates the execution histogram by a wide margin; real blocks appear between dispatcher entries; execution sequence looks like `D R1 D R2 D R3 D` | Trace histogram + the state variable's constants: each dispatcher entry is preceded by a store of the next real block's id |
| **Bogus control flow (opaque predicate)** | A branch whose condition is algebraically constant (`(x*x) >= 0`, `x*x % 2 == 0`), with a junk block on the impossible side; junk blocks reference unreachable names | The junk branch **never appears** in the trace at all: not once, at any input | Trace absence, and then dead-code deletion of the never-taken side. Absence in a trace is only evidence if the trace covers the input that would take it |
| **Instruction substitution** | Arithmetic that is correct but unrecognisable: add chains with negated constants, `xor`/`and`/`or` rewrites of a comparison, MBA identities | The function's **result** is still correct at its boundary, and the single-step value at a known point (e.g. the return register) matches the un-obfuscated expectation | Symbolic execution or a brute-force input sweep; a trace alone does not simplify arithmetic |
Worked example of the distinction: a flattened function with substituted instructions produces a
trace whose histogram has an obvious top, but the arithmetic between dispatcher entries still tells
you nothing. Flattening is a **control**-flow problem and the trace answers it; substitution is a
**data**-flow problem and needs.
**Reported, not verified here:** a community write-up of this exact pipeline — trace, aggregate,
solve with a symbolic executor, patch the binary — is
[基于动态指令追踪与符号执行对抗OLLVM控制流平坦化的工程化实践](https://cloud.tencent.com/developer/article/2721228)
(cloud.tencent.com/developer/article/2721228, published 2026-08-05). It reports, on a 2,800-block
x64 fixture: 15 s of tracing ≈ 220 MB of raw log, aggregation to 48 KB of block records, ~12 min of
parallel symbolic execution resolving 67 dispatcher targets, and 1.2 s of binary patching over 147
sites. **Those numbers are the author's, on a Windows x64 DLL, and were not reproduced here** —
treat them as an order-of-magnitude sketch for the data-reduction ratio (raw log to block table is
roughly 4000:1 there), not as a budget for an arm64 Android target. The article's code snippets are
illustrative rather than runnable: its `onReceive` handler indexes parsed events by field name
(`item.kind`, `item.start`), while `Stalker.parse(..., {stringify: false})` returns **positional
arrays**. Take the method from that article; take the API shape from this repository and from the
script header in `scripts/stalker_trace.js`.
## 2. Pick the observation before you pick the tool
| Question | Observation | Tool |
|---|---|---|
| Which blocks executed, and how often? | Block-level trace with execution counts | `scripts/stalker_trace.js` + `scripts/stalker_report.py` |
| Which functions inside the module call each other? | Call edges from the same trace | same pair (`call` events) |
| Which imported function does this stub reach? | Relocation table, not trace | `scripts/elf_plt.py` |
| Where did the process die, and did it look arranged? | Crash record, signal, fault address, backtrace split by owner | `scripts/native_crash.py` |
| What value must the state variable have to reach block X? | Constraint solving over the dispatcher | angr / Triton () |
| What is the value at a specific point for a specific input? | Deterministic per-instruction replay with memory operands | QBDI () |
| What does this code do with no device at all? | Emulation of the `.so` with a synthetic environment | unidbg / Unicorn / QEMU |
The first two rows are one run of one script — take both. `elf_plt.py` and `native_crash.py` are not
competitors here: they answer a symbol and a death respectively, and both work when no trace can be
taken at all.
## 3. Recording a trace with `scripts/stalker_trace.js`
Frida Stalker recompiles every basic block a followed thread executes and hands the events back to
JavaScript. It needs no symbols, no source and no disassembler on the host, which is exactly why it
works on stripped OLLVM output.
**Three rules, each one a failure mode if broken**
1. **Follow one trigger, not the whole process.** An unrestricted follow of a busy thread produces
gigabytes and slows the target down until it dies.
2. **Filter to one module.** `targetModule` is the only module whose blocks are reported; call edges
report when either end is inside it. Filtering happens on the device, not in post-processing,
which is what keeps the log usable.
3. **Cap the volume.** `maxBlocks` stops the trace instead of letting the log eat the disk, and the
`DONE` line records whether truncation happened.
**Configuration** (edit `CONFIG` in the script, or override at runtime through `rpc.exports.config`
/ `script.post({type:'cfg', payload:{...}})`):
| Key | Meaning |
|---|---|
| `targetModule` | the one `.so` whose blocks are reported |
| `trigger` | `{kind:'export', module, export}` — start on a named export (a JNI function is the classic); `{kind:'offset', module, offset}` — start at module-relative offset; `{kind:'java', cls, method}` — start at the Java→native boundary; `{kind:'main'}` — follow the thread right after load |
| `followMs` | hard stop, so a forgotten trace cannot kill the process |
| `maxBlocks` | event cap; the `DONE` line reports truncation |
| `events.compile` | first translation of each block — deduplicated block set in first-visit order |
| `events.block` | every executed block — the execution histogram |
| `events.call` | call edges |
| `events.exec` | **per instruction; enormous.** Enable only around a known point |
| `events.ret` | return edges |
**Customising what gets translated (`transform`).** Event flags shape *what you are told*; the
`transform` callback shapes *what runs*. It fires before a block is translated and receives the
instruction iterator, so it can rewrite the code Stalker is about to execute — inline a counter at one
call site, neutralise a logging call inside a hot loop, or substitute a value at the one address you
care about. Reach for it when the question is "is this site reached, and how often" rather than "which
blocks ran"; do not reach for it when the answer must be an observation, because a transform changes
the process's behaviour and stops being passive measurement. Two properties to respect: it can run
once per block per thread, so any rewrite must be idempotent, and the rewritten bytes are the DBI's
copy, so a crash inside them will not point at a file offset. `inferred`: `scripts/stalker_trace.js`
uses events and `onReceive` only; the transform route is documented here, not exercised by this
repository's pass.
**Running it.** With the bundled injector, which writes every event to a log file *and* stdout:
```
python scripts/run_probe.py scripts/stalker_trace.js 1 --pkg <pkg> --via usb --log trace.log
```
With any driver that speaks Frida's rpc, so no file edit is needed:
```js
script.exports.config({ targetModule: 'libapp.so',
trigger: { kind: 'offset', module: 'libapp.so', offset: 0x9f6c0 },
followMs: 3000, maxBlocks: 50000 });
script.exports.start(<tid>); // follow this thread
script.exports.status();
script.exports.stop();
```
Pass `start(tid)` explicitly rather than relying on the default. **`Process.getMainThreadId()` is
not the Android main thread in practice**: on the measured run below, a process with pid `19938` was
followed on tid `25606`, and an idle-looking thread produced zero events. On Android the main thread
tid equals the pid, which is the value to start from.
**Output grammar** (one logical line per event; `scripts/stalker_report.py` parses it, including
logs where each line is wrapped by `run_probe.py` as `[host ts] [dev ts] TRACE ...`):
```
READY ... config echo and module status; nothing works before it
MOD name base=0x.. size=.. path=..
BB <seq> <mod>+0x<offset> block first translated (dedup'd skeleton)
BLK <seq> <mod>+0x<offset> [size=n] block executed (histogram input)
CALL <depth> <from> -> <to> call edge
DONE reason=timeout|trigger-leave|maxBlocks|stop-rpc blocks=n blk=n calls=n truncated=0|1
FATAL / TRIG-FAIL what did not install and why; the script keeps running
```
**Reducing the log**
```
python scripts/stalker_report.py trace.log --top 30 --skeleton 80 --edges 15 --json report.json
```
Four reductions, each answering a different question: the **execution histogram** (top of the list =
dispatcher candidate), the **first-visit order** (deduplicated block set ≈ CFG skeleton with the
flattening state machine stripped out), the **call-edge table** (module-internal communication with
no symbols), and the **collapsed execution sequence** (`block*N -> block -> ...`, which is where the
`D -> R -> D -> R` flattening rhythm becomes visible).
Diagnostics the report prints, and what they mean:
- `DONE ... truncated=1` — the trace hit `maxBlocks`. Ratios remain useful; **absolute counts do
not**, and a claim about a dispatcher's multiplicity must be re-measured with a higher cap.
- `zero BLK with nonzero BB` — the thread was translated but executed nothing in-window: the window
closed, or the trigger returned immediately.
- `zero BB and zero BLK with DONE present` — the module never ran on the followed thread. Change the
thread or the trigger; do not tune the follow time.
- Everything zero **and no `DONE`** — the trace never started. Read the `READY` / `TRIG-FAIL` lines
first; that is a plumbing problem, not an analysis result.
## 4. Stalker, QBDI, or emulation
| Situation | Choice | Why |
|---|---|---|
| Need block-level coverage and execution counts on a real device, no per-instruction semantics | **Stalker** | Cheapest to start, no host toolchain, works on code with no symbols |
| Need per-instruction values, memory operands, or a deterministic replay that can be re-run at a recorded PC | **QBDI** (QuarkslaB Dynamic Binary Instrumentation), via its Frida binding | An independent DBI engine with its own register/memory API; the trace is a program, not a log. It instruments at a different layer than Stalker, which matters on ART: Stalker translates the code it sees, QBDI exposes the instruction stream and its operands directly |
| Need to hurt the target as little as possible while instrumenting a hot path | **QBDI** | Deterministic, no reliance on the app's own JIT/AOT output |
| Need a result with **no device and no app process** — a hardened sample refuses to run, or the algorithm must be called thousands of times | **Emulation** (unidbg, Unicorn, QEMU) | Runs the `.so` in a synthetic environment with JNI stubs; completely different trade-off (environment fidelity for scale) |
| Need "what input reaches block X" rather than "what happened for input I" | **Symbolic execution** () | A trace is one path; a solver enumerates the condition |
**Stalker and ART do not compose for free.** The followed thread's code can be recompiled by ART's
JIT while Stalker has already translated it, and a block's address is only meaningful inside one
process lifetime. Practical consequences: keys in a log are `module+offset` and stay comparable
across runs only for code that is mapped from file; do not compare a `libart`-materialized block
address between two runs; and never place a follow/unfollow pair on a hot function (measured failure
in).
### The cost is not linear, and on arm64 it decides the tool choice
Two costs decide whether Stalker is the right tool, and neither is visible in the API:
- **Translation multiplier.** A followed thread pays a large constant factor on every translated
block. Community reporting for arm64 puts it at **20-50x**; the mechanism is not controversial
(every block is copied into the DBI's code cache and routed through a dispatcher), while the exact
number is workload-dependent. This one is `inferred` — this repository has not measured the
multiplier itself, only its consequences below.
- **What you let into the trace.** Every block of every library the thread touches is a candidate.
Following into `libc` / `libart` / `libhwui` is how a four-second window becomes a device-wide
event: measured here, one such run took the test phone's `load average` to `34.11` on 8 cores and
destroyed a co-running job's attach ().
`Stalker.exclude()` is the control for the second cost, and it is not optional.
`scripts/stalker_trace.js` applies it before `follow()` from its `excludeModules` list and reports
what it actually excluded on one `EXCL` line — read that line before believing any trace, because a
module that was not loaded at follow time was **not** excluded. Exclude every system library you are
not studying; the target module is never excluded even if its name appears in the list.
Three failure shapes follow from ignoring this: deadlock, a watchdog `SIGABRT`, and the zero-event
trace. The deadlock and the watchdog shape remain community reports (`inferred`); the other two were
measured, **and the measurement splits the claim in two** — the split matters more than the summary:
| Arm — one device, one package, one module, 6 s follow | Outcome |
|---|---|
| attach + resume, **no follow** | process survives |
| follow, `excludeModules: []` | **process dies**, script destroyed |
| follow, 20 system modules excluded | process survives, **but `blocks=0 blk=0 calls=0`** |
Exclusion is what keeps the target alive — now `observed`, with the no-follow arm ruling out "it
would have died under frida anyway". But exclusion does **not** restore event delivery: the
zero-event trace survived the treatment arm unchanged. Treat those as two problems, and do not offer
`exclude` as the fix for a follow that delivers nothing. Commands and outputs:
`references/evidence-summary.md` §The capability matrix.
**arm64 also raises the floor.** PAC/BTI-bearing code gives a translator more ways to mis-handle a
block than armv7 did, which is one more reason a trace that works on an emulator is not evidence
about a device. Treat any arm64 result as needing a control run (a followed thread executing a known
loop, nonzero `BLK` lines) before it is interpreted.
**Emulation is a legitimate alternative, not a consolation prize.** When the target is a pure
computation inside a `.so` and its environment can be faked, a Unidbg/Unicorn trace can be both
faster and more stable than Stalker on a real arm64 device: no device load, no watchdog, no PAC, and
a deterministic replay. That inversion of the usual intuition is half the reason
`emulation-and-rpc.md` exists — decide with its decision table rather than by habit.
## 5. From a trace to a deobfuscated function (inferred)
This is the part the community write-up above also describes, and it is **inferred** here — the
trace end was not reproducible on the test device, so the steps below are a method, not a record.
1. **Find the dispatcher.** Rank by execution count. In a flattened function the dispatcher runs
once per real block; loops multiply it further. A block that is (a) at the top by a wide margin,
(b) entered from many distinct predecessors, and (c) followed by many distinct successors, is the
dispatcher. `stalker_report.py` prints exactly the three items needed to check that.
2. **Find the state variable.** Disassemble the dispatcher entry window statically (the offset is in
the trace, so no symbol is needed). Look for the register or stack slot written immediately
before the indirect branch; in flattened output this is the value the dispatch table is indexed
by. If more than one candidate exists, correlate: the state variable is the one whose written
constants match the distinct block ids seen in the trace.
3. **Recover the real CFG.** Convert the flat sequence into edges: `dispatcher → real block →
dispatcher`. The real blocks' order and repetition come from the trace; the state constants come
from step 2; the mapping `state value → real block` turns the dispatcher into a jump table you
can then remove in the binary (the LIEF step in the write-up).
4. **Solve what the trace did not cover.** A trace is one input's path. For a state value you never
observed, symbolically execute the dispatcher **only** (a few tens of bytes: load the state,
bounds-check it, index the table) rather than the whole function. This is where angr or Triton
earns its cost: the entry point is the dispatcher offset the trace gave you, and the goal is the
table index, not the program's semantics. Reported pitfall from the same write-up, worth repeating
because it is structural: OLLVM inserts transitions that land outside the table, so prune states
whose index exceeds the table bound instead of letting the solver chase them.
5. **Only then patch the binary.** Direct-branch rewriting must respect instruction length: an
absolute jump needs more bytes than the `mov; jmp` pair it replaces, so pad rather than move
anything. `scripts/elf_plt.py` is the tool that tells you what a rewritten site actually reaches
(by relocation, not by position), and a byte-diff of two builds is how you prove your patch set is
what you think it is.
## 6. Failure modes
**Zero events, follow installed (measured, cause unverified).** With host frida 16.7.19 and a
matching on-device frida-server 16.7.19 on Android 11 / arm64, `Stalker.follow` on a main thread for
4 s produced `blocks=0 blk=0 calls=0` on two different system processes, and an `export`-triggered
follow produced `reason=trigger-leave blocks=0` on every one of hundreds of firings. Every line
before that was healthy: `READY`, `TRIG following tid=…`, `MOD libc.so base=0x…`. The conclusion to
carry forward is narrow and should not be widened: **on that combination, follow installs but events
do not arrive in `onReceive`.** It is not a licence to assume Stalker is broken everywhere, and not
evidence about any other ROM, frida version or target. Validate the pipeline before trusting a zero
result: follow a thread you control executing a known loop, and confirm nonzero `BLK` lines.
**High-frequency follow/unfollow crashes the target (measured).** Using a hot libc export as the
trigger — every call starts a follow and every return ends it — a system UI process died with
`SIGSEGV` after hundreds of cycles; the crash record's frame `#00` sat in an anonymous region (the
DBI's code cache) and the return address led into `libart`. The process restarted under a new pid.
The lesson is not "Stalker is unsafe"; it is **do not use a hot function as a follow trigger**, and
do not build a follow/unfollow cycle per-call in a hot path. A single follow of a chosen thread for a
bounded window is the shape that works.
**The device becomes the experiment (measured).** While this pipeline was running, the test device's
`load average` reached `34.11` on an 8-core mid-range phone, and a co-running dynamic job on the same
phone (a dex-dumping attach belonging to another task) was destroyed mid-flight. On a shared device
these are not independent experiments: one heavy DBI run makes every other dynamic result
unattributable. Announce the window, keep `followMs` in the low seconds, cap `maxBlocks`, never spawn
when attach is enough, and treat a `script has been destroyed` in a *different* job as a signal that
your load — not that job's code — is the variable. This is `long-task-discipline.md` §single-variable
discipline applied to hardware.
**A trace of a VM is a trace of the interpreter.** If the target is a virtualized function (private
bytecode executed by a handler loop), Stalker will happily report the interpreter's blocks: a huge,
flat, repetitive histogram around one dispatch loop. That is a valid finding — it tells you the code
is virtualized — but it is **not** the guest program's control flow. Recovering that needs handler
identification and a private opcode table; see
`code-virtualization-and-custom-linkers.md`, and do not present an interpreter trace as the
deobfuscated function.
**Instrumentation was detected before you started.** If the process dies when you attach, refuses to
run under root, or the module you want is never loaded while attached, the problem is upstream of
this file: read the first section of `detection-and-anti-analysis.md` before escalating the trace,
because the cheap answer is usually to switch to static analysis with `elf_plt.py` and
`native_crash.py`, not to fight the detector.
## 7. Reporting what a trace proves
- A trace proves **what executed on the followed thread inside the followed window**. It does not
prove absence: `BB` never appearing means it did not run *for that input, in that window* — which
is meaningful for an opaque-predicate junk block only when the rest of the analysis says the input
would have taken it.
- Counts are evidence only when `truncated=0`. State the cap.
- A dispatcher identification is a hypothesis about a block's role. Confirm it by disassembling the
block and finding the table it indexes; a high count alone is also what a spin loop or a memory
allocator's fast path looks like.
- Write the product's before/after into the evidence record condensed in `references/evidence-summary.md` §Where the full record lives, with the exact command, the
module, the offsets, and the strength label. The next person's first question is "was the pipeline
known to work when you got that zero", and the answer belongs in that record, not in a footnote.

View File

@@ -0,0 +1,293 @@
# Native Tamper Response — When the Library Kills Its Own Process
Load this when the process dies **without a Java stack trace**, when a build that passed every
static check crashes seconds after launch, or when you are about to neutralise a native check.
This file exists because of one specific, expensive mistake. Read §The rule before you edit a
single byte of a shell's native library.
## The rule
> **Never neutralise a terminate path by making it not return. Make it return.**
A terminate routine is reached from ordinary code paths. If you replace it with something that
**never returns** — an infinite loop, a self-branch, a stub that spins — the caller never resumes,
locks are never released, and every unrelated thread that touches those locks wedges. You have not
suppressed the check; you have frozen the process.
The distinction decides the outcome:
| Neutralisation | Effect on the caller | Result |
|---|---|---|
| `ret` / return a benign value | continues normally | check suppressed, process healthy |
| spin / self-branch / `while(1)` | never resumes | process freezes, watchdog or user kills it, symptom looks unrelated |
**Two failure shapes this produces, both of which have been misread as "the patch did not work":**
- The app hangs with no crash record at all, then disappears. Logs show nothing, because nothing
crashed — it stopped.
- The watchdog or a system supervisor kills the frozen process, producing
`Force stopping … from uid 0` or an app-restart loop. The uid-0 killer is a strong tell: an app
cannot spawn a root-owned killer, so the executioner is **outside** the app, which means the app
froze rather than failed.
Both are self-inflicted and both are avoided by returning instead of spinning.
### The corollary: do not touch normal-path symbols
A hardening library imports a lot of libc. Some of those symbols are terminate paths; most are not.
**Symbols that must stay untouched** — they are on ordinary code paths and freezing them breaks
everything:
`pthread_exit` · `exit` · `abort` · `android_set_abort_message` · `snprintf` · `closedir` ·
`__cxa_atexit` · `__stack_chk_fail`
A concrete case: a set of hand-made patches rewired five PLT stubs to self-branches on one
architecture and a different five on another. On the first architecture two of the five were
`snprintf` and `closedir` — a string formatter and a directory-close — so the app froze on the
first log line. On the second the five included `pthread_exit`, so every thread that finished
wedged. The patches were meant to suppress a suicide check and instead destroyed the process.
**Before touching a stub, resolve what symbol it belongs to.** Do not infer it from the stub's
position or from a comment. See §Resolving a stub to its symbol.
## How a hardened library terminates the process
Enumerate these before you patch anything. Each has a different signature and a different remedy.
| Mechanism | Log signature | Has tombstone? | How to neutralise |
|---|---|---|---|
| `kill(getpid(), SIGKILL)` | `Process N exited due to signal 9 (Killed)`, **no** exit code, **no** tombstone | no | patch the call site or make the `kill` stub return 0 |
| `exit()` / `_exit()` | process ends with an exit code, no signal | no | find the caller; usually one branch of a check |
| `abort()` | `signal 6 (SIGABRT)` + tombstone | yes | usually a genuine assertion — find what tripped it |
| **Deliberate crash** (see §Deliberate-crash stubs) | `signal 11 (SIGSEGV)`, tiny `fault addr` such as `0x4`, `Cause: null pointer dereference` | yes | nop the faulting store |
| `tgkill` / `raise` | same as kill/abort | varies | often not imported at all — check the import table before assuming |
**Distinguish these before patching.** "The app crashed" is not a diagnosis. The signal number and
the presence or absence of a tombstone split the space in half; getting this wrong sends you to the
wrong layer for hours.
### Deliberate-crash stubs
The most deceptive mechanism: the library does not call anything. It **arranges a fault** so the
death looks like an ordinary bug.
```asm
; arm64 shape observed in the wild
mov x0, #4 ; load a small constant…
mov w1, #1
str w1, [x0] ; …and use it as a pointer. fault addr = 0x4
```
Runtime shape: `SIGSEGV`, `SEGV_MAPERR`, `fault addr 0x4` (or `0x0`/`0x8`), `Cause: null pointer
dereference`, and — critically — **the register holding that small constant value**.
How to recognise it rather than treating it as a real bug:
1. The faulting address is a **small integer**, not a plausible pointer.
2. Disassembling backwards from the faulting `pc` shows the constant loaded **a few instructions
earlier, in the same basic block**, with nothing in between that could produce a real pointer.
3. The block sits immediately before a function epilogue (canary check + `ret`), i.e. the normal
exit is right there and this store is bolted on in front of it.
4. There is often a **delay loop just above it** — `sleep`/`usleep` repeated N times — because this
is a *watchdog*: wait a while, then die if the condition still holds. That delay is why the crash
is always "a little while after launch" rather than immediate.
**Neutralise it by nop-ing the faulting store**, leaving the surrounding arithmetic alone. The
thread then falls through into the epilogue and returns normally. Do not remove the whole block:
the two `mov`s are harmless and keeping them makes the diff minimal and reviewable.
**This is why patching `kill` alone is not enough.** A deliberate crash uses no imported symbol at
all, so every PLT-level fix — including a correctly implemented one — leaves it untouched.
### Find every instance, not the first one
Search the binary for the **byte pattern**, not with a linear disassembler. The exact encoding is
architecture-specific and must be derived from the crash you already have, then searched as bytes:
- Take the faulting `pc`, read the instruction bytes backwards until you have the full constant-then-
store sequence.
- Search the whole file for that byte sequence.
- If the search returns more than one hit, patch each; if it returns exactly one, you have the
complete set — record that fact, it is a real result.
A linear disassembler is the wrong tool here for a reason covered in §Scanner traps.
## Resolving a stub to its symbol
When a stub's target is a PLT entry, the symbol comes from the relocation table, **not** from the
stub's bytes or its neighbours. Two architectures encode this differently:
- **x86_64** — the stub is `jmp qword ptr [rip+disp32]` (6 bytes). The target address is
`stub_addr + 6 + disp32`; look that address up in the PLT relocation map.
- **aarch64** — the stub is **four instructions / 16 bytes** (`adrp` → `ldr` → `add` → `br`). The
GOT address is `page(adrp) + add_imm`. Do not assume a 4-byte stub: a 16-byte stub leaves room
for a **two-instruction replacement** (`mov x0, #0` + `ret`), which is exactly what you need, and
assuming 4 bytes pushes you into borrowing the neighbouring slot — which belongs to a different
symbol and breaks it.
`scripts/elf_plt.py` does both architectures, including the relocation lookup.
**Never splice in a neighbouring stub's bytes.** Stubs are laid out back to back; the next one
along is a different function, and corrupting it produces a second, unrelated failure that you will
then spend hours attributing.
## Patching a terminate path correctly
Work in this order.
1. **Confirm the mechanism first.** Get the tombstone or the signal number. Do not patch on a
guess about *how* it dies.
2. **Decide between call-site and stub.** Call sites are precise but you must find all of them.
A stub covers every caller at once, including ones your scan missed — but it changes behaviour
for every symbol user, so it is only safe when that symbol means "terminate" and nothing else.
3. **For a stub, return success, not failure.** Callers commonly inspect the return value. Returning
**0** (success) lets the caller take its "already handled" branch; returning **-1** pushes it
into an error path that may try a different termination method. Make the stub *succeed*.
4. **Preserve every byte you did not intend to change.** Record the original bytes so the patch is
reversible and auditable.
5. **Re-verify the file after patching.** Length unchanged, only the intended offsets differ, and
the normal-path symbols are byte-identical to the original. `scripts/elf_plt.py --diff` prints
exactly that.
### What "faithful suppression" looks like
A library that decides "tampered" and then cannot terminate has an inconsistent state — it believes
it has killed the process and continues. That is **acceptable and usually harmless**, because the
terminate path is the end of the check; nothing downstream re-reads "did I die". But verify it
rather than assuming: after a successful suppression the app must reach its **normal UI and stay
there**, not loop through half-initialised states.
## ELF hardening you will meet in these targets
Hardening libraries are not normal ELF files. Assume the following until measured otherwise.
### Forged section headers
A shell may ship a library whose section header table is deliberately wrong — `.text` sized to a
handful of bytes, `.dynsym` truncated, sections overlapping. Tools that walk sections therefore
produce **confidently wrong** answers (a symbol list with one entry, an import table that is empty).
**Work from the program headers instead.** `PT_LOAD` gives you the real mapped segments and their
permissions; `PT_DYNAMIC` gives you the pointers you need, walked by hand:
```
DT_STRTAB(5) DT_SYMTAB(6) DT_STRSZ(10) DT_SYMENT(11)
DT_JMPREL(23) DT_PLTRELSZ(2) DT_PLTREL(20) DT_PLTGOT(3)
```
Then translate any virtual address to a file offset through the `PT_LOAD` that contains it. A
library with forged sections that yields a full import list this way is a good sign; one that
still yields almost nothing means the hardening is deeper and you should say so rather than
reporting the truncated result as the truth.
### Function boundaries come from `PT_GNU_EH_FRAME`, not from prologue guessing
You will need "where does this function start". Two bad answers and one good one:
- **Reverse-decoding backwards from a call site does not work on aarch64.** Almost any 4-byte
window decodes as *some* instruction, so a naive scanner reports hundreds of "function starts"
that are all fiction.
- **Guessing at prologues** (`sub sp, sp, #imm` + `stp`) produces a plausible set with no guarantee
of completeness.
- **`PT_GNU_EH_FRAME` is authoritative.** If the segment survives (it usually does, because
unwinding is needed at runtime), parsing it yields the real function entry table — every entry,
exactly. It is several lines of code and it removes an entire class of wrong conclusions.
**Check for it first.** If it is present, use it; if it is absent, say your boundary list is
heuristic.
### A function's entry is not where its logic is
Callers reach a routine through several routes: a direct branch, a cached table slot, or a function
pointer in writable data. Rewriting the entry point only covers the first. If a call resolves
through a cached pointer, your entry-point change is invisible to it — so before patching an entry,
scan for the other routes (`scripts/elf_plt.py`, or a byte-pattern scan for the address).
## Scanner traps
**A linear disassembler can stop silently.** Given a buffer whose start is not code, a decoder may
return a handful of instructions and then nothing, with no error. Concluding "there are no such
call sites in this library" from that output is a false negative with real consequences.
Guards, in order of preference:
1. **Byte-pattern search** for a sequence you already know (from a crash or a known call site).
Fast, exhaustive, no decoder involved.
2. **Fixed-bit-pattern scans** for instruction classes whose encoding is regular — the branch
instructions are the useful ones here (`BL`/`B` on aarch64, `call rel32` on x86_64). This finds
every call site without depending on linear decoding.
3. **Resynchronising scan** — decode, and on failure advance by one instruction unit (4 bytes on
fixed-width aarch64, 1 byte on x86_64) and retry. Slower, but it does not stop early.
Whichever you use, **state which one you used and what it can miss**. "No hits" from a scan whose
coverage you cannot describe is not evidence.
## Deciding whether a dynamic-resolution path matters
Hardening libraries frequently build a table of function pointers with `dlopen` + `dlsym` — often
40+ symbols including terminate calls. This raises an obvious worry: *a call through that table
bypasses the PLT stub, so my stub patch does nothing.*
**Measure it instead of assuming either way.** For each symbol of interest, find whether its table
slot is ever **read**:
- Locate the slot's address (the store from the `dlsym` return, or the table base + index).
- Search the code for instructions that load from that address (`adrp`+`add`+`ldr` on aarch64).
- **Zero readers means the entry is written and never used.** The table is a redundant fast path,
not a bypass, and PLT-level patching is complete.
Real outcome from a hardened target: the `kill` slot had **zero** readers while the `exit` slot had
exactly one, inside a routine that was already suppressed — so no dynamic-resolution bypass existed
at all, and the expensive "patch `dlsym` itself" plan would have broken 40 unrelated symbols for
nothing.
**Do not blanket-patch a symbol resolver.** It feeds `open`, `read`, `mmap`, `dlopen` and more.
Changing its behaviour to reach one terminate symbol is a large blast radius for a problem you may
not have.
## What these checks actually detect — and why your environment changes the answer
A terminate path is not reading your patch. It is reading **the world around the process**, so what
trips it is environmental, and the same build can die in one setup and live in another.
| Signal | Typical probe |
|---|---|
| a debugger is attached | `ptrace`, `TracerPid` in `/proc/self/status`, `prctl` |
| the parent process was replaced | a cached `getppid()` compared against a fresh one |
| the process was suspended | wait status showing `SIGSTOP` / `SIGTRAP` |
| instrumentation is present | hook-framework artefacts: listening ports, thread names, unusual mappings |
| a "non-standard" environment | root binaries, emulator fingerprints, writable system paths, build props |
Two consequences decide whether your experiments mean anything at all:
1. **A pass under translation is not a pass on hardware.** Under an ARM-on-x86 translator the
environment probes return different answers — in *both* directions. A check that fires natively
may stay quiet here, and one that is quiet natively may fire. Emulator or translated results are
a mid-task checkpoint, never the final verdict.
2. **Your own tooling can be the thing that trips it.** If the app only dies while you are attached,
you are looking at a probe aimed at *you*, not at the app's ordinary startup path. Reproduce with
nothing attached before you patch anything.
**Run the unmodified original through the identical conditions first.** If it dies the same way, the
check is firing on the environment rather than on your change, and every conclusion drawn from the
patched build's death is void. This is the same control-build rule as everywhere else in this skill,
and it is the single cheapest way to avoid chasing a detection that is not about you.
## Verification
A native suppression earns no credit until:
1. The process **survives the window in which it used to die** — and that window must be measured,
not guessed. If the observed death was ~40 s after launch, a 30-second test proves nothing.
2. It then **keeps surviving** through real interaction, not just idling.
3. `crash`/`tombstone` buffers are clean, **and** the process id never changed (a restart with a new
pid is a death even when nothing logged a crash).
4. The normal-path symbols are byte-identical to the original build.
5. A **control build** — same pipeline, no native patch — still dies the same way. Without the
control you cannot tell suppression from an unrelated change in timing.
**Record the observed time-to-death before patching.** It is the only thing that later tells you
whether your patch worked or merely moved the window.

View File

@@ -0,0 +1,145 @@
# On-device tooling — MT Manager, APK MCP, LSPosed, Termux
Sometimes the PC is the wrong place to work: the device already has the APK installed, the edit is
one string, the target is a 38 MB repack you would otherwise push back and forth, or there simply
is no laptop in the loop. This file covers the **on-device toolchain** and — more importantly —
when each tool beats its PC counterpart and when it does not.
Evidence basis: the facts below come from a real configured environment (Android 11, Magisk +
Zygisk, LSPosed v1.9.2, MT Manager 2.26.9). Framework
activation and the module set are `observed` there; per-workflow usability notes are `measured`
where marked, otherwise `inferred`. The MT MCP tool surface is from MT's official documentation
(`observed` as a list; live invocation requires the service to be running — see).
**Load this when:** the work is better done on the phone than on the PC -- one-string edits, an already-installed target, or no laptop in the loop. It gives MT Manager edit/repack/sign and its APK MCP surface, LSPosed Manager, and Termux.
## 1. MT Manager as a reverse-engineering workbench
MT Manager (`bin.mt.plus`) is a file manager plus APK editor. For this skill's purposes it is a
**complete repack pipeline that runs on the phone**: view, edit, rebuild, sign.
What it covers, mapped to this skill's workflow stages:
| Skill stage | MT capability | Notes |
|---|---|---|
| Recon (manifest, SDKs) | APK viewer: manifest, entry list, string search across dex/resources | String search is the fast path; no full Java decompile in the editor workflow |
| Dex reading/editing | Dex viewer + smali-level editing | Smali is the editing granularity — same discipline as `dex-patching.md` |
| Removing signature verification | Built-in "去除签名校验" (signature-check killer) feature | Convenience feature; audit what it actually patched — a Java-layer signature killer can be a decoy while a native check still kills you (`code-virtualization-and-custom-linkers.md` §decoy) |
| Extracting artifacts | Pull `classes.dex`, `.so`, `resources.arsc`, axml out of any APK/zip | Useful to move a single `.so` to the PC toolchain without a full unzip |
| Repack + sign | Rebuild APK and sign it on device | Output lands in MT's working directory; verify with the same discipline as `repack-and-sign.md` (alignment, `resources.arsc` rules still apply — the platform enforces them regardless of which machine built the file) |
**When MT beats the PC pipeline:** quick single-file edits, on-the-spot verification on the same
device the app is installed on, and sessions where shuffling 40 MB APKs over USB dominates the
work. **When the PC wins:** anything scriptable/repeatable, dex-wide cross-referencing
(`find_refs.py`), byte-precision patching with header recomputation, and anything needing a real
decompiler. The honest split: MT for **breadth on device**, PC for **depth and repeatability**.
`inferred`.
## 2. MT's built-in APK MCP server
MT 2.26.9+ ships an "APK MCP" service: it exposes MT's analysis and modification capabilities to
an MCP (Model Context Protocol) client — which in practice means an AI agent drives MT's tooling
over HTTP while the human sees the results on the phone. The official framing: AI understands the
request and calls the tools; MT does the local read/modify/repack/sign.
**Transport and endpoint** (`measured` on the reference environment):
- Streamable HTTP MCP at `http://127.0.0.1:8787/mcp` from the PC, via `adb forward tcp:8787
tcp:8787`; or directly `http://<phone-ip>:8787/mcp` on the LAN.
- **The service must be started by hand in the MT UI** (side drawer → Tools → APK MCP → Start).
adb cannot start it. Before it is started, the port is simply not listening — probe with
`scripts/mt_mcp_probe.py`, which prints waiting instructions in that state.
**Tool surface** (official documentation; names observed from the published list):
| Category | Tools |
|---|---|
| Open/selection | `mt_apk_list_available_apks`, `mt_apk_open` |
| Reading/search | `mt_apk_list`, `mt_apk_search`, `mt_apk_read_text`, `mt_apk_read_bytes`, `mt_apk_continue` |
| Dex analysis | `mt_apk_dex_outline_class`, `mt_apk_dex_xref` |
| Resources | `mt_apk_resource_read`, `mt_apk_resource_xref` |
| Native static | `mt_apk_native_inspect`, `mt_apk_native_read_items`, `mt_apk_native_map_address`, `mt_apk_native_xref`, `mt_apk_native_disassemble`, `mt_apk_native_function_cfg` |
| Modification | `mt_apk_edit_open`, `mt_apk_edit_text`, `mt_apk_edit_resource`, `mt_apk_patch_bytes`, `mt_apk_native_patch_instructions`, `mt_apk_native_patch_string`, `mt_apk_edit_check`, `mt_apk_build` |
| Cleanup | `mt_apk_close` |
**Official constraints — budget your plans around these, not around hopes:**
1. **No Java decompilation.** The stated position: AI reads smali directly; decompiled Java of
hardened/R8'd code misleads more than it helps. Cross-references (`dex_xref`) and class
outlines are the structural view.
2. **`resources.arsc`: existing entries only.** No new locales or new entries — edit what ships,
do not plan feature-additions through arsc.
3. **`.so` support is static analysis only.** No dynamic execution, no emulation, no C/C++
pseudocode. Xrefs and CFG are "best-effort within a limited budget" — direct static results,
**not** proof of a complete call chain or of runtime behaviour. For depth, the PC side (IDA/
Ghidra-class, see `toolchain.md`) remains the answer.
**Handing an APK to the server** (three supported ways): open an APK in MT's file list and stay
on its info dialog ("the current APK file"); open an installed app's info dialog the same way; or
drop the APK into MT's configured MCP working directory and refer to it by filename/package. The
working directory is also where rebuild output lands.
**Probing from the PC:** `python scripts/mt_mcp_probe.py` does the full JSON-RPC handshake
(initialize → initialized → tools/list) with no MCP SDK dependency and prints a grouped tool
inventory. If MT's MCP has not been started in the UI, it exits with waiting instructions and
code 2 — usable as a loop check ("is it up yet") while you start it by hand. `measured`.
## 3. LSPosed Manager — the module route on device
LSPosed (via Zygisk) gives you a **Java-layer hook platform that needs no PC at runtime**: write/
install a module once, and it applies to target apps at every launch.
Facts from the reference environment (`observed`):
- Activation is verifiable from logs: the `ZygiskCompanion: welcome to LSPosed!` banner, the
version line, the `lspd` daemon process running as system.
- Install discipline: `magisk --install-module` unpacks to `/data/adb/modules_update/` and only
**merges after a reboot** — a module that "did not install" is usually one that has not merged
yet. `ls /data/adb/modules/` is the truth; `modules_update/` non-empty means pending.
- Zygisk itself needs one reboot after being enabled before its injection layer exists at all.
Limits that matter for this skill: LSPosed hooks **Java/ART** — it cannot hook inside a native
`.so` (that is Frida/IDA territory, and the reason `framework-runtimes.md`'s runtime check
exists), and it does nothing for Flutter/Dart AOT logic below the platform channel. Use it for
the layer it owns: Java API interception, intent/telemetry rewriting, feature gates that live in
Java. A hook module is also **not a deliverable** for a request that wants an installable APK —
it is an environment change on your device (`detection-and-anti-analysis.md` Step 4 keeps these
separate).
## 4. Termux + frida-server — device-side dynamic analysis
The phone can run the whole dynamic stack alone: Termux (or a plain root shell) hosts
`frida-server`, and scripts run through Termux's own Python.
- **frida-server version must match the PC-side frida tooling** when the PC is also in the loop
(`dynamic-frida.md` owns this). Device-only operation still needs a matching `frida` client in
Termux.
- **Disguise the server when the target hunts it**: a renamed binary and a non-default port are
the minimum; a detection-savvy target scans process names and default ports. The reference
environment carried renamed server copies from prior work — the technique is standard, and the
detection mechanics are `kernel-and-environment-hardening.md`.
- **32/64-bit**: a `zygote64_32` device runs both ABIs; make sure the frida-server architecture
matches the process you are attaching to (`environment.md` §which architecture is actually
executing).
- Keep sessions simple: attach-mode on a hardened target inherits every §1 problem of
`kernel-and-environment-hardening.md`; prefer spawn, and prefer `spawn_patch_detach.py`-style
patch-then-detach when you only need a memory fix to stick.
## 5. On-device forensics — where the data is
Rooted-phone data inspection does not need a PC round-trip. The full layout, formats and
ownership rules live in `runtime-data.md` (DataStore/MMKV/SQLite paths, `chown`/`restorecon`
after restores, the "app rewrites your edit" decision tree) — on device, MT Manager's file
browser with root covers the same reads, and Termux covers scripted pulls. The two on-device
cautions that most often bite: edit files **only with the app force-stopped** (the in-memory
copy wins otherwise), and re-check ownership after any reinstall (the uid increments).
## Checklist
- [ ] Chose the workbench deliberately: MT (on-device, fast) vs PC (scriptable, deep) — not by habit
- [ ] MCP started in MT's UI and probed (`mt_mcp_probe.py` exits 0) before planning agent-driven edits
- [ ] MCP plans respect the three official constraints (no Java decompile, arsc entries-only, `.so` static-only)
- [ ] LSPosed modules verified merged (`/data/adb/modules/`, not `modules_update/`) before testing
- [ ] frida-server architecture matches the target process; disguised if the target scans
- [ ] Data edits done with the app stopped; ownership fixed after reinstalls

View File

@@ -0,0 +1,155 @@
# Hardened / Packed Targets
Load this when recon says the app's `Application` class is not the app's own, or when edits make the
app die before your code ever runs. Packing is the single most common reason a correct patch appears to
"do nothing", so treat it as its own phase with its own evidence standard.
**Not packed, and still dying?** That is a different layer and a different file:
`code-virtualization-and-custom-linkers.md`. There `Application` is the app's own, the dex is fully
readable, and yet whole classes are `native` declarations and a private loader carries an embedded
validation payload. The single most expensive recon error in this domain is reading "no packer" as
"the code is editable" — check for that shape explicitly before planning an edit.
## What a packer actually does
A shell (jiagu / weapon / leggu / 360 / ijiami / bangcle class) replaces the manifest's
`application android:name` with its own stub. At process start the stub:
1. loads its own native library from a directory it controls (often extracted at first launch),
2. decrypts and loads the real dex, usually from `assets/` or a same-named container,
3. optionally verifies that nothing about the APK, its own components, or the environment changed,
4. only then forwards to the app's real `Application`.
Consequences that shape every later decision:
- **Your code in the app's own dex may never run** if the shell aborts first.
- **The shell's native library is the gatekeeper**, and it is usually the only thing that decides
"tampered vs clean".
- **The shell's own entry is native**, so Java-level reflection into it is usually a dead end.
## Reading the rejection signal
The most informative failure looks like this:
```
UnsatisfiedLinkError: JNI_ERR returned from JNI_OnLoad in "<app>/.jiagu/lib<shell>.so"
```
Read it precisely: the shell's **own** `JNI_OnLoad` returned `JNI_ERR`. That means the shell
**decided** to refuse — it is not a crash, not a missing library, not an ABI problem. Distinguish it from
the look-alikes:
| Message | Meaning |
|---|---|
| `JNI_ERR returned from JNI_OnLoad in <shell>.so` | shell actively refused (integrity or environment) |
| `dlopen failed: library "X" not found` | a dependency you added does not exist — **your** bug |
| `CANNOT LINK EXECUTABLE` / relocation errors | ELF you produced is malformed |
| `ClassNotFoundException` during `Application` init | a manifest component you renamed no longer resolves |
This distinction matters more than it looks: several classes of "the shell rejected me" are actually
self-inflicted. Always grep the crash for `linker`, `CANNOT LINK`, `dlopen` and `not found` **before**
concluding that the shell detected anything.
## Map the validation boundary with single-variable tests
Do not guess what the shell checks. Measure it. The only reliable method is one change at a time, each
with its own install-and-launch run:
1. **Baseline control** — repack with no changes at all, re-sign, install, launch. If this fails, stop:
your pipeline is the problem, not the shell.
2. **Existing file, semantically inert change** — flip one byte in a section that nothing reads
(a `.comment` string, a duplicated `DT_NEEDED` entry). Survives ⇒ edits to existing files of that
class are allowed.
3. **New file added** — add one benign ELF under the native library directory. Very commonly this alone
is enough to trip a check, which is a distinct policy from (2).
4. **Component rename in the manifest** — equal-length rename of one lazy component
(an `activity`). Survives ⇒ you have a cheap way to neutralise features.
5. **Structural change** — move a program header table, add a segment, add a relocation.
Record every result. The boundary is usually **not uniform**: typical real outcomes are
"existing native libraries editable, new ones not", "activities renamable, providers not",
"this dex fingerprinted, that container ignored".
### Why one variable at a time is not optional
The most expensive mistake in this phase is testing two hypotheses in one build. If you both edit a
shell component **and** add a library, and the app dies, you have learned nothing — and you will likely
blame the wrong one, discard a viable route, and lose hours (or in this skill's history, dozens of
rounds) re-deriving what a cleaner experiment would have shown in one run.
### Component-type rules worth knowing up front
- **Providers are instantiated by the system during process start.** Renaming one so its class no
longer resolves fails inside `Application.onCreate` and kills the process before anything else runs.
Providers are therefore a poor rename target; prefer disabling the feature that consumes them, or
hooking it.
- **Activities and services are resolved lazily**, only when actually used. Renaming them is a cheap,
low-risk way to make a feature unable to display, and it survives most integrity checks because it
changes nothing about the shell.
- **Equal-length renames** keep binary XML chunk structure valid, so no manifest re-encoding is needed:
replace the name bytes in `AndroidManifest.xml` in place, size unchanged.
## Native libraries as a place to live
When the shell blocks dex-level changes, native libraries often remain open — but only some of them are
useful. A library is a valid host for your code **only if all three hold**:
1. **It is actually loaded** in the scenario you care about.
2. **It is editable** (per your boundary map above).
3. **Something in it is called early enough** to matter.
Condition 1 is the one people skip, and it is the one that wastes the most time. A library shipped in
the APK is not necessarily loaded. **Check `/proc/<pid>/maps`** after a real launch rather than assuming:
```
grep -o '/[^ ]*\.so' /proc/<pid>/maps | sort -u
```
Libraries belonging to optional SDKs are frequently lazy: they exist, they may even be unpacked to disk,
and they are never mapped during the startup path you are trying to influence. Unpacked-but-unmapped is
the signature of a lazy SDK, not evidence that your file was rejected.
Pick a host that is (a) present in `maps`, (b) exports a JNI entry point the runtime must call, and
(c) editable. A library that exports `JNI_OnLoad` and appears in `maps` is the strongest candidate,
because the runtime calls `JNI_OnLoad` on its own, without any trigger from you.
## Where integrity evidence actually comes from
Shells commonly check a mix of: APK signature, `AndroidManifest.xml`, their own native libraries, the
dex they load, and environment signals (root, emulator, hooking frameworks, debugging).
Practical consequences:
- **Your own re-signing is itself a detected change** in most signature-based schemes. Assume any
signature check will see your build as tampered and plan around it rather than hoping otherwise.
- **The environment matters.** Hooking frameworks, debugging, and non-standard filesystems are commonly
probed. A patch that works on a clean device may be refused on a rooted one, and vice versa.
- **Absence of a log line is not absence of a check.** A shell that fails silently looks identical to a
shell that never checked.
## Locating what the dialog/detection is attached to
Blocking dialogs ("tampered", "third-party environment", "abnormal") are the usual user-visible symptom.
Establish **which layer draws it** before trying to suppress it:
- If it is a Java dialog, hooking the dialog class shows a caller stack that names the detection.
- If hooking the dialog classes produces **no** hits, the UI is likely drawn by a cross-platform runtime
(see `framework-runtimes.md`) or by native code, and Java-level hunting is the wrong axis entirely.
The cheapest high-value experiment, and one that is routinely skipped: run the **unmodified official
build** and the **repackaged build** through the *same* probe and diff what differs. The difference is
your detection signal. Doing this early is far cheaper than enumerating candidate checks one by one.
**When the symptom is a death rather than a dialog** — the process disappears, hangs, or dies at a
roughly constant time after launch — the check is not drawing anything, it is terminating. The
question then becomes *which mechanism*, and the answer decides the whole approach:
`native-tamper-and-suicide.md` covers how to tell an imported terminate call from an **arranged
fault** (which calls nothing and therefore defeats every PLT-level fix), how to find the site, and
why making a routine "not return" freezes the process instead of suppressing the check.
## Reporting
State the boundary you measured, the evidence for each entry, and what remains untested. "The shell
rejects X" must always carry the run that proves it, including the exact failure string. An unverified
claim about integrity behaviour is worse than no claim, because it silently removes routes from
consideration.

View File

@@ -0,0 +1,154 @@
# Auditing your own patch — proving it landed, and proving it is legal
Two independent questions, and they fail independently:
1. **Did the edit land?** Did the bytes you intended actually reach the artifact?
2. **Is the result legal?** Will the runtime's verifier accept it?
A patch can land and be illegal (the class fails to load). It can also fail to land while every
build step reports success. Both are silent, and each needs its own check.
**Load this when:** you must prove the edit landed **and** that it is legal. It gives the length-versus-bytes comparison that catches equal-length blind spots, verifier-level legality, and how to report a patch that did not apply.
## 1. Did it land? Compare *length*, not bytes
The naive check is a byte-level diff of the target method against the original.
**It produces a flood of false positives.** A disassemble→reassemble round-trip rebuilds the
string pool — the assembler **de-duplicates identical strings** — which shifts every later
string index by a small delta. Hundreds of methods then look "changed" while their behaviour is
identical.
Observed shape of that noise: **~800 methods reported as differing, 84% of them by only 1–4
bytes**, all of them constant-pool index shifts, with the method byte-length unchanged.
**Use instruction-stream length as the first-order predicate.** A real patch that changes control
flow or constant structure changes the method's `code_item.insns_size`. Pure index shifts and
debug-info reordering do not.
### The blind spot, and how to close it
**An equal-length replacement changes nothing measurable by length.**
Examples that bite: `move-result vX` → `const/4 vX, 0x0` (both one code unit); swapping one
`const` for another of the same width; any edit that keeps the instruction count identical.
Length-only auditing therefore **silently under-reports**: an audit reported 20 changed methods
on a build whose patch set actually touched 24 — the four missing ones were all equal-length
replacements.
**Two-tier rule:**
1. **Tier 1 (length):** scan every method. This yields candidates with essentially zero false
positives.
2. **Tier 2 (opcode-level):** compare the two versions opcode by opcode **for every method the
patch set declares as a target** — not merely the Tier-1 candidates.
**Tier 2's input cannot come from Tier 1's output.** Equal-length replacements produce no Tier-1
candidate at all, so the only reliable index of "what to check at Tier 2" is the patch set's own
declarations.
⇒ **Corollary, which is really a documentation requirement: every patch must declare its target
method signature.** A patch that is both equal-length and undeclared is invisible to both tiers;
finding it requires opcode-comparing every class, which is possible but expensive.
### A sharper Tier-2 shape for equal-length replacements
Compare the **position set of one opcode** inside the method:
```
target method, `move-result` (0x0a) position sets
original : [25, 43, 66, 72, 216, ... , 899] (16 entries)
patched : [25, 43, 72, 216, ... , 899] (15 entries) <- exactly one removed, pc=66
```
This proves two things at once: the intended site was hit, **and** the other same-shaped call
sites in that method were not. Asking "did anything differ" cannot distinguish those.
## 2. Is it legal? The verifier is a layer static checks cannot see
An assembler accepting your smali proves the file is **syntactically** valid dex. It does not run
the bytecode verifier. A build can pass assembly, pass every class-table check, and still be
rejected at class load:
```
java.lang.VerifyError: Verifier rejected class X: void X.<clinit>() failed to verify:
[0x1C8] copyRes1 v0<- result0 type=Undefined
```
**Root cause in the observed case:** padding instructions were inserted **between** a producer and
its `move-result`. DEX requires `move-result*` to **immediately follow** the instruction that
produces the result (`invoke-*`, `filled-new-array*`). The padding broke adjacency, the result
type became `Undefined`, and the whole method — and therefore the class — failed to verify.
The length arithmetic ("the shorter form plus two padding units preserves the total") was
**correct** and still produced an illegal method.
**Four layers, cheapest first:**
| Layer | Check | Catches |
|---|---|---|
| 1 | assembler exit code | syntax, register overflow, bad labels |
| 2 | class-table diff (`scripts/dex_classdiff.py`) | class-set / access-flag drift |
| 3 | **predecessor legality of `move-result*`** | result consumers detached from their producer |
| 4 | device: first execution of that method | everything above, plus real semantics |
**Layer 3 is a set-difference, not an absolute count.**
- For both builds, collect every `move-result*` whose immediately preceding instruction is **not**
a producer → the "offenders" set.
- Compare the sets. **New offenders must be 0.**
- **Never use the absolute size as a signal.** A perfectly healthy build can show hundreds of
offenders under this definition — payload pseudo-instructions (`packed-switch-payload`,
`fill-array-data-payload`, …) are normally read as ordinary instructions by a naive linear
scan — so the absolute number is dominated by scanner artifacts. Only the delta means anything.
Worked consequence: a build whose sole new offender was one `move-result` following padding
crashed at class load on device. After moving the padding to **after** the `move-result`, the
delta returned to 0 and the same build ran. **The delta predicted the device result before the
device was touched.**
## 3. When patches are applied by text matching
Some toolchains apply patches by literal match against a text form (smali) rather than through a
dex API. The failure modes are textual and unforgiving:
- **Whitespace is content.** A fragment indented with 5 spaces will not match a file using 4.
There is no fuzzy matching; one misplaced space is a zero-hit.
- **Line endings.** If the reader normalises CRLF→LF and the writer does not restore it, the
patched file becomes LF-only. A later byte-level comparison against the CRLF source then shows
the *entire file* as changed. Diff with an ignore-CR-at-EOL option, or compare structure.
- **Instruction names must be complete.** `move-result vX` and `move-result-object vX` are
different instructions; an anchor missing the suffix matches the wrong thing or nothing.
- **Operand punctuation matters.** `instance-of p3, p0, LType` is not the source text when the
source reads `LType;`.
- **Uniqueness is a hard requirement.** A matcher that finds several hits must fail loudly rather
than patch the first. When a one-line anchor is not unique, extend it with a second unique line;
**when the *combination* is still not unique, add a third anchor that discriminates the
homographs** — usually the branch target label, since identical call shapes often differ in
where they jump.
- **A re-emitted old fragment is not a failed write.** A patch may legitimately re-emit the old
text inside the new one (inserting a guard *before* the lines it matched). A read-back check
asserting "the old text is gone" then reports failure on that patch forever. Assert instead
that the **new, unique** part of the replacement is present.
- **One patch document, one patch block.** A parser that treats every fenced block as a candidate
will apply an *example* diff as a real patch — silently cancelling the real one, or editing
unrelated code. Keep examples in prose, never in the same fence format.
## 4. Audit the patch set, not a patch
For a multi-patch build:
```
expected = every method declared as a target by the patch set
tier1 = methods whose insns_size changed
tier2 = methods whose opcode stream differs (run over `expected`)
missing = expected - (tier1 U tier2) # must be empty
```
**`missing` is the number that matters.** A build can be green on every build step and still be
missing a patch. Report `missing` explicitly; do not report the total as if the total were the
goal.
**And re-verify the artifact, not the intent.** If the tool says "applied" but the build on the
device is unchanged, you have a stale build, a second copy, or the wrong file
(`pitfalls.md` P24). A patch tool's success message describes the tool, not the artifact.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,97 @@
# Precedent library — what a pass got right, and how it got there
`pitfalls.md` is the negative catalogue: what went wrong. This directory is the other half —
**cases where the work actually converged**, recorded so the route can be walked again instead
of re-derived. The two do not overlap on purpose: a case here points at `pitfalls.md` by entry
number rather than restating the failure, and a pitfall that already has a case does not need a
second copy in prose.
Read a case when you are **about to do this kind of work again** — not when you are stuck. A case
answers "what sequence produced a defensible result, and what did it cost"; when you are stuck
the symptom index in `SKILL.md` is the faster route.
## Why this exists at all
This repository's most expensive recurring loss is not ignorance. It is **a conclusion that was
correctly retracted and then re-adopted**, because the retraction lived in a conversation while
only the original claim was ever written to a file. Three of the cases below are exactly that
shape: an attribution that was wrong, corrected by measurement, and corrected again. In each one
the *wrong* version is the one that reads as more coherent, which is why it is the one that would
be believed by a reader who only saw the summary.
A case therefore records the execution chain **including the dead ends** — the wrong turn, and
the measurement that killed it. Without that part, the case is a success story, and success
stories teach nothing that survives the next surprise.
## The cases
| Case | Route it establishes | Strongest claim in it | Grade |
|---|---|---|---|
| `flutter-plus-shell-case-1.md` | classify a hardened Flutter sample before trusting any dynamic result: a pid that keeps moving has to be attributed before it is explained | the same drift was attributed to frida, then to memory pressure, then to the sample's own root-environment check — only the third survived | observed (drift and its cause), unverified (that this generalises) |
| `frida-spawn-hangs-zero-events-case-2.md` | a harness can manufacture the very negative result it is looking for: `spawn` leaves the process suspended, and `CONFIG.autoStart` follows before your configuration lands | a zero-event trace was produced by two independent harness defects, not by the target | observed (the mechanism, and the warning that now fires) |
| `logd-broken-module-never-ran-case-3.md` | decide whether a hooking module ran at all, on a device whose log daemon is broken | nine successful injections produced **zero** `logcat` lines; the only evidence was a file under `/data/adb/lspd/log/` | observed (both channels), inferred (the framework-bridge explanation) |
| `l1-equal-length-patch-case-4.md` | the full chain from a two-byte dex edit to a visible change on screen, with a control build | the dialog disappeared **and** the control build failed the same way first, which is what makes the four bytes responsible | observed end to end |
| `manual-grep-finds-what-nobody-grepped-case-5.md` | why a leak scan is a gate and not a document, told from this repository's own near-miss | a shipped pass had to be corrected after the fact for identity a human grep found | observed (the omission and the correction), inferred (that a scanner would have caught it) |
Each case ends with an action checklist naming the **repository files that should be written
back to** if you hit the same thing. A case that does not produce a file edit has not finished.
## Template
Copy this skeleton. Keep the headings — the read-back check depends on them, and the grade
column is the part that stops a hypothesis from drifting upward into a fact.
```markdown
# <Title> — one line naming the conclusion, not the activity
## Metadata
| Field | Value |
|---|---|
| Context | what was being done, in one line |
| Cost | what it cost before it converged, measured |
| Outcome | what artifact or conclusion exists now |
| Evidence | the evidence files, by path |
| Related | `pitfalls.md` entries, and the neighbouring reference files |
## Assertions and grade
| # | Assertion | Grade | Evidence |
|---|---|---|---|
| 1 | ... | observed / inferred / unverified | exact command, file, or line |
Reserve `observed` for a claim with a command and its output behind it. An assertion whose
evidence is "the mechanism implies it" is `inferred`, and saying so is not hedging.
## Execution chain (including the dead ends)
The wrong turn, the measurement that killed it, and only then the route that held. Numbered,
because the order is the information: a reader who starts from the conclusion will pick the
wrong sequence.
## Pits
| Pit | Cost | What it was mistaken for |
|---|---|---|
| ... | measured number, or a count of re-runs | ... |
Use a cost you measured. "Two round trips" is a measurement; "a while" is not.
## Reusable pattern
The part that transfers to a different target with no editing. If it needs this target's names,
it belongs in the chain above instead.
## Write back to the repository
- [ ] `<file>` — <what to add to it, so the next reader does not pay this again>
- [ ] `<file>` — <index line, if a new file was created>
```
## Rules
- **Grade every assertion, and grade it down when in doubt.** `observed` means a command was run
and its output is in the evidence record condensed in `references/evidence-summary.md` §Where the full record lives. "I reasoned it out" is `inferred`. This is the
same standard `references/long-task-discipline.md` §Grade your own conclusions sets for a live
record, and it is the reason a case is worth reading a year later.
- **Record the retraction with the claim.** If an earlier conclusion was wrong, both versions
belong in the case: the wrong one, why it was wrong, and what replaced it. Deleting the mistake
loses the most transferable thing here.
- **Do not restate `pitfalls.md`.** Point at the entry. A duplicated failure mode drifts out of
sync with its original and then contradicts it.
- **One case, one route.** A case that establishes three unrelated things establishes none of
them; split it.
- **A case with no write-back checklist is a diary entry.** The last section is what makes it
knowledge the repository holds rather than knowledge the author had.

View File

@@ -0,0 +1,74 @@
# A pid that keeps moving must be attributed before it is explained
## Metadata
| Field | Value |
|---|---|
| Context | dynamic analysis of a hardened Android sample (Flutter AOT payload behind a third-party application-stub) on a rooted physical device |
| Cost | three attribution corrections; two of them were written down as findings before being refuted |
| Outcome | the drift is attributed to the sample's own environment check, and the operational rule that falls out of it ("configure root hiding, then re-baseline") |
| Evidence | `references/evidence-summary.md` §The capability matrix (the pid-drift section and the frida-dexdump section); `references/evidence-summary.md` §The capability matrix (the root-hiding layer) |
| Related | `SKILL.md` §Stop conditions (last bullet); `pitfalls.md` P9, P18; `references/detection-and-anti-analysis.md` |
## Assertions and grade
| # | Assertion | Grade | Evidence |
|---|---|---|---|
| 1 | The sample starts and stays resident with no instrumentation attached | observed | `ps -A \| grep <PKG>` → `u0_a623 22691 … 642488 SyS_epoll_wait 0 S <PKG>`, 12 s after `am start` |
| 2 | During a session its pid changed repeatedly: `22691 → 23452 → 24267 → 27274 → 31284 → 32259 → 1368 → 8161` | observed | the same evidence file's pid-drift transcript |
| 3 | The drift reproduces with `frida-server` **stopped** and no attach | observed | a 90-second observation loop, four pid changes, with the frida process absent |
| 4 | The process dies as the **foreground top activity**, with no crash, no tombstone and no ANR record, and the platform relaunches it on a 7–18 s cycle | observed | `adb logcat \| grep <PKG>` → `Process <PKG> (pid N) has died: fg TOP` followed by `Start proc <pid> for top-activity <PKG>` |
| 5 | No application on the device was hidden from root at the time | observed | `magisk --denylist status` = enforced, `--denylist ls` printed nothing, `/data/adb/shamiko` absent (the root-hiding section of `references/evidence-summary.md` §The capability matrix) |
| 6 | The cause is the sample detecting its rooted environment and exiting on purpose | **inferred** — the mechanism fits every observation, but it was not proven by disabling the check |
| 7 | Memory pressure was **not** the cause | observed (refuted) | the drift continued at 300–750 MB free of 11.5 GB, and a reclaim kill leaves an `lmkd` line and a low-memory record — neither present |
| 8 | Instrumentation was **not** the cause | observed (refuted) | the `frida-server`-stopped control run |
| 9 | The device's own hooking framework was already damaged during the window (`lspd` alive but not updating its configuration, `zygote crashed too many times, rolling-back`) | observed | the framework-damage section of `references/evidence-summary.md` §The capability matrix |
| 10 | A `start timeout` kill line discriminates between these causes | **observed negative** — it reads identically for a slow init, a reclaiming device and a deliberate delay, so it is not evidence either way | the `ActivityManager` transcript quoted in the same evidence file |
## Execution chain (including the dead ends)
1. The control run looked healthy: start the sample with no instrumentation, wait 12 s, `ps` shows
it resident at 642 MB.
2. Later the pid had changed eight times. The natural reading — *the sample fights instrumentation*
— was written down as the finding. **Dead end 1.**
3. The control that killed it: stop `frida-server` entirely, restart the sample, observe for 90
seconds. The pid still moved, roughly every 10–20 s. Instrumentation is out.
4. The replacement attribution: memory pressure while a Flutter app saturates the CPU during its
startup window (439 % CPU, `kswapd0` at two thirds of a core were both measured). **Dead end 2.**
5. The measurement that killed it, and the reason it is worth recording: **a different instrument
was pointed at the same question.** `logcat` — not pid sampling — showed
`has died: fg TOP` with no crash record and an immediate relaunch. A reclaiming device does not
do that to a foreground process, and a memory-pressure kill leaves different traces.
6. That left "the sample checks its own environment and quits", and the environment check was then
findable: nothing on the device was hidden from root. A hardened sample that refuses to run
rooted produces exactly this loop, forever.
7. Only after the attribution did the *practical* conclusion exist: re-run the baseline with root
hiding configured, and treat every earlier dynamic result on that device as measured against a
process that was trying to die.
## Pits
| Pit | Cost | What it was mistaken for |
|---|---|---|
| Sampling the pid instead of reading the platform's lifecycle log | two full attribution rounds | a target-resistance hypothesis, then a host-resource hypothesis — the log settled it in one command |
| A 12-second control window | it looked like a stable baseline | the sample's lifetime was 7–18 s, so a single sample can land inside one life and read as stability |
| A third candidate mechanism discovered mid-pass (a zygote in a crash-and-rollback cycle restarts app processes by itself) | it invalidated single-cause attribution for the whole window | it did not create the drift, but it means "the pid changed" had at least three independent causes on this device at that moment |
| Two refuted attributions already written into an evidence file | deletion cost, not re-measurement cost | each reads as more coherent than the truth, which is why they would have been believed |
## Reusable pattern
- **Attribute before explaining.** A target that dies, drifts, or restarts gets a *mechanism* from
the platform's own record first (`logcat`, tombstones, `dumpsys`) — sampling tells you that it
happened, never why.
- **Refute the cheapest alternative first, with a control that removes it entirely.** Stopping the
instrumentation is one command; it eliminated the leading hypothesis in a single run.
- **When a measurement disagrees with a recorded conclusion, reopen the conclusion.** That rule
(`SKILL.md` §Stop conditions) was applied twice to this pass's own record, and both times the
record was the thing in the wrong.
- **Record the device's framework state next to any dynamic result.** This device could restart
the target without the target being involved.
## Write back to the repository
- [ ] `references/detection-and-anti-analysis.md` — a "the target exits on a rooted device, and the
environment is why" subsection: the `fg TOP` + immediate relaunch shape, the two kills that
look like it and are not, and the re-baseline step.
- [ ] `references/precedents/README.md` — already indexed as case 1; update the grade column if a
future pass proves assertion 6 by disabling the check.
- [ ] `references/pitfalls.md` — only if the *sampling-instead-of-logcat* mistake is not already
covered there; if it is, extend that entry with this measured cycle length rather than adding
a second one.

View File

@@ -0,0 +1,76 @@
# A zero-event trace was manufactured by the harness, not by the target
## Metadata
| Field | Value |
|---|---|
| Context | instruction-level tracing with Frida Stalker on Android 11 / arm64-v8a, on system processes, to test whether `Stalker.exclude()` fixes the community-reported zero-event and crash behaviour |
| Cost | three arms plus one harness correction; the two defects below both produce a *believable* zero-event trace, so they would have been read as a target finding |
| Outcome | the crash half has a clean cause (`exclude` fixes it); the zero-event half does not, and is now an explicit warning in the script rather than a silent empty log |
| Evidence | `references/evidence-summary.md` §The capability matrix; `references/evidence-summary.md` §The capability matrix (device attempts A and B) |
| Related | `references/native-dbi-and-deobfuscation.md`; `scripts/stalker_trace.js`, `scripts/stalker_report.py`; `pitfalls.md` P26 |
## Assertions and grade
| # | Assertion | Grade | Evidence |
|---|---|---|---|
| 1 | A follow with an **empty** exclusion list killed the target process; the same process survived an attach + resume with no follow at all | observed | three-arm run: `baseline alive=True`, `control alive=False err=script has been destroyed`, `treatment alive=True` |
| 2 | Excluding 20 system modules kept the process alive through the follow window | observed | `treatment … DONE\|reason=timeout` with the process still running, `EXCL\|excluded=20/20 […] not-loaded=3` |
| 3 | Exclusion did **not** restore event delivery | observed | `treatment` still reported `blocks=0 blk=0 calls=0 truncated=0` after the full 6 s window |
| 4 | The zero-event warning added to the script fires correctly | observed | `treatment` emitted `WARN\|zero events 1500ms after follow … -- the pipeline is NOT proven`; `control` could not, because its process was already gone |
| 5 | `CONFIG.autoStart` defaults to `true`, so loading the script starts a follow with default options before any runtime configuration can land | observed | the design section of `references/evidence-summary.md` §The capability matrix; the harness patches it to `false` and asserts the patch count |
| 6 | `frida` spawns a process **suspended**: without an explicit `device.resume(pid)` the followed thread executes nothing and the run reports zero events | observed | the same design section, and the `DONE … blocks=0` transcript in `references/evidence-summary.md` §The capability matrix |
| 7 | `Process.getMainThreadId()` returned a tid that was **not** the pid on Android (26261 for a pid of 19938), so the script's default thread selection watched the wrong thread | observed | `TRIG following tid=26261 (main-thread follow (pid=19938))` |
| 8 | Re-running with the real main thread `start(19938)` produced the same zero | observed | `DONE reason=timeout blocks=0 blk=0 calls=0` |
| 9 | A hot-function trigger (`malloc` in `libc.so`) produced 201 complete follow cycles with zero blocks in any of them | observed | 609 log lines, 201 follows, 201 `DONE`s, `nonzero_blk = 0` |
| 10 | A per-call follow/unfollow cycle on that hot function crashed a system process | observed once | `systemui` pid `18030 → 26932`, `SIGSEGV` with frame `#00` in an anonymous executable region and the return path into `libart` |
| 11 | The crash is the explanation for the zero-event results | **observed negative** — those runs produced their zeros before any death, on two other processes, and one never touched an export trigger | the separation stated in the same native-DBI evidence file |
| 12 | Why events do not arrive | **unverified** | the measurement says it is not the exclusion list; it does not identify the cause |
## Execution chain (including the dead ends)
1. The question was framed as a two-claim question, which is why it resolved: does `exclude` stop
the crash, and does it restore events? Three arms, one device, one package, one module, one
follow window, exactly one variable moving between them.
2. The `baseline` arm exists purely to make a death attributable: without it, "following killed it"
and "the app dies under frida anyway" are the same observation.
3. Two harness defects had to be removed **before** the measurement meant anything, and both were
found by noticing that an arm produced the *expected* result for the wrong reason:
- `CONFIG.autoStart: true` followed once at `load()` time with default options, which is neither
the control nor the treatment, and therefore contaminated both.
- spawn leaves the process suspended, so with no explicit `device.resume(pid)` the followed
thread ran nothing at all.
4. Result: the crash half is answered with a cause, and the zero-event half is answered as a
**negative** — exclusion is not its fix, and the community framing that lists "no events" among
the symptoms `exclude` cures is contradicted by this measurement.
5. Independent corroboration that the pipeline itself was healthy: the export-trigger run produced
201 complete follow cycles. A trigger that never fired would look different; a filter that
dropped everything would look the same, which is exactly why the claim is limited to "events do
not arrive" rather than "Stalker is broken".
## Pits
| Pit | Cost | What it was mistaken for |
|---|---|---|
| `autoStart` following at `load()` | it invalidated two arms at once | a legitimate treatment result |
| No `device.resume()` after spawn | a whole arm's worth of zero events | the target refusing instrumentation |
| Reading a zero-event log as "the code did not run" | the reason the `WARN\|zero events` line was added to the script this pass | a finding about the target |
| `Process.getMainThreadId()` trusted as "the main thread" | one wasted run | a mis-selected thread |
| A system process died mid-run from the trigger pattern | one attribution round | an unrelated crash that could have been folded into the wrong conclusion |
## Reusable pattern
- **A negative result needs its own control arm.** `baseline` (attach, resume, never follow) is
what converts "it died" into "the follow killed it".
- **Engineer the harness's own failure modes out before measuring.** Both defects here produced the
exact shape under investigation; a run that reports the expected symptom is not yet a run that
measured anything.
- **Make a dead pipeline announce itself.** A warning line the moment the follow window ends with
zero events turns an ambiguous empty log into a labelled observation — and it is testable, because
the arm whose process died cannot print it.
- **Separate "the follow is unsafe" from "the follow observes nothing".** Two arms, two conclusions,
never one.
## Write back to the repository
- [ ] `references/native-dbi-and-deobfuscation.md` §6 — keep the two measured boundaries; add the
harness-defect paragraph if absent, because a reader who reproduces a zero event will
otherwise attribute it to the device.
- [ ] `scripts/stalker_trace.js` — already carries the zero-event warning; ensure the `autoStart`
comment says *why* the default is dangerous (it is a trap, not a convenience).
- [ ] `references/precedents/README.md` — indexed as case 2; move assertion 12 to `observed` only if
a future pass identifies the cause.

View File

@@ -0,0 +1,80 @@
# Four bytes of dex, and a control build that still failed the old way
## Metadata
| Field | Value |
|---|---|
| Context | an equal-length dex patch against a public crackme (`UnCrackable-Level1`), taken all the way to an installed, launched build whose blocking dialog is gone |
| Cost | one full pipeline re-run after the repository changed mid-pass; one false verification caused by a reused remote filename |
| Outcome | the end-to-end chain is measured, with a zero-change control through the **same** pipeline — which is what makes the four bytes, rather than the pipeline, responsible |
| Evidence | `references/evidence-summary.md` §The capability matrix (the B1 sections); the benchmark matrix (`references/evidence-summary.md` §The capability matrix) row B1 |
| Related | `references/byte-level-patching.md`, `references/patch-audit.md`, `references/repack-and-sign.md`, `references/verification.md` (the control-build rule) |
## Assertions and grade
| # | Assertion | Grade | Evidence |
|---|---|---|---|
| 1 | The shipped dex's `signature` field is stale while its `checksum` is correct | observed | `checksum field 7fb7d9fa` / `adler32(d[12:]) 7fb7d9fa` matches, while `sha1(d[32:])` does **not** match the stored signature |
| 2 | The edit site is the first arm of a three-way root `OR`, and the *fall-through* is the "continue" side | observed | `0x9d0 39000e00 if-nez v0 -> 0x9ec` preceded by `invoke-static …a()Z` / `move-result`, and `0x9ec` is `const-string "Root detected!"` |
| 3 | Branch polarity was pinned by the following instruction, not by the method name | observed | `expect_next` required `invoke-static …b()` (the *next* root check) after the branch — `polarity: expect_next satisfied` |
| 4 | The patch owns exactly 4 bytes and the header fields own 24; nothing else in the file moved | observed | whole-file diff offsets `['0x8','0xa','0xb','0xc'…'0x1f', '0x9d0','0x9d2']`, and both header fields recomputed from the written file |
| 5 | The signature was computed **before** the checksum | observed | `zlib.adler32(d[12:])` and `hashlib.sha1(d[32:])` recomputed on the output reproduce both stored fields, which is only possible in that order |
| 6 | `repack.py` reported `resources.arsc STORED and 4-byte aligned (OK)` for control and patched alike | observed | the alignment gate output, identical for both builds |
| 7 | Both builds signed with v1+v2+v3 and the same certificate digest | observed | `apksigner verify --print-certs --verbose --min-sdk-version 21` |
| 8 | The installed artifact is the built artifact | observed | device `base.apk` sha256 equals the local sha256, for control (`e5a9f335…`) and patched (`ad6a51ce…`) |
| 9 | The control build — same pipeline, byte-identical dex — **still shows** the blocking dialog | observed | four window-focus samples, 2 s apart: `Window{… Root detected!}` for original and control, `Window{… <PKG>/…MainActivity}` for patched |
| 10 | The patched build reaches its own UI without a dialog | observed | the screenshots: control/greyed-out input with the modal over it, patched with the input live, caret present, `VERIFY` enabled |
| 11 | A second, independently built patched APK from the one-command pipeline produced the **same** sha256 as the manual route | observed | `ad6a51cef4699b73ff052d4e049ca9efd64e9570900d19509f0b7a7c20f87e56` |
| 12 | The app's functional check compares the user's input against a decrypted plaintext, and the 16-byte AES key is a `const-string` in the dex | observed | `a.b` hook output: key `8d12…73cc`, data decrypts to `I want to believe`, `[a.a] REAL input="" -> false` |
| 13 | The input field cannot be driven by `adb shell input text` on this ROM | observed | 7 of 17 characters survived and every space was dropped, across three independent quoting routes; `uiautomator dump` after each attempt is what distinguished "the app said Nope" from "the input never arrived" |
| 14 | The success branch was reached by forcing the boolean, not by typing the secret | observed, and labelled as such in the evidence file | `evidence/09_success_branch/*.png` shows the app's own `Success! …` dialog |
## Execution chain (including the dead ends)
1. **Read the branch structure before editing.** The edit target was chosen because its fall-through
continues into the next root check — not because of its name. That is the difference between a
patch that removes one arm of an `OR` and a patch that removes the check.
2. Dry run first: the tool prints the site with the instructions before and after, and the polarity
evidence. Nothing was written until `polarity: expect_next satisfied`.
3. Applied, then proved with a byte-level diff of the whole file rather than the tool's own report:
four payload bytes and the two header fields, and nothing else.
4. Repack. **Dead end:** the documented default signing route failed with
`error: signer jar not found: uber-apk-signer.jar`, and its own hint — "any zipalign+apksigner
based signer works here" — pointed at a code path that did not exist yet. The working route was
the supported `--no-sign` seam plus explicit `zipalign` → `apksigner`, and the repository changed
mid-pass (another writer landed an `apksigner` route), so the pass recorded **both** states and
re-ran the one-command form, which produced byte-identical output. The lesson kept with it:
*a "this tool cannot do X" finding on a shared worktree has an expiry date.*
5. Install through the **root** path (`pm install -r -d`), because this ROM intercepts the streaming
install (see case 5's sibling record for the `[-99]` shape).
6. Verify, in the order that matters: hash the installed `base.apk` against the local build; `am
start -W`; then window focus sampled four times; then **look at the screenshots**.
7. Build a zero-change control through the identical pipeline and repeat steps 5–6. It still fails
the old way, which is what closes the attribution.
## Pits
| Pit | Cost | What it was mistaken for |
|---|---|---|
| Scanning the method for a plausible name instead of reading the branch structure | would have patched an `OR` arm that changes nothing | a successful patch |
| Relying on the tool's own "self-verify: checksum_ok=True signature_ok=True" without an independent diff | the stale-signature starting state could have hidden a wrong header order | a correct header |
| Reusing the `--tag` value as the remote filename across runs | the final verification showed the **control** build's dialog, from the control run's leftover remote path | a patch that did not work |
| `adb shell input text` on a field that silently drops characters | two attempts read as "wrong secret" | a wrong secret |
| Trusting a screenshot alone | it cannot tell which build is installed | a verified patch |
## Reusable pattern
- **Equal-length byte patches are the cheap route; choose the site by branch semantics.** Nothing
moves, so no offset, `try` block or debug pointer can be invalidated, and the diff is auditable
down to the byte.
- **A patch needs a same-pipeline control that fails the old way.** "It installs and runs" and "the
behaviour changed" are different claims, and only the second one is the deliverable.
- **Hash the artifact on the device, at the moment you observe it.** This is the guard that caught
the reused-tag mistake, and it is one command.
- **Look at the screen, and look at more than one sample.** A modal that appears and is gone between
samples is invisible to a single capture.
- **Re-derive the header yourself once.** Recomputing both fields from the written bytes is what
proves the *order*, and the tool's own success message cannot.
## Write back to the repository
- [ ] `references/repack-and-sign.md` — if the `signer jar not found` message and its apksigner
route are not already reconciled, that is a documented contradiction worth closing.
- [ ] `references/verification.md` — the control-build rule is the load-bearing part of this case;
add the "hash the installed APK before observing" step if it is only implicit there.
- [ ] `references/precedents/README.md` — indexed as case 4; the row's grade stays `observed` end to
end.

View File

@@ -0,0 +1,70 @@
# "The module never ran" was an artefact of a broken log channel
## Metadata
| Field | Value |
|---|---|
| Context | verifying that an LSPosed hook module was actually injected into a hardened target, on a rooted Android 11 device |
| Cost | the verification criterion itself had to be corrected; `logcat` was empty across nine successful injections |
| Outcome | the injection is proven from `/data/adb/lspd/log/modules_<timestamp>.log`, and the read rule is now "both channels, always" |
| Evidence | `references/evidence-summary.md` §The capability matrix (the injection-line, log-surfaces and scope sections) |
| Related | `references/evidence-summary.md` §The capability matrix (the framework-damage section); `references/lsposed-and-modules.md`; `references/environment.md` |
## Assertions and grade
| # | Assertion | Grade | Evidence |
|---|---|---|---|
| 1 | The module was injected nine consecutive times, each run reaching the target's `MainActivity.onCreate`, the WebView process and an ad SDK activity | observed | nine full injection blocks; nine pids in ~3 minutes (`8111 → 9392 → 10557 → 11453 → 12345 → 13236 → 14155 → 15061 → 15934`) |
| 2 | `logcat -s <TAG>` was **empty**, as was `logcat -s LSPosed-Bridge` and a full-buffer `grep -i <TAG>` | observed | the injection-line section of the LSPosed evidence file, stated as the correction to the pass's own earlier draft |
| 3 | The module's output existed only in `/data/adb/lspd/log/modules_<timestamp>.log` | observed | the evidence file for the pass is 201 lines, of which 126 are `<TAG>` |
| 4 | The same file earlier appeared to contain nothing but `Logd maybe crashed (err=Socket operation on non-socket), retrying in 1s...` | observed | the log-surfaces section of the same evidence file quotes the entire content of an older `modules_*.log` |
| 5 | The ROM's `logd` route is broken, so LSPosed's logcat channel delivers nothing while its file channel works normally | **inferred** — the two observations above are consistent with exactly this, and the mechanism was not probed further |
| 6 | A reader debugging a module with `logcat` alone would conclude the module never ran, while looking at nine successful injections | observed (as a statement about the evidence), inferred (as a statement about other readers) |
| 7 | The target's double-`Application` packer swap is visible in the hook order, and hooks installed at `handleLoadPackage` time survive it | observed | `Application.attach` fires twice — the real `BaseApplication`, then the stub — and by `Activity.onCreate` the reported classes are the **unpacked** ones |
| 8 | Scope is per app, not per process | observed | the module also injected into the target's `com.google.android.webview` process and reported an ad SDK activity |
| 9 | The pre-reboot self-restart cycle (10–20 s) can be compared with the post-reboot cycle (22–23 s) | **inferred at best, and not a controlled result** — a reboot moves several variables at once | the injection-line section of the same evidence file |
| 10 | `verbose_*.log` is a full logcat snapshot, useful for correlating a run with system state and useless as a module log | observed | the log-surfaces section of the same evidence file |
## Execution chain (including the dead ends)
1. The module was built, installed, PM-enabled, and scoped through LSPosed Manager's UI — read from
`uiautomator dump` coordinates, never tapped by guess. The scope change was confirmed in the
framework's own SQLite database (`scope` gained `(2, '<PKG>', 0)`, `modules.enabled` flipped to
`1`, and the database file grew from 156,376 to 201,696 bytes), which is what proves the
framework — not merely its daemon process — came back.
2. The pass then read `logcat -s <TAG>` and found nothing. **Dead end:** the criterion in the
reference file, in the pass's own earlier draft, and in the scaffold's generated README all said
to watch `logcat`.
3. Rather than conclude "injection failed", the other channel was read: LSPosed's file log. Nine
full injection blocks.
4. The correct criterion was written down, and it is the transferable part: read **both** channels.
`logcat -s <TAG>` where the platform is healthy; `/data/adb/lspd/log/modules_*.log` always.
5. The reason the two disagree on this device is a broken `logd` (`Socket operation on non-socket`),
which is also visible in unrelated incident lines from the same window (`system server died`,
`am is dead`, `no response from bridge, retry in 1s`, `Magisk: zygote crashed too many times,
rolling-back`).
## Pits
| Pit | Cost | What it was mistaken for |
|---|---|---|
| Trusting one log channel on a ROM whose log daemon is damaged | it would have produced a false negative on a working feature | "the module never loaded" |
| `logcat` silence read as absence of evidence *for* the target's behaviour | the verification criterion itself was wrong, not just the reading | a statement about the module |
| The same silent-failure shape appears twice in the pass | once for the module channel, once for the framework's own state | two different "not installed"-looking states, one of which was real |
## Reusable pattern
- **Before accepting a silence as a finding, ask whether the instrument could have spoken.** This is
`pitfalls.md` P26's rule pointed at a *log channel* rather than at a script: check that the channel
has ever delivered for a healthy case.
- **Prefer a channel that is a file over a channel that is a daemon.** `logcat` depends on `logd`;
a file in the module's own directory depends on the module.
- **Verify the verifier.** The scope change was confirmed in the framework's own database rather
than in the manager's UI reflecting it back — a UI can render a cached state, and the file is the
state.
- **On a packed target, a Java-layer hook is reachable without touching the APK**: the double
`Application` swap is observable, and hooks installed before it survive it. That is a *capability*
result, separate from the log-channel lesson, and it is the reason this route is worth the setup.
## Write back to the repository
- [ ] `references/lsposed-and-modules.md` — the verification section must name both channels and the
exact file path pattern; a single-channel instruction is what this case disproves.
- [ ] `scripts/lsposed_scaffold.py` — the generated README's verification line should carry the same
two-channel instruction, because it is the artifact a user reads first.
- [ ] `references/precedents/README.md` — indexed as case 3; assertion 5 stays `inferred` until the
logd mechanism is probed directly.

View File

@@ -0,0 +1,83 @@
# The leak nobody grepped for, found by hand and only after the fact
## Metadata
| Field | Value |
|---|---|
| Context | this repository's own history: a complete verification pass was recorded, reviewed and merged, and the target identity in its evidence files was found afterwards |
| Cost | a manual sweep, and the omission survived at least one pass that produced evidence files |
| Outcome | a desensitization convention (`<PKG>` / `<DEVICE>` placeholders, target identity absent by policy) **and** an automated scan (`scripts/scan_leaks.py`), added in the same pass that recorded this case |
| Evidence | `references/evidence-summary.md` §The capability matrix; `references/evidence-summary.md` §The capability matrix (its identifier-convention and injection-line sections); `references/evidence-summary.md` §The capability matrix (its environment section) |
| Related | `references/desensitization-and-leak-scans.md`; `references/long-task-discipline.md` §Keep a live record, not a log; `references/verification.md` §Reporting |
## Assertions and grade
| # | Assertion | Grade | Evidence |
|---|---|---|---|
| 1 | Identity was found by a human grep after the material had already been written, and the files had to be corrected | observed as this repository's stated history; the correction itself is visible in the convention every evidence file now opens with | the environment section of `references/evidence-summary.md` §The capability matrix: *"Target identity is deliberately absent. The sample is referred to as the sample, and its package identifier appears as `<PKG>`"* |
| 2 | The convention was enforced per file, by hand, at write time | observed | the identifier-convention section of `references/evidence-summary.md` §The capability matrix normalises every occurrence of the package identifier and its sub-package prefixes, and states that the transcripts are otherwise verbatim |
| 3 | Verbatim transcripts were kept, and that is deliberate | observed | the same section keeps the hook-reported class names, "those are what make the evidence checkable" |
| 4 | No structural gate could have caught it | observed | `check_repo.py` validates layout/frontmatter/`--help`; `check_refs.py` validates section anchors; neither reads content for identity. Both passed on the material that had to be corrected |
| 5 | An automated scan would have caught it | **inferred** — the scanner was built and measured against a planted corpus, but it was never run against the historical, pre-correction tree | `references/evidence-summary.md` §The capability matrix, the planted-corpus and repository-scan sections |
| 6 | The scan finds the four classes that actually occur here — bundle ids in `pm`/`ps`/`manifest` contexts, 16-character serial-shaped tokens, inline key assignments, host user paths | observed | planted corpus: 30 findings across 6 categories, every rule firing |
| 7 | The scan's first run against the real tree was overwhelmingly false positives, and each one was a shape that legitimately appears in this documentation | observed | the repository-scan section of `references/evidence-summary.md` §The capability matrix: 40 findings when the file above was still being written, of which 26 were `strong`/`certain` — and every one of those 26 was inside that pass's own evidence file. After the correction: 0 strong, 4 weak (RFC 5737 usage examples) across 129 files |
| 8 | Over-redaction is the failure on the other side, and it is not hypothetical here | observed | the do-not-anonymize list in the reference file is derived from identifiers that a shape-only scanner flagged in this tree: tool names, SDK packages, dex constant identifiers, public crackme names |
| 9 | The ratio, not the count, decides whether a gate survives | observed from this case | the pass that produced 27-of-28 false positives also had to fix 26 real ones in its own report on the same day. Both numbers moved the design: exemptions for the shapes that legitimately appear, and a split exit code so the remaining documentation-range hits do not make the gate permanently red |
## Execution chain (including the dead ends)
1. The convention existed and was written down per evidence file: identity absent, placeholders for
the package, the device serial and the host paths, verbatim transcripts otherwise.
2. It was enforced by hand, at write time, by whoever wrote the file. **This is the dead end**: a
convention enforced by attention has no failure signal. The omission was invisible until someone
went looking, and by then the material was already in the tree.
3. The correction happened after the fact, which is why this case exists at all. A search for the
shape — not for the value — is the only route that scales, because the value is exactly what you
do not yet know.
4. The scan was then built, and the interesting result was **not** "it finds leaks" (that was the
easy part). It was that a first run over the real tree produced 28 findings of which 27 were
legitimate documentation: a JDK version that looks like an IPv4 literal, tombstone hex tokens
that look like serials, a public crackme's package name, a `probe.synthetic.*` fixture, a
property lookup. A scanner that shipped in that state would have been ignored within a week, and
the leak it exists to catch would have gone with it.
5. The final state separates the two decisions: the scanner reports shapes and admits known-benign
explanations **with a printed reason** (`--show-exempt`), and a human makes the identity call
from the context window the report already contains.
## Pits
| Pit | Cost | What it was mistaken for |
|---|---|---|
| A convention with no automated check | one manual sweep after the fact | adequate coverage |
| Searching for the *shape* after the fact, rather than during the write | it only works if you already suspect | a finished pass |
| A shape-only scanner | 27 false positives in 28 findings on this tree alone | a working gate |
| Suppressing the false positives by blocking whole address ranges | it would also have hidden the one real weak hit (`scripts/tls_check.py`'s RFC 5737 example) | noise reduction |
## Reusable pattern
- **Desensitize at write time, and verify by shape, not by value.** The value you have to remove is
the one you cannot enumerate in advance; the shape is the thing you can write a rule for.
- **Decide in code what is exempt, and let the code print why.** An exemption list that cannot be
audited is indistinguishable from an exemption list that is too broad, and only one of those is
visible in review.
- **Keep the transcripts verbatim and replace the identity with a placeholder.** Redacting the
*evidence* destroys the thing that makes the record checkable; redacting the *identifier* costs
nothing.
- **Measure the scanner against your own tree before trusting it.** The false-positive ratio, not
the true-positive count, decides whether anyone will keep running it.
## Write back to the repository
- [ ] `references/desensitization-and-leak-scans.md` — the reasoning, the split rule, and the
failure-mode table; this case is its motivating evidence.
- [ ] The evidence record's own index — `docs/tool-verification/README.md` at the **repository root,
not shipped** — carries the topic entry. The in-skill condensation is
`references/evidence-summary.md`.
- [ ] `references/precedents/README.md` — indexed as case 5.
- [ ] **Closed in this pass**: `scripts/scan_leaks.py` is wired into the repository workflow (run it
over the tree before a commit) and the `RESULT=` convention is documented in
`references/long-task-discipline.md` §Every script answers with a token, not with prose.
- [ ] **The scanner caught its own first pass.** The tool written to prevent this class of leak found
26 `strong`/`certain` hits on the day it was written, all of them inside its own evidence file —
the fixture's literal match values, quoted to prove the rules fire. The fix was to keep the
location, rule, category and grade for all 30 findings and replace every literal with a shape
description. Boundary that makes it a rule rather than a scare: **the corpus may contain values;
the published surface may not.** Evidence and transcripts: the "self-leak this file carries" and
"inherited versus post-fix" sections of `references/evidence-summary.md` §The capability matrix.
This is the strongest available argument for the gate — the author of the rule needed it on the
same day, and did not see it by hand.

View File

@@ -0,0 +1,338 @@
# Protocol reverse engineering
**Load this when the app's traffic is not readable by the usual means**: the body is protobuf with no
schema, the transport is gRPC, the connection is QUIC rather than TCP, or the client refuses to trust
your proxy because it validates the certificate inside a native library.
Everything in this file assumes you have already decided the traffic matters and that a proxy is
allowed to see it. If a *feature-scoped* TLS failure has appeared while the rest of the app works,
that is a different problem with its own file: read `tls-and-cert.md` first — it owns the trust-chain
attribution and the permissive-defaults template, and this file deliberately does not repeat them.
**Claim strength.** `measured` = an exact command and output exist in
`references/evidence-summary.md` §The capability matrix. `inferred` = documented behaviour or a step that
follows directly from a measured one. `unverified` = reported by others, not reproduced here. The
wire-format section is `measured` on a self-built fixture; every tool named below is `unverified`
unless stated otherwise.
## 1. Protobuf on the wire (measured)
Protobuf has no field names on the wire and no header. A payload is a flat sequence of
`tag, value` pairs, and a nested message is just a value of type "bytes whose content is another
sequence". That is the whole format; the schema supplies meaning and nothing else.
| Element | Encoding |
|---|---|
| Varint | base-128, least-significant group first, high bit set on every byte but the last |
| Tag | `(field_number << 3) \| wire_type`, itself a varint |
| Wire type 0 | varint value (int32/int64/uint/bool/enum) |
| Wire type 1 | fixed 8 bytes (fixed64/sfixed64/double) |
| Wire type 2 | length-delimited: length varint, then that many bytes (string, bytes, nested message, packed repeated) |
| Wire type 5 | fixed 4 bytes (fixed32/sfixed32/float) |
| Wire types 3/4 | group start/end, legacy; treat as unsupported when walking a modern payload |
A host-side check of the rules, including the canonical `150 -> 96 01`, was run independently of the
shipped decoder — `references/evidence-summary.md` §The capability matrix records that cross-check against
the official runtime. Measured output on the fixture built for it:
```
message (29 B): 089601120774657374696e671a0508011201782206038e029ea7052800
field 1 varint 150
field 2 len-delimited(7) 'testing'
field 3 len-delimited(5) nested message:
field 1 varint 1
field 2 len-delimited(1) 'x'
field 4 len-delimited(6) hex '038e029ea705'
field 5 varint 0
rebuild from the walked rows equals original: True
```
Three consequences that decide how you interpret any decoded body:
1. **Field numbers are local to their message.** In the walk above, field 3 of the outer message is a
nested message whose own field 1 exists independently. Nothing on the wire says which message a
field number belongs to; only a schema does.
2. **A packed repeated field is invisible without a schema.** Field 4 above is three varints
(`3, 270, 86942`) packed into one length-delimited value. A schema-free decode can only report it
as opaque bytes — the length is known, the element boundaries are not.
3. **A decoded `0` and a field that was never set are two different questions.** An implicit
(no-presence) proto3 field whose value is the default writes **nothing at all**, so `field = 0`
and "never touched" produce identical bytes; a field **with** presence (`proto2 optional`,
`proto3 optional`) set to `0` does put its tag and a zero byte on the wire. Both halves were
measured against the official runtime; a schema-free decoder can only flag the case, never
resolve it. So a decoded `0` was emitted on purpose by some writer, and a field *missing* from a
decode is not evidence that 0 was the value in use.
### Decoding without a schema
- `protoc --decode_raw < body.bin` — the reference implementation's own raw decode; prints field
numbers and values in exactly the shape above. Note it prints strings and nested messages without
distinguishing them, which is why the fallback order matters: try a nested walk first, then
printable UTF-8, then hex.
- `protobuf-inspector` and `blackboxprotobuf` are the pip-installable equivalents, and both add
heuristics: guessing whether a length-delimited value is a string or a submessage, and (for
`blackboxprotobuf`) inferring a candidate type set per field across many samples. Useful when you
have traffic but no `protoc`; still unverified here.
- Decode **many samples of the same message**, not one. The number of distinct field numbers in a
large sample set is what tells you which fields are optional, which repeat, and which are enums
whose value set you can then enumerate.
- Anything the app stores locally is a sample source that needs no proxy at all: an OkHttp disk cache
(the `journal` file plus its entries), a DataStore file, or a Room blob column. `runtime-data.md`
covers reading these, and `datastore_inject.py` can re-encode an edited DataStore value once you
know the message.
### The decoder that ships here (measured)
`scripts/protobuf_decode_raw.py` walks a payload with no schema and prints a tree. Input is a hex
string, a binary file, or stdin (hex text and raw bytes are told apart automatically); `0x` prefixes,
spaces, commas, newlines and `\xNN` escapes are all accepted, and `|` splits independent frames.
`--json` emits the same tree as data, `--max-depth` bounds the nesting, and `--reencode` writes a
decoded (possibly hand-edited) tree back to bytes and can diff the result against the original.
```
$ python skills/apk-reverse/scripts/protobuf_decode_raw.py --hex "08 96 01 12 07 74657374696e67"
frame 0 bytes 0..12 (12 B) status=end_of_buffer
f1 varint 150
f2 len-delimited(7) view=utf8_string
candidate utf8_string 0.50 valid UTF-8 with no control characters
candidate packed_varint 0.50 the payload is exactly a sequence of 7 varint(s) ...
candidate bytes 0.20 opaque bytes: always consistent with the payload ...
tie: packed_varint and utf8_string are equally consistent with these bytes ...
string: 'testing'
```
The point of the tool is the candidate list. Fed a nested message that `google.protobuf` 6.33.6
itself produced (inner payload `0801120178`), it answers:
```
nested_message 0.50 | packed_varint 0.50 | utf8_string 0.35 | bytes 0.20
tie: nested_message and packed_varint are equally consistent ... the view is a display default
```
Both readings really are legal protobuf for those six bytes, and no heuristic closes the gap. The
confidence numbers are a documented ordering, not probabilities, and the `view` exists only so the
tree can be expanded and re-encoded -- it is not a claim about what the field is.
**Round trip, which is the strongest evidence available without a schema.** Decode, re-encode, and
compare bytes: the message serialized by the official runtime re-encodes byte-identically to
`SerializeToString()`; a real AndroidX DataStore file (81 B, written by `scripts/datastore_inject.py`)
round-trips byte-identically; and editing one value in the JSON tree and re-encoding produced a file
that the *other* tool read back as the new value. Two of the built-in fixture families are exceptions
by design and are reported as such rather than hidden: a non-canonical varint re-encodes to the
minimal form, and a `--split varint-length` run compares frame bodies only, since the length prefixes
are framing and not message data.
**What a schema-free decode cannot conclude -- write none of these down:**
- that a length-delimited field *is* a string, a submessage or a packed array, rather than that it
could be any of them;
- the element boundaries of a packed array, or its element type;
- that a field's value was 0 because it is absent from the decode, or that a sender "set" a field
because a 0 appears (see the two traps above);
- which message a field number belongs to, or that the same field number in two payloads means the
same thing;
- that a payload which parses cleanly is a real message at all -- §6 has that failure mode, and the
fixes are a second sample and a round trip, not a longer look at the first one.
The measured record for this tool -- every command, both input forms, the round trip, and the
framing mistake it does *not* protect you from -- is in
`references/evidence-summary.md` §The capability matrix.
## 2. Recovering the schema from the APK
### What generated protobuf code looks like
| Marker in the decompiled output | What it tells you |
|---|---|
| `extends GeneratedMessageLite` / `GeneratedMessageV3` | protobuf class; `Lite` means the reflection/schema runtime, not the descriptor-based one |
| `static final int FIELD_NUMBER = 3;` (or `int <name>_ = 3`) | the field number, usually right next to the field's getter |
| `switch (tag >>> 3)` with `case` constants | the parse loop's field dispatch — each `case` is a field number, in declaration order |
| `input.readInt32()`, `readInt64()`, `readBool()`, `readStringRequireUtf8()`, `readBytes()`, `readMessage(...)`, `readEnum()` | the field's wire type and semantic type, read *after* the tag |
| `input.readTag()` returning 0 as the loop exit | the standard `parsePartialFrom` skeleton; the loop body is where all field knowledge lives |
| `writeTo(CodedOutputStream)` / `getSerializedSize()` / `dynamicMethod(...)` | the class really is a generated message, not a hand-written DTO |
| `newMessageInfo(instance, "<escaped string>", ...)` | protobuf-lite's compact schema blob (see below) |
The generator keeps those framework calls even after R8, so an obfuscated app usually still carries
readable protobuf structure: class and field *names* are gone, field *numbers* and wire types are
not.
### Two routes to a `.proto`
**Route A — read the field table out of the bytecode (inferred).** Decompile the class, walk
`parsePartialFrom`, and record `case <n>` → the `read*` call that follows. Field name unknown? Name
it `field_<n>`; a valid `.proto` does not need the original identifiers, and the app's own getter
names often survive anyway. Then verify immediately against captured bytes with
`protoc --decode=<message> <schema.proto>`: a decode that produces a coherent structure for **every**
sample is the proof that your field table is right, and it costs seconds. A field that decodes into
nonsense means a wrong wire type, not a wrong field number — check the `read*` call before moving on.
**Route B — read protobuf-lite's own schema blob (inferred).** protobuf-java-lite 3.x embeds a
compact schema string (`rawMessageInfo`) per message class, consumed by `MessageSchema` at runtime.
Recovering it means locating that blob and feeding it to a matching runtime build. It is a strictly
larger win when it works — it carries field numbers, types, and labels in one place — and it is
version-sensitive, so treat Route A as the default and Route B as the acceleration. `unverified`
here: no target in this repository was analysed this way.
**Tooling that automates this (unverified):** `pbtk`/`protodump`-style scanners walk dex/JAR inputs
looking for the generated-code shape above and emit `.proto` files, and a protobuf-aware decompiler
plugin can do the same from a loaded APK. Use them to produce a first draft, then verify it with
`protoc --decode` against real captured bytes — that verification is the step that turns a guess into
a schema, and it is the step worth spending time on.
## 3. gRPC
gRPC is HTTP/2 with a message framing convention, so the first decision is whether you can see
HTTP/2 at all.
- **Recognise it.** A request carries `:method: POST`, `content-type: application/grpc[+proto]` (also
`+json`, `+thrift`), and `:path: /<package>.<Service>/<Method>` — that path shape is the reliable
fingerprint; the package in it usually matches a package name visible in the APK, which is how you
connect traffic to a client class.
- **Message framing.** Each message is: one compression-flag byte, then a 4-byte **big-endian**
length, then that many bytes of (possibly compressed) protobuf. Several messages can share one
DATA frame and one message can span frames, so reassemble by length, not by frame. The call's
outcome is in **trailers** (`grpc-status`, `grpc-message`), which many proxies display separately
from the body — a `200` with no body and a nonzero `grpc-status` is a normal gRPC error.
- **Read it after capture.** Extract the reassembled message bytes and hand them to
`protoc --decode_raw` or your recovered schema. `grpcurl -plaintext -protoset file.protoset`
replays against an endpoint once you have a schema, which is also the fastest way to confirm that
a recovered `.proto` has the right shape.
- **Capture it.** A transparent TCP proxy sees HTTP/2 fine; a tool that only understands HTTP/1.1 may
show the tunnel without the frames, which looks like "the app is not making requests". Interception
tooling for this comes in two shapes: a proxy add-on that understands the gRPC framing and prints
messages (`grpc-dump` and similar mitmproxy add-ons, or a gRPC-Web decoder when the client is a
browser-style stack), and a command-line client (`grpcurl`) for replay against a live endpoint. If
the app uses TLS (gRPC-Java does by default), the certificate question comes first — see
`tls-and-cert.md`, and the native-material trust chain below.
## 4. QUIC / HTTP/3
The failure to expect: a working HTTP proxy, a working CA, and **an empty capture**.
- **Why.** An HTTP proxy is a TCP endpoint, and a client reaches it with `CONNECT`. QUIC is a
UDP-based transport with TLS 1.3 built into the handshake; there is no TCP connection for a
`CONNECT` to carry and no separate TLS layer for a proxy to terminate.
- **Recognise it before debugging anything else.** `Alt-Svc: h3=":443"` in an earlier HTTP response
announced the upgrade; on the wire you see UDP traffic to port 443 whose first packets are QUIC
Initials (long header, version field, and a TLS ClientHello inside), and the client's ALPN is `h3`.
Check for UDP activity when a TCP proxy stays idle while the app plainly works.
- **Options, in order of how little they change:**
1. **Force a fallback to TCP.** Block or blackhole UDP 443 for the device. A client that cannot
reach an origin over QUIC falls back to TCP/HTTP2 if the server also offers it, and then your
existing proxy works unchanged. This is the cheapest option and the one to try first, but it is
a **network-condition change**: state it in the report, because "the app failed to load over
QUIC" and "the app failed" look identical in a screenshot.
2. **Decrypt with keys, not with a proxy.** TLS 1.3 key logging (`SSLKEYLOGFILE`-style) works for
QUIC exactly as it does for TLS-over-TCP, and a packet capture plus the key log can be
decrypted in Wireshark. It requires the client to support a key-log callback — Chromium-based
stacks and BoringSSL generally do, Java/OkHttp-based stacks generally do not — so this is a
per-client property, not a general solution.
3. **Turn HTTP/3 off in the client** by hooking whatever enables it (a library version check, a
remote-config flag, or a build-time constant). This is a client-side patch and therefore
belongs to the repack decision, not to the capture step.
- Everything in this section is `inferred`/`unverified` here: no HTTP/3 target was exercised in this
repository. Treat the ordering as a plan whose first step (block UDP 443) needs no tool support to
test.
## 5. SSL pinning that lives inside a native library
Three different mechanisms are routinely called "pinning", and only the last one survives a system
certificate module.
| Mechanism | Where it runs | What defeats it |
|---|---|---|
| OkHttp `CertificatePinner` (or a custom `TrustManager`) in Java/Kotlin | app's own client | Client-level, **not** the system path: `tls-and-cert.md` §Step 4 and §"If OkHttp is the failing path" own this case — do not apply the permissive-defaults template to it |
| System trust decisions via `HttpsURLConnection` / platform stack | platform | System store policy — see `tls-and-cert.md` §Step 5 |
| Verification performed by a **library the app ships**, e.g. a Flutter app's own BoringSSL, or a bundled `libcurl`/`mbedtls` | `libflutter.so`, `libapp.so`, another `.so` | Only an in-memory change to that library's verification path (below), because the library does not consult the platform trust manager you can influence from Java |
**Flutter, specifically.** A Flutter app's `dart:io` HTTP client runs on the engine's BoringSSL
inside `libflutter.so`; the app's own Dart code can additionally supply a custom trusted-certificate
context or a bad-certificate callback. Two consequences that decide which direction to work in:
- If the app is **not** pinning and simply fails to trust your CA, getting your CA into the **system**
store is the normal fix. `MoveCertificate`-style Magisk modules exist precisely because Android 7+
ignores user-store CAs for app traffic; moving the CA into the system store is a **device change**,
not an artifact, and it must be labelled as a fallback rather than the deliverable. Its boundary is
exactly this: it changes what the platform trust store contains, so it can only help a client that
reads that store.
- If the app **is** pinning, or supplies its own verification context, the system store is irrelevant
and the change has to land on the library's verification function.
**Locating the verifier inside `libflutter.so` (unverified — reported method).** The published
approach in
[universal-flutter-ssl-pinning](https://github.com/vichhka-git/universal-flutter-ssl-pinning)
(`github.com/vichhka-git/universal-flutter-ssl-pinning`) automates the anchor search with PyGhidra:
1. load `libflutter.so` headlessly in Ghidra and scan its defined strings for `ssl_client`;
2. resolve cross-references from that string to the **containing functions**;
3. decompile each candidate to count its parameters;
4. take the **3-parameter** function — that is BoringSSL's
`ssl_crypto_x509_session_verify_cert_chain`, the certificate-chain verification entry point;
5. emit the discovered RVA into two ready-to-run artifacts: a Frida script that attaches to that
address and replaces the return value with `1` (success), and a Renef script that patches the
function entry with the ARM64 sequence `MOV X0, #1 ; RET`.
The repository reports it working on Google Flutter arm64-v8a and Shorebird-patched builds with
Frida 16.x/17.x, and notes the RVA is identical across build types for the same engine version. The
same project also ships an HTTP monitor that auto-locates BoringSSL's `SSL_write`/`SSL_read` in
`libflutter.so` and prints reassembled, de-chunked plaintext — which is the route to take when the
traffic cannot be proxied at all, because it never needs a certificate the app will accept.
**Two things to verify before trusting that recipe on your target.** First, the string anchor is a
build artifact: a stripped or repacked engine can lose `ssl_client` while keeping the function, in
which case fall back to the structural search — find the `X509_verify_cert`-shaped call sequence in
the handshake code, or identify the function called with the handshake structure during
`certificate_verify`. Second, the failure mode of a wrong anchor is a **silent** success: replacing a
return value in a function that merely *looks* like the verifier makes the certificate check pass
in a process that then fails somewhere else, which is easy to misread as "the app detected my hook".
Always confirm the hook fires on a connection that would otherwise fail, and confirm it is
`SSL_VERIFY`-shaped semantics (the same value the library itself uses for success), rather than
assuming `1` is right.
**When the string anchor is gone (the `session_creator` route).** A second family of anchors does not
depend on the `ssl_client` literal at all: instead of finding the verifier through a string it
references, find it through the call chain that reaches it. BoringSSL's handshake creates the session
and then verifies the peer certificate chain, so the target is the function invoked with the
handshake/session structure on that path — this is the shape that Flutter-unpinning write-ups call the
session-creator route. It survives string and symbol stripping, because the call graph survives, and
it costs more: you must identify the handshake structure first, which is why the string anchor is the
fast path and this is the fallback when an engine build has dropped the string. `unverified` here — no
engine binary was analysed in this repository.
**When the system store is genuinely enough.** If the client is Java-side and non-pinning, moving the
CA into the system store is the least invasive change and needs no per-library work. The decision is
therefore: identify the client first (`tls-and-cert.md` §Step 4 attribution), then choose the
mechanism. Patching a library that was never consulting the store is work with no effect; installing
a system CA when a native verifier ignores it produces the same empty capture you started with.
## 6. Failure modes
**A decode that looks valid but is not.** protobuf is self-synchronising enough that random bytes
often decode into a plausible-looking field list. Guard against it by (a) decoding several samples of
the same message and expecting the same field set, (b) checking that nested lengths exactly consume
their parents, and (c) when you have a schema, re-encoding the decoded structure and comparing bytes
with the input — the same round-trip check used in, which caught a decoder bug in this
repository's own fixture.
**A framing choice that is wrong without being refused.** `--split varint-length` means protobuf's own
`writeDelimitedTo` framing: a varint length, then the message. gRPC frames a message differently — one
compression-flag byte, then a **four-byte big-endian** length (). Handed a real gRPC frame, the
varint splitter reported **five** frames rather than failing, while the same bytes decoded with no
splitting reported `stray_end_group`. Strip the 5-byte gRPC prefix yourself, and check the frame count
either way: a wrong split still produces a decode, which is what makes it expensive.
**Assuming the proxy sees everything.** The proxy sees what the client sends through it. An app that
uses QUIC (), a raw socket, a native client bypassing Java's HTTP stack, or a certificate-pinned
connection shows up as silence. Silence is evidence about the transport, not about the app: check
UDP activity, check whether a native library is doing the I/O (`lib_map.py`, `dynamic-frida.md`),
and only then conclude that a feature is client-side.
**Patching the wrong verifier.** Covered in §5: the failure is silent, and the resulting state is
worse than a clean failure because it looks like progress. Verify the hook against a connection
known to fail.
**Treating a recovered schema as ground truth for the server contract.** A schema recovered from the
client tells you what the client expects to send and parse — it does not prove the server honours
those fields, and it says nothing about authorization. `server-api.md` owns that question. The
recurring expensive mistake is to assume that because a field exists and is settable client-side, the
server will act on it.

View File

@@ -0,0 +1,157 @@
# rasc and droidsaw — the Rust ASC, and what it changes about the indexer-first workflow
Load this when: you are about to reach for `droidasc` (ASC) to locate classes or references, or when
the Python indexer is the bottleneck on a real archive. It states what the Rust re-implementation is,
how it was measured here, and **the one class shape where it silently loses code** — which is the
part that decides whether you use it alone or keep a second tool next to it.
**When to prefer it, in one line:** `rasc` is the same interface with the same answers, several times
faster, and one measured blind spot; use it as the indexer, keep `droidasc` and JADX as the
cross-checks the rest of this skill already requires.
## What it is
`MG1937/ASC` has two implementations on different branches. `main` is the Python tool this skill
already documents (`pip install droidasc`). The **`rust` branch is `rasc`**, a re-implementation that
keeps the CLI shape and drops the Python runtime:
| | Python `droidasc` main | `rasc` (rust branch) |
|---|---|---|
| Runtime | CPython + androguard | none — one static-ish binary, **2.13 MB** |
| Decompiler behind `getclass` | androguard's | **`droidsaw-dex 2.0.0`** (pure Rust; its project advertises byte-identical DEX re-emission on F-Droid) |
| Install | `pip install droidasc` | **must be built** — no release asset, no crate (`cargo install rasc` fetches an unrelated maths parser) |
| Commands | `listclass` `getmanifest` `getclass` `findrefs` | `classes` `manifest` `getclass` `findrefs` |
| Flags | `--prefix` on `listclass` | `--filter` on `classes` (case-insensitive substring, not a prefix) |
| Shared flags | `--threads/--thread`, `-o/--output`, `--debug` | same, plus `--debug` prints phase timings on stderr |
`scripts/rasc_build.py` wraps the build and the verification, because **a build only one machine has
performed is a build nobody else can reproduce**: `--check` reports what is present, `--build`
clones and compiles, `--verify <apk>` compares the class-definition sets against `droidasc` and fails
if they differ.
## Measured here
Environment: Windows 11, rustup `stable-x86_64-pc-windows-gnu` (rustc 1.98.1), MinGW-w64 gcc 16.1.0
(UCRT, 64-bit) as the linker. Build from a clean checkout: **117 s**, `cargo build --release` with
FatLTO. Binary `rasc` 0.1.0, 2,236,416 bytes, sha256 `1becdb86b444fe7910589f8ade5bccf13794de9f2b466a1c07748b5669c0264b`,
from `MG1937/ASC@e809c7b4b454ebc8eb4467f56d44a82e6139472b` (rust branch).
Two archives, fastest of N runs, same machine, `PYTHONUTF8=1` for the Python side:
| Archive | Scenario | rasc | droidasc | ratio | answers |
|---|---|---|---|---|---|
| MASTG `UnCrackable-Level3.apk` (1.4 MB) | `classes` | 0.025 s | 0.116 s | 4.7× | **identical set** (1396 / 1396) |
| | `findrefs string http` | 0.021 s | 0.299 s | 14.0× | identical (3 / 3) |
| | `findrefs type String` | 0.021 s | 0.307 s | 14.7× | rasc superset (390 vs 384) |
| | `getclass` ×2 real classes | 0.086 / 0.082 s | 0.297 / 0.267 s | 3.3–3.5× | both emitted Java |
| a real 34.8 MB app (30,768 classes) | `classes` | 0.048 s | 0.190 s | **4.0×** | **identical set**, 0 differences both ways |
The speedup is smaller than the upstream README's geometric mean of 8.0× because that figure was
measured on a 343 MiB archive with 567k classes, where startup cost disappears; on a mid-size app the
Python runtime's import time keeps the ratio near 4×. Both are real, and the small-archive numbers are
the ones a normal task hits.
`python skills/apk-reverse/scripts/rasc_build.py --verify <apk>` reproduces the class-set comparison
and is the check to run after any rebuild.
## The blind spot: `getclass` on the outer enum does not inline the constant bodies
`bench/jadx_parity.py` from the `rasc` repository compares **string literals** between JADX and `rasc`
per class, counting a literal JADX has and `rasc` does not as a lost-code signal. Run here on a fair
sample — app-like classes, no obfuscated identifiers, since the harness itself refuses to judge a
class JADX cannot decompile — **20 classes picked, 17 judged, 1 with a difference, 0 reorderings**.
The one difference is `org.bouncycastle.crypto.PasswordConverter`, an enum whose constants each
override an abstract method. JADX inlines each constant's anonymous subclass; `rasc` prints the
constant list:
```java
// JADX
public enum PasswordConverter implements CharToByteConverter {
ASCII { // from class: ...PasswordConverter.1
@Override public byte[] convert(char[] cArr) { ... }
@Override public String getType() { return "ASCII"; }
},
...
```
```java
// rasc -- getclass on the OUTER class
public enum PasswordConverter implements org.bouncycastle.crypto.CharToByteConverter {
ASCII, UTF8, PKCS12;
public PasswordConverter() { }
public PasswordConverter(PasswordConverter$1 v3) { }
}
```
**What is actually lost, measured directly on the constants.** The bodies live in their own classes,
so the question is whether they survive as their own classes. They do:
| class | `rasc getclass` | `droidasc getclass` |
|---|---|---|
| `PasswordConverter$1` | 404 B, contains `convert(`/`getType(` | 460 B, contains both |
| `PasswordConverter$2` | 407 B, contains both | 463 B, contains both |
| `PasswordConverter$3` | 406 B, contains both | 462 B, contains both |
So the correct severity is **a missing inlining step in the outer-class view, not unrecoverable code**:
the source is one `getclass` away, on the anonymous subclass JADX names in its own comment
(`// from class: ...PasswordConverter.1`). This reference said "the method bodies are gone" when this
row was first written, and that was wrong — the subclasses were never queried. The two Python-visible
symptoms are the same in both tools (neither inlines), so the difference here is JADX's convenience,
not a `rasc` regression against the Python implementation.
`droidasc`'s outer output is nevertheless richer than `rasc`'s for the same class — 2,170 B against
314 B, carrying the synthetic `$VALUES`, `$values()` and the constructors explicitly — which matters
when the enum's *shape* is the thing you are reading.
The rule, now proportional to what was measured:
- **Use `rasc getclass` for locating and reading ordinary classes.** That is what it was measured on.
- **When the class is an `enum` with constant-specific bodies, read the constants.** A collapsed enum
is visible on sight (bare constant list, no bodies). Either query the anonymous subclasses directly
(`Lpkg/Enum$1;`) or use `droidasc`, whose outer-class listing is more complete.
- **Never treat a single decompiler's output as the definition of what exists.** The same rule the VMP
section states about opcode tables, applied to a class body.
Frequency for calibration: 1 of 17 judged classes here, and the other 16 agreed literal for literal.
Adopt it, and keep the cross-check — but do not describe this as lost code.
## What it does not change
- **The workflow.** The indexer is the same *stage* it always was: `rasc` answers *location*
questions, and reading a class you have already located is what a decompiler is for. It does not
decompile a whole APK into a project, and neither does the Python tool.
- **The verification discipline.** Every claim above is a measurement with a command behind it;
where `rasc` and `droidasc` disagree in the tables, the disagreement is recorded rather than
averaged away.
- **Packer behaviour.** On this repository's hardened sample both tools correctly return 4 classes —
the payload is encrypted inside the shell and is not in any DEX either tool can read. A small class
count on a hardened target is the target, not the tool.
## Building it yourself
```bash
python skills/apk-reverse/scripts/rasc_build.py --check # what is present
python skills/apk-reverse/scripts/rasc_build.py --build # clone + cargo build --release
python skills/apk-reverse/scripts/rasc_build.py --verify app.apk # compare against droidasc
```
Requirements and the traps measured here:
- **Rust 1.93+** (the crate is `edition = "2024"`; this build used 1.98.1) and `git`.
- On Windows, the **GNU host toolchain needs a 64-bit MinGW-w64 gcc**. A 32-bit MinGW fails at link
with `sorry, unimplemented: 64-bit mode not compiled in` — the fix is a 64-bit distribution (this
build used winlibs gcc 16.1.0), not a flag.
- The build fetches crates from crates.io; `vendor/` in the repository only carries the two patched
crates (`axmldecoder`, `droidsaw-dex`), so `--offline` fails with `no matching package named anyhow`.
- `cargo`/`rustc` installed to a non-default `CARGO_HOME` are invisible to a non-interactive PATH;
`rasc_build.py` prepends the known locations rather than reporting the toolchain as absent.
## Provenance
`https://github.com/MG1937/ASC` (branch `rust`, commit `e809c7b`), retrieved 2026-09-22.
`https://github.com/droidsaw/droidsaw` and `https://crates.io/crates/droidsaw-dex` (2.0.0),
consulted 2026-09-22. The Python implementation is `pip install droidasc` (0.1.1.post1 seen here;
0.1.1.post2 published 2026-09-21). Nothing from either project is vendored into this repository —
`rasc_build.py` clones it into the gitignored `tools/_work/` and the measurements above name the
commit they came from.

View File

@@ -0,0 +1,204 @@
# Recon — build the picture before touching anything
Ten minutes here prevents hours of wrong work. Answer the five questions from `SKILL.md` with concrete evidence.
**Load this when:** starting any new sample. It gives identity, packer detection, embedded SDKs, ABI, and where the app's own code lives -- the ten minutes that prevent hours of wrong work.
## 1. Identity
```bash
# Without a full decompiler (fast, works everywhere):
aapt dump badging app.apk | head -20
# or, from the extracted manifest:
# package name, versionName, versionCode, sdkVersion, targetSdkVersion
# or, if you have ddc (no JVM, single binary, also gives the launcher):
ddc info app.apk
```
Record: package name, version name/code, min/target SDK, all requested permissions, all declared components.
**Do not hand-parse the package name out of the binary manifest as your primary
source.** Real AXML string tables are full of class-name fragments that look
exactly like package names — in one sample, the strings visible without a real
parser suggested `com.<app>.offline` (a service class prefix) while the actual
package was `com.<vendor>.app`. Every later `pm list packages`,
`dumpsys package`, `/data/data/<pkg>` and `pm install` command depends on this
value, so getting it wrong sends the whole recon down a path where nothing is
found and the natural conclusion is "the tool is broken".
Cross-check two independent sources and require agreement:
- `ddc info app.apk` → `package`, `label`, `launcher`
- `aapt2 dump badging app.apk | head -1` (or `aapt dump badging`)
If they disagree, resolve it before proceeding. A single `label` value is also
worth having: it is what appears on the launcher, which makes device-side
identification unambiguous later.
**Note:** some `aapt` builds choke on non-ASCII paths. Normalize sample paths to ASCII before running tooling.
## 1b. Snapshot the config surface early
If the app has a remote-config endpoint, its DTOs are the cheapest thing in the
package to find, because data classes serialise their own field names into strings
that survive R8:
```bash
ddc findrefs app.apk string "SplashConfig" # or Config, Popup, Banner, Tabbar...
ddc strings app.apk -f "enabled" --with-locations
```
Do this during recon rather than later: it tells you immediately whether the
launch screen, popups and tab set are **client decisions** or **server data**,
which is the difference between a five-minute patch and a dead end. See
`server-config-and-updates.md`.
## 2. Is it packed?
Read `AndroidManifest.xml` → `application android:name`.
| What you see | Meaning |
|---|---|
| The app's own class (e.g. `com.example.app.App`) | **No packer.** Dex is directly editable. |
| A vendor class (`com.stub.StubApp`, `com.secneo...`, `com.tencent.StubShell`, `s.h.e.l.l.*`, `com.nagain.*`, …) | **Packed.** Unpack first. |
| A short meaningless name that does not match the app's package (e.g. `com.bc41`), plus `android:appComponentFactory` pointing at another short class | **Dex-level whole-APK packer.** The shell registers its own Application/proxy under names unrelated to the app. |
| `classes.dex` is only tens of KB and holds nothing but shell classes | The real code is not on disk. |
| A shell library in `lib/<abi>/` (`libDexHelper.so`, `libjiagu*.so`, `libshell*.so`, …) | Native half of the shell: decrypts and loads the payload. |
| One or more large high-entropy `assets/` blobs, possibly **disguised as JPEG** (valid magic bytes, no decodable image) | Encrypted payload. |
| `assets/` contains a second `.apk` or `.jar` | Wrapped/loader build. |
**A clean shape is also a result: record it and move on.** An app whose
`application android:name` is its own class, with a single plain `classes.dex` and
no native libraries, is directly editable — and that finding is what lets you skip
the entire packer/unpack branch of this skill. It is worth one explicit line in the
record ("no packer: application is the app's own class; dex readable; no lib/") so
that later steps cannot re-open a question that was already answered. Most real
targets are this shape; the packer material in this repository exists for the rest.
Decide from sizes and structure, not from names:
```bash
unzip -l app.apk | sort -k1 -n | tail -20 # biggest entries: assets? libs? dex?
```
**Loaded-shell check (highest fidelity):** if the app runs and its own classes are reachable, it is unpacked *at runtime* regardless of what the manifest says. If the app's own code is not among the dex classes, it is packed on disk.
### Unpacking a dex-level packer
**1. Map payload → dumped dex by length.** If the shell uses **length-preserving** encryption, each encrypted `assets/` payload has **exactly the same byte count as the plaintext dex** it decrypts to. That equality is the strongest single discriminator for "which dump is a real original dex" — far more reliable than sorting dumps by size. Measure it per file before trusting any dump.
**2. Check how the real dex is loaded.** A log line like `InMemoryDexFile[cookie=[0, ...]]` means the dex is built in memory from the payload and never written to disk, so there is no dropped file to find. Use a **memory-scanning** dumper (`frida-dexdump`, or your own script walking the class loaders) instead of hunting for artifacts under the app's data directory. See `references/dynamic-frida.md`.
**3. Two dumper caveats that cost real time.**
- `-D <device-id>` does **not** accept a remote device (`Device '<id>' not found`). Register a remote device with `-H <host>:<port>`; keep `-D` for local/USB ids.
- The `-o <dir>` output directory **must already exist** — the tool does not create it. Otherwise every dex write fails and the run still ends with `All done`, which reads exactly like a successful dump of nothing. `mkdir -p out` first, and judge the result by the file count, never by the final message.
**4. Filter the dumps before using any of them.** A memory dump contains three kinds of garbage:
- **Duplicate copies of one dex.** Identical byte count does **not** imply identical content — files of the same size have been measured to differ in ~73% of their bytes, carry a different dex version, and fail to parse. Dedupe by **content hash**, never by size.
- **SDK plugin dexes downloaded at runtime**, which were never part of the APK.
- **Structurally broken dexes** whose header and body disagree.
Cheap header check that catches the third class, plus the hash used for the first:
```python
import struct, hashlib
d = open(p, 'rb').read()
assert d[:4] == b'dex\n' # magic
assert struct.unpack_from('<I', d, 32)[0] == len(d) # file_size == actual size
assert struct.unpack_from('<I', d, 36)[0] == 112 # header_size
sha = hashlib.sha256(d).hexdigest()
```
Then confirm survivors actually parse (`baksmali`/dexlib2) or load on device. Header consistency alone is not proof of a usable dex.
**5. Which dump is the app's own business dex.** Count string-feature hits per dump instead of trusting file order:
```bash
python scripts/dex_strings.py <dump_dir> --find 'Lcom/example/app/' --per-file
python scripts/dex_strings.py <dump_dir> --find 'Lcom/example/adsdk/' --per-file
```
Heuristic: the business dex mixes the app's package prefix (hundreds of hits) with an ad-SDK prefix (thousands of hits). A pure SDK plugin dex has one vendor prefix at its maximum count and **zero** app-package hits. Zero app-package hits means it is not the app.
## 3. Where does the app's own code live?
| Indicator | Code location |
|---|---|
| Plain `classes*.dex`, app classes visible in them | **Dex** — patchable with dexlib2/smali |
| `libflutter.so` + `libapp.so` | **Flutter (Dart AOT)** — dex contains only a thin shell |
| `libil2cpp.so` + `assets/bin/Data/` | **Unity (IL2CPP)** — native |
| `libmono*.so` + `assets/bin/Data/Managed/` | **Unity (Mono)** — managed DLLs under `assets/` |
| `libreactnativejni.so` + `assets/index.android.bundle` | **React Native** — JS bundle under `assets/` |
| `libhermes.so` | **Hermes bytecode** |
| Almost everything in `.so` | **Native** — different toolchain entirely |
For a **Kotlin/Java app**, note the module layout: app code often lives in a small subset of dex files while large SDKs occupy the rest. Finding which dex holds the app's package is a huge time-saver:
```bash
python scripts/dex_strings.py <dex_dir> --find 'Lcom/example/app/' --per-file
```
Consequence for editing: you usually only need to replace **one or two dex files**, which keeps the repack minimal. When the app is packed, run this check against the **dumped** dexes, not the ones on disk ().
## 4. SDK and library inventory
Extract strings across all dex and group by vendor markers. This reveals ads, analytics, crash reporting, attribution, and any anti-tamper SDK, in one pass.
```bash
python scripts/dex_strings.py <dex_dir> --urls
python scripts/dex_strings.py <dex_dir> --find 'anythink|openadsdk|com.qq.e|umeng|bugly|crashsdk'
```
What to look for:
- **Ad networks / aggregators**: `openadsdk` `TTAdSdk` `Pangle` `com.qq.e` `GDTAd` `anythink` `ATSDK` `ksad` `mobads` `sigmob` `bdxadsdk`
- **Analytics / crash / attribution**: `umeng` `bugly` `crashsdk` `appsflyer` `adjust` `UMCrash`
- **Identity / device id**: `oaid` `msa` `SupplementaryDID` `freemme`
- **Anti-tamper / root / hook detection**: `libsgcore` `libInno` `libqmcheat`, strings like `frida` `xposed` `magisk` `substrate`, `/system/xbin/su`
- **Media stack**: `libmpv` `libavcodec` `libplayer` `libgdx`
Important distinction: **a vendor marker in the string table does not mean the app uses that feature**. Presence of `xposed`/`frida` strings is often just an SDK's own detection list. Decide based on *behavior*, not on strings (`references/verification.md`).
## 5. Signature and tamper checks
Search the app's **own** packages only (ignore third-party SDKs) for:
- `getPackageInfo`, `PackageInfo`, `signatures`, `GET_SIGNATURES`, `Signature` → app-side signature verification
- hardcoded SHA-1/SHA-256 hex constants, base64 license blobs → certificate pinning to the original signer
- `checkSignature`, `verifySignature`, `signCheck` → a named check
**Beware false positives.** `SignatureCheck` and `verifySignature` exist inside `okhttp3` (`SuppressSignatureCheck`, `BasicCertificateChainCleaner.verifySignature`) — TLS plumbing, not app tamper checks. Likewise a native `lib*.so` named like a security library may be an ad SDK's payload decryptor, not a shell.
Confirm by checking whether **app code** references it. If the only callers are library-internal, it is not your problem.
## 6. API surface
```bash
python scripts/dex_strings.py <dex_dir> --urls
```
Collect: base URLs, DoH/DoT lookups, path constants, CDN hosts, custom headers.
Treat **unique** strings as navigation aids — they can often be searched byte-wise in the dex to find the owning class (see `references/dex-patching.md` §finding-the-call-site).
Two patterns worth recognizing early:
- **Dynamic gateway**: the real API host is fetched at runtime (e.g. via a DNS TXT record over DoH). The hardcoded host in dex may be only a fallback. This affects server-side analysis, not patching.
- **Minimal headers / no request signing**: if auth is just `Authorization: Bearer` plus a static app-name header, then a repackaged client is not distinguishable to the server by request signature — which matters for `references/server-api.md`.
## Recon output
Write a short profile before patching. Minimum:
```
package / version / ABI
packed? (evidence)
if packed: packer family, unpack method, kept dumps + sha256
code location (dex / flutter / native)
app code in which dex files
ad SDK(s) and the app's wrapper class
analytics/crash SDKs
anti-tamper: present? where? app-side or library-internal?
API base + notable endpoints
device plan (rooted real device / emulator / static only)
```

View File

@@ -0,0 +1,279 @@
# Repack and sign
Rebuilding is the step where otherwise-correct patches die. Two mistakes account for nearly all of it: **stripping `META-INF/`** and **recompressing entries that must stay stored**.
**Load this when:** rebuilding, signing, installing, or a repacked build misbehaves. It gives the two mistakes that account for nearly all failures here, the STORED-and-aligned rule, and every install refusal that looks like a broken build.
## The rules
**1. Strip only signature artifacts, never the whole directory.**
`META-INF/` contains ServiceLoader registrations the app needs at runtime. Details and the exact failure in `references/pitfalls.md` P1.
```python
SIG_EXT = ('.SF', '.RSA', '.DSA', '.EC')
def is_signature_entry(name):
if not name.upper().startswith('META-INF/'):
return False
rest = name[len('META-INF/'):]
if '/' in rest: # keep services/, androidx/, native-image/ ...
return False
up = rest.upper()
return up == 'MANIFEST.MF' or up.endswith(SIG_EXT)
```
**2. `AndroidManifest.xml` and `resources.arsc` must stay uncompressed (STORED).**
Recompressing them produces builds that fail to install or misbehave.
**2a. `resources.arsc` must ALSO be 4-byte aligned, and the failure is an install refusal.**
On Android 11+ (targetSdk 30+) the package manager rejects the install outright:
```
Failure [-124: Failed parse during installPackageLI: Targeting R+ (version 30 and
above) requires the resources.arsc of installed APKs to be stored uncompressed and
aligned on a 4-byte boundary]
```
Two independent requirements hide in that one sentence: STORED, and the entry's
**data offset** divisible by 4. Uncompressed `lib/*.so` follows the same rule.
A naive `zipfile.ZipFile(...).writestr(...)` loop satisfies neither reliably:
Python's zip writer gives you no control over entry offsets. `scripts/repack.py`
therefore emits local headers itself and pads the **local extra field** to reach
the boundary. The padding arithmetic has one trap worth knowing:
> A zip extra area is a sequence of `(id, size, payload)` records, so its minimum
> useful length is 4 bytes. A required pad of **1-3 bytes cannot be expressed**.
> When the natural pad falls in that range, insert a stored filler entry of
> exactly that size instead -- the filler has a computable size, so the following
> entry still lands on the boundary.
Verify by reading the archive, not by trusting the writer: for each entry Android
cares about, parse its local header, compute `header_offset + 30 + namelen +
extralen`, and check `compress_type == 0` and `offset % 4 == 0`.
`repack.py` prints exactly this ("alignment gate") and `check_alignment()`
returns the complaint list programmatically.
**Do not plan on repairing alignment with `zipalign` after the fact when you can
produce a correct archive directly.** A post-hoc pass rewrites the file; on a
target that fingerprints its own byte layout, that is a much larger change surface
than writing it correctly the first time. Keep `zipalign -c -v 4` as an
independent check when the tool is available.
**3. Keep the APK's zip entry metadata.** Preserve `compress_type`, `external_attr`, `date_time` for entries you copy through.
**4. Changing any dex changes nothing about resources.** If you only edited dex, do not touch `res/`, `assets/`, or `lib/`.
**4a. A full `apktool b` rebuild rewrites resource paths even when you edited none.** Decoding with
`apktool d` (without `-s`) and rebuilding re-encodes resources, and obfuscated short names are
expanded back to readable ones — an entry originally shipped as `res/-B.png` comes back as
`res/drawable-hdpi/<real-name>.png`. Expect an entry-level diff against the original to show on the
order of a thousand "removed + added" pairs that are **pure renames**, not content changes.
This is expected and usually harmless, but two consequences matter:
- **Never read that diff as "I broke something".** Compare by identity (rename-aware, or by content
hash grouped by size) before drawing a conclusion. A toy diff that reports 1100 changes when you
edited one method is a **tool** artifact and will send you hunting a bug that does not exist.
- **It changes the byte layout of the whole archive.** On a target that fingerprints its own file
against a stored hash, or whose protection binds offsets into a container, a full rebuild is a much
larger change surface than a dex-only swap. If your only edit is dex, prefer replacing the
`classes*.dex` entries inside the original zip (`compress_type` preserved) over a full rebuild.
Keep that dex-only path as a fallback for exactly this reason.
If you must avoid resource churn entirely, decode with `-s` (do not decode resources) and only
rebuild what you changed.
**5. Signing creates new `MANIFEST.MF`/`*.SF`/`*.RSA`.** That is expected — the check is that **no signature artifact from the ORIGINAL** survives, and **no non-signature entry was lost**.
**6. Any dex you edited must have its header integrity fields recomputed.**
Every dex carries two fields that cover the rest of the file:
```
bytes 12..32 = sha1(data[32:]) # signature — must be computed FIRST
bytes 8..12 = adler32(data[12:]) # checksum — covers the signature, so it is LAST
```
Skipping this does not always stop the app from starting, which is what makes it
dangerous. Android logs
`Failure to verify dex file ...: Bad checksum (computed, expected)` — the real
value appears as "expected", which reads backwards — and then falls back to
interpreting the dex instead of using a verified image. The visible symptom is an
unrelated startup failure such as `ClassNotFoundException` for an ordinary class
(the Application class, or a small AndroidX component), so the trail points at the
APK structure rather than at two stale header words.
`scripts/dexutil.py` exposes `fix_dex_header()` (correct order, plus
`verify_dex_header()` so you can assert the result), and
`scripts/dex_patch_bytes.py` runs both automatically before writing.
**7. `jarsigner` rewrites the archive; `apksigner` does not.**
Adding a v1 JAR signature with `jarsigner` recompresses entries as a side effect,
which **destroys the alignment from rule 2a**. The signature still verifies, so the
build looks correct until the install is refused with `[-124]`. Sign with
`apksigner` (v1+v2+v3) unless you have a specific reason not to.
## Repacking an unpacked (de-shelled) app
When the sample was packed, the dex you are about to patch came out of a memory dump while the APK still carries the shell. Build a **de-shelled base APK** first, then treat it as an ordinary APK for the rest of this document.
1. **Assemble the base.** Start from the original APK: replace `classes*.dex` with the dumped real dexes, and delete the shell library under `lib/<abi>/` plus the encrypted `assets/` payloads. Strip **only** signature artifacts — `META-INF/*.SF|*.RSA|*.DSA|*.EC` and the top-level `MANIFEST.MF`. Never delete the whole `META-INF/` (`references/pitfalls.md` P1).
2. **Decode with `apktool`.** `apktool d base.apk` gives the smali tree and a **readable text manifest**.
3. **Fix the manifest.** Point `application android:name` at the app's real Application class and **remove** `android:appComponentFactory`. Grep the decoded tree for leftover shell class references before building.
4. **Patch, then rebuild.** `apktool b` → `zipalign -p -f 4` → `apksigner` (v1+v2+v3) → install and verify against the checklist at the end of this file.
Why `apktool` rather than editing binary AXML in place: changing `android:name` in binary AXML means hand-editing the string pool and the attribute/chunk sizes around it, and a mis-sized chunk produces an APK that installs but throws far from the edit, typically in component lookup at startup. The text round-trip moves the risk to "did the rebuild preserve everything else", which item 5 of the checklist verifies directly.
## Pipeline
`scripts/repack.py` implements all of the above. Conceptually:
```
open original (zip)
for each entry:
skip entries that are signature artifacts (per rule 1)
skip entries being replaced
copy through, preserving compression + attributes
write replacement entries
close
sign (v1 + v2 + v3)
verify
```
Usage:
```bash
python scripts/repack.py --apk original.apk --dexdir extracted_dex_dir --out out.apk
# or replace specific dex files:
python scripts/repack.py --apk original.apk --dexdir extracted_dex_dir \
--dex "classes7.dex=patched/classes7.dex" \
--dex "classes8.dex=patched/classes8.dex" \
--out out.apk
```
`--dexdir` supplies the untouched dex files; `--dex name=path` overrides specific ones.
## Signing
Two viable routes.
**Route A — `uber-apk-signer` (handy, wraps zipalign + apksigner):**
```bash
java -jar uber-apk-signer.jar --apks in.apk --ks ks.jks \
--ksAlias <alias> --ksPass <pass> --ksKeyPass <pass> -o out_dir
```
Known quirk: `-o` and `--overwrite` are mutually exclusive; passing both errors out. Also, it **silently skips already-signed APKs** (`0 processed`) — so de-sign first.
**Route B — `zipalign` + `apksigner` directly (most explicit):**
```bash
zipalign -p -f 4 unsigned.apk aligned.apk
apksigner sign --ks ks.jks --ks-pass pass:<pass> --key-pass pass:<pass> \
--v1-signing-enabled true --v2-signing-enabled true --v3-signing-enabled true \
--out signed.apk aligned.apk
apksigner verify --print-certs --verbose \
--min-sdk-version 21 --max-sdk-version 34 signed.apk
```
Enable **v1 + v2 + v3**. v1 is needed for older Android; v2/v3 for modern verification.
Generate a keystore once:
```bash
keytool -genkeypair -v -keystore ks.jks -alias <alias> \
-keyalg RSA -keysize 2048 -validity 36500 \
-storepass <pass> -keypass <pass> \
-dname "CN=<name>, OU=dev, O=dev, L=NA, ST=NA, C=NA"
```
## `apksigner verify` picks schemes from the APK's own minSdk
`apksigner verify` decides which signature schemes to check from the APK's own `minSdkVersion`. With `minSdk >= 24` it prints v1/v2 as `false` **by design**, even though `META-INF/*.SF` is present and the signature is valid. That output is indistinguishable from "signing did not apply" and sends you into a re-signing loop over a file that was never wrong.
Always pass the explicit range (Route B above) and read the scheme list from that run only. Never judge a build from a default-argument `apksigner verify`.
## Installing over an existing app
| Situation | Command |
|---|---|
| Same signing key as installed version | `pm install -r` — keeps app data (login state, caches) |
| Different signing key | `pm uninstall` first, then install. **App data is lost** — say so in the delivery notes |
| OEM installer refuses (`INSTALL_FAILED_*`, vendor restrictions, a bare negative code) | push the APK and install via root: `su -c 'pm install -r -d /data/local/tmp/app.apk'` |
| Downgrade needed | add `-d` |
| Test-only flag needed | add `-t` |
Useful detail: keeping the same keystore across builds lets you iterate with `-r` and **preserve a logged-in session**, which matters a lot when the feature you are testing needs auth.
### Vendor install interception (OEM shells)
Some ROMs route `adb install` through their own "security centre", which can return
a bare failure code while the APK itself is fine:
```
Performing Streamed Install
adb.exe: failed to install app.apk: Failure [-99]
```
The device log shows the real actor and reason, and it is not your build:
```
ColorPackageInstallInterceptManager: OPPO_ADB_INSTALL_CANCEL ... packageName=<pkg>
```
**The root path bypasses the interception**, which is why the table above lists it:
```bash
adb -s <serial> push app.apk /data/local/tmp/app.apk
adb -s <serial> shell "su -c 'pm install -r -g -d /data/local/tmp/app.apk'"
```
Recognise the shape: a numeric-only failure with no `INSTALL_FAILED_*` constant, an
APK that installs fine through the device's own UI, and a device process (package
installer / security centre) in the log. Attributing that to the patch is a
classic wasted hour.
### The installer may still own the screen afterwards
After an intercepted or UI-driven install, a vendor **installer confirmation window
can remain the foreground activity indefinitely** — sometimes showing a stale
package name from an unrelated earlier attempt. Consequences:
- `am start` on your app "does nothing", because another window owns the display.
- Screenshots of your supposed launch actually show the installer.
- `am start -W` can block past any reasonable timeout waiting for a first frame
that will never come.
Clear it (`am force-stop <installer pkg>`, or a HOME key event) and re-launch. Before
trusting any capture, check the foreground component:
```bash
adb -s <serial> shell "dumpsys activity activities | grep -m1 ResumedActivity"
```
If it is not your package, the frames are evidence about something else.
`scripts/coldstart.py --expect-activity` performs this check automatically.
## Post-install / post-upgrade hazards
- **Data directory uid mismatch.** After reinstall the app uid increments; a restored `/data/user/0/<pkg>` directory owned by the old uid is unreadable. Symptom: crash in a DB-init path (`Cannot open database`). Fix:
```
su -c "chown -R <uid>:<uid> /data/user/0/<pkg>"
su -c "restorecon -R /data/user/0/<pkg>"
```
Get `<uid>` from `dumpsys package <pkg> | grep userId=`.
- **Restoring a data backup can itself cause this.** Prefer `cp -f` over an existing file (preserves owner) rather than deleting and re-extracting.
## Verify the build, not just the signature
Signature verification proves the file is well-formed. It does **not** prove the app works. Always:
1. `apksigner verify` passes with the explicit SDK range above — a default-args run can report v1/v2 `false` on a perfectly valid signature.
2. Install succeeds.
3. Launch succeeds; process stays alive.
4. `logcat` shows no `FATAL EXCEPTION` / `VerifyError` / `IncompatibleClassChangeError` / `uncaughtException`.
5. `META-INF/services/*` count matches the original.
6. Unchanged dex files are byte-identical to the original (compare hashes); in a de-shelled build, compare against the de-shelled base instead.
Build a **control** at least once: same pipeline, zero patches. If the control fails, your pipeline or environment is at fault, not your patch (`references/pitfalls.md` P9).

View File

@@ -0,0 +1,194 @@
# Routing tables — the on-demand inventory behind `SKILL.md`
This file is the **lookup layer**. `SKILL.md` loads in full when the skill activates, so it carries
only what has to be one hop there: the symptom index (recognising a failure must not become a two-hop
lookup) and the four gates with their pass criteria.
Everything you consult *after* deciding to work lives here, and one load gets all of it:
| You want | Table |
|---|---|
| every reference file, and when to load it | **Reference index** |
| every script this skill ships, and what it does | **Script index** |
| the symptom table as part of a whole-inventory read | **Symptom index** (mirror — the live copy is in `SKILL.md`; edit it there) |
Coherence is enforced: `python check_routing.py` fails when this file and `SKILL.md` disagree, when a
reference file is not named here, or when a script is not named here.
## Symptom index
| What you observe | Load first |
|---|---|
| A repackaged/re-signed build **dies before your code runs**; `SIGSEGV`, all registers zero, `pc=0`, `fault addr` near `0x0` | `native-tamper-and-suicide.md` (deliberate crash), then `code-virtualization-and-custom-linkers.md` |
| **No packer** (Application is the app's own, dex readable) **and it still dies** | `code-virtualization-and-custom-linkers.md` §a loader is still a possibility; but if the same build also dies on a *second, unrelated* device you are looking at an ordinary startup fault, not a hardened one |
| The app dies at startup on **every** device, packed or not, with **no tombstone** while `crash_dump` says `already traced` and logcat says `exited cleanly (0)` | a bundled crash reporter has taken the signal handlers, so the platform's own trail is gone. Frida spawn-gating is the recovery route |
| A `FORTIFY: pthread_mutex_lock called on a destroyed mutex` abort in a Flutter app, on the **main** thread, before the first frame completes | `dart-aot.md` — check `libapp.so` is actually being loaded; Flutter's engine bootstrap is the usual place a native lifecycle fault surfaces |
| Log says a **Java-layer** signature/integrity check **passed**, yet the process dies | `code-virtualization-and-custom-linkers.md` §a Java-layer "signature killer" is a decoy |
| Deleting a library fixes validation but yields `UnsatisfiedLinkError: dlopen failed: library "X" not found` | `code-virtualization-and-custom-linkers.md` §the deadlock that eats hours |
| Whole classes appear as bare `native` declarations with no body | `java2c-and-jni-sinking.md` — read it **before** dumping memory: if this is Java2C there is no DEX to find, at any point in the process lifetime. A handful of `native` methods in an otherwise ordinary dex is JNI sinking, not this |
| A `Java_*` search over a hardened library returns nothing at all | `java2c-and-jni-sinking.md` §The JNI boundary — why a symbol search fails silently — dynamic registration, or `-fvisibility=hidden`. The check that works is "exports `JNI_OnLoad` and zero `Java_*`" |
| You are about to publish an evidence file, a transcript or a README that quotes real work | `references/desensitization-and-leak-scans.md` — run `scripts/scan_leaks.py` **before** it is committed; the hit list is a set of lines to look at, and `--show-exempt` is where the wrong suppressions show |
| A hooking module appears to have run but its log tag is silent, and you are about to record "it never loaded" | `references/precedents/logd-broken-module-never-ran-case-3.md` — a broken `logd` delivers nothing on `logcat` while the module's whole run sits in LSPosed's file log; read both channels |
| A library's **SONAME does not match its filename** | `code-virtualization-and-custom-linkers.md`, `native-and-so.md` |
| Your edit had **no effect at all**, with no error | `server-config-and-updates.md` §3 (the value may be server-sent), then `packers.md` §map the validation boundary |
| Process **hangs** with no crash record, or dies to a `uid 0` killer | `native-tamper-and-suicide.md` §the rule (you probably made a terminate path *not return*) |
| Death looks like an ordinary null dereference in a hardened library | `native-tamper-and-suicide.md` §deliberate-crash stubs |
| The app dies **only while you are attached/rooted** | `detection-and-anti-analysis.md`; run the unmodified original under identical conditions first |
| **Install fails with `[-124]` and mentions `resources.arsc` / alignment** | `repack-and-sign.md` §2a — STORED **and** 4-byte aligned, both required |
| **Install fails with a bare numeric code (e.g. `[-99]`) and no `INSTALL_FAILED_*`** | `repack-and-sign.md` §vendor install interception — a device-side interceptor, not your build. Use the root `pm install` path |
| **After an install, `am start` does nothing / screenshots show another app / `am start -W` hangs** | `repack-and-sign.md` §the installer may still own the screen |
| Log shows `Failure to verify dex file ...: Bad checksum` and a startup `ClassNotFoundException` for an ordinary class | `byte-level-patching.md` §the dex header has two integrity fields — order matters |
| An install "succeeded" but nothing changed, or the version did not move | `long-task-discipline.md` §keep the observation window clean |
| Evidence contradicts itself, or a capture looks like two states mixed | `long-task-discipline.md` §keep the observation window clean |
| You took screenshots but drew the conclusion from logs or from the patch itself | `long-task-discipline.md` §captures you never looked at are not evidence |
| You are about to re-run an experiment whose result you already recorded | `long-task-discipline.md` §long-context decay |
| A script will not start, or a tool "is missing" | `scripts/doctor.py`, then `toolchain.md` §"not on PATH" is not "not installed" |
| A hook or probe reports **no events at all**, and you are about to call it detection | `scripts/anti_detect_probe.js` for the environment self-report first, then `detection-and-anti-analysis.md` §Step 3: locating the check — the order of search from Stage 0 |
| `attach` hangs and then fails **while the process is still in `ps`** | `detection-and-anti-analysis.md` §Step 3 Stage 0 — check for `D` in `/proc/<pid>/stat`, and attach a *different* pid as a one-line control before blaming the target |
| The app exits with no tombstone, no crash and no ANR record | `detection-and-anti-analysis.md` §Step 3 — a clean self-exit means the check ran before your hooks existed; the branch conditions there say which Stage |
| A dump region validates as the wrong thing, or an `r--s` view of `base.apk` looks like a dex | `advanced-unpacking.md` §What this route cannot do, and how to tell before you spend the window |
| Feature-scoped network failure (login/register/pay) while the rest works | `tls-and-cert.md` — do not assume your patch caused it |
| Everything works but **every signed request fails** after repack | `signature-derived-keys.md` |
| A re-signed build **runs fine, renders its whole UI and logs no error — but one feature silently never loads**, and `dumpsys`/DNS/logcat show **no request for it at all** (not a rejected request: *no request*) | `code-virtualization-and-custom-linkers.md` §what the native check actually reads — refusing **before** the request is built. Not the row above: "sent and rejected" and "never sent" have different owners |
| You cannot tell whether a missing feature is **your patch's fault or the target's own behaviour** | `long-task-discipline.md` §single-variable discipline. Run the **zero-change control through the same pipeline**, and the decisive variant: the unmodified original with the patch applied **in memory only**, same device, same network |
| Under Frida `spawn`, the UI never appears — `mCurrentFocus` stays `null`, screenshots come back blank, the Activity stack never builds | `dynamic-frida.md` §spawn keeps the Activity stack down: write the patch into memory, **detach**, then start the Activity normally |
| `frida-server` keeps disappearing mid-experiment, or the device reboots itself while you are working | `dynamic-frida.md` §when the ROM hunts your instrumentation |
| Ads still appear after a patch that should have killed them | `server-config-and-updates.md` §6 (cached config / remote re-enable), then `ad-removal.md` §step 4 (count the SDK's own log lines; n -> 0, not "I did not see it") |
| A forced-update or "must update" gate blocks the build | `updates-and-forced-upgrade.md` §step 6 |
| The dialog is gone but the feature is still locked | `membership-and-limits.md` / `account-gates.md` — decide server vs client authority before patching again |
| You are about to discard a route as "blocked" | `packers.md` — re-read it before writing any route off; mis-attributed failures have removed viable routes for hours |
| The task has run long and you are unsure what is already proven | `long-task-discipline.md` §keep a live record |
| A dumped dex parses in full, the classes are all there, and most method bodies are `return-void` stubs or nop fills | `references/advanced-unpacking.md` — an extraction shell: measure the `stub%` with `scripts/dex_dump_validate.py` before trusting any of it, and know that recovering the bodies is a different route |
| Your `frida` dump dies mid-write (`script has been destroyed`), or the process you are dumping keeps changing pid | `advanced-unpacking.md` §dumping when frida is refused — rule out memory pressure first; a reclaim-and-relaunch needs no instrumentation |
| A repack is refused by several independent checks, or the build has to keep working through store updates | `references/lsposed-and-modules.md` — deliver a module instead of an APK; G1's form table says when |
| A hook module is installed, enabled and scoped, yet its log tag never appears — and you are about to conclude it never ran | `lsposed-and-modules.md` §Deploy, enable, and verify — **a `logcat`-only verdict has already been wrong here**: on one ROM `logd` is broken and output reaches only `/data/adb/lspd/log/modules_<ts>.log` |
| A module's entry class is missing from its own dex (so it can never load), yet the package installs, enables and looks healthy | `references/lsposed-and-modules.md` — check `assets/xposed_init` against the dex's actual classes; installation is not evidence of anything |
| You only need to **call** the target's own routine (sign, token, encrypt) rather than change the app | `references/emulation-and-rpc.md` — emulate it, or service-ify the live function over Frida RPC |
| A native function is a many-thousand-line `switch` state machine, or the decompiler's output is meaningless | `references/native-dbi-and-deobfuscation.md` — OLLVM shapes, a Stalker trace, and how far a trace actually gets you |
| `Stalker.follow` installs but no events arrive, or following a hot libc export crashes the process | `references/native-dbi-and-deobfuscation.md` §6 failure modes — this repository measured both |
| The traffic is protobuf/gRPC/QUIC, or a proxy sees TLS but requests still fail on a Flutter app | `references/protocol-reverse.md` — schema-less protobuf, frame capture, and native-side pinning |
| Userspace hooks land and the app still dies: the check reads `/proc/self/status` through a raw `svc`, or runs before `JNI_OnLoad` | `references/kernel-and-environment-hardening.md` — what the next layer up and down can actually do, and when to stop |
| You must edit, repack, sign or inspect the APK **from the phone itself** | `references/on-device-tooling.md`, `scripts/mt_mcp_probe.py` |
| A captured body decodes to nothing readable, or you cannot tell whether a length-delimited field is a string, a nested message or a packed array | `protocol-reverse.md`. Protobuf on the wire (measured) — run `scripts/protobuf_decode_raw.py`; the candidate list and its `tie:` lines are the answer |
| Method bodies are present but decode as **private opcodes**, and you need the mapping rather than an explanation of why VMP is hard | `vmp-differential-analysis.md`, then `advanced-unpacking.md` for the shape diagnosis |
| A store build arrives as `base.apk` + `split_config.*.apk`, or a rebuilt build is refused **as a set** although every file verifies on its own | `split-apk.md` — one keystore across every member for `pm install-multiple`, and check that a merge is legal before trusting a merged single APK |
_54 row(s) below the header._
## Reference index (no longer inline in `SKILL.md`)
| File | Load when |
|---|---|
| `references/recon.md` | Starting any new sample; identifying packer, SDKs, code location, ABI |
| `references/server-config-and-updates.md` | **The launch screen, a popup or the tab set is server-sent**; no ad SDK was found; a removed promo came back; anything controlled by a `*Config`/`*Popup` DTO with an `enabled` flag |
| `references/byte-level-patching.md` | You want to change behaviour by editing a few bytes rather than rebuilding a method — equal-length patches, locating an instruction's exact offset, dex header integrity fields, branch polarity, verifier legality |
| `references/packers.md` | The app is packed/hardened, or an edit makes it die before your code runs. Also load before discarding any route as "blocked by the shell" |
| `references/code-virtualization-and-custom-linkers.md` | **No packer, dex is readable, and a re-signed build still dies** — whole classes turned `native`, a private loader with a mismatched SONAME, a self-decrypting payload, a Java-layer "signature killer", and the keep-it/drop-it deadlock |
| `references/java2c-and-jni-sinking.md` | **Whole classes are bare `native` declarations and you are about to hunt for a decrypted DEX** — Java2C has none, ever; the code is in a `.so`. Also the JNI boundary: why a `Java_*` symbol search fails silently |
| `references/vmp-differential-analysis.md` | Method bodies are present but decode as **private opcodes** — a real Dex VMP: the known-plaintext differential, which links can and cannot be automated, how to *prove* a derived opcode table, smali generation, and when the route is closed |
| `references/framework-runtimes.md` | The UI is not native (Flutter / React Native / Unity / Cordova), or Java-layer hooks fire zero times while the UI clearly works |
| `references/dart-aot.md` | The logic lives in a Dart AOT snapshot (`libapp.so`): pinning the Dart version, building a matching decompiler, the object pool and reference indexing, register/boolean conventions, locating and patching Dart code |
| `references/native-and-so.md` | Patching in a `.so`, needing code to run before the app's own code, hand-built native payloads that crash inside the linker, or **deciding which library/ABI is actually loaded and executing** |
| `references/native-tamper-and-suicide.md` | The process dies on its own (no Java stack, or a native crash that looks like a bug); you are about to neutralise a `kill`/`exit`/`abort` path; or a hardened library's sections/function boundaries look wrong |
| `references/detection-and-anti-analysis.md` | The app fights back: it dies after you attach, refuses to run, detects root/hook/debugger. **Cost-first (A/B/C), plus the order of search** (see its Step 3 stage funnel) and when to go static |
| `references/toolchain.md` | Choosing or invoking tools, something is not installed (including "not on PATH but present on disk"), a tool's output smells wrong, or you need to know which tools exist only as a GUI |
| `references/ad-removal.md` | Task involves ads, trackers, sponsored cards, splash/interstitial/reward |
| `references/updates-and-forced-upgrade.md` | The patched build must **keep working over time**; the app has any version check, forced-upgrade dialog, self-update installer, or hot-update/resource channel. Load this for essentially every build you intend to ship. |
| `references/account-gates.md` | Task mentions "no login required", "don't force sign-in", "skip phone binding", "guest mode"; or a screen/feature is unreachable signed-out. Also load before promising that an account-scoped screen will show anything |
| `references/membership-and-limits.md` | Task involves VIP, subscription, paid content, unlock, "fully cracked" |
| `references/server-api.md` | The behavior is decided by a response, or you need to know if a patch can even matter |
| `references/dex-patching.md` | Any actual editing of dex/smali, choosing a patch layer, choosing a tool |
| `references/patch-audit.md` | Proving a patch **landed**, or that it is **legal**: length-vs-bytes comparison, the equal-length blind spot, verifier-level legality (`move-result*` adjacency), text-matching patch traps, and how to report a missing patch |
| `references/repack-and-sign.md` | Rebuilding, signing, installing, or a repacked app misbehaves |
| `references/signature-derived-keys.md` | The app reads `signatures[0]`/`toCharsString()`, or a rebuilt APK installs and runs but every signed request fails (`sign`/`_p`/`uth` empty or `-1`) |
| `references/runtime-data.md` | Local state matters: DataStore, SharedPreferences, SQLite, protobuf caches, tokens — **or your data edit keeps being reverted, or a stored value looks encrypted** |
| `references/dynamic-frida.md` | Frida setup, hooking strategy, tracing caller chains, finding the real call site |
| `references/environment.md` | Device/emulator setup, root, ADB, networking, offline devices, emulator console control and recovery, **the preflight check to run before every experiment block** |
| `references/verification.md` | Defining what "done" means; building the evidence chain |
| `references/tls-and-cert.md` | One feature fails at runtime (login, registration, payment, an API-backed screen) while the rest of the app works |
| `references/third-party-builds.md` | The input is a "cracked"/"modded" build you did not produce — audit it before trusting it |
| `references/long-task-discipline.md` | The task will run long, or you are resuming one. Live record, conclusion grading, drift checkpoints, **deliverable-form drift (rooted-only vs shippable)**, bound-your-waits, **captures-you-never-looked-at**, **long-context decay**, handover |
| `references/pitfalls.md` | Always worth a skim before building. This is the failure catalogue. |
| `references/rasc-and-droidsaw.md` | You are about to reach for the ASC indexer, or the Python one is the bottleneck: the Rust re-implementation, its measured speedup and **identical class sets**, the one class shape where it silently drops code, and how to build it |
| `references/coverage-and-limits.md` | You need to weigh a claim before trusting it: the evidence behind each covered item, the dependencies this skill does not ship, and the record of what was never exercised |
| `references/coverage/route-inventory.md` | You must answer "**can this skill actually do X**" in one pass: the per-route inventory — covered by verified mechanism, covered only by a documented route, the dependencies not shipped, and what is out of scope |
| `references/evidence-summary.md` | You need to know **whether a route was verified and how strong the evidence is**, from inside an installed copy: capability → one-line conclusion → strength → where the evidence lives |
| `references/routing.md` | You want the **whole inventory in one load**: every reference file with when to load it, every script with what it does, and a mirror of the symptom index |
| `references/desensitization-and-leak-scans.md` | You are about to publish anything derived from real work — evidence, a transcript, a README — or a leak scan reports a hit: what must be desensitised versus kept, the do-not-anonymize list, and how the scan gates a commit |
| `references/precedents/` | You are about to do a kind of work this repository has already converged on — a hardened Flutter target, a zero-event trace, a module whose log is silent, an equal-length patch taken to the device. Positive cases with graded evidence and dead ends |
| `references/handoff-boundaries.md` | A routing decision is about to cross into another discipline: the JNI form table, the packer-versus-loader split, and what "verified" means for each of G1's four deliverable forms |
| `references/advanced-unpacking.md` | **The dump landed but the bodies are empty** (an extraction shell), or decode as private opcodes: the stub-ratio measurement, why FART's hooks died on Android 12-16, the root-side dump, its **boundary**, the layered descent, and where recovery stops |
| `references/lsposed-and-modules.md` | The client-side logic is reachable but **a rebuilt APK is refused**: delivering a system-level hook module instead, its gradle-free build chain, scope configuration and verification, and the layer a Java module cannot reach |
| `references/emulation-and-rpc.md` | You need to **call** a routine rather than change the app — a signing routine, a token, a cipher: emulated execution (Unidbg/Unicorn) with its environment-filling cost, versus service-ifying the live function over Frida RPC |
| `references/native-dbi-and-deobfuscation.md` | A native function is an OLLVM state machine, or you need instruction-level execution evidence: Stalker traces, the trace-to-CFG route, the Stalker/QBDI/emulation decision, and the **measured** zero-event and crash boundaries |
| `references/protocol-reverse.md` | The traffic is protobuf without a schema, gRPC, or QUIC/HTTP3; or a proxy sees TLS while the app still fails — schema recovery, frame capture, and native-side certificate pinning (Flutter/BoringSSL) with its boundaries |
| `references/kernel-and-environment-hardening.md` | Userspace hooking provably cannot reach the check — raw `svc` syscalls, `init_array`-early detection: what each layer can still do, the kernel-route map with its version gate, and when escalating is wrong |
| `references/on-device-tooling.md` | Working **from the phone itself**: MT Manager edit/repack/sign and its built-in APK MCP, LSPosed Manager, Termux+frida, and on-device data inspection |
| `references/split-apk.md` | The target is a **split APK / App Bundle set** (`base.apk` + `split_config.*.apk`), or `pm path <PKG>` returned several files: reading a set, merge versus unified re-signing, and the install refusal each mistake produces |
_43 row(s) below the header._
## Script index (no longer inline in `SKILL.md`)
| Script | Purpose |
|---|---|
| `scripts/doctor.py` | **Run this first.** Script inventory is scanned from the directory (`registered/checked = N / N`, unjudgeable entries counted as `unknown`, never skipped) and capability is resolved as a real requires-closure — so it reports BLOCKED with the missing piece and an installable next step instead of inferring "can re-sign" from the presence of a JVM |
| `scripts/dexutil.py` | **Dependency-free dex reader**: structural walk, exact instruction decode, `fix_dex_header`/`verify_dex_header` in the correct order, branch/operand helpers. Shared by the dex scripts; also dumps one method with offsets standalone |
| `scripts/dex_find_insn.py` | Locate an instruction by **decoded semantics** and get its exact byte offset, with context and both sides of any branch. This is how you find a patch site without guessing offsets or scraping listings |
| `scripts/dex_patch_bytes.py` | Apply **equal-length byte patches** from a JSON spec: semantic match, polarity pin via `expect_next`, equal-length enforcement, verifier legality, dex header recompute, re-decode to prove the edit landed. `--dry-run` first |
| `scripts/dex_check_verifier.py` | Tier-3 check: does any **conditional branch target a `move-result*`** (bypassing its producer, so the class fails to load)? Compares two builds and distinguishes pre-existing findings from regressions your patch introduced |
| `scripts/coldstart.py` | Cold-launch and capture a **timed screenshot burst + logcat signals + installed-build facts + launch timing**, and warn when the foreground activity is not your app |
| `scripts/smtool.py` | baksmali/smali wrapper with a bundled classpath (assemble/disassemble dex) |
| `scripts/patch_smali.py` | Method-body replacement in a smali tree, matched by signature |
| `scripts/dex_strpatch.py` | Byte-level string constant patch with **string_ids ordering guard** |
| `scripts/dex_classdiff.py` | Compare two dex class tables (set + access flags) to prove a patch was surgical |
| `scripts/dex_strings.py` | Dump/extract strings and endpoints from dex without a decompiler |
| `scripts/dart_pprefs.py` | Build/query the object-pool offset -> code-site index for a Dart AOT snapshot (arithmetic decode; seconds, not minutes) |
| `scripts/dart_pool_strings.py` | Recover string literals from a Dart AOT snapshot: framed entries, the one-byte vs UTF-16 split, file offsets, and a run-length noise filter |
| `scripts/dart_disasm.py` | Annotated windowed disassembly of Dart AOT code (pool + boolean annotations) plus a B/BL caller index |
| `scripts/find_refs.py` | Count and list callers of a method/field (blast-radius check). Takes a smali tree, a `.dex`, a directory of either, or an `.apk`. Always prints what it scanned, so "the input was unreadable" cannot be mistaken for "nothing references it" |
| `scripts/repack.py` | Rebuild an APK: replace dex, strip only signatures, keep `META-INF/services/`, write a **4-byte-aligned** archive, sign, verify. Also **split sets**: `--split-dir`, `--split-mode resign`/`merge`, `--signer auto/jar/apksigner` |
| `scripts/dexpatch/` | dexlib2 method-level rewriter (for changes that genuinely need new instructions) + build notes |
| `scripts/devsh.py` | Quoting-safe ADB shell helper for rooted devices |
| `scripts/usb_net_proxy.py` | Give an offline device network over USB (adb reverse + local proxy) |
| `scripts/datastore_inject.py` | Encode/inject AndroidX DataStore preferences (protobuf) safely |
| `scripts/probe_api.py` | Probe an app's HTTP API with correct headers, report status/shape |
| `scripts/install_test.py` | Install a build and run a launch/health check with logcat signal extraction |
| `scripts/frida_probe.js` | Four-layer runtime probe: app network layer + OkHttp + java.net + swallowed exception messages |
| `scripts/run_probe.py` | Inject a probe, stream it to a timestamped log file, stay resident while you operate the app |
| `scripts/tls_check.py` | Strict certificate check for one or more hosts (expired / wrong host / untrusted CA) |
| `scripts/preflight.py` | Read-only environment check before every experiment block: device, root, ABI/translation, clock skew, leftover proxy/forwards, dead device server. Run this before blaming a patch. |
| `scripts/lib_map.py` | What is **actually mapped** into a live process: per-library path, base, architecture (`ELF e_machine`), and classification (system / from-APK / runtime-materialized). Answers "which library and which ABI is really executing". |
| `scripts/elf_plt.py` | Resolve a PLT stub to its imported symbol (x86_64 + aarch64) from the **relocation table**, list a symbol's callers, and **byte-diff two libraries naming the symbol each changed stub belongs to**. Run before patching any stub |
| `scripts/so_constpatch.py` | Same-length rewrite of an isolated string constant, for **redirecting a library load instead of defeating a check**. Enforces equal length, refuses substrings of longer identifiers, reports constant-pool hits, patches inside an APK or a bare `.so`. **APK rebuild preserves per-entry zip metadata** (STORE stays STORE, `extra`/`external_attr`/`create_system` untouched, `resources.arsc` STORED and 4-byte aligned) and **refuses by default** when `extractNativeLibs="false"` or the arsc is not STORED+aligned — pointing at `repack.py`; only `--unsafe-rebuild` forces it |
| `scripts/apk_diff.py` | Entry-level diff of two APKs by content hash: changed / **added** (injection candidates) / removed. Audits a third-party build and proves your own was surgical |
| `scripts/native_crash.py` | Locate a native death from a log or tombstone: signal, fault address, registers, frames split app vs system, the faulting instruction — plus a flag when the fault looks **arranged** rather than accidental |
| `scripts/grab_crash.py` | Recover a stack that a crash-reporter SDK swallowed, when the log shows the app died but prints no backtrace of its own. |
| `scripts/blob_decode.py` | Decode an opaque stored value by searching the parameter space (base64/base64url/hex × rotation × deflate/zlib/gzip) instead of guessing, then re-encode an edited payload with the same parameters. |
| `scripts/snap.py` | Bounded burst screenshot + control-tree capture, with a stall detector and an explicit verdict on whether the accessibility tree is usable at all. Use it so you *look* at the screen instead of driving blind. |
| `scripts/sig_probe.py` | Find the exact `signatures[0].toCharsString()` value: offline candidates from an APK (`--apk`), or the authoritative read from a live package (`--live`) |
| `scripts/spawn_patch_detach.py` | **Spawn under a probe, detach, then drive the UI.** Under spawn the Activity stack often never comes up; memory writes survive detach while hooks do not. Ordering matters: resume *before* waiting for `PATCHED` |
| `scripts/hook_patch_only.js` | The minimal probe for `spawn_patch_detach.py`: neutralise one native death site by offset and report `PATCHED`. `MODULE_NAME`, `FILE_OFFSET`, `PATCH_BYTES`; the replacement must be an equal-length "return" epilogue |
| `scripts/dex_dump_validate.py` | Dedupe, validate and rank dumped dex images: sha256 grouping, header integrity, extraction-shell discrimination via the **trivial-body ratio** (bimodal, not a threshold — `advanced-unpacking.md`), ranking, `--json`, `--trim` for page-aligned captures |
| `scripts/dex_mem_scan.py` | Find embedded dex images in memory captures and extract each at the size **its own header declares**: chunked scanning, magic validation, sha256 dedupe, `--dump DIR`, `--keep-partial` |
| `scripts/lsposed_scaffold.py` | Generate a minimal LSPosed/Xposed **module project skeleton**: manifest with the xposed meta-data, `assets/xposed_init`, the hook class, and build notes for a gradle-free toolchain (javac → d8 → aapt2 → zipalign → apksigner) |
| `scripts/frida_rpc_serve.py` | Bridge a Frida script's `rpc.exports` to a local caller with reconnect handling, so a live native function can be **called** instead of reversed |
| `scripts/rpc_template.js` | The editable companion to `frida_rpc_serve.py`: an `rpc.exports` skeleton plus a native-function call placeholder |
| `scripts/stalker_trace.js` | Instruction-level tracing with Frida Stalker: configurable module/offset targets, trigger selection, the event stream, output-size rules, and `transform` customisation |
| `scripts/stalker_report.py` | Reduce a `stalker_trace.js` log into block histograms and call sequences, and print an explicit diagnostic for the measured **zero-event** case |
| `scripts/mt_mcp_probe.py` | Probe MT Manager's on-device APK MCP (Streamable HTTP, `127.0.0.1:8787/mcp`): JSON-RPC handshake plus grouped `mt_apk_*` tool inventory; prints start-it-by-hand instructions and exits 2 while the service is down |
| `scripts/java2c_probe.py` | The evidence that separates **Java2C** from an extraction shell, a VMP and JNI sinking: per-class `native` density and stub ratio from the dex, plus `Java_*` / `JNI_OnLoad` / registration evidence from the `.so` |
| `scripts/protobuf_decode_raw.py` | **Schema-free protobuf decode**: hex / file / stdin to a JSON tree, every length-delimited field as a candidate set with ties labelled; `--reencode --check` for a byte-exact round trip |
| `scripts/vmp_diff_harness.py` | Differential hardening for Dex-VMP: build a labelled opcode-coverage fixture (**218 of 224 opcodes, measured**), derive a candidate private-opcode map from an original/hardened dex pair, verify it in a closed loop, render smali |
| `scripts/kernelsu_syscall_mask.py` | Generate a KernelSU/APatch syscall-masking scaffold: an installable userspace module plus KPM/LKM/eBPF kernel-side templates, each with its version gate and an explicit unverified label. **A userspace module cannot change a syscall return value** |
| `scripts/capabilities.py` | The capability registry `doctor.py` reads: per capability the requires-closure, what is present, what is missing, an install hint and an estimated cost — so "can this machine do X" is computed rather than guessed |
| `scripts/device_shell.py` | Shared device-side command construction: POSIX single-quote escaping for `su -c`, argv arrays rather than string concatenation, and Android identifier validation (`--pkg`, components) so a malformed value is rejected here instead of being parsed by the device shell |
| `scripts/rasc_build.py` | Build and verify **rasc**, the Rust re-implementation of ASC: `--check` what is present, `--build` clone + cargo (no release asset exists), `--verify <apk>` compares its class-definition set against the Python `droidasc` and fails on any difference |
| `scripts/scan_leaks.py` | Scan a repository for target identity before it is published: bundle ids in manifest/`pm`/`ps` contexts, serial-shaped tokens, PATs, inline appkey assignments, literal endpoints, host user paths. Built-in do-not-anonymize exemptions, context-bearing findings, `--show-exempt` prints why a hit was suppressed, `--fail-on strong\|any`, exit 0/1/2 with a `RESULT=` token |
| `scripts/svc_scan.py` | Name the syscall behind an inline `svc` and the segment it sits in — which decides whether a libc-level hook can observe the call at all. `--context` shows neighbours, because a byte scan also matches data |
| `scripts/anti_detect_probe.js` | Observer-only probe (patches nothing): path/loader/thread/kill hooks with **caller module + offset**, an environment self-report (`TracerPid`, frida-named mappings), and live streaming so a sub-second target still yields evidence |
_53 row(s) below the header._

View File

@@ -0,0 +1,255 @@
# Runtime data — DataStore, SharedPreferences, SQLite, protobuf
Sometimes the cheapest fix is not code at all, but a value in app data. And sometimes that is exactly the wrong fix, because it does not survive a fresh install (`pitfalls.md` P11).
**Load this when:** the cheapest fix looks like a stored value rather than code -- DataStore, SharedPreferences, SQLite, a protobuf cache, a token. It gives how to edit each safely, and how to tell a value the app rewrites from one it keeps.
## Decide first: data or code?
| Question | Answer |
|---|---|
| Does the deliverable need to work after a clean install? | Then it must be **code**, not data |
| Are you iterating on your own device? | Data edits are a fast way to test a hypothesis **before** writing the patch |
| Is the value written by the app itself (a token, a cache, a counter)? | Data — and the server may re-assert it |
**Recommended workflow:** use a data edit to *prove the hypothesis* (cheap), then implement the same effect in code (durable), then verify with a clean install.
## Where app data lives
```
/data/data/<pkg>/ (== /data/user/0/<pkg>)
├── files/
│ ├── datastore/*.preferences_pb AndroidX DataStore (protobuf)
│ ├── mmkv/<name> MMKV (Tencent) — one file per named store
│ └── ...
├── shared_prefs/*.xml SharedPreferences
├── databases/*.db SQLite (Room, etc.)
└── <sdk caches>/ crash logs, download state, ad configs
```
Read with root:
```bash
adb shell "su -c 'ls -la /data/data/<pkg>/files/datastore/'"
adb shell "su -c 'xxd /data/data/<pkg>/files/datastore/<name>.preferences_pb | head -20'"
```
## AndroidX DataStore (protobuf)
Format:
```proto
PreferenceMap { map<string, Value> preferences = 1; }
Value {
oneof value {
bool boolean = 1; float float = 2; int32 integer = 3; int64 long = 4;
string string = 5; StringSet string_set = 6; double double = 7; bytes bytes = 8;
}
}
```
**Encoding an entry requires TWO tag levels:**
```
outer (map field 1, wire type 2) : 0A <len(inner)>
inner (one map entry) : 0A <len(key)> <key> 12 <len(value)> <value>
value payload (e.g. int64) : 20 <varint> # field 4, wire type 0
value payload (e.g. int32) : 18 <varint> # field 3, wire type 0
value payload (e.g. bool) : 08 <0x00|0x01> # field 1, wire type 0
value payload (e.g. string) : 2A <len> <utf8> # field 5, wire type 2
```
Omitting the **outer** tag produces a file the app cannot deserialize, and the failure is usually a **stack-less crash** (`pitfalls.md` P8). Use `scripts/datastore_inject.py` rather than hand-rolling it.
Procedure:
1. `am force-stop <pkg>` (DataStore caches in memory and writes back)
2. Write the file (as root), preserving ownership — `cp -f` over the existing file keeps its owner
3. `restorecon` if SELinux is enforcing
4. Start the app and observe
Verify by reading the file back with `xxd` before launching.
## SharedPreferences (XML)
Simple XML. Edit with the app stopped. Types are explicit (`<boolean>`, `<int>`, `<long>`, `<string>`). Same ownership rules.
## MMKV (Tencent)
Common in apps whose SDK stack is Chinese-market. One file per named store under `files/mmkv/`,
with a `.crc` companion. It is **not** a database and **not** XML — it is a small custom binary
format:
```
offset 0 uint32 actual_size size of the data region
offset 4 uint32 crc32 over the data region
offset 8 data region repeated: varint key_len, key, varint val_len, value
```
Files are preallocated, so trailing zeroes are normal; `actual_size` is what matters.
Parsing is mechanical, and no library is needed:
```python
import struct
def parse(blob):
size = struct.unpack_from('<I', blob, 0)[0]
pos, end, out = 8, 8 + size, []
def varint(p):
v = s = 0
while True:
b = blob[p]; p += 1
v |= (b & 0x7F) << s
if not b & 0x80:
return v, p
s += 7
while pos < end:
klen, pos = varint(pos); key = blob[pos:pos + klen]; pos += klen
vlen, pos = varint(pos); val = blob[pos:pos + vlen]; pos += vlen
out.append((key, val))
return out
```
Writing a change back means rebuilding the region and **recomputing both header fields**:
```python
import struct, zlib
body = new_region_bytes
blob = bytearray(capacity) # keep the original file length
struct.pack_into('<I', blob, 0, len(body))
struct.pack_into('<I', blob, 4, zlib.crc32(body) & 0xFFFFFFFF)
blob[8:8 + len(body)] = body
```
Cautions:
- **A wrong crc32 makes the library throw the whole store away.** The visible symptom is "my
edit did nothing" or "the app reset every setting", never an error.
- **Values are raw bytes**, not always text. JSON is common; a serialized protobuf or a small
encrypted payload is also common (`scripts/blob_decode.py` searches the framing).
- **The in-memory copy wins until the process restarts.** `am force-stop` before editing and
re-launch after, or the running app overwrites your change on exit.
- **Do not delete the `.crc` companion.** Leave it in place when hand-editing the store; it is
validated and regenerated by the library.
## SQLite
```bash
adb shell "su -c 'sqlite3 /data/data/<pkg>/databases/<db>.db \".tables\"'"
adb shell "su -c 'sqlite3 ... \"select * from <table> limit 5;\"'"
```
Useful for: auth sessions (tokens), user profile caches (often a raw JSON blob), and any server state the app persists. A JSON column often contains the exact server payload — the fastest way to learn field names.
## Ownership — the silent killer
The app runs as its own uid. A file written by root with the wrong owner is unreadable, and directory permissions are `drwx------`.
```bash
uid=$(adb shell "dumpsys package <pkg> | grep userId=" | tr -dc '0-9')
adb shell "su -c 'chown -R $uid:$uid /data/user/0/<pkg>'"
adb shell "su -c 'restorecon -R /data/user/0/<pkg>'"
```
Symptom of getting this wrong: crash in a database-init path right after launch, often `Cannot open database ... Directory ... doesn't exist`.
Note: after every reinstall the app's uid **increments**, so a restored data directory needs this again.
## Introducing a value that does not exist yet
If the key is absent, the app uses a default. Two options:
1. **Add the key** with the exact protobuf shape above.
2. **Confirm the default first** — sometimes removing a key already yields the behavior you want (e.g. clearing an expiry so it reads as "not set").
## Making the fix durable
If the effect must ship inside an APK, move it into code. Patterns, in order of preference:
- **Patch the read path** so the value is always the desired one. Find where the Flow/getter is built and make it emit a constant.
- **Patch the decision**, not the data: find the comparison that consumes the value and make it resolve the way you want.
- **Do not patch the generic encoder/boxer** used by the whole app (`pitfalls.md` P6).
Reference case: a promo popup was gated by `<key>_expires_at`. Writing a large value via DataStore suppressed it on the test device, but a fresh install lost it. The durable fix replaced the dedicated map-lambda that produces the value so it always yields a far-future timestamp — a single-purpose class, safe to patch, no effect on the shared serialization helpers.
## When the app rewrites your edit
A very common sequence: you change the value, relaunch, and the value is **back** — often byte for
byte identical to what it was before. That is not a failed write. It is the app re-establishing the
value from a source of truth you have not touched yet.
Two causes look identical and need different fixes:
| Cause | How to tell | Fix |
|---|---|---|
| The app **re-fetches from the server** and re-persists | take the app offline and relaunch: does your value survive? | lock the file, block the refresh request, or move the change into code |
| The app **rewrites the file on every start** from its own defaults | your value does not survive even with the network down | the value is not authoritative — patch the read path instead |
Do the offline test first. It costs one launch and separates the two cases.
### Making a data edit stick (in order of durability)
1. **Immutable file attribute** — the reliable, reversible way to stop a rewrite:
```bash
su -c "chattr +i <path/to/prefs.xml>"
su -c "lsattr <path/to/prefs.xml>" # expect the 'i' flag, e.g. -----i-------
# to undo:
su -c "chattr -i <path/to/prefs.xml>"
```
Verify with `lsattr`, not by assuming the command worked.
**Why permissions do not achieve this.** Tightening the mode or changing the owner looks like the
obvious move and does not work, because the app does not open-and-write the existing file — it
**deletes the file and creates a new one**. The new file is created by the app, with the app's own
mode and owner, so a restrictive `chmod` or a `chown root` is simply not inherited. The immutable
attribute is enforced by the filesystem against the delete itself, which is why it holds.
2. **Keep the app offline** so the refresh source is unreachable. Effective for a test, and
sometimes acceptable in use — but be explicit about it, because a fix that only works offline
fails the "works under normal conditions" constraint. See the deliverable-drift section in
`long-task-discipline.md` before presenting this as a result.
3. **Block the refresh request** at the runtime or network layer — durable while your instrumentation
is running, and requires you to have identified the endpoint (`references/server-api.md`).
4. **Move the change into code** — patch the read path or the decision. This is the only form that
ships inside an APK (`§Making the fix durable` above).
### Two cautions about locking
- **Verify the feature, not the value.** Blocking a write the app depends on can make it misbehave
or fail loudly. After locking, exercise the feature you care about — "the file still has my value"
is not the same as "the app still works".
- **A lock is device state; it does not travel with an APK.** If the recipe requires it, record it as
an environment requirement, so nobody later mistakes it for something baked into the artifact.
## Encoded values: do not assume "encrypted"
Preference values that carry server configuration are frequently stored as a single opaque string
rather than readable XML. Before concluding the value is encrypted, check whether it is merely
**framed**: a common shape is `base64( rotate( deflate( json ) ) )`, where the cyclic rotation exists
precisely so a naive decode fails.
`scripts/blob_decode.py` searches the parameter space instead of guessing it — outer encoding
(base64 / base64url / hex), rotation offset, and compression type — and reports every combination
that yields a structured document, plus the exact parameters to re-encode your edited payload:
```bash
# pull the value straight out of a preferences XML and decode it
python scripts/blob_decode.py --prefs-xml prefs.xml --name <key> # (see --help for exact name)
# after editing the decoded payload, rebuild the value with the winning parameters
python scripts/blob_decode.py --encode --file decoded.bin --outer base64 --inner raw --cut <N>
```
The rotation search is cheap — a few tens of thousands of candidates resolve in well under a second
— so there is no reason to guess. **Distinguish framing from real encryption before spending time
on a key:** a framed blob becomes structured the moment you remove the framing, whereas a keyed blob
stays random-looking. If it stays random, treat it as opaque and move to the code path that consumes
it.
## Cautions
- Editing data while the app is running is unreliable: in-memory caches win. Force-stop, edit, then launch.
- The server may overwrite your value on next sync — see *When the app rewrites your edit* above.
- Do not edit a token you do not own and expect it to be accepted; tokens are validated server-side (`references/server-api.md`).
- A restored data directory needs ownership fixed again after every reinstall, because the uid increments.

View File

@@ -0,0 +1,94 @@
# Server-side analysis — deciding whether a patch can even matter
If the behavior you want to change is produced by a response, patch the client all you like: nothing real changes. Establishing server authority early is the cheapest high-value step in any "crack this" task.
## Harvest the API surface from the client
```bash
python scripts/dex_strings.py <dex_dir> --urls
python scripts/dex_strings.py <dex_dir> --find 'https?://'
```
Collect: base URL(s), path constants, auth headers, and any dynamic-gateway mechanism.
Two patterns to watch for:
- **Static base URL** — straightforward.
- **Dynamic gateway** — the real host is fetched at runtime (commonly a DNS TXT record over DoH, or a small config endpoint). The hardcoded host may be a fallback. Symptom: requests go to a host that differs from anything in the dex.
```bash
# TXT lookup, the usual shape:
# https://<doh-provider>/resolve?name=<domain>&type=txt
# returns something like "https://<gateway-host>"
```
This is an anti-blocking technique; it does not block repacking, but it means your static host guess may be stale.
## Reproduce requests faithfully
```bash
python scripts/probe_api.py --base <base_url> --path /some/path --header 'X-App-Name: <value>'
```
Rules that make the difference between 403 and 200:
- **Send the app's real headers.** A single custom header (e.g. an app-name header) frequently decides whether a WAF lets the request through. The header value is a string constant in the dex.
- **`User-Agent` matters** — often `okhttp/x.y.z` or a runtime-specific default (e.g. `ktor-client`).
- **Watch for a CDN/WAF in front.** A 403 with an HTML body naming a WAF means your request shape is wrong, not that the endpoint is gone. Compare with what the app sends (capture it from the device).
- **Beware your own egress.** If your host goes through a proxy/VPN, a WAF may block by geography while the device succeeds. Do not conclude "the server rejects the app" from a host-side 403.
- **`/health`-style endpoints are gold** — a 200 with a small JSON body proves the backend and its response envelope are alive before you debug anything else.
## Response envelope
Most apps wrap responses uniformly, e.g. `{"code":0,"msg":"","data":{...}}`. Learn it once, then read endpoints by shape rather than guessing:
- `code != 0` + a message → business error, and **the message often enumerates valid inputs** (this is how an ad `position` whitelist was discovered).
- `data:null` with HTTP 200 → soft failure.
- `401` with a null envelope → auth gate.
## Prove who owns the gate
Run this matrix and record it. It is the single most decisive artifact for a paywall or entitlement question.
| Request | Expected if client-owned | Expected if server-owned |
|---|---|---|
| protected endpoint, no credentials | would still succeed | **401 / 403** |
| same, with a forged token | would still succeed | **401 / 403** |
| metadata endpoint | — | 200, often **omitting** the protected field |
| list/config endpoints | — | 200 with data |
If the valuable field is simply **absent** from an otherwise-200 metadata response, no client change can invent it. Say so, with the evidence.
## Also test the *unknown input* case
When you plan to change a request parameter (a path, a `position`, an id), probe what the server does with an **invalid** value first:
- A `400` with a validation message tells you the field is whitelisted, and hands you the full list of valid values.
- A `404` tells you the path is exact.
**This matters before you patch**: redirecting a parameter to an invalid value may produce an error status that the client treats as fatal (`pitfalls.md` P5). Knowing the failure mode in advance prevents breaking the app.
## Behaviors that help you and behaviors that do not
**Repack-friendly (do not block you):**
- Minimal auth: `Authorization: Bearer <token>` and a static app-name header, with **no request signing, no nonce, no HMAC**.
- No client-certificate pinning to the app's own signature.
**Repack-hostile (will break a re-signed client):**
- Signature/certificate checked server-side, or pinned in native code.
- Request signing derived from the APK signature — the client computes with its own
certificate, so re-signing changes the key. Silent and easy to misdiagnose; detection,
extraction and the differential test are in `references/signature-derived-keys.md`.
- Device attestation (Play Integrity / SafetyNet, OEM attestation).
Determine which you face **before** shipping a repack. A client that authenticates fine on first launch but silently fails features later is the classic signature-validation symptom — and when computed signing parameters (`sign`, `_p`, `uth`) evaluate to `-1` or empty, it is the signature-derived-key case rather than a server decision.
## What to record
```
base url / gateway mechanism
required headers (exact values)
auth mechanism (token? signed? attested?)
response envelope shape
endpoints actually used, and their auth requirement
protected-asset endpoints, with status codes for: none / forged / (real if available)
invalid-input behavior for any parameter you plan to change
```

View File

@@ -0,0 +1,160 @@
# Server-driven UI config — promos, popups, tab bars, and remote re-enable
Many apps ship a **remote configuration endpoint** that decides what the client
renders: a launch screen image, a popup, an announcement, which tabs exist. This is
the single most common shape of "ad" in a modern app, and the one most often
mis-diagnosed, because there is no ad SDK anywhere in the package to find.
Two consequences that change the whole plan:
- **Searching for an ad SDK finds nothing, and that is arithmetically correct.** No
amount of further SDK hunting will help. Recognise the shape early and stop.
- **The behaviour can be re-enabled server-side without shipping a new client.**
Any patch that just leaves the config unread is a patch the operator can undo.
The durable fix is in the client's decision path, not in the data.
**Load this when:** a launch screen, popup, announcement or tab set must go, and there is no SDK to find. It gives the server-issued-config shape, how to tell it from a client-side flag, and the remote re-enable that undoes your patch.
## 1. Two-layer fetch: local default, then remote override
The most useful diagnostic — and it is a runtime observation, not a static one — is
that the config object **changes value once during startup**:
```
before the network round-trip: SplashConfig(enabled=true, duration=5, image=<built-in drawable>)
~1s later (config response): SplashConfig(enabled=true, duration=5, image=https://<cdn>/<promo>.png)
```
Seeing this pair proves three things at once: the screen is config-driven, the
built-in asset is only an offline fallback, and the content actually shown came
from the server. **Capture both states before designing any patch** — a
hook on the config type's constructor/factory plus its `toString()` is usually
enough (data-class `toString()` keeps the real field names even after R8, which is
why it is such a convenient anchor).
If you only ever see the built-in default, you are looking at an offline or
pre-config state and have not observed the real behaviour yet.
## 2. Locate the config surface
The DTOs are usually the easiest anchor in the entire app, because data classes
serialise their own field names.
```
ddc findrefs app.apk string "SplashConfig" # or any of the field names below
ddc strings app.apk -f "enabled" --with-locations
```
Field names worth grepping, in the plural because they recur:
```
splash, splashImage, splashSkip, splashLogo, launchScreen, bootAd
noticePopup, announcementPopup, updatePopup, forceUpdate, upgradeDialog
tabbar, tabs, items, enabled, showMode, startAt, endAt, showOnce
promo, promotion, banner, bannerList, featured, sponsored
```
A `*Config` / `*Popup` / `*Banner` suffix with an `enabled` boolean is the signal
you are in this layer. **The `enabled` flag's polarity is not guaranteed by its
name** — see.
Then find the **consumption point**, which is where you patch: the method that
reads the flag and decides. The DTO itself is usually immutable and shared, so
editing it is both fragile and unnecessary.
## 3. Patch the decision, not the data
Ordered from most to least durable:
| Approach | Durable? | Why |
|---|---|---|
| Neutralise the client-side branch that consumes `enabled` | **yes** | The server can send anything; the client no longer acts on it |
| Force the config field to its "off" value at parse time | partly | Works until a second parse path or a cache exists |
| Block or 404 the config endpoint | **no** | Usually breaks the screen that shares the request (see `pitfalls.md` P5) |
| Delete the local fallback asset | no | Only affects the offline case |
**How to neutralise a branch correctly** is in `byte-level-patching.md`: replace the
conditional branch with `nop`s so execution takes the fall-through path, rather
than redirecting the branch. Forcing a specific side by redirection adds a control
flow edge and can trip the verifier.
**Mandatory before editing: decode both sides of the branch and write down what
each one does.** Field names lie. In one real case the field was named `enabled`,
and the branch structure was:
```
iget-boolean v, obj -> Config.enabled
if-nez v, :countdown ; enabled == false jumps AWAY
invoke startMainScreen() ; fall-through: straight into the app
:countdown ... ; branch target: show the promo for N seconds
```
so `enabled == true` was the value that **skipped** the promo. Taking the name at
face value and "enabling the skip" produces a build that shows the ad on every
launch — and it starts cleanly, so nothing catches it until someone looks at the
screen.
## 4. Decide the scope of "remove" before you edit
Serve-side config usually controls several things at once, and they are not all
ads. Classify each field the same way you would a UI element:
| Field shape | Usually is | Usually not |
|---|---|---|
| Launch screen image + countdown with no dismiss | promotional | a functional splash |
| Popup with a channel/group link and `showOnce` | promotional | |
| Popup with changelog + `force` + an external URL | update nag → `updates-and-forced-upgrade.md` | |
| Tab bar item with `enabled=false` | absent by operator choice | |
| Announcement that is off server-side | nothing to remove | |
If the user asked for "no ads", removing a genuine brand splash is a judgement
call, not an automatic win — a self-owned logo screen with no third-party content
is arguably not an ad. State which one you removed; if the request was ambiguous,
the safer default is to remove only the server-sourced promotional content and
leave a purely local brand screen alone. See `long-task-discipline.md` on not
substituting your own goal for the stated one.
## 5. Verify at three levels, not one
A config-driven behaviour needs more evidence than a log line:
1. **Screen, before and after, frame by frame** — `scripts/coldstart.py`. The
observable is a visible change plus a change in time-to-first-meaningful-screen.
2. **The config object's own values at runtime** — if the app logs or you can hook
it, show the value it received. This separates "the server stopped sending it"
from "the client stopped obeying it", which are different claims.
3. **Correct negative**: if the flag arrives `enabled=true` and nothing happens,
that is the strong result. If it arrives `enabled=false`, you have verified
nothing about your patch.
## 6. Remote re-enable: the durability question
Two channels can undo client-side work without a version bump:
- **The config endpoint itself.** Your patch must be in the decision path so a
future `enabled=true` is still ignored (see). A patch that reads the flag and
happens to work today is not durable.
- **A hot-update / dynamic-resource channel** (a downloaded bundle, a patch dex, a
remotely loaded layout). Look for a cache directory that is not the config cache,
or a download-then-load path during startup. If one exists, the patch has to
survive it — which usually means the patched decision is the right place anyway,
because remote resources rarely replace compiled logic.
Also check whether the config is **cached to disk** (`SharedPreferences`, a JSON
snapshot, a DataStore file). A cached copy means the app can exhibit the old
behaviour offline for one launch after your change, which looks like a failed
patch. Clear the cache as part of the experiment, and say so when reporting.
## 7. Reporting template
```
Promo/popup surface: <field> (<DTO class>) | source: server config at <endpoint or unknown>
Observed before: <exact values, incl. whether enabled was true/false>
Observed after: <exact values / absence>
Patch: <class#method, byte offset, bytes before -> after>
Durability: <why a future server-side change cannot restore it>
Not removed: <fields deliberately left, with reason>
Side effects: <what shares the same config or code path>
Unverified: <anything not directly observed>
```

View File

@@ -0,0 +1,149 @@
# Signature-derived keys — when re-signing silently breaks the app
A class of app that repack-friendly guidance does not cover: the client feeds **its own APK
signing certificate** into a native routine and uses the result as the key for request
signing or for SDK payload encryption. Re-sign the APK and the key changes, so every signed
request fails — while the app itself launches perfectly.
This is distinct from "the server checks the signature". Here the *client* uses the signature
as key material, and the server was built against the original key. The failure looks like a
server problem and is actually a signing problem.
**Load this when:** the app reads `signatures[0]`/`toCharsString()`, or a rebuilt APK installs, launches and logs nothing wrong while every signed request fails. It gives the 15-minute check that prevents the most expensive silent repack failure.
## How to spot it (four greps, minutes)
```bash
# the usual shape: signature bytes handed to a crypto helper
grep -rn "toCharsString()" <decompiled_sources>
grep -rn "getPackageInfo(.*, *64)" <decompiled_sources> # 64 = GET_SIGNATURES
grep -rn "signatures\[0\]" <decompiled_sources>
# and the same pattern in SDK-internal helpers
grep -rn "signatures\[0\].toByteArray" <decompiled_sources>
```
Signals that confirm it matters:
- The value is passed into a `native` method (JNI) or a helper that also does HMAC/AES/DES.
- The string table of the native library contains algorithm primitives (`hmac_sha256`,
`hmac_md5`, `des_crypt`, `sha256_update`, `initKey`, `make_SubKey`, …) but **no hardcoded
certificate or key blob** — i.e. the key is supplied from Java.
- Request URLs carry computed parameters (`sign`, `_p`, `uth`, `sig`, `nonce`) that are not
derivable from the visible app data.
- The same certificate value is also fed to a third-party ad/OAID SDK helper.
If those hold, **any rebuilt APK needs the original certificate value hardcoded**, not the
new one. Patch that before you patch anything else, or you cannot distinguish "my patch broke
it" from "signing broke it".
## The trap: what that value actually is
Do **not** assume `signatures[0].toByteArray()` is the whole `META-INF/*.RSA` file. It is
platform-dependent, and the difference is invisible offline:
| Platform behaviour | What you get |
|---|---|
| Old JAR-signature path | the full PKCS#7 blob (`META-INF/CERT.RSA` content) |
| Modern (v2/v3 present) | a **single certificate from the chain**, DER-encoded — not the container, and not necessarily the first one |
Measured consequence on one sample: the `.RSA` file was 1199 bytes / 2398 hex chars, but the
runtime value was **777 bytes / 1554 hex chars** — the *last* certificate inside the PKCS#7.
Hardcoding the file bytes produced correct-looking smali, a clean build, a successful install,
and every request signing parameter evaluating to `-1`.
**Therefore: never derive this value offline and trust it.** Read it from the running app
(`scripts/sig_probe.py --live`), and only fall back to offline extraction to cross-check.
## Extract the real value
**Route A — from the device (authoritative):**
```bash
python scripts/sig_probe.py --live <pkg> # needs Frida + a rooted device
```
`--live` takes the package name as its own argument; there is no `--pkg` flag. Verify the call
against the script's own usage (`python scripts/sig_probe.py --help`) rather than against this page —
`check_commands.py` at the repository root validates documented commands for exactly this reason.
It prints `signatures[0].toCharsString()` and its length, read from the package manager on the
device. This is the only value that is guaranteed to match what the app computes.
**Route B — offline, to cross-check:**
```bash
python scripts/sig_probe.py --apk <original.apk>
```
This enumerates the candidate certificate blobs inside `META-INF/*.RSA` (each top-level
SEQUENCE inside the PKCS#7 `certificates` set) and prints each as hex with its length. Pick the
length that Route A reported; if Route A is unavailable, generate one build per candidate —
there are rarely more than two.
## Patch it
Replace every signature read with a string constant holding the original value, rather than
trying to make the new signature look like the old one.
In smali, the pattern to replace is an invoke/move-result pair:
```smali
invoke-virtual {vX}, Landroid/content/pm/Signature;->toCharsString()Ljava/lang/String;
move-result-object vX
```
Rewrite both instructions into a single load (a regex over the smali tree handles all sites at
once):
```smali
const-string vX, "<original-value-hex>"
```
Notes that save a build cycle:
- **Do not delete the surrounding `getPackageInfo` calls.** They are harmless and removing
them perturbs register allocation. Only the two instructions above need to change.
- **Miss one site and it still fails.** Count the sites before and after (`grep -c`), and assert
the post-patch count is zero. There is usually more than one — one per crypto helper.
- Keep the smali file's `.orig` backup so the patch is re-runnable; some build pipelines pick
`.orig` up as an unknown file and warn, which is harmless.
- The same treatment applies to copies of the pattern inside bundled SDK packages
(`…/secure/…Utils`, `…/oaid/…`), not just the app's own util class.
## Verify with a control, not with a feeling
A launch that "looks fine" proves nothing here: the app starts, then quietly fails every
network call. Use a differential test.
1. **Measure the signing parameter on both builds.** Hook the request builder and print the
computed parameter for a request the app always makes on startup.
- original APK → a value with the normal shape (encoded / base64-ish)
- rebuilt APK → the same shape ⇒ signing is consistent
- rebuilt APK → `-1`, empty, or null ⇒ the key is still wrong; go back to Route A
2. **Compare a full request URL**, parameter by parameter, between the two builds. Anything
that differs beyond a timestamp/device id is a candidate root cause.
3. **Exercise the feature**, not the launch: an API-backed screen must actually render data.
"Network error" on a screen that is otherwise intact is the signature of exactly this bug.
Before blaming your patch: run the **unmodified original** on the same device and network. If
it also fails, the problem is environmental (`references/tls-and-cert.md`,
`references/pitfalls.md` P9) and not the repack.
## Related variants of the same root cause
- **OAID / device-id SDKs** hash the signature (`MessageDigest("SHA1").digest(signatures[0].toByteArray())`)
and the result goes into a request header. Same fix, different call site.
- **Ad SDKs** use the signature to encrypt their own config payloads; leaving them unpatched
usually degrades ads rather than breaking the app, so patch them after the core path works.
- **Certificate pinning to the app's own signature** is a different problem: it is verified,
not used as a key. See `references/tls-and-cert.md`.
## Cost of getting this wrong
Without this step the task stalls in the worst possible way: the artifact is correct, the
patch is correct, the build is correct, and the app is unusable — which reads as "the client
patch is impossible" and sends you back to static analysis for hours. Budget 15 minutes for
this before the first repack of any signed-request app.

View File

@@ -0,0 +1,325 @@
# Split APKs and App Bundles
A store build is frequently **not one file**. Play and most OEM stores ship App Bundles, and the
device receives a set: `base.apk` plus one `split_config.*.apk` per ABI, screen density and language
(and, for a dynamic-feature bundle, the feature modules as well). Every member carries **the same
package name and its own signature**, and the package manager treats the set as one package.
Two failures follow from treating such a set as an APK, and both look like a broken build when they
are nothing of the kind:
- Signing only the base leaves the set disagreeing about its certificate; the installer rejects the
whole set and names the package, not the member you forgot.
- "Just merge them into one APK" is free *only* for members that carry code or native libraries. A
member that carries resources cannot be folded into the base without merging `resources.arsc`,
which is a resource-compiler job. A wrongly merged archive installs and then renders the wrong
thing, or dies on the first resource lookup.
`scripts/repack.py` handles both routes and **refuses to guess**: it inventories the set, decides
which route is legal from what the members actually carry, prints the reason, and only then acts.
**Strength labels** (same three this repository uses everywhere): **observed** = reproduced with an
exact command and output; **inferred** = follows from observed facts, step not executed;
**unverified** = reported or assumed, not independently confirmed. The measurements behind this
file are recorded in `references/evidence-summary.md` §The capability matrix.
**Load this when:** the target arrives as `base.apk` plus `split_config.*.apk`, or a rebuild is refused as a set although every member verifies. It gives reading the set, merge versus unified re-signing, and the refusal each mistake produces.
## 1. What the set is
| Member | Manifest `split` attribute | Carries | Foldable into the base? |
|---|---|---|---|
| base | *(absent)* | all `classes*.dex`, the main `resources.arsc`, every resource not moved out, the real `<application>` | — it *is* the base |
| ABI split | `config.arm64_v8a`, `config.armeabi_v7a`, `config.x86_64`, … | `lib/<abi>/*.so` only | **yes** — entry names do not collide |
| density split | `config.hdpi` … `config.xxxhdpi` | its own `resources.arsc` **and** `res/` entries | **no** — requires a resource merge |
| language split | `config.en`, `config.zh`, … | its own `resources.arsc` (strings) | **no** — same reason |
| feature split | `feature_x` (+ `isFeatureSplit="true"`, `configForSplit="base"`) | its own `classes*.dex`, possibly resources | **code only**; its resources hit the same wall |
**Observed** on a real 9-member set pulled from a stock Android 11 device (arm64-v8a target):
- the base carried `classes.dex` (872 KB) and `resources.arsc` (71 KB);
- the ABI member carried two `lib/arm64-v8a/*.so` files and **no `resources.arsc` at all**;
- each density member carried its own `resources.arsc` and its own `res/` entries;
- a second, 8-member set's density members carried 39-78 `res/` entries each and 9-15 KB tables.
So "which member owns the resources" is answerable **from the zip entry list alone** — no device, no
resource compiler, no decompiler. Copy a set to disk and ask.
**A member can own a nearly empty table.** A split is *required* to carry a `resources.arsc`, so a
placeholder one is normal: on the same real set, eight density members carried a **40-byte** table
and no `res/` entries at all — they are placeholders, not resources. Treating "has an arsc" as "owns
resources" would block a merge that is perfectly legal. `repack.py` uses *entries under `res/` or a
table bigger than 1 KB* as the test.
## 2. Getting the set off a device
`pm path` prints **one line per member**; a package with several lines is a split set. This is how
you find one to copy, and it is also how you tell, before anything else, that the thing you are about
to patch is not a single APK:
```bash
adb shell 'pm path <PKG>'
package:/system/priv-app/<DIR>/<APP>.apk
package:/system/priv-app/<DIR>/<APP>-arm64_v8a.apk
package:/system/priv-app/<DIR>/<APP>-xxhdpi.apk
```
To enumerate every split set on a device (toybox shell — no `--time-style`, no bash arrays):
```bash
adb shell 'for p in $(pm list packages | cut -d: -f2); do c=$(pm path $p | wc -l); \
if [ $c -gt 1 ]; then echo "$c $p"; fi; done'
```
Copy the whole directory out; the members live side by side:
```bash
adb shell 'su -c "ls -la /system/priv-app/<DIR>/"'
adb pull /system/priv-app/<DIR> <dest>/
```
Two traps, both **observed**:
- `adb pull <dir> <dest>` creates `<dest>/<dir>/` — the set arrives one level deeper than you expect.
`repack.py --split-dir` searches recursively for exactly this reason.
- A **third-party** split set is rare on a stock ROM. On the device measured here, `pm list packages -3`
returned **zero** split sets among ~40 third-party apps, while four preinstalled Google components
were split. If you need a real set and the third-party list is empty, that is the normal
distribution, not a mistake on your side.
**Do not test by reinstalling a pulled set under its own package name.** It is already installed on
that device, owned by the vendor certificate you do not have, so the install fails for a reason that
has nothing to do with your pipeline (). Either work on a set you own, or rename the pulled one
() to get an installable fixture.
## 3. The decision: merge or resign
Run this first — it writes nothing and answers with a reason:
```bash
python skills/apk-reverse/scripts/repack.py --split-dir <set>/ --split-mode analyze
```
| Situation | Route | Why |
|---|---|---|
| Every non-base member carries only code and/or `lib/` (a placeholder `resources.arsc` is fine) | **merge** | nothing that needs a resource table moves |
| Any member carries `res/` entries or a real table | **resign** | a resource merge rewrites the table; see §5 |
| You must keep the build installable **and** the app is distributed as a bundle | **resign** | this is what the store does; the set stays a set |
| You need one file to hand someone, sideload, or feed to a tool that takes an APK | **merge**, if legal | the only way to get a single artifact |
| The delivery must survive store updates, or the ROM's own installer refuses multi-APK installs | **resign** (`pm install-multiple`), or merge and accept a fixed build | — |
| A member declares `isFeatureSplit="true"` and ships `classes*.dex` **and** resources | **resign** | its code could fold, its resources cannot; a half-merged build is worse than either route |
Failure modes, one per route:
- **merge**, when a resource-carrying member is dropped to make it legal: the app starts and shows
the base's resources. Missing density variants show up as blurry or missing images; missing
language splits show as untranslated strings. This is a **downgraded** build and has to be
delivered as one — `repack.py` prints `DROPPED n entry/ies` and refuses unless you pass
`--drop-split-resources`, so the loss cannot be silent.
- **merge**, when the base still declares `isSplitRequired="true"`: install succeeds and start is
refused (`INSTALL_FAILED_MISSING_SPLIT`,). The attribute has to be cleared first, and that is a
binary-AXML edit.
- **resign**, when one member is missed: the set fails as a set, with a signature error naming the
package. Sign the directory, not a list you typed by hand.
- **resign**, when the target ROM's installer is not the platform installer: `adb install-multiple`
can be intercepted by an OEM security centre, exactly as single-APK installs are
(`references/repack-and-sign.md` §Vendor install interception (OEM shells)). Use the root
`pm install-multiple` path.
## 4. Unified signing — the route that always works
```bash
python skills/apk-reverse/scripts/repack.py \
--split-dir <set>/ --split-mode resign \
--split-out-dir <signed_set>/ \
--ks key.jks --apksigner <path/to/apksigner>
```
Each member is de-signed (only the signature entries), rewritten as a 4-byte-aligned archive, and
signed with **one** keystore and **v1+v2+v3**; the run then verifies every member and prints the
signer fingerprint per file. The acceptance criterion is not "each file verifies" — it is **one
certificate across the whole set**:
```
[result] OK: 8 members, one certificate (sha256 1eb31d9c…2c44)
```
Then install the whole set, base included:
```bash
adb install-multiple -r <signed_set>/*.apk
adb shell "su -c 'pm install-multiple -r /data/local/tmp/set/*.apk'" # when an OEM installer refuses the multi-APK form
```
**The signature must cover every member**, and `--abi`/density selection happens at install time, not
at signing time — sign them all, let the installer pick (see).
### Toolchain: `uber-apk-signer` is optional, `apksigner` is not
`repack.py`'s original signing route went through `uber-apk-signer` only, so a machine with a full
Android build-tools directory and no uber-apk-signer could not produce a signed build at all. The
script now picks the route itself:
- `--signer jar` — the uber-apk-signer path (used automatically when the jar exists);
- `--signer apksigner` — `zipalign -p -f 4` then `apksigner sign --v1 --v2 --v3`, which is the whole
of what the jar was doing for us;
- `--signer auto` (default) — jar if present, else apksigner.
`--apksigner` accepts all three shapes a build-tools install ships: `apksigner.bat`, the `apksigner`
shell wrapper, or the bare `lib/apksigner.jar`. **On Windows pass the `.jar` or the `.bat`** — the
extensionless wrapper is a shell script and will not execute. A `.jar` value is run through
`java -jar`, so `--java` (or java on PATH) is required for it.
Order is load-bearing: **align, then sign**. `apksigner` adds signature entries without reshuffling
the archive; the v1 (JAR) path emulates `jarsigner`, whose rewrite of the zip would destroy the
alignment Android R+ requires of `resources.arsc`.
## 5. Merging into a single APK
```bash
python skills/apk-reverse/scripts/repack.py \
--split-dir <set>/ --split-mode merge --out merged.apk \
--abi arm64-v8a --ks key.jks --apksigner <path/to/apksigner>
```
What folds: `classes*.dex` (renumbered so a member's `classes.dex` becomes `classes2.dex`, keeping
the base's own dex intact), `lib/**`, `assets/**`. What does not, and why: `res/**` and
`resources.arsc`.
**Why resource merging is not attempted.** `resources.arsc` is an indexed table: a global string
pool plus type-spec and type chunks, with every resource identified by a `(package, type, entry)`
triple and every string referenced by pool offset. Folding a member's table into the base's means
rewriting pool offsets, type/entry counts and the alignment of every string, in both directions —
that is `aapt2 link`, not a byte-level edit. The failure it produces is the worst kind: the archive
is *structurally* valid, installs, and then resolves resource ids to the wrong entries or to nothing.
An APK that renders garbage is harder to diagnose than one that refuses to install.
If you genuinely need one standalone file for a bundle whose resources are split, the honest routes are:
1. **`bundletool build-apks --mode=universal`** (the vendor tool, needs the original `.aab`) — this is
what merges resources correctly, and it is the only route that does.
2. **Keep the split set** and install it as a set ().
3. **Accept the downgrade** with `--drop-split-resources`, and say so in the delivery.
**Manifest constraints when you do merge — and when you patch any split at all.** These attributes
are the difference between "installs and runs" and an install or start refusal, and they are easy to
miss because they live in binary AXML:
| Attribute | Lives on | Effect if you get it wrong |
|---|---|---|
| `split="config.xxhdpi"` | `<manifest>` of a **split** | Identifies the member. A split whose `package` differs from the base's, or whose `split` name is duplicated, is refused as a set |
| `configForSplit="base"` | `<manifest>` of a split | Ties a configuration split to a feature module. Changing package/module names without updating it silently detaches the split |
| `isFeatureSplit="true"` | `<manifest>` of a feature split | Marks an install-time/on-demand module. A merge that folds code in but leaves the attribute declared describes a member that no longer exists |
| `isSplitRequired="true"` | `<manifest>` of the **base** | The platform refuses to start a base that believes it is incomplete. This is the one that breaks merged builds () |
Read them without a decompiler through `scripts/repack.py`'s own AXML reader (`--split-mode analyze`
prints the `split` name per member; `inspect_apk()` returns `package`, `split`, `configForSplit`,
`isSplitRequired`, `isFeatureSplit`). Changing any of them means editing the binary string pool and
the attribute chunks around it, which is what `apktool` is for — and note that a full
`apktool b` rebuild rewrites the whole archive
(`references/repack-and-sign.md` §Repacking an unpacked (de-shelled) app).
## 6. Install refusals, and what each one means
| Message | What it actually says | Attribution |
|---|---|---|
| `Failure [INSTALL_FAILED_INVALID_APK: ... signatures do not match previously installed version]` / `Package <PKG> signatures do not match` | The set you are installing is signed with a different certificate than the one already on the device | **Not** a malformed build. Either re-sign to the original key, or install to a package name that does not exist yet |
| `Failure [INSTALL_FAILED_ALREADY_EXISTS]` / `INSTALL_FAILED_UPDATE_INCOMPATIBLE` | Same package, different signer (or a downgrade without `-d`) | A pulled system component cannot be reinstalled over itself; see §7 |
| `Failure [INSTALL_FAILED_MISSING_SPLIT: Missing split for <PKG>]` | The base declares `isSplitRequired="true"` and the set is incomplete — or you merged it | The base is telling the truth: it expects members. Clear the attribute, or install the complete set |
| `Failure [INSTALL_FAILED_INVALID_APK: Failed to parse ... split ... has different signature]` | **One member** of the set is signed differently from the rest | This is the failure mode of signing only the base. Sign the whole directory |
| `Failure [-124: ... resources.arsc ... stored uncompressed and aligned on a 4-byte boundary]` | One member's `resources.arsc` is compressed or unaligned | Per-member, not per-set. `repack.py` reports it per member before signing (`references/repack-and-sign.md` §2a) |
| `Failure [-99]`, or a numeric-only code with no `INSTALL_FAILED_*` | An OEM installer interceptor rejected the request | Nothing to do with the set; use the root `pm install-multiple` path |
| `adb install-multiple` on a device that has no `install-multiple` support (very old `pm`) | Install each member with `pm install` in order, base last | Use `pm install-create` / `install-write` / `install-commit` explicitly |
`INSTALL_FAILED_INVALID_APK` in particular is a **family**, not one error: it covers stale
signatures, malformed base/split relationships, and alignment violations. Read the text after the
colon before attributing anything — the constant alone will send you looking at the wrong file.
## 7. Making an installable fixture from a pulled set
**Observed, and the reason this section exists:** a set pulled from the device is installed *on that
device*, under a name owned by the vendor certificate. Re-signed copies cannot be installed back
over it, so "does our signed set install?" cannot be answered with the pulled set as it stands.
The cheap, structure-preserving way out is an **equal-length package rename**: replace every
occurrence of the old package name in `AndroidManifest.xml`, `resources.arsc` and `classes*.dex`
with a new name of exactly the same length. Nothing moves — no length prefix, no string-pool offset,
no dex `string_ids` offset, no resource index — so the only field that must be recomputed is the dex
header's integrity pair (signature first, checksum last, see
`references/repack-and-sign.md` §Repacking an unpacked (de-shelled) app). Then run the ordinary
`--split-mode resign` over the renamed directory and install that.
A detail worth knowing before you touch a manifest: **a member's Java classes need not live under its
manifest `package` at all**. On the real set measured here, the manifest package was
`com.google.android.<name>` while all 677 class references in the dex were
`Lcom/android/<name>/...` — and every `<activity android:name>` was fully qualified. Renaming the
manifest package therefore left every component resolvable, with no dex edit needed. Assume the
opposite (that the two must match), and you will edit a dex for nothing.
## 8. ABI and density matching
The device tells you what it can run:
```bash
adb shell getprop ro.product.cpu.abilist # e.g. arm64-v8a,armeabi-v7a,armeabi
adb shell getprop ro.product.cpu.abi # the primary ABI
adb shell getprop ro.product.cpu.abilist32 # 32-bit list, on a 64-bit device
```
Match against the **split name**, not the file name, and mind the separator: split qualifiers use
**underscores** (`config.arm64_v8a`) while `lib/` directories use **hyphens** (`lib/arm64-v8a/`). The
mapping is a substitution, not a lookup — `arm64_v8a` → `arm64-v8a`, `armeabi_v7a` → `armeabi-v7a`;
`x86` and `x86_64` are unchanged. That is why `--abi arm64-v8a` matches a split named
`config.arm64_v8a`.
Four rules that decide ABI questions:
- **A 64-bit device still installs a 32-bit-only set**, and then runs it 32-bit. If the base carries
no native code and only the `armeabi_v7a` split does, the app is a 32-bit app on that device.
- **The ABI split is selected by the installer, not by `repack.py`.** `--abi` exists for *merging*
(where the choice is baked into one file); for `--split-mode resign`, sign every member and let
`pm` pick.
- **Merging without `--abi` folds in every ABI member**, which makes one APK with two or three copies
of the same library set. Legal, larger, and pointless on a single-device delivery.
- **`getprop` reports what the device claims**; what runs is a live mapping question
(`scripts/lib_map.py`). A device that reports `arm64-v8a` first can still execute a 32-bit process.
Density is the same shape without the separator trap: `config.xxhdpi` matches
`res/drawable-xxhdpi/`, and the installer picks the closest variant the device's `ro.sf.lcd_density`
can use. There is nothing to select on your side.
## 9. Traps worth paying for once
- **`resources.arsc` belongs to whoever ships it.** In a split set it exists *per member*, and the
base's table does not contain the density member's entries. Reading the base's table and concluding
"the resource exists" is wrong; it exists in one configuration only.
- **`android:extractNativeLibs`.** If the base declares `extractNativeLibs="false"` (the default for
modern targetSdk), the `.so` files are mmapped straight out of the archive, and they must be
**uncompressed and page-aligned** — this is what `zipalign -p` exists for. Merging native members
into a base and then writing the archive without that alignment produces an install that succeeds
and a start that dies in the linker. `repack.py` writes uncompressed `lib/**` 4-byte aligned, and
`zipalign -p -f 4` adds the page alignment the loader wants for them.
- **A placeholder `resources.arsc` is not a resource split** (). Blocking on "has an arsc" refuses
legal merges.
- **Do not `zipalign` after signing to "fix" a member.** It rewrites the archive and invalidates the
signature. Align, then sign.
- **`apktool` may not be usable even when a wrapper exists.** Measured on the pass machine: the
`apktool.bat` wrapper pointed at a jar that was not on disk
(`Error: Unable to access jarfile <missing>.jar`), while `apksigner.jar` and `zipalign.exe` in the
same layout were fine. Check the wrapper's target before planning a manifest edit around it.
- **The AXML `split` attribute is a string-pool entry**, which is why an equal-length rename of it is
safe and why a length-changing edit is not: the pool's own length prefixes and the attribute chunk
sizes around it would both need recomputing.
## 10. What this file does not claim
- **It does not merge resources.** No route here reproduces `bundletool build-apks --mode=universal`.
A bundle whose resources live in splits stays a set, or becomes a downgraded single APK by explicit
choice.
- **It does not split a monolithic APK back into modules**, and it cannot turn a `.aab` into APKs —
that is `bundletool`, and the `.aab` is the input it needs.
- **`--split-mode merge` covers code and native libraries only** (). Feature-split *code* folds;
feature-split *resources* do not.
- The install-failure texts in §6 are the shape the platform produces; **the exact wording varies by
Android version and by OEM installer**. Read the text after the constant, not the constant.

View File

@@ -0,0 +1,142 @@
# Third-party builds — auditing a "cracked" or "modded" APK before trusting it
**Load this when:** the input is a "cracked" or "modded" APK you did not produce. It gives the audit that tells you what was injected, and why such a build is never a patching workbench.
## Why this exists
A modded build of the exact app you are targeting is the most tempting shortcut available:
it appears to prove the goal is achievable, and it may already contain the patch you were
about to write. It is also the single most common source of wasted hours and of real risk,
for two reasons.
1. **It is usually not editable.** Authors who redistribute mods protect them, often with
more layers than the original app ever had. You cannot treat someone else's build as a
patching workbench.
2. **Its target-audience behaviour is unverified.** The binary may carry injected components,
extra endpoints, or embedded credentials. "It runs and the ads are gone" tells you nothing
about what else it does.
Treat a third-party build as an **untrusted sample to be audited**, never as a baseline.
The useful output of an audit is a decision plus evidence — not a binary you start from.
## Step 1: Can you even edit it?
Find the app's own package prefix (the one that appears in its own class definitions, e.g.
`com/<vendor>/<app>/`) and count how many times it appears as a **defined class** in each
plaintext dex.
```
business-prefix hits per dex:
classes.dex 0 <- shell
classes2.dex 0
classes3.dex 0
classes4.dex 0
```
Zero everywhere means the business code has been **moved out of the plaintext dexes** — the
build is protected at a level you cannot patch directly, even though it launches and works.
Stop here and treat it as a black box.
Also check the reported dex count against the file count: a manifest-declared app with only
library classes present is the same signal.
## Step 2: How many protection layers does it carry?
Mods frequently stack protection from multiple vendors, sometimes because the author
re-protected an already-protected app. Look for independent, coexisting families:
| Layer kind | Typical static evidence |
|---|---|
| Commercial packer | shell `Application`/`appComponentFactory` in the manifest, a `lib*protect*.so`, encrypted assets, `assets/*.jar` with near-maximal entropy |
| DEX-to-native (dex2c) | a "stub" `lib*.so` containing the tool's name, `EntryPoint`/`protect`-style symbols, and **business class names as strings** alongside a near-empty dex layer |
| Anti-debugging / RASP | strings such as `TracerPid`, `ptrace`, `NoNewPrivs`, `CheckPtraceSelf`, `AntiFrida`, plus a self-protection log path |
| Generic hardening marker files | small text files under `assets/` naming a protection vendor and a protection timestamp |
The practical consequence of an anti-debugging layer is that Frida-based dynamic work will hit
a self-check before it hits your hook. Note it, and prefer static conclusions on such a sample.
## Step 3: What did the author ADD?
This is where the audit earns its keep. Diff against the **original build of the same version**
(referenced as `<original>` below).
### New components
Build the set difference of `activity` / `service` / `receiver` / `provider` declarations,
then check whether each new class name actually exists in the plaintext dexes.
- Class exists and is readable → you can judge its purpose.
- Class name appears **nowhere** in plaintext but shows up inside a native library's strings →
the logic is in the native/dex2c layer, purpose unknown. Say so.
- Nothing registered with an implicit-launch intent-filter (`BOOT_COMPLETED`, `MAIN`) → it cannot
be started implicitly, which limits (but does not eliminate) the risk.
- A provider with an `authorities` value containing a package-name rewrite (a suffix/prefix that
is not the current package) is a strong hint the build was produced by **repackaging or app
cloning**, not by editing the original.
### New outbound endpoints
Extract every URL/domain/IP from the modded build's `lib/*.so`, `assets/*`, and dex strings,
and subtract the original's set. Any domain that exists **only** in the modded build is the most
important finding in the audit.
Rank what you find:
- A domain that also serves install attribution or update checks is explainable.
- A domain paired with an authorization-looking path (e.g. a `GET...`-style endpoint name
nearby in the same string neighbourhood) is *not* explainable by de-ad work.
### Embedded secrets
Search native libraries and assets for private-key markers:
```
-----BEGIN ... PRIVATE KEY----- , PRIVATE KEY , PKCS#8 , RSA PRIVATE
```
A **private key shipped inside a client artifact** means its "authorization" or "integrity"
path is not a security boundary — anyone can extract the key. Report it as a red flag on the
build's trust claims, regardless of intent.
## Step 4: Capability picture — read both sides
A native library's import table is only half the story.
- **What it does not have** is meaningful: no `socket`/`connect`, no `execve`/`fork`/`system`,
no `ptrace`, no installer-session strings → no direct evidence of command execution or
silent install.
- **What it references** can override that impression: string references to Java-layer
`java/net/Socket`, `InetSocketAddress`, reflection entry points, or executor services mean the
capability exists **through the Java layer**, off the native import table.
State findings as "capability present / no evidence of use" rather than "safe" — an import table
is evidence of shape, not of intent.
## Step 5: The verdict
Do not hand back a binary. Hand back a decision with evidence and explicit unknowns.
```
VERDICT: use / use with caution / do not use
EVIDENCE:
- business code plaintext? yes/no (+ counts)
- protection layers list
- injected components list, each with purpose known/unknown
- endpoints present only in this build list
- embedded credentials yes/no
UNCONFIRMED:
- <each item you could not establish, stated plainly>
```
"Use with caution" is the honest answer whenever a build is un-analysable **and** carries new
endpoints or unknown injected components. Do not upgrade it to "safe" because it launches
cleanly, and do not call it malicious without evidence — both are guesses.
## Using the audit result
Two legitimate outcomes:
1. The audit shows the mod is an honest de-ad of the same version → you may use it to **learn the
approach** (diff its patch points against the original), but still rebuild from the original
yourself so the artifact you ship has a known provenance.
2. The audit shows it is re-protected, injected, or un-analysable → discard it and do the work on
the original. That is usually faster than reverse-engineering someone else's protection *and*
your target's at the same time.

View File

@@ -0,0 +1,181 @@
# TLS and certificate failures
**Symptom class:** after a repack, login or registration fails with a TLS error while the rest of the app behaves normally. This is the most common way a *correct* patch gets blamed for a *pre-existing, server-side* condition. Work the five steps below before changing a single byte of the app.
**Load this when:** one feature fails at runtime -- login, registration, payment, an API-backed screen -- while the rest of the app works. It gives the five steps that separate a real TLS/certificate problem from the patch you just built.
## Fast path
| # | Action | Evidence |
|---|---|---|
| 1 | Read the full exception chain, prove it is a date rule | `THROW` events from the probe, or logcat |
| 2 | Validate the certificate from a host you trust | `python scripts/tls_check.py <host>` |
| 3 | Compare another host from the same app, and the device clock | same script, `adb shell date` |
| 4 | Decide which trust chain owns the failing request | `RAW-URL` vs `OKHTTP` probe events |
| 5 | Only then patch the system-trust path | fix template below |
## Symptoms
- Login or registration reports a generic network failure: `Network request failed: Chain validation failed`.
- Everything else works: home, feed, images, playback, guest browsing.
- The full chain, visible only if something captures swallowed exceptions:
```
javax.net.ssl.SSLHandshakeException: Chain validation failed
caused by: java.security.cert.CertificateException: Chain validation failed
caused by: java.security.cert.CertPathValidatorException: timestamp check failed
```
- `timestamp check failed` is the decisive token. The validator rejected the chain on a **date rule**, not on an untrusted issuer or a hostname mismatch. Exactly two conditions produce it: the certificate is expired or not yet valid, or the **clock of whoever performs the check is wrong**.
## Step 1: get the real host out of the runtime
Never trust a host recovered from strings alone; a build config can hold dead or staging hosts. Get the host the app actually connects to:
- Run the four-layer probe: `scripts/frida_probe.js`, launched and streamed to a log by `scripts/run_probe.py` (setup, version alignment, and hooking strategy: `dynamic-frida.md`). Three event labels carry what you need.
- `RAW-URL`: the request went through `java.net.URL.openConnection` / `openStream`, the system trust path.
- `OKHTTP`: the request went through the app's own OkHttp client, a different trust path.
- `THROW`: the original text of an exception an upper layer caught and swallowed. This is where the real TLS message appears when the UI only shows a generic network error.
Output you need: the exact URL, its host, and which label logged it.
## Step 2: validate the chain out of band (no app involved)
This step is independent of the app, the patch, and the device. It runs on a host whose clock you trust:
```bash
python scripts/tls_check.py <host> # strict verification + certificate detail
python scripts/tls_check.py <host> <host2> # compare hosts in one run
python scripts/tls_check.py <host> --json # machine-readable; exit 2 if any host fails
```
The script validates strictly (system CAs, expiry, hostname) and, when verification fails, still decodes the peer certificate locally, so a failure arrives with a reason instead of a stack trace:
```
EXPIRED certificate has expired
NOT-YET-VALID certificate is not valid yet
HOSTNAME-MISMATCH chain is fine, the name does not match
UNTRUSTED-CA self-signed, or issuer not in the trust store
CHAIN-BROKEN bad signature / invalid CA / incomplete chain
```
Interpretation:
- `EXPIRED` reported from a trustworthy clock is proof that the chain is rejected by the certificate itself, not by the device, the network, or your patch.
- A clean pass means the certificate is fine and the failure is a client-side trust decision or a clock problem. Go to Step 3.
- Non-443 endpoint, or an address that needs a different SNI name (an IP connect, a CDN edge): `--port <port>` and `--sni <host>`. Getting this wrong produces a `HOSTNAME-MISMATCH` that has nothing to do with the real problem.
Minimal equivalent, if the script is not at hand:
```python
import socket, ssl
ctx = ssl.create_default_context() # strict: system CAs, expiry, hostname
with socket.create_connection(("<host>", 443), timeout=10) as raw:
with ctx.wrap_socket(raw, server_hostname="<host>") as tls:
print("OK", tls.version())
# on failure: ssl.SSLCertVerificationError: ... certificate has expired
```
Dates, subject, and issuer in one line, if you prefer no script at all:
```bash
openssl s_client -connect <host>:443 -servername <host> </dev/null 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates
```
## Step 3: compare another host, and check the device clock
Run the same check against every host you extracted from the app, then compare:
- A very common shape: the business API host is expired while the config, image, or CDN host is valid. Same app, same network, same machine, different certificates. That pattern exonerates the network path and points at one certificate.
- Check the device clock; it costs one command:
```bash
adb shell date # compare against a known-correct time source
```
If Step 2 passes but the device fails, the cause is the device clock, not the certificate. Correcting the clock and relaxing the trust policy are different decisions; do not do both silently.
- Check the **unpatched original** in the same environment. See `verification.md` §the control build rule and `pitfalls.md` P9. If the original fails identically, the patch is not the cause, and no amount of dex work will fix it.
## Step 4: which trust chain owns the failing request
One app can carry two independent trust chains, which is exactly why "login is broken but everything else works".
| Path | Client | Trust configuration | Failure signature |
|---|---|---|---|
| OkHttp | OkHttp / Retrofit, via `newCall` | app-supplied `SSLSocketFactory` + `HostnameVerifier`, often pinning | pin-related messages (`CertificatePinner`, pin verification failed), or nothing at all if the app relaxed it |
| `java.net.URL.openConnection()` | `HttpsURLConnection`, also used by many image loaders, downloaders, and legacy HTTP wrappers | **platform default** trust store and default verifier (`HttpsURLConnection.setDefault*`) | `Chain validation failed` / `timestamp check failed`, the signature above |
Discriminating, in increasing order of certainty:
1. **Probe events.** The failing request appears under exactly one label from Step 1: `RAW-URL` (system path) or `OKHTTP` (app client). This is a fast screen, not proof.
2. **Trust-manager attribution (definitive).** Hook every `X509TrustManager.checkServerTrusted` implementation and print the instance class name plus a short `new Exception().getStackTrace()`. If the caller frames sit in `okhttp3.*`, or in a custom class the app installs on its own client, the OkHttp path owns the failure. If they sit in a platform trust manager reached through `HttpsURLConnection` / `SSLSocket` internals, the system path owns it. Wording alone is version-dependent, so trust the caller frames, not the message.
3. **Cross-check with the fix below.** If OkHttp owns the failing request, the template does nothing for it: that path never reads `HttpsURLConnection` defaults and needs a client-level change (`SSLSocketFactory` / `HostnameVerifier` / pinner; see `dex-patching.md` §where to patch).
Why the asymmetry exists: the app configured its own trust behavior for OkHttp (a relaxed verifier or a pin), so that path never consults the platform date rule, while the legacy `HttpsURLConnection` path still validates against the system trust store and rejects the expired chain.
## Step 5: fix template (system path only)
Install permissive TLS defaults for the `HttpsURLConnection` path at the earliest point of `Application.onCreate()`:
```java
// Call from Application.onCreate() before any other initialization.
private static void installPermissiveTlsDefaults() {
try {
javax.net.ssl.SSLContext ctx = javax.net.ssl.SSLContext.getInstance("TLS");
ctx.init(null, new javax.net.ssl.TrustManager[]{
new javax.net.ssl.X509TrustManager() {
public void checkClientTrusted(java.security.cert.X509Certificate[] c, String a) {}
public void checkServerTrusted(java.security.cert.X509Certificate[] c, String a) {}
public java.security.cert.X509Certificate[] getAcceptedIssuers() {
return new java.security.cert.X509Certificate[0];
}
}
}, new java.security.SecureRandom());
javax.net.ssl.HttpsURLConnection.setDefaultSSLSocketFactory(ctx.getSocketFactory());
javax.net.ssl.HttpsURLConnection.setDefaultHostnameVerifier(
new javax.net.ssl.HostnameVerifier() {
public boolean verify(String hostname, javax.net.ssl.SSLSession session) { return true; }
});
} catch (Exception ignored) {
// Leave platform defaults in place: pre-patch behavior, no new failure mode.
}
}
```
Land it as a small utility class plus its inner classes in smali, or as a dexlib2 rewrite that inserts the call at the head of `Application.onCreate` (`dex-patching.md`). Keep it inside the app's own package; do not edit third-party classes.
Why this shape is correct:
- It changes **one** trust decision: the default `HttpsURLConnection` factory and verifier. OkHttp derives its trust manager through `TrustManagerFactory` and its own socket factory, so app-side pinning and verifier behavior is untouched. You are not silently weakening a control the app deliberately added.
- Call it **before any other initialization**. Globals set after the first `HttpsURLConnection` use still apply, but a library that cached a factory reference earlier keeps the old one, which produces a confusing partial fix.
- The scope is still wider than "login". **Every request that goes through `URL.openConnection()`** inherits it, including image loaders, download managers, and older push or analytics SDKs. State that in the report instead of implying you touched one endpoint.
- The `catch` that leaves platform defaults alone is deliberate: the worst case is the behavior you started with.
- This is a **client-side workaround for an external condition**. The correct fix is renewing the server certificate. Say so, and do not present the patch as the fix.
Do not: change the device clock, install your own CA into the device trust store (device-side, harder to roll back, and not part of a shippable artifact), rewrite the request host to a different domain, or reach for `System.setProperty` tricks. Each of them either misses the actual cause or widens the change far beyond the failing flow.
## If OkHttp is the failing path
Then the failure is about the app's own client configuration, not the platform defaults. Typical causes: a pin that no longer matches, a hostname verifier rejecting a changed host, or a custom `SSLSocketFactory` built from a bundled keystore. Locate it the same way (Step 4, item 2) and patch at the client construction site instead of applying the template above. `dynamic-frida.md` covers finding the real construction site; `dex-patching.md` covers the patch layer.
## Method notes
- **Order of investigation is the lesson.** Every piece of evidence above comes from the server, the network, or the platform before anything is attributed to your patch: full exception chain, real host, out-of-band validation, second-host comparison, control build. Only when all of them point at a client trust decision is a patch justified. `pitfalls.md` P16 is this mistake written up as a failure.
- **This is a control-build case, not a patch case.** `verification.md` §the control build rule applies directly: same pipeline, zero patches. If the control fails the same way, stop debugging the patch.
- **Report the residual honestly.** "The server certificate is expired; the client patch relaxes validation on the `HttpsURLConnection` path only, OkHttp is unchanged, the proper fix is on the server" is the useful statement. "I fixed login" is not.
- **Claim strength.** This evidence chain reaches rung 6 on the `verification.md` claim ladder: the mechanism is proven, not merely observed. Say which rung you reached.
## Triage checklist
```
[ ] Full exception chain captured, contains "timestamp check failed"
[ ] Exact host and URL obtained from a runtime probe (not from strings)
[ ] Standalone strict validation run: EXPIRED / clean pass
[ ] A second host from the same app compared (isolates the certificate)
[ ] Device clock checked against a known-correct source
[ ] Original unpatched build reproduces the same failure (control build)
[ ] Failing request attributed to a trust manager implementation, not to a message
[ ] If system path: permissive defaults installed at the top of Application.onCreate
[ ] If OkHttp path: client-level config is the patch target instead
[ ] Delivery notes state the client-side scope and the server-side root cause
```

View File

@@ -0,0 +1,598 @@
# Toolchain — What to Use, How to Invoke It, and Where It Lies
Load this when you are choosing tools, when a tool produces an answer that smells wrong, or when
something is not installed. It is a map, not a tutorial: each entry says what the tool is *for*, how
to drive it non-interactively, and the failure mode that wastes time.
**Prefer tools you can drive from a command line.** An agent cannot click. A tool that ships only a
GUI is not automatically out of reach — check for a headless or MCP path first, which is an
install-and-set-up step rather than a reason to substitute the tool. When no such route exists, ask
for a human instead of stalling or silently downgrading to a weaker method.
## Tier 0 — present on almost any machine, no install
Use these before reaching for anything heavier. They answer structure questions in seconds and
cannot be broken by a missing dependency.
| Tool | Use it for |
|---|---|
| `file`, `readelf -h/-l/-d/-r`, `objdump -d`, `nm -D`, `strings` | ELF structure, program headers, dynamic section, relocations, dynamic symbols |
| `unzip -l`, `zipinfo` | what is actually inside an APK, entry sizes and compression |
| `sha256sum` / `Get-FileHash` | identity. **Always hash before and after; never trust "it should be the same file"** |
| `xxd` / `hexdump` | byte-level ground truth when a tool disagrees with another tool |
| `python3` | all the scripts here; the standard library alone covers most parsing |
`readelf`/`objdump` walk **section** headers. If a target's section table is forged (see
`native-tamper-and-suicide.md` §Forged section headers) they will print confidently wrong output —
cross-check with the program headers.
## Tier 1 — dex and Java
| Tool | Invoke | Why this one |
|---|---|---|
| `baksmali` / `smali` (jars) | `java -cp <jars> org.jf.baksmali.Main d <dex> -o <dir>` | round-tripping, reading a method precisely |
| `dexlib2` | small Java program | method-level rewrite, leaves everything else untouched (`dex-patching.md`) |
| **`rasc`** (Rust ASC) | `rasc findrefs app.apk string <S>` | **the same index, 4–15× faster**, no Python runtime, a Rust DEX decompiler behind `getclass`. Reach for this FIRST when it is built — its one blind spot is documented in `rasc-and-droidsaw.md` |
| **`droidasc`** (ASC) | `droidasc findrefs app.apk string <S>` | the original Python index, one `pip install` away, and the cross-check for `rasc`. Reach for it FIRST when `rasc` is not built |
| **`ddc`** | `ddc app.apk -c <Class>` | **fastest read of dex as Java, plus query subcommands** — reach for this SECOND, to *read*. See below |
| `apktool` | `java -jar apktool.jar d/b` | whole-app decode including resources |
| `jadx` | `jadx --no-res -d <out> <apk>` | readable Java for orientation; **not** a source of truth, and **not** a recon entry point (see below) |
| `aapt2` | `aapt2 dump badging <apk>` | manifest facts, package name, versions |
| `zipalign`, `apksigner` | from build-tools | alignment and signing |
### rasc — the same index in Rust, when you have built it
`rasc` is the `rust` branch of the same project (`MG1937/ASC`). Same CLI shape — `classes`,
`manifest`, `getclass`, `findrefs {string,type,method,field}` — no Python at all, and a 2.13 MB
binary. **Measured on this repository's archives: identical class-definition sets on both a 1.4 MB
MASTG challenge and a 34.8 MB app (30,768 classes, 0 differences either way), at 4.0–4.7× for
`classes` and up to 14.7× for `findrefs`.**
It must be built — there is no release asset and no crate — so the kit wraps that:
```bash
python scripts/rasc_build.py --check # what is present
python scripts/rasc_build.py --build # needs git + rustup; ~2 min
python scripts/rasc_build.py --verify app.apk # compares class sets against droidasc, fails on a difference
```
**It is a registered capability, so G2 does not have to guess:** `doctor.py` reports `dex_index_rust`
as `OK` when a binary is present (PATH, `APKREV_TOOLS`, or the work-area path `rasc_build.py` builds
into) and `BLOCKED` with that build command as the next action when it is not — alongside
`dex_index_python` for the `pip install` route. A machine with neither is not stuck: the Python
indexer is one command away, and the two are cross-checked against each other by
`rasc_build.py --verify`.
**The one thing to know before trusting its `getclass`:** on an `enum` whose constants override an
abstract method, the outer class is printed as a bare constant list and the per-constant bodies are
**not inlined** — no warning, and none in the Python tool either, because both leave those bodies in
their own subclasses. They are one query away (`Lpkg/Enum$1;`, and both tools decompile those fully);
`droidasc`'s outer-class listing is simply richer (2,170 B vs 314 B on the measured class, carrying
`$VALUES`, `$values()` and the constructors). Sampling 20 app-like classes from a real app, 17 were
judged by the JADX-parity harness and 16 agreed literal for literal; the one that differed was exactly
this shape. Use it to *locate* and to read ordinary classes, and read the constants when an enum shows
no bodies. The measurements and the failing class are in `references/rasc-and-droidsaw.md`.
### droidasc (ASC) — ask an APK "who references this?", in one query
Install it in one line. No JVM, no Android SDK, no GUI, no first-run indexing:
```bash
pip install droidasc # provides the `droidasc` CLI
```
It treats the artifact as a **read-only database** instead of exporting a source tree: it probes the
deflate stream in place and rebuilds only the minimal dex it needs, in memory, for the one class you
asked about. Nothing is inflated to disk; there is no index-building phase to wait through. (Design
and measurements are the author's; the BlackHat EU 2026 Arsenal abstract is the reference.)
```bash
droidasc findrefs app.apk string "/data/app/" # every site that mentions a literal
droidasc findrefs app.apk string com.example/sdk # a channel name, a URL fragment, a field name
droidasc findrefs app.apk type com/foo/Bar # who references a type
droidasc findrefs app.apk method notify --class MainActivity --fuzzy-class
droidasc listclass app.apk --prefix com/poc # what classes exist (28210 classes: seconds)
droidasc listclass app.apk -o classes.txt
droidasc getclass app.apk Lcom/poc/Main; -o Main.java
droidasc getmanifest app.apk -o AndroidManifest.xml
```
Each hit names the **class and method** it sits in. That is the whole value: it turns "which of 28,000
classes mentions this string" from a crawl over an exported tree into one sub-second query, and it works
straight off the APK you were handed.
**What it is for, and where it stops.** ASC answers *location* questions. It is not where you read a
method body, and it does not replace the kit's dex tooling for patching. Its place is the first ten
minutes of recon plus every later "where else is this used?" — the questions that otherwise send you
grepping a hand-exported smali tree.
**Three limits, and one of them is a trap.**
- **A non-ASCII needle is not trustworthy through a shell.** A `findrefs string` query for a non-ASCII
literal returned nothing under Windows/PowerShell, and **an empty result is indistinguishable from a
broken argument encoding.** Many non-ASCII literals genuinely live in a native library rather than in
dex, so "nothing came back" is a plausible answer — which is exactly what makes the unverified
negative dangerous. Confirm it another way before recording it: search the dex bytes directly with a
`\u`-escaped needle, or run a control query you know must hit (a path fragment, a package name you
already read in the manifest) in the same session.
- **A mangled class name may return only resources.** `listclass apk --prefix <the plugin's package>`
can come back with nothing but `R$anim` / `R$string` / `R$style` entries, which is **not** a dead end:
it is evidence the real class was renamed by R8. `findrefs string <the plugin's channel name>` still
hands you the obfuscated class implementing it. This is the single situation where ASC is not an
alternative to `ddc` but the only route — `ddc -c <FQCN>` needs the post-R8 name to start with.
- **A hit is a *reference*, not a *call*.** `findrefs` proves a literal or type is mentioned in a method;
whether that method runs on the path you care about is a different question, and the answer comes from
the disassembly or from runtime (`dynamic-frida.md`).
The division of labour is worth stating plainly: **ASC locates, `ddc` reads.** Running a full `ddc`
export first, then grepping it, is the slow path this tool exists to replace.
**Measured in the extension pass** (droidasc `0.1.1.post1`, installed with `python -m pip install
droidasc`; dependency is androguard only, no JVM, no SDK):
| Command | Result on a 38.7 MB hardened APK |
|---|---|
| `getmanifest` | 0.44 s → 32,345 B of complete XML |
| `findrefs … string jiagu` | 0.42 s → hits inside the shell class's `<clinit>` and `attachBaseContext` |
| `getclass com/stub/StubApp` | 0.33 s → 22,802 B of Java |
| `listclass` (whole APK) | 0.18 s → **4 classes** |
Two facts from that run are worth carrying into any recon: the CLI name is **`droidasc`**, not `asc`
(there is no `asc` binary, and `asc --version` is simply "command not found" — `python -m droidasc`
also works), and on a packed APK a full `listclass` returning four classes is not a broken tool: the
business dex is not in the file. Reaching for `getclass` on a class that is not there exits **1** with
`Class … not found in APK.` — treat ASC as a manifest-and-shell inspector on hardened input, and take
the business-logic question to a different layer.
### ddc — dex-to-Java with query subcommands (worth adopting)
A single-file Rust binary, no install, no JVM. Two things make it more useful than
"a decompiler":
```bash
ddc info app.apk # label, package, version, launcher, sdk, per-dex counts
ddc findrefs app.apk string "SomeToken" # every reference site, class#method
ddc strings app.apk -f "splash" --with-locations
ddc app.apk -c com.example.Foo # one class as Java
ddc app.apk -o out/ # full decompile, a few seconds for a normal APK
```
Measured on a 4.7 MB APK (4,070 classes / 30k methods): `info` ~0.13 s, `findrefs`
~0.11 s, one class ~0.25 s, full decompile ~2.8 s producing ~4,000 files.
**Measured in the extension pass** (ddc `0.1.8`, a single Rust binary — on Windows `bin/ddc.exe`,
1,028,096 B, **not on PATH** and carrying no PE version resource, so `ddc -V` is the only way to read
its version; source: the project's GitHub releases):
| Command | Result on a 38.7 MB hardened APK |
|---|---|
| `ddc info` | 0.24 s → label / package / version / application / launcher / sdk / size / md5, plus per-dex class, method, field and string counts |
| `ddc findrefs <apk> string jiagu` | 0.13 s → tabulated `dex | kind | class | method | refs` |
| `ddc strings -f jiagu --with-locations` | 0.14 s → strings mapped to the methods that hold them |
**The trap on this one is the exit code.** Asked for a class that is not present, `ddc` exits **2**
*and still prints its help text to stdout* — so a wrapper that tests "did anything come back?" reads a
usage error as an answer. Branch on the exit code, never on output emptiness. The same run also
confirms the layered picture from the other direction: `info`'s per-dex counters report 4 classes and
29 methods with code for the whole 8.9 MB shell dex.
Why the query subcommands matter more than the speed: **string cross-referencing
becomes a lookup instead of a crawl.** Asking "which class mentions this config
field name" is a sub-second command here and easily an hour of smali grepping by
hand. That single property is what turns "find the convergence point" from
exploration into a query.
`info` also **prevents a specific, costly mistake**: reading the package identity
out of the binary manifest by hand. Hand-extracted AXML strings are ambiguous —
class-name fragments look exactly like package names, and picking the wrong one
sends every later `pm`/`dumpsys`/data-dir query to a package that does not exist.
Let the tool report `package`, `label` and `launcher`, and cross-check with
`aapt2 dump badging` when available.
Limits to plan around: types are erased (no generics); R8 short names stay short,
so you still need string cross-references to infer meaning; output is a **reading
aid, not compilable source** (occasional declaration/use ordering is inverted, and
numeric resource ids appear as decimal integers); very large methods produce
thousands of lines and are better approached via `findrefs`/`getmethod` first.
Two more, both measured on a 28,210-class R8-flattened Flutter APK:
- **`-c <FQCN>` cannot reach a renamed class, and `listclasses <pattern>` cannot recover it either.**
Asking for the plugin's documented name returned `class not found`; asking for its package prefix
returned only `R$anim` / `R$string` / `R$style` resources. The implementing class existed under a
short R8 name, reachable only by string cross-reference. **Plan for `droidasc` to close this gap** —
it is not a nicety, it is the difference between locating the class and not.
- **A decompiled body can be semantically wrong, not merely ugly.** In one 9 KB helper class the
compiler emitted a `return 0;` inside a method declared to return `String`, reordered field
assignments, and produced empty `if/else` branches. None of that is safe to reason *about* control
flow without checking smali or bytecode. Treat a surprising construct as a decompiler artifact until
the bytecode agrees.
**Use `findrefs` to locate, `-c`/`getmethod` to read, and the bytecode to decide.** Reading order
matters: a full `ddc <apk> -o out/` export is a reasonable thing to *have*, but grepping that tree is
not a substitute for one indexed query, and it is the slower of the two by a wide margin on any APK
worth analyzing.
**Classpath gotcha.** `baksmali`/`smali` need their dependency jars on the classpath together
(`smali`, `antlr-runtime`, `stringtemplate`, `baksmali`, `dexlib2`, `util`, `jcommander`, `guava`).
Missing one produces `ClassNotFoundException` that reads like a broken target. `scripts/smtool.py`
carries the classpath so you pass it once.
**Entry-point gotcha.** The main class has moved between versions — `org.jf.*` in older builds,
`com.android.tools.smali.*` in newer ones. If `ClassNotFoundException` names the **main class**, it
is a version mismatch, not a missing jar.
**jadx is for reading, not for concluding.** Decompiled Java reorders and rewrites control flow;
line numbers and even which branch is which can differ from the bytecode. When a decision depends on
it, verify in smali or at runtime. `jadx` writing `(unknown)`/empty method bodies means it failed,
not that the method is empty.
**`smali` assembling can abort silently** — the tree produces no dex while the process still exits
0. Never treat exit code as proof of a build. Assert the artifact exists, is fresh, and is non-empty
(`patch-audit.md`).
## Tier 2 — native
| Tool | Invoke | Notes |
|---|---|---|
| `radare2` / `rizin` | `r2 -q -c '<cmds>' file` | scriptable disassembly/analysis; the practical choice when no GUI is available |
| `Ghidra` (headless) | `analyzeHeadless <proj> <name> -import <file> -postScript <s>` | decompiler without a GUI; slower to script but far better output than a raw disassembler |
| `capstone` (python) | library | decoding in your own scripts — see the silent-stop trap below |
| **`svc_scan.py`** | `python scripts/svc_scan.py libfoo.so [--context 3]` | **names the syscall behind an inline `svc`** and reports which PT_LOAD segment each hit is in. Use it *before* planning a libc-level hook: a module with its own `exit`/`kill` `svc` sites is not observable through libc, and one without them means a missing event is a finding about your hook. Read the neighbours (`--context`) — a byte scan for `svc` also matches inside data. Measured on the test device: device `libc.so` = 4 termination sites (its own exports), a shell library's 21 hits = all data |
| `keystone` (python) | library | assembling a short patch when you are editing bytes by hand |
| `pyelftools` (python) | library | **section-based — unreliable on hardened targets.** Prefer hand-walking `PT_LOAD`/`PT_DYNAMIC`, as `scripts/elf_plt.py` does |
| IDA Pro | GUI, **or headless via `idalib-mcp`** | the strongest decompiler output. Do not assume it needs a human in front of it — a headless MCP server exists (see *MCP tool servers* below). Ask for a human only when that setup is genuinely unavailable |
| `x64dbg` | GUI, Windows | human-driven live debugging of a native Windows target |
| `gdb` / `lldb` | CLI | live debugging where a device or emulator allows it |
**capstone can stop silently.** Decoding a buffer that does not begin at an instruction boundary may
return a few instructions and then nothing, with no error. A scan built on that reports "no matches"
for a library full of matches. Use byte-pattern search or a resynchronising scan for anything where
completeness matters (`native-tamper-and-suicide.md` §Scanner traps).
**Disassembler output is a hypothesis.** Fixed-width architectures (aarch64) decode almost any
4-byte window into *some* instruction, so a wrong start offset yields plausible-looking garbage.
Bound your window with a known entry point or a known call site.
**A decoder is a decoder, not an oracle.** Two independent decoders agreeing is the signal worth
having (measured: a hand-written word scan for the `svc` encoding and the `capstone`-based scan in
`svc_scan.py` returned the identical 214-site set on a device `linker64`, set difference empty); one
decoder's disagreement with itself is not. **`dexdump`** (from build-tools, e.g.
`E:\tools\android-14\dexdump.exe`) is the cheap independent reader for the dex half of this: use it
to count a dump's class and method records rather than trusting a self-written parser, the same way
`advanced-unpacking.md` §Establish "the bodies do not decode" with a decoder you did not write
requires.
### Ghidra — the decompiler for a target whose decisive layer is aarch64
The recurring shape: the app is fine, the dex is readable, and the layer that actually decides
behaviour is a stripped aarch64 `.so`. A disassembler gives you instructions; only a decompiler gives
you control flow. **Hand-walking an OLLVM control-flow-flattened function with `capstone` is the single
most expensive route available**, and it is what you fall into when no decompiler is installed.
Ghidra is free, runs headless, and decompiles aarch64. Setup is unzip plus a JDK (11+), which is why
"there is no decompiler on this box" is normally an install step rather than a finding about the target
(§Closing a capability gap).
```bash
# one-time: unzip the release; point Ghidra at a JDK (JAVA_HOME or the launch script)
# headless: import, auto-analyse, run a post-script, discard the project
analyzeHeadless <proj_dir> <proj_name> -import target.so \
-postScript DumpFunction.java <addr_or_symbol> -scriptPath <dir> -deleteProject
```
Three caveats learned the hard way:
- **`analyzeHeadless` is slow and its auto-analysis is not free.** Import plus analysis of a 6 MB
stripped library is minutes, so it is exactly the kind of run that needs an explicit timeout. Budget
it once and reuse the project; do not re-import per question.
- **Flattened control flow defeats the decompiler as well.** A `br x8` dispatcher with four constant
arithmetic operations in every function header is OLLVM flattening, and the output is a state machine
that resembles the program without being readable. **Removing the flattening is the work**
(`code-virtualization-and-custom-linkers.md`), not a prerequisite you can skip.
- **A decompiler cannot go where the data is not.** When a library's strings and action names are
**encrypted in the file and exist only once the module is mapped in memory**, no static tool recovers
them by reading harder — escalate to runtime (`dynamic-frida.md`) instead of escalating the static
toolchain. Telling "install a decompiler" apart from "the decompiler cannot help here" is worth real
time: the two look identical from the outside, and only the first has a static answer.
## Closing a capability gap — installing the tool IS the task
A missing tool for the layer you must work in is not a constraint to design around. It is the next
step. The measured cost of installation is almost always smaller than the cost of the workaround, and
the workaround is what produces "we could not determine X" reports on targets where X was decidable.
**The decision is not "can I do without it" — it is "how long does installing it take".** Three real
numbers, from one Windows box working an R8-flattened Flutter target:
| Gap | Install cost | What the workaround would have cost |
|---|---|---|
| No whole-APK cross-reference index | `pip install droidasc` — one command, seconds, no JVM, no SDK | hours of `grep` over a hand-exported smali tree, **per question asked** |
| No arm64 decompiler | Ghidra — unzip plus the JDK already present; minutes | a manual ELF/`capstone` walk over an OLLVM control-flow-flattened 6 MB library, per function |
| No Dart AOT snapshot front end | `aotopsy` — unzip and run (pure Go, no toolchain); or `blutter`, measured **≈78 s** end to end | "pool offsets can never be mapped", which turned out to be false |
The third row is the instructive one. The documented cost ("tens of minutes for the first build") was
about **30× pessimistic**, and the documented toolchain requirement was overstated. **A tool's stated
prerequisites are a claim, not a measurement**, and the cost of checking is one attempt.
Rules that follow:
1. **Write the capability down before spending against it.** "arm64 decompilation — absent — install
Ghidra" is a task item. "arm64 decompilation — absent" with nothing after it is how a route gets
written off for the wrong reason.
2. **Check off-PATH before declaring anything absent** (§"not on PATH" is not "not installed").
`APKREV_TOOLS` exists so a project-local tools directory or an SDK folder resolves.
3. **Install, do not substitute.** A weaker tool's output presented as equivalent to the stronger
tool's output is a wrong conclusion with a clean audit trail — worse than an admitted gap.
4. **Report the gap only when it is real.** "No arm64 decompiler exists here and it cannot be installed
because `<reason>`" is a valid finding. "I used `readelf` instead" is a different statement, and it
is not a finding about the target.
5. **"Paid" and "GUI-only" are questions, not walls.** IDA has a headless MCP path; Ghidra has
`analyzeHeadless`; both are set-up steps. Ask a human only after that route is genuinely
unavailable.
6. **One real disqualifier.** A decompiler cannot go where the data is not: if a target's strings and
action names are **encrypted in the file and only exist in memory**, then no static tool recovers
them by reading harder. Recognise that shape early and move the effort to runtime
(`dynamic-frida.md`) instead of escalating the static tools — that distinction is the difference
between "install a decompiler" and "the decompiler cannot help here", and they look identical from
the outside.
## Where to get a tool we do not ship — a sourced gap list, not a link dump
The list below exists because "install the tool" needs a *source*, and searching for one on a target's
clock is how a ten-minute install becomes an hour. **Two grades are used here and they are different
claims:** `URL verified` means the repository was resolved on the date given; `tool unverified` means
nobody has run it here. Most rows are `URL verified / tool unverified` — treat them as leads with a
date on them, not as recommended tools, and check the row's own prerequisites when you install it
(§Closing a capability gap — installing the tool IS the task). Rows marked `measured` have been used here.
| Gap | Source | Grade / what is actually known |
|---|---|---|
| Decompiler for dex/Java | `https://github.com/skylot/jadx` | URL verified 2026-09-21; tool unverified here (not installed). The kit's route is `droidasc`/`ddc` for queries and jadx only to *read* a class already located |
| Repacking beyond `scripts/repack.py` | `https://github.com/iBotPeaches/Apktool` | URL verified 2026-09-21; tool unverified. Not installed on this host, and the `apktool.bat` in `E:\tools\bin` is a dead link — do not trust a `.bat` to mean the tool works |
| smali round-trip outside the bundled dexlib2 kit | `https://github.com/JesusFreke/smali` | URL verified 2026-09-21, **last upstream push 2024-01-17** — maintenance status is a fact to check before adopting it for a new dex version |
| Anti-detection on the frida side (patched server plus a script surface aimed at RASP) | `https://github.com/CrackerCat/strongR-frida-android` | URL verified 2026-09-21; tool unverified. A patched `frida-server` is an **environment** change: record it in the task record, because results obtained under it are not comparable to a run without it |
| A dex dumper that does not use `ptrace` | `https://github.com/index-login/MobileRE-Skill` (`.kilo/skill/rev-dex-dumper/`) — `panda-dex-dumper`, `mem-dex-dumper` + C source | **Partly measured**: both ELF images were inspected here and neither imports a `ptrace` symbol (`panda` = aarch64 `ET_DYN`, 48 symbols; `mem` = aarch64 static, stripped), so the *claim* is consistent at the symbol layer; the **dump behaviour was not run here**. Our own `/proc/<pid>/mem` read is `advanced-unpacking.md`'s root-side route and is measured |
| eBPF-based dex extraction | `https://github.com/LLeavesG/eBPFDexDumper` | URL verified 2026-09-21; tool unverified, **and unusable on `<DEVICE>` anyway** — kernel 4.14 against a 5.10+ requirement (`kernel-and-environment-hardening.md`). Listed so the next reader does not re-derive the version gate from scratch |
| Decompilation/deobfuscation/unpacking without a JVM | `https://github.com/adam-040/Enigma`, `https://github.com/1-3-7/disrobe` | URLs verified 2026-09-21; tools unverified. Both are interesting precisely because they remove the JVM/IDE dependency a Ghidra or IDA route carries — evaluate prerequisites before adopting |
| Java2C generation to *build* a fixture | `https://github.com/amimo/dcc` | **Partly measured here**: `dcc` itself was run in a previous pass, but the C compile needs an NDK this host does not have, so no Java2C artifact was ever produced (`java2c-and-jni-sinking.md`) |
| Building an LSPosed module without gradle, or comparing against a maintained template | `https://github.com/Jordan231111/lsposed-universal-template` | URL verified 2026-09-21; tool unverified. Its layout is gradle-based, which this host cannot build — useful as a **manifest/scope reference** only. (`mabbcoll13/xposed-module-kit` no longer resolves: HTTP 404 on 2026-09-21, which is why this row's date is worth writing down) |
| Dart AOT snapshot front end | see the §Closing a capability gap table above | measured here: `aotopsy` runs with no toolchain; `blutter` measured ≈78 s end to end on this host |
Two habits that keep this list from rotting: **write the access date next to a URL** (a dead link with
no date reads as a current recommendation — one row above is already 404), and **promote a row only
when someone runs the tool**, so `URL verified` never silently becomes "we use this".
## MCP tool servers — an external dependency, not a tool on the shelf
An MCP server is neither a CLI tool nor one of the scripts here. It is a **separate installation plus
a running process**: something to install, a service to start, usually an extension to load, often an
authorization step. Listing one beside `readelf` would imply it is already present. Declare it as a
dependency with its prerequisites, and check whether it is actually available *before* planning work
that needs it — discovering this mid-task is the "missing tool" failure again.
### IDA Pro, headless
IDA does **not** require a human at a GUI. `mrexodia/ida-pro-mcp` ships `idalib-mcp`, a headless MCP
server that drives an IDA database with no GUI process at all:
```sh
uv run idalib-mcp --host 127.0.0.1 --port 8745 path/to/executable # open a binary up front
uv run idalib-mcp --host 127.0.0.1 --port 8745 # open databases on demand
uv run idalib-mcp --stdio # for stdio-based clients
```
Prerequisites, all of which must be arranged before the first call: IDA Pro 8.3+ (9 recommended;
**IDA Free is not supported**), a Python 3.11+ that `idapyswitch` can select, `uv`, and an `idalib`
activated globally via `py-activate-idalib.py`. Each open database lives in a worker process that
outlives the supervisor and is adopted transparently by a later supervisor on the same host, so
several sessions can share one analysis. Every tool call carries an explicit `database` argument —
there is no implicit "current database" — and `idb_open` returns the session id you must pass.
The GUI-plugin variant of the same project is deprecated upstream in favour of `idalib-mcp`; do not
set that up for agent work.
### Ghidra
`LaurieWired/GhidraMCP` is a Ghidra **extension** plus a separate Python bridge, not a headless
analysis server: Ghidra must be running with the plugin loaded (its HTTP server defaults to
`127.0.0.1:8080`) and the bridge is another process your client starts. Treat it as requiring an
interactive GUI session. For unattended work the shape that actually runs without a GUI is
`analyzeHeadless` with a post-script.
### The boundary: deobfuscate before you hand the binary to a model
Both are LLM-driven, and they inherit the model's weaknesses. The IDA MCP authors say it plainly in
their own README: **"LLMs will not perform well on obfuscated code"**, and they advise removing
string encryption, import hashing, control flow flattening, code encryption and anti-decompilation
tricks *before* asking a model to solve anything.
That matters more here than in most domains, because it is precisely the shape of the targets this
skill deals with. An OLLVM-style or virtualized binary is not a job for a model reading decompiler
output; the deobfuscated version is a *different binary*, and producing it is the work already
described in `code-virtualization-and-custom-linkers.md` and
`native-tamper-and-suicide.md`. The same advice has a second half: resolve library code first
(FLIRT/Lumina) so the model is not asked to reason about `memcpy` as if it were the program.
Two consequences worth carrying into the plan:
- Ask a model for **navigation and reading** — rename, comment, summarise a function, convert an
immediate — and verify anything that decides the patch against the disassembly yourself.
- If the server is not installed, that is a **missing dependency to report**, not a licence to present
a weaker tool's output as equivalent (`packers.md`).
## Tier 3 — runtime and network
| Tool | Invoke | Notes |
|---|---|---|
| `frida` (host) + `frida-server` (device) | `frida -U -f <pkg> -l script.js` | **host and device versions must match** — skew produces errors that look like a broken target (`pitfalls.md` P15) |
| `objection` | `objection -g <pkg> explore` | quick Java-layer poking on top of Frida |
| `mitmproxy` | `mitmproxy --mode regular` | request/response inspection; a device proxy is usually faster to set up than a transparent one |
| `HaE` (Burp extension) | load into Burp, then read its rule set | **traffic *triage*, not traffic capture.** See below |
| `adb` | everything | the primary device interface |
**HaE is a reader, not a capture tool, and it is not always the right next step.** HaE (Highlighter and
Extractor, `overspace-labs/HaENet`) is a Burp Suite extension that tags and extracts fields from HTTP
messages — tokens, IDs, secrets, endpoints, fingerprints — so that a large session becomes readable
without hand-scanning every request. It is genuinely useful when the bottleneck is *"there is traffic
and I cannot see the parts that matter."*
It is the wrong tool when the bottleneck is one of these three, and reaching for it there costs rounds:
- **The requests never leave the client.** No capture tool can display a request that was never built
(`code-virtualization-and-custom-linkers.md` §what the native check actually reads). **Establish
whether traffic exists before buying tooling to read it** — a DNS/`connect`-level probe answers that
in a single run, and on the measured target it was the observation that ended the investigation
branch, not a capture.
- **The body is encrypted by the app**, so the capture is ciphertext (`server-api.md`).
- **Certificate pinning defeats the proxy**, in which case the empty capture is itself the finding
(`tls-and-cert.md`).
Ordering that follows: **first prove there is readable traffic, then choose the reader.** `mitmproxy`
captures; HaE triages; neither substitutes for the DNS/connect check that decides whether there is
anything to triage. Treat "install a Burp plugin" as a step that must be justified by an existing
capture, not as recon.
Version alignment for Frida is a **hard gate**, not a nicety. Check both sides before writing a
script, and check the device architecture — the server binary is per-ABI.
## Tier 4 — cross-platform runtimes
| Runtime | Identifier | Tool |
|---|---|---|
| Flutter / Dart | `libflutter.so` + `libapp.so` | `blutter` (needs the **matching Dart version**) — `dart-aot.md` |
| Unity / IL2CPP | `libil2cpp.so` + `global-metadata.dat` | Il2CppDumper + a native decompiler |
| React Native | `libhermes.so` / `index.android.bundle` | Hermes bytecode tooling, or plain JS if the bundle is unminified |
| Cordova / hybrid | WebView + `assets/www` | read the JS directly; Java layer is usually thin |
**A version mismatch here wastes the most time of any tool in this file.** A Dart decompiler built
for a different engine version produces output that is subtly wrong rather than obviously broken.
Pin the version first (`dart-aot.md`) and do not "try it and see".
## Using the kit's scripts instead of writing your own
The scripts exist so that the *expensive, generic* parts of this work — decoding a dex instruction at an
exact offset, recomputing a dex header in the right order, writing a 4-byte-aligned archive, resolving a
PLT stub, bursting screenshots with evidence attached — are not re-derived per task.
Re-deriving them is not neutral. A hand-rolled instruction decode that silently loses sync, or a
hand-rolled repack that quietly drops alignment, produces **a confident wrong answer**, which is the
exact failure class this skill exists to prevent. The cost shows up later, as a patch that "had no
effect" or an install refused with a bare numeric code.
**The rule: before writing a script, check whether one here already does it.** The index is in
`SKILL.md`; the entry points that matter most are `doctor.py` (what can run here),
`dex_find_insn.py` (get an exact offset instead of guessing one), `dex_patch_bytes.py` (apply and prove
an equal-length patch), `apk_diff.py` (prove your own build was surgical), `repack.py` (aligned rebuild)
and `snap.py` / `coldstart.py` (look at the screen with the evidence attached).
**When a script here is genuinely wrong, that is a finding to fix and report — not a reason to quietly
route around it.** Two live examples of the difference:
- A decoder utility in this kit carried an **opcode name/width table misaligned by one entry across
`0x16`–`0x2C`** (23 rows). Everything decoding through that range **silently lost sync** and produced
offsets that were plausible and wrong — no exception, no warning. It was found by comparing against an
independent decoder and noticing a disagreement. **If two tools disagree, the disagreement is the
product**; it is the cheapest bug signal in this domain and the easiest to throw away by picking the
answer you preferred.
- Conversely, a *tool's* negative result is not a finding about the target (§the general trap below).
Establish that the tool could have found the thing before recording that the thing is absent —
especially for string searches, where an empty result is exactly what an unfound string looks like.
**Corollary for reporting.** "The kit's script X reported Y, and I cross-checked it against Z" is a
conclusion. "I wrote my own parser because the script was awkward" is a gap you introduced, and it
should be reported as one.
## "Not on PATH" is not "not installed"
`doctor.py` and `preflight.py` report whether each tool resolves on `PATH`. That
is a statement about `PATH`, not about the machine, and the difference has cost
real time: a full custom zip writer was built to work around a missing
`apksigner` that was already installed in an SDK directory nothing had added to
`PATH`.
**Before treating a tool as absent, search the filesystem once:**
```bash
# POSIX
find / -name apksigner -o -name zipalign -o -name baksmali.jar 2>/dev/null | head
# Windows (PowerShell) -- list every drive letter first, then search each
(Get-PSDrive -PSProvider FileSystem).Root |
ForEach-Object { Get-ChildItem -Path $_ -Recurse -Depth 5 -ErrorAction SilentlyContinue `
-Include apksigner.bat,zipalign.exe,keytool.exe }
```
Common places that are *not* on `PATH`:
| Artefact | Typical location |
|---|---|
| `apksigner`, `zipalign`, `aapt2` | any `build-tools/<ver>/` under an Android SDK, and portable tool bundles |
| `apksigner.jar` | inside those same directories (runnable as `java -jar`) |
| `keytool`, `jarsigner`, `javac` | a JDK install whose `bin` was never added to `PATH`; `java -XshowSettings:properties -version 2>&1 \| grep java.home` reveals the JDK root even when `keytool` does not resolve |
| `uber-apk-signer.jar`, `baksmali*.jar` | bundled with portable APK toolkits |
Record the resolved absolute paths once, in your running notes, and pass them to
the scripts that accept `--apksigner` / `--zipalign` / `--ks` style flags. Every
script in this repository takes an explicit path for this reason.
**A useful asymmetry:** missing `apksigner` is survivable (a v2-only signature
block can be appended without reordering the archive), but missing `zipalign` is
not a reason to hand-roll alignment either -- write the archive aligned in the
first place, as `scripts/repack.py` does. Prefer producing a correct archive over
repairing one afterwards: post-hoc alignment tools rewrite the file, which
changes every offset and can break a target that fingerprints its own layout.
## Signing: pick the signer deliberately
Two signers, and the choice changes the output bytes:
- **`apksigner`** (build-tools) appends the v2/v3 signature block after the zip
content. It does **not** reorder entries, so a carefully aligned archive stays
aligned. This is the default choice.
- **`jarsigner`** (JDK) rewrites the archive as a side effect of adding the v1 JAR
signature, which recompresses entries and **destroys the alignment** that
Android R+ requires. A build signed this way can fail to install with
`Failure [-124] ... resources.arsc ... aligned on a 4-byte boundary` even though
the same archive installed fine moments earlier, and `jarsigner -verify` reports
success. If you have run `jarsigner` on an archive and a previously-working
install starts failing, re-pack and sign with `apksigner` before investigating
anything else.
Enable **v1 + v2 + v3**. v1 keeps very old devices working; v2/v3 are what modern
platforms actually verify, and v1-only builds are rejected in more configurations
than people expect.
## The general trap: a tool's failure is not a finding about the target
A missing jar, a version skew, a forged section header, and a genuinely absent symbol all produce
**empty or partial output**. The output looks the same; the conclusions are completely different.
Before reporting "there is nothing there", confirm the tool was actually capable of finding it:
- Did it read the file at all? (entry count, segment count, non-zero output somewhere)
- Does a **known-present** item show up? (search for something you already know exists — a symbol
you saw in the dynamic table, a string you read in the hex dump)
- Would a different method see it? (byte pattern vs decoded scan)
**This is `pitfalls.md` P25 and P26 in tool form.** A self-built analyser that fails quietly reads
exactly like a clean target, and it removes routes from consideration for free.
## Working without a network
Assume nothing can be downloaded. What still works, in order of usefulness:
1. Tier 0 in full, plus the Python standard library — enough for structure, strings, hashing, and
most ELF/dex parsing.
2. Whatever jars and binaries are already present. **Locate them once and reuse the paths** rather
than re-searching on every command.
3. A local package cache or an offline mirror, if the environment has one.
If a tool genuinely cannot be obtained, say which step is blocked and **what a substitute would
give up** — do not silently substitute a weaker method and report its result as equivalent.
## Recording the toolchain actually used
When you report, name the tools and versions that produced each finding. Two agents with different
tool versions will reach different conclusions about the same binary, and without the version that
difference is unresolvable later.

View File

@@ -0,0 +1,243 @@
# Updates and forced upgrade
Load this whenever the deliverable is a patched build that must **keep working over time** — which is
almost always. A build that is correct on the day you ship it and dead a week later has failed, and the
cause is nearly always an update path you did not neutralise.
This is the file that decides whether a patch is *durable*, and it is cheap: the whole job is usually
one or two edits.
## Why this is not optional
Three independent failure modes, all of which present as "the mod stopped working" with no obvious link
to updates:
1. **Forced upgrade.** The app checks a version endpoint, sees a newer build, and blocks its UI behind a
non-dismissible dialog until the user installs the official (unpatched) package. Your patch is gone.
2. **Self-update install.** The app downloads an APK and hands it to the package installer. Because the
downloaded build is signed differently, the install either fails or — worse — succeeds and replaces
your work.
3. **Silent "compatibility" downgrade.** The server decides the client version is too old and starts
omitting fields or returning an error page. No local code changed, so nothing local looks broken.
A fourth case is a **resource / hot-update channel**: the app fetches a bundle (JS, a Dart/RN patch, a
config blob, a plugin APK) that can reintroduce the behaviour you removed. That is a separate code path
from version checking — handle it explicitly if the app has one (see §Step 5 below).
## Step 1: locate the check
There is usually exactly **one** update service. Find it before patching anything else.
**From the client's own vocabulary** (works on any runtime):
```
update / upgrade / new version / check version / force update
版本更新 / 检查更新 / 发现新版本 / 立即更新 / 强制更新
```
Search those in the app's string table and in class names; on heavy runtimes search the string pool,
remembering its encoding (see `framework-runtimes.md` §Locating logic without symbols — searching only one encoding
misses half your hits, and "nothing found" is usually the encoding, not an absence).
**Signals that a version check exists even if you cannot read the strings:**
- A network call early in startup whose response contains a version number, a download URL, and
sometimes a `force` / `must` / `minimum` flag.
- A stored "last update check time" preference (throttling a check implies one exists).
- The app ships the package installer path or requests `REQUEST_INSTALL_PACKAGES`.
- A permissions/manifest entry for an "unknown sources" install intent.
**Also look for the *coupling*.** Update checks are frequently run from the same startup routine as ad
loading and splash dismissal, and a blocking update dialog often **suspends those siblings** — you may
find logging that literally says so ("update dialog showing, skipping X"). That tells you both where the
check lives and that your patch must not leave the dialog state machine half-finished.
## Step 2: classify the shape
| Shape | How it behaves | Where to cut |
|---|---|---|
| **Optional update** | a dismissible prompt with a download button | suppressing the dialog is enough |
| **Forced update** | non-dismissible; blocks the app | must be cut at the decision, not the dialog |
| **Silent version gate** | no dialog; features degrade or the server 400s | client-side is *not* fixable — see §the honest answer |
| **Self-update installer** | downloads and installs an APK | cut the check, and ideally the install path too |
| **Hot-update / resource patch** | fetches a bundle that can restore behaviour | separate route; treat as a data channel |
Assume **forced** until you have evidence otherwise, because that is the case that costs the user the
most when missed.
## Step 3: patch the decision, not the dialog
**Prefer the earliest safe exit over every downstream surface.** Supressing the dialog only hides it;
the download may still start, and a later code path may still act on the "new version" verdict.
Two layers, in this order:
**Layer 1 — make the check itself a no-op.** Replace the entry of the update routine so it returns
immediately. Nothing is requested, nothing is compared, no dialog state is created. This is the root fix
and it removes the network call entirely, which also makes verification trivial (Step 4).
**Layer 2 — neutralise the comparison.** Somewhere the client compares the server version against its
own and branches on the result. Force that branch to the "already current" side. This catches any path
that reaches the comparison without going through the entry you patched (a second caller, a manual
"check for updates" button, a settings screen).
Layer 2 alone is a valid minimal fix if Layer 1 is awkward; Layer 1 alone is valid if you have confirmed
a single entry. **Doing both is cheap insurance and is what makes the claim "this build cannot be
upgraded into uselessness" defensible.**
```
target routine: <updateChecker>(...) -> return immediately (no request, no state)
target branch: <versionCompare> result -> force the "no update" side
```
**Do not** patch the dialog itself as your primary fix, and **do not** patch the comparison to lie in
the *other* direction (claiming an update when there is none) — that produces exactly the blocking
dialog you are trying to avoid.
### What not to touch
- **Do not rewrite the app's own version number** in the manifest or in a version string. It is the
cheapest-looking fix and it breaks things you are not looking at: server-side feature negotiation,
analytics, cached asset keys, and the app's own self-comparison. Change the *decision*, not the
identity.
- **Do not strip the installer permission** unless you have checked nothing else uses it.
- **Do not block the update hostname at the transport layer.** That is the same class of mistake as
blocking ad hosts (`pitfalls.md` P5): it fails offline/online transitions, needs a helper on the
machine, and is not a property of the artifact. It is also not required — the check is local code.
## Step 4: verify
Update paths are checked once at launch, so verification is a single clean cold start:
1. **Clear logs, cold start, and grep for the update vocabulary.** Expect **zero** hits. Not "quieter" —
absent. If the routine still runs, the string will still appear.
2. **Watch the screen through startup.** The failure mode is a modal, so a sample you did not look at is
not evidence (`environment.md` §look at the screen). A blocking dialog is unmistakable and it will be
on screen for a long time.
3. **Check the network.** With the check disabled there should be **no request** to the version/update
endpoint at startup. This is the strongest signal: it distinguishes "dialog suppressed" from "check
never happened".
4. **Exercise the manual path** if the app has a "check for updates" entry in settings — that is the
caller Layer 2 exists for.
5. **Confirm what you disabled is only updates.** The update routine sometimes shares a bootstrap with
other startup work. If you no-op'd a routine rather than its decision, verify that splash dismissal,
first-run setup, and the ad/consent flow still complete.
**Two-sample rule for the durability claim.** If you are asked whether the build *survives* a server-side
version bump, you cannot test that directly. What you can honestly claim is: *(a)* no version request is
issued at startup, and *(b)* the comparison no longer reaches its "newer" branch. State it in those terms
instead of asserting the future.
## Step 5: hot-update and resource channels
If the app can fetch a bundle at runtime, version checking is not the only way your change can be undone.
- **What to look for**: a startup fetch of a signed bundle/zip/config with a version or hash, a local
unpack directory, a dynamic plugin loader, or a JS/Dart/RN patch channel.
- **Why it matters**: even with updates disabled, a remotely fetched config can re-enable a feature, and
a fetched bundle can overwrite patched logic in the parts it owns.
- **Handle it like a data channel, not like an update**: identify what is fetched, whether the patched
behaviour is inside that payload, and cut the consumption point if it is. If the patched logic lives in
the shipped artifact and the fetched payload cannot override it, say so and move on — that is a
finding, not a gap.
Note the flip side: a **server-issued configuration** is the same category. If the thing you removed was
driven by remote config, the client patch holds only while the server keeps sending that config — see
`references/server-config-and-updates.md` §6 and `references/membership-and-limits.md` for the general
rule that *client-side enforcement can be patched, server-side authority cannot*.
## The honest answer for silent version gates
If the app degrades because the **server** decides the client is too old — no local dialog, just missing
data or errors on some screens — then there is no client-side fix. The only honest report is:
- the version the client reports to the server (with evidence of where it is read),
- the endpoint that makes the decision (with the observed response),
- and a clear statement that this is server-side authority.
Do not paper over it by forcing the version string at random read sites. If you do change a reported
version as an experiment, label it experimental, and remember that a version number is used in more
places than the one you are looking at.
## Delivery note: always state what was disabled
Update suppression changes behaviour a user can observe ("it never tells me about new versions"). Put it
in the delivery notes, in one line, together with anything else you neutralised. If the app also has a
legitimate reason to update (security fixes), say that the build will not pick them up automatically.
## Step 6: when the check is not where you expect
Three shapes that break the "find the Java comparison and flip it" plan. Recognise them early,
because each has a different cheapest remedy.
### The check is native
Symptom: you grep the whole dex tree for the version-comparison routine, the update endpoint, or the
dialog text and find **the method declared, and zero callers**. A `static native` update entry point
with no Java caller means the invocation lives in a `.so` (`registerNatives` from a static block is
the usual giveaway — see `code-virtualization-and-custom-linkers.md`).
Options, cheapest first:
1. **Attack the response, not the check.** Block or rewrite the update endpoint at the network layer,
or empty the version/config response. The native code still runs and still decides "no update".
This needs no native work at all and survives a rebuild — check this before reaching for a
disassembler.
2. **Send the check a version it likes.** If it compares against a value the app itself supplies
(a config key, a stored preference, a build field), setting that value is a one-line change.
3. **Patch the native comparison.** Last, because it is the most expensive and the most fragile.
Do not conclude "unpatchable" from "no Java caller". The gate usually reads a value you *can* control.
### The update hands off to a browser or a WebView
Very common in this category: the dialog's button fires an `ACTION_VIEW` intent at a download URL,
or loads it in an in-app WebView. Two consequences:
- **You will not find an installer call** in the app, so searching for `PackageInstaller`,
`REQUEST_INSTALL_PACKAGES` or a download service finds nothing. That is not evidence the dialog is
harmless; the browser does the install.
- **Logcat truncates the URL.** `ActivityTaskManager` prints `dat=https://host/...` and elides the
rest, so a naive grep gives you a host and no path. Do not build a plan on a truncated URL.
Ways to get the real URL, cheapest first:
| Approach | Notes |
|---|---|
| Hook `Intent` construction / `Context.startActivity` and log `intent.getDataString()` | Most reliable. A truncated log line is a *logging* limit, not a limit on what you can read |
| `dumpsys activity activities` / recents while the browser is still in the stack | Free; works only if the activity is still alive |
| The browser's own history database (root) | Survives the browser being closed; needs the right DB, and modern browsers may encrypt or prune it |
| Read the update *response* instead | Often easier: the URL came from a JSON field you can fetch or hook directly |
**Deliberately tapping the button is a legitimate probe** — but treat it as a measurement, not a
side effect: capture the intent (or the screen) in the same window, and know that on some devices a
second tap resumes an already-started download rather than re-issuing the intent.
### The gate is server-issued with a local fallback
`fetchEnabled()`-style helpers frequently **default to a local value on any failure** (non-200,
timeout, malformed body, signature mismatch). Two things follow:
- **Read the failure branch before you touch anything.** If failure ⇒ "no update" or "no ad", then
merely making the request fail is a complete fix, and it is far cheaper and more robust than
patching the comparison.
- **The fallback also tells you how to test offline.** Disconnect the device and relaunch: if the
gate disappears, the fallback is benign and you have a zero-code workaround as well as a
verification signal.
Conversely, if failure ⇒ "must update", failing the request makes things worse, and you must patch the
decision instead.
## Checklist
- [ ] Update vocabulary searched in **both** string encodings, in the right layer for the runtime
- [ ] The single update entry point identified (or its absence established, with evidence)
- [ ] Shape classified: optional / forced / silent / installer / hot-update
- [ ] Decision-level patch applied, not just the dialog
- [ ] Comparison branch neutralised as a second layer
- [ ] Manifest version and identity left alone
- [ ] Cold start: **zero** update log lines, no blocking modal, **no version request on the wire**
- [ ] Manual "check for updates" entry exercised
- [ ] Startup siblings (splash dismissal, ads, consent, first-run) still complete
- [ ] Hot-update / remote-config channel considered explicitly
- [ ] Delivery note states what is now disabled

View File

@@ -0,0 +1,131 @@
# Verification — what "done" means
The difference between a hobby patch and a usable deliverable is verification. "It assembles" and "the log is quiet" are not verification.
**Load this when:** you are about to claim the work is done, or you need to define done for a reviewer. It gives the claim ladder, the evidence chain, and what each deliverable form has to show before it counts.
## The claim ladder
Each rung is stronger evidence. Climb as high as the task requires, and **state clearly which rung you reached**.
| Rung | Claim | Evidence required |
|---|---|---|
| 0 | "The dex was edited" | Byte-level diff of the target method |
| 1 | "The structure is intact" | `dex_classdiff`: same classes, zero `ACC_INTERFACE` drift, unchanged classes byte-identical |
| 2 | "It builds and signs" | Signature verification passes |
| 3 | "It installs and launches" | Process alive, `logcat` free of fatal signatures |
| 4 | "The target behavior changed" | The specific feature observed changed (screen exercised, endpoint observed, UI confirmed) |
| 5 | "Nothing else regressed" | Adjacent features exercised: images, playback, lists, login, settings |
| 6 | "The mechanism is proven" | Independent evidence of *why* — e.g. the SDK's domains are never resolved because init never ran, or the SDK never created its working directory under the app's private storage |
| 7 | "The *distributed* artifact is the verified one" | Re-download the published file and compare its hash against the local build you tested |
**Rung 3 is the minimum for any deliverable. Rung 4 for a claim that the patch solved the problem. Rung 5 before handing it to a user. Rung 6 when the claim is "the subsystem is dead", not merely "the ad is hidden". Rung 7 whenever the deliverable leaves your machine — an upload can truncate, a build can be re-signed by a pipeline step, and the person downloading it has no way to know.**
## The control build rule
Before blaming a patch, build a control: **the same pipeline with zero patches**.
```bash
python scripts/repack.py --apk original.apk --dexdir <extracted> --out control.apk
```
- Control fails → your pipeline, the environment, or the device is at fault. Stop debugging the patch (`pitfalls.md` P9).
- Control passes, patched fails → the patch is implicated. Bisect: revert one dex at a time.
Do this **whenever something unexpectedly fails**, and at least once per project.
## Structural verification (automate it)
After every dex edit:
```bash
python scripts/dex_classdiff.py <original.dex> <patched.dex>
```
Expect exactly:
```
only_in_A=0 only_in_B=0
ACC_INTERFACE mismatch: 0
access_flags diff (same interface-ness): 0
```
Caveat, and it is an important one: **this check cannot detect code-item damage.** A whole-tree smali round-trip can pass every table check and still crash with `IncompatibleClassChangeError` (`pitfalls.md` P3). Passing this check is necessary, not sufficient.
The checks that *can* see further — instruction-length auditing, the equal-length-replacement blind spot, and verifier-level legality (`move-result*` adjacency) — are in `references/patch-audit.md`. Use them before claiming a patch landed.
Also confirm you changed what you intended and nothing more:
- Reverse the patched dex and inspect the target method.
- Compare unmodified classes byte-for-byte against the original where feasible.
## Runtime verification
```bash
adb -s <serial> logcat -c
adb -s <serial> shell "am start -n <pkg>/<activity>"
sleep 5; adb -s <serial> shell "pidof <pkg>" # empty == died
sleep 20; adb -s <serial> shell "pidof <pkg>" # same pid == stable
adb -s <serial> logcat -d -v brief | grep -E 'FATAL|VerifyError|IncompatibleClassChange|uncaughtException|Failure starting process'
```
**Sample the pid twice, spaced apart.** A single reading catches apps that die in a crash loop.
Failure signature → cause table lives in `references/environment.md`.
**Logcat is often not enough.** If the app installs a crash handler (友盟/UCrash/Bugly), the Java stack never reaches logcat. A process that dies with only `uncaughtException time: ...` and no stack **has crashed** — treat it as a failure, not as noise. Extraction techniques: `references/environment.md` §signal extraction.
## Behavioral verification (the part people skip)
Launching proves nothing about your change. Exercise it.
- **Ads removed** → open the screens that had ads (splash, home, detail, player, reward button). Confirm absence *and* confirm the screens still function. Use **three independent signals**, not one: the screens look right, the SDK's log tags disappeared, and the SDK never created its working directory under the app's private storage. An SDK that silently failed to fill an ad slot looks exactly like an SDK that was never initialised — only the last two signals tell them apart.
- **A gate patched** → walk the gated flow end to end.
- **A timeout/limit patched** → trigger it.
- **Data patched** → stop the app, clear/re-write the value, cold start, re-check (proves it is not an in-memory artifact).
**And then test the neighbours.** The most common real-world regression is a patch that fixed its target and quietly broke something unrelated:
- Images / cover art render?
- Video or audio playback works?
- Lists scroll and paginate?
- Login / session still valid?
- Settings screens open?
- Downloads still start?
This is exactly how a "generic image-card composable" patch removed all cover art while the ad still showed (`pitfalls.md` P6).
## Screenshot discipline
Capture before/after screenshots for each claim. Text descriptions are not evidence; a screenshot of the screen without the ad, alongside one from the control build with it, is.
## Reporting
```
Build: <path> <sha256>
Base: <original apk name/version>
Changes: <dex → what changed>, one line each
Structural: dex_classdiff result (per dex)
Signature: verified with --min-sdk-version 21 (v1/v2/v3); report the raw command, not just "ok"
Device: <model / android / abi / rooted?>
Runtime: pid stable over N s; no fatal signatures
Behavior: <screens exercised>, <before/after>
Regressions: <adjacent features checked>
Distribution: <where it was published> re-downloaded, sha256 matches the tested build
Rung reached: 0..7
Residual: <what is NOT fixed, and the exact reason>
```
**Be explicit about residuals.** "Ad X remains because it is delivered inside the screen's main data payload, and suppressing it at the transport layer takes the whole screen down" is a useful, honest result. Silently omitting it is not.
**Separate the two audiences.** The artifact goes to an end user; the analysis report goes to whoever asked for the work. User-facing notes stay neutral and practical — package name and version, install prerequisites, the signature-conflict warning (a different signer means the old install must be removed first), the checksum, and a scope/disclaimer line. Keep the mechanism, the patch points, and the reasoning in your own report, not in the file the end user opens.
## Anti-patterns
- Declaring success from a successful assembly.
- Claiming "no ads" from a quiet logcat when the SDK never printed anything either way.
- Concluding from a single launch without exercising the feature.
- Quoting emulator results as device results.
- Reporting a fixed feature without checking adjacent features.
- Hiding a known residual failure.
- Blaming your own patch for a failure the **unmodified original** also has — run the control build first.
- Accepting a UI automation result as proof when the interaction may never have fired: verify field contents and look for the actual request, not just the screen.
- Treating a feature-scoped network error as a patching problem before checking the server's certificate (`references/tls-and-cert.md`).

View File

@@ -0,0 +1,237 @@
# Differential hardening (the known-plaintext route to a private opcode table)
Load this when the dump is a **real Dex VMP** — bodies present, decoding as nonsense to a
dalvik disassembler, a native interpreter loop behind them — and you have decided the
private opcode space is worth attacking rather than walking away from. The neighbouring
file owns the diagnosis (`advanced-unpacking.md` §The honest boundary: real Dex VMP) and
the sentence that sends you here. This file owns what happens next, and — more of it than
is comfortable — what does not.
**Strength note, read before trusting any number here.** Every mechanism below was
exercised on locally built fixtures, and the closed loop is measured: `simulate` relabels
a fixture through a known bijection and `compare` re-derives that table **exactly, 218 of
218 emitted opcodes, zero wrong entries, zero fabricated entries** (`references/evidence-summary.md` §The capability matrix).
What was **not** exercised is the other half of the premise: **no third-party hardening
platform was contacted**. The assumption that a real engine performs a per-opcode
substitution at stable instruction length is **inferred** from public VMP write-ups and is
exactly the assumption this method can fail on. The cost table's platform column is
therefore reasoning from how those platforms are distributed, not from a measured upload.
## 1. The method, and the one thing it needs to be true
The hardened body cannot tell you where its instruction boundaries are: its opcodes are
private, so the format specification's length table does not apply to it. That is the
whole obstacle — a disassembler that cannot decide where instruction *n* ends cannot name
instruction *n+1*.
The differential dissolves it with an oracle you control. Compile a fixture whose every
instruction you know; get the same platform to harden it; then **take the original's
instruction boundaries and project them onto the hardened bytes**. Original offset
`0x14` was a `mul-int` occupying 2 code units, so hardened bytes `0x14..0x17` are the same
instruction, re-encoded. Read the hardened opcode byte at each projection and you have a
`(original opcode → private byte)` sample. Aggregate over thousands of instructions and
the substitution table falls out.
Three preconditions, and they are not negotiable:
| Precondition | Why it must hold | What it looks like when it fails |
|---|---|---|
| **The hardened body keeps the same length** | The projection is a stencil; a length change slides every boundary after the first edit | `compare` reports `resized` for the whole method and refuses the run |
| **The substitution is per-opcode, not per-method** | One private byte per original opcode is what makes the table a table | Every original opcode claims the same private byte (`compare` → `NOT-USABLE`, stub shape) |
| **The body is still in the dex** | Alignment needs two byte streams | Method has `code_off == 0` (`compare` reports `stripped`) |
The third is the one that kills the method most often in practice: a VMP that *extracts*
bodies to a native interpreter leaves nothing to align. The differential route then has no
input at all, and no amount of clever fixture design creates one. Check for it before
spending any time here.
## 2. Cost table — which links can be automated, and which cannot
This is the part the method's public descriptions leave out. The chain is six links; two
of them are unavoidably human, and pretending otherwise is how a "harness" ships that has
never once been run against a real target.
| # | Link | Automation | Where a human must stand | Time cost | Failure mode |
|---|---|---|---|---|---|
| 1 | Compile the coverage fixture into a dex + APK | **Full** — `scripts/vmp_diff_harness.py build`, measured | none | ~40 s on this machine (javac + d8 + aapt2), ~1 s of it the script | toolchain absent or version-drifted; `d8` rejects a non-existent `--output` directory, `aapt2 link` rejects `android:` attributes without `android.jar` |
| 2 | **Submit to the hardening platform** | **None. Not automatable.** | 100 % — a browser, an account, sometimes a queue and a review | minutes to **days**; the platform decides | **the binding constraint of the whole route.** No public API; per-account rate limits; uploads are frequently gated behind registration; the artifact often comes back as a download page rather than a file path. A synthetic fixture may simply be **refused** (no real app shape, no certificate, no package identity) |
| 3 | Retrieve the hardened output and extract its dex | Partial — if the output is an APK, `unzip` + `dexutil.load_dex` handles it | download and hand-off | seconds, once the file exists | output is an APK whose dex is *also* encrypted, or the platform returns only a repacked APK with a native loader and no readable dex |
| 4 | Derive the correspondence | **Full** — `compare`, measured end to end | none | **sub-second** on a 3,065-instruction fixture | alignment slips (see); engine is not a pure relabelling |
| 5 | Prove the derived table | Partial — the mechanical half is scripted and measured; the judgement is not | decide whether the held-out check was passed or merely not attempted | minutes | a table that is *self-consistent* but describes a stub () |
| 6 | Render Smali from the restored stream | Full — `emit-smali`, measured | none | sub-second | table has holes; restored stream decodes short of the method end |
**Conclusion, stated plainly: do not build a fully automatic pipeline.** Link 2 is
irreducibly manual — the platform has no interface a script can drive, and its output
cadence is not one a pipeline can wait on. The right shape is what this repository ships:
- links 1, 4 and 6 as **reliable local tools** (they are deterministic and were measured);
- link 5 as **a checklist plus one scripted test** () — the judgement stays human;
- link 2 as **a documented manual step**, with its cost stated up front, so nobody writes
an orchestrator that waits forever on a web form;
- and links 4–5 **self-verifiable without the platform at all**, which is what `simulate`
exists for. You can prove your comparison logic against a known table today; you cannot
prove the engine's behaviour until a human uploads a fixture.
The practical consequence: the deliverable of this route is an **analysis report with a
stated boundary**, not a pipeline. Say so before starting.
## 3. The coverage fixture — what a compiled probe can and cannot reach
A Java-compiled fixture reaches **218 of the 224 named opcodes in this repository's format
table (97.3 %)** — that number is measured, not estimated, and the six it misses are
structural rather than incidental. The reason to care is direct: **an opcode your fixture
never emits leaves no known plaintext, so its substitution is unreadable by construction.**
| Missed opcode | Why a javac + d8 fixture cannot reach it |
|---|---|
| `const-string/jumbo` (0x1b) | needs a `string_ids` index > 65535, i.e. a fixture shipping 65,536+ string constants |
| `goto/32` (0x2a) | needs a >32767-code-unit backward jump; javac refuses the method first with `error: code too large` — its own 64 KB per-method bytecode cap. **Measured** on a generated 33,000-statement loop |
| `const-method-handle` (0xfe), `const-method-type` (0xff) | no Java-language literal exists for these constants; reachable only via hand-written smali or direct dex construction |
| `move-object/16` (0x09) | needs **both** registers ≥ 256; the object frame pushes the source past 256 but d8 encodes that as `move-object/from16` |
| `invoke-custom/range` (0xfd) | d8 emitted the 35c form for every lambda shape tried, including a six-parameter one |
What the fixture **does** cover, by construction, and the design decisions that get it
there (each of these was a measured gap before it was closed):
- **Three-register forms.** javac emits the `/2addr` form whenever the destination is one
of the operands, so `sub-int`, `div-int`, `rem-int`, `and-int`, `or-int`, `xor-int`,
`shl-int`, `shr-int`, `ushr-int` and their long/float/double counterparts are **absent**
from ordinary code. Three distinct locals per operation is what forces them into the
stream; the `/2addr` forms then need the accumulator spelling. Both are wanted.
- **Literal widths.** `div-int/lit16` needs a divisor in 128..32767; `and-int/lit8` needs a
mask inside −128..127. Writing `& 255` produces `and-int/lit16` and silently skips the
lit8 form.
- **Per-type field access.** `iget-wide`, `iget-object`, `iget-boolean`, `iget-byte`,
`iget-char`, `iget-short` and the `sget` set all appear only when the load targets a
**fresh local**. Accumulating into an existing register is where d8 narrows the access.
- **`/range` invokes.** The 35c invoke forms carry at most five argument registers, so a
seven-register call (`this` plus six ints) is what makes the `/range` encoding the only
legal one.
- **Wide registers and long jumps** — `move/16`, `move-wide/16`, `move-object/from16`,
`goto/16` — have **no hand-written form at all**. They exist only past a register-number
(≥256) or branch-distance (>127 code units) threshold, so the generator emits bulk
source: 300 int locals, 140 long locals, 160 object locals, a 200-statement loop body.
Measured yield: `move/16` ×46, `move-wide/16` ×20, `move-object/from16` ×161, `goto/16` ×1.
- **Payloads and structure**: `packed-switch`/`sparse-switch` with their payload blocks,
`fill-array-data`, `filled-new-array/range`, try/catch ranges with `move-exception`,
`monitor-enter`/`monitor-exit`, `instance-of`/`check-cast`, and `invoke-custom` /
`invoke-polymorphic` (which is why `d8 --min-api 26 --no-desugaring` is the default —
desugaring replaces lambdas with synthetic classes and the two opcodes disappear).
**Report the coverage every time.** `build` prints it, `audit` recomputes it for any dex.
A differential run whose coverage is 60 % produces a table that is 40 % missing, and
nothing in the output will say so unless you look at this number.
## 4. Extracting the correspondence
`compare ORIGINAL HARDENED` does four things, and the first three are diagnostics that
decide whether the fourth is meaningful:
1. **Pair methods** by `(class, name, descriptor)` and classify each pair. `aligned` means
both sides have a `code_item` of **identical `insns_size`** — the only state in which
projection is legal. `resized` means the length moved. `stripped` means the hardened
method has `code_off == 0`. `missing`/`added` mean the method set itself moved.
2. **Project and sample.** For each aligned pair, walk the original's decoded instruction
list; for instruction at original offset *o*, read the hardened byte at
`hardened.insns_off + (o − original.insns_off)`. Record `(original opcode → hardened
byte)` with a count.
3. **Aggregate and audit the aggregate.** Per original opcode: one candidate byte and the
row is `high`; more than one and the row is a `conflict`. Then the **reverse
direction** — build `private byte → set of original opcodes` and flag every private byte
claimed by more than one original. This reverse pass is not a nicety; it is the only
thing that catches the stub shape ().
4. **Emit a run-level verdict**, because a table is dangerous without one: `usable`,
`partially-usable`, `not-usable`, or `not-applicable` with the reason. A `not-applicable`
run exits non-zero.
Unreadable opcodes are reported as `undetermined` with the reason — either the fixture
never emitted them () or the dex slot is unallocated. They are **not** guessed at.
## 5. Proving the table — the part that decides whether any of this is real
A candidate table is a hypothesis. Four checks, in increasing order of what they can
falsify:
**a. Closed-loop regression — measured, and the strongest available without a platform.**
`simulate` relabels a fixture's opcodes through a bijection whose ground truth it writes
out; `compare` must re-derive that table from the two dexes alone. Measured on this
repository's fixture: **218 rows derived, 218 of 218 emitted opcodes recovered, zero
entries wrong, zero entries fabricated, zero non-injective private bytes, verdict
`usable`.** Any change to the comparison logic that breaks this check is a regression, and
the check needs no platform, no device and no network.
**b. Injectivity — the check that catches a confident wrong answer.** Every private byte
must be claimed by exactly one original opcode. Measured against the stub shape (bodies
replaced by an equal-length `return-void` fill): per-opcode reading looks *perfect* — 218
rows, all `high`, zero conflicts — because every original opcode maps to the same byte.
Only the reverse direction exposes it: one private byte claimed by **218** originals →
`NOT-USABLE`. A table produced without this check would have been silently worthless.
**c. Held-out consistency.** Derive the table from one subset of methods, then use it to
disassemble a **different** subset that contributed nothing to the derivation. A correct
table restores the instructions such that the walk lands exactly on
`insns_off + insns_size × 2` and every branch target lands on an instruction boundary. A
wrong or incomplete table desynchronises and says so. `emit-smali`'s
`instructions restored` / `unmapped-opcode stops` counters are the readout: on the measured
fixture, 3,065 restored and 0 stops.
**d. Cross-check against a second, independent recovery.** The same private opcode space
observed through a *different* route — an in-memory dump of the original dex alongside the
hardened one, or the `/proc/<pid>/mem` route in
`advanced-unpacking.md` §Dumping when frida is refused — is the only evidence that the
platform's transformation is the one you assumed. **Not done here**; it requires a real
VMP sample and a real platform, and is the honest gap in this file.
Note what none of these checks establish: that the engine's substitution is *stable across
inputs*. Fixture-derived tables have been reported in public work to drift per
vendor-version and per hardening pass. Re-derive per target; never cache a table.
## 6. Generating Smali from a restored stream
`emit-smali HARDENED_DEX --table TABLE.json` restores opcode bytes through the table and
renders method bodies as an annotated smali **reading skeleton**:
- Boundaries come from the table itself — once a byte maps back to a standard opcode the
standard length table applies to that instruction, so the walk is self-bootstrapping.
A byte with no table entry stops that method's walk, and the count of such events is
printed as the honest coverage measure.
- `.class` / `.super` / `.source` / `.method` headers are real, with access flags decoded
from `access_flags`, so the skeleton is readable as smali rather than as a hex dump.
- Payload blocks (`packed-switch-payload` etc.) are stepped over by their own layout, not
treated as opcodes.
**It is explicitly not assembliable, and the output says so in its first ten lines.**
Recovered: instruction boundaries and opcode names. Not recovered: register numbers,
field/method/string indices as the engine rewrote them, and any register re-allocation —
**no map for those exists in this method at all.** Feeding the skeleton to a smali
assembler produces an empty method, which looks like success and is not.
## 7. When this route does not work — the honest retreat
| What `compare` reports | What it means | Do this |
|---|---|---|
| `verdict: not-applicable`, `stripped` > 0 | bodies left the dex for a native interpreter | the differential has no input. Stop. Return to `advanced-unpacking.md` §The honest boundary: real Dex VMP and price the island honestly |
| `verdict: not-applicable`, `resized` > 0 | the engine's stream has its own length table | the projection premise is false. Stop rather than guessing boundaries |
| `verdict: not-usable`, one private byte shared by many originals | bodies replaced by a common stub | the engine did not relabel per-opcode here. Stop |
| `verdict: partially-usable`, conflicts ≤ 30 % | partial relabelling, or alignment slipping on some methods | usable only for the `high` rows; say so and stop at the unreadable ones |
| `usable`, but coverage was low | a complete table over an incomplete opcode set | usable, with the uncovered opcodes named as unreadable |
And the retreat that outranks all of them: **the cheaper question is whether the behaviour
you need exists outside the virtualized island.** A hook at the method's Java boundary, an
emulated call to the routine rather than a reading of it
(`emulation-and-rpc.md`), or a server-side answer frequently reaches the goal without ever
paying for the island. Weeks of table recovery is the expensive way to ask a question a
boundary hook answers in an hour.
## 8. Decision summary
| Observation | Action | Where |
|---|---|---|
| Bodies present, decode as nonsense | Diagnose real VMP before anything else | `advanced-unpacking.md` §The honest boundary: real Dex VMP |
| Need the private opcode mapping | Build the coverage fixture, then submit it by hand | `scripts/vmp_diff_harness.py build`; §2 of this file |
| Asked to "automate the whole chain" | Say which link cannot be automated and what it costs | §2 — the platform step is manual, by construction |
| Fixture coverage below ~90 % | Add the missing opcode classes before submitting |, and the `audit` report |
| Two dexes in hand | Derive the candidate table, read the verdict first | `scripts/vmp_diff_harness.py compare` |
| About to trust a derived table | Run the closed loop, the injectivity check and a held-out subset | §5 — a/b/c are scripted |
| Table complete | Render the skeleton; expect operands, not registers | `scripts/vmp_diff_harness.py emit-smali` |
| `resized` / `stripped` / stub verdict | Stop and say so | §7 |

View File

@@ -0,0 +1,353 @@
/*
* anti_detect_probe.js -- an OBSERVER. It changes nothing.
*
* Purpose: answer "if a detector is running, what is it looking at?" without
* answering it with a patch. That division is deliberate and is the point of this
* file: an observation module must not also intervene (no exit_blocking, no
* Interceptor.replace of a detection function, no NOPing). A probe that patches
* while it watches produces a process whose behaviour you can no longer use as
* evidence about the original -- the death you prevented is exactly the signal you
* were there to date.
*
* What it reports, as one `RESULT=` block:
* - the mapped libraries, so a detection .so can be named rather than guessed
* - every open/openat/fopen/access/stat on a path, with the *caller module+offset*
* for the ones that look like environment probing (/proc, /sys, magisk, su,
* frida, xposed). The caller offset is the deliverable: it is what turns
* "something checks root" into "0x1cef8 in libX.so checks root".
* - kills: kill/tgkill/exit/exit_group, with caller, so a self-destruct is
* attributed to a module instead of to the platform
* - thread creation (pthread_create/clone) with the real entry point, because a
* polling detector thread is a different shape from an in-line check
* - dlopen/dlsym, so a runtime-resolved symbol is visible
* - the process's own view of "am I instrumented": TracerPid, frida-named maps,
* frida-named threads, and TCP listeners (a detector often reads these first)
*
* Load it with spawn (-f) to see the early phase, attach (-p) to see the steady
* state. On this repository's test ROM attach-by-name fails, so use --pid.
*
* Usage (from the kit's own driver):
* python scripts/run_probe.py scripts/anti_detect_probe.js <pid> --via usb --log probe.log
* Usage (raw frida, explicit pid because -n does not resolve on this ROM):
* frida -H 127.0.0.1:27099 -p <pid> -l scripts/anti_detect_probe.js
*
* Config comes from CONFIG below or from RPC:
* script.exports.config({ watchMs: 8000, pathFilter: '/proc' })
*/
var CONFIG = {
// How long to keep collecting before printing the RESULT block. 0 = print only
// on the stop() RPC, for an interactive session.
watchMs: 12000,
// Stream a heartbeat every N ms. This exists because of a measured failure mode:
// a detector that kills the process inside the watch window means a probe that
// only reports at the end reports nothing at all -- the one run that mattered
// produced no data. The heartbeat says "the script was still alive at T", which is
// what separates "the target died" from "my script never armed".
heartbeatMs: 1000,
// Stream a line the moment a category fires for the first time, so the last
// events before a death are on the wire already.
streamFirstHit: true,
// Substrings that mark a path access as environment probing. Everything else is
// still counted, but not individually reported (it would drown the interesting
// lines -- a busy app opens thousands of files).
pathFilter: ['/proc', '/sys', 'magisk', 'supersu', 'frida', 'xposed', 'gum-js',
'/data/local/tmp', 'busybox', 're.frida', 'linjector'],
// Report process-wide events for every thread (false) or only the main thread.
allThreads: true,
// Hard cap on reported lines per category, so a chatty target cannot flood the log.
maxLinesPerCategory: 60,
};
if (typeof CONFIG_OVERRIDE !== 'undefined' && CONFIG_OVERRIDE.anti_detect_probe) {
Object.assign(CONFIG, CONFIG_OVERRIDE.anti_detect_probe);
}
var counts = {};
var lines = {};
var started = Date.now();
function bump(cat, msg) {
counts[cat] = (counts[cat] || 0) + 1;
if (!lines[cat]) lines[cat] = [];
if (lines[cat].length < CONFIG.maxLinesPerCategory) lines[cat].push(msg);
if (CONFIG.streamFirstHit && counts[cat] === 1) {
// On the wire immediately: if the target dies a moment later, this is the only
// trace of the event that existed.
send({ type: 'LIVE', cat: cat, msg: msg, t: Date.now() - started });
}
}
function backtraceOwners(ctx) {
// Module+offset for every return address inside a mapped module. The offset is
// what a static tool needs; the module name is what tells a shell library from
// libc. The link register is read first because on arm64 it is where the caller
// of the hooked export lives, and it survives even when Thread.backtrace cannot
// unwind (Frida's unwinder needs the target's frames to be intact, which is not
// guaranteed in a constructor or a signal path).
try {
var owners = [];
var lr = null;
try { lr = ctx.lr; } catch (e) { lr = null; }
if (lr) { owners.push(describe(lr, 'lr')); }
var bt = Thread.backtrace(ctx, Backtracer.ACCURATE);
for (var i = 0; i < bt.length && owners.length < 5; i++) {
owners.push(describe(bt[i], 'bt' + i));
}
return owners.length ? owners : ['no-unwind'];
} catch (e) {
return ['bt-failed:' + e.message];
}
}
function describe(addr, tag) {
try {
var d = DebugSymbol.fromAddress(addr);
if (d && d.moduleName) {
var base = Module.findBaseAddress(d.moduleName);
var off = base ? addr.sub(base) : null;
return d.moduleName + (off ? '+0x' + off.toString(16) : '+?') + '(' + tag + ')';
}
if (d && d.name) return d.name + '(' + tag + ')';
return 'anon:' + addr + '(' + tag + ')';
} catch (e) {
return 'addr?' + addr;
}
}
function ownerString(ctx) {
// Takes the context explicitly. `this` inside a Frida Interceptor callback is not
// reliably set when the callback is an anonymous function expression, and the
// measured symptom of getting this wrong is a log full of
// "cannot read property 'context' of undefined" -- which reads like a hooking
// failure and is actually a JavaScript binding mistake. Callers pass `this.context`.
if (!ctx) return 'no-ctx';
return backtraceOwners(ctx).join(' | ');
}
function looksInteresting(p) {
for (var i = 0; i < CONFIG.pathFilter.length; i++) {
if (p.indexOf(CONFIG.pathFilter[i]) >= 0) return true;
}
return false;
}
function hookExport(modName, exp, cat, argIndex) {
var addr = Module.findExportByName(modName, exp);
if (addr === null) return false;
try {
Interceptor.attach(addr, {
onEnter: function (args) {
try {
var p = null;
if (argIndex >= 0) p = args[argIndex].readUtf8String();
var who = ownerString(this.context);
if (p === null) { bump(cat, exp + '() <- ' + who); return; }
if (looksInteresting(p)) {
bump(cat, exp + '("' + p + '") <- ' + who);
} else {
counts[cat + '_quiet'] = (counts[cat + '_quiet'] || 0) + 1;
}
} catch (e) { /* unreadable pointer: count it, never throw out of a hook */ }
}
});
return true;
} catch (e) {
bump('hook_errors', modName + '!' + exp + ': ' + e.message);
return false;
}
}
function hookTerminators() {
// A detector's self-destruct is often a direct libc call; the caller offset is
// what identifies it. Note: a module that issues `svc #0` itself will NOT show up
// here -- the absence of a report plus a death is itself the signal that a libc
// hook cannot see it (scripts/svc_scan.py decides that statically).
['exit', 'exit_group', '_exit', 'abort', 'kill', 'tgkill', 'raise', 'pthread_kill']
.forEach(function (fn) {
var a = Module.findExportByName('libc.so', fn);
if (a === null) return;
try {
Interceptor.attach(a, {
onEnter: function (args) {
bump('termination', fn + '(' + args.join(',') + ') <- ' +
ownerString(this.context));
}
});
} catch (e) { bump('hook_errors', fn + ': ' + e.message); }
});
}
function hookThreadCreation() {
var pc = Module.findExportByName('libc.so', 'pthread_create');
if (pc !== null) {
try {
Interceptor.attach(pc, {
onEnter: function (args) {
bump('thread_create', 'pthread_create entry=' + args[2] + ' <- ' +
ownerString(this.context));
}
});
} catch (e) { bump('hook_errors', 'pthread_create: ' + e.message); }
}
// clone() is how a detector bypasses the pthread wrapper; args[3] points at the
// argument block whose fifth word holds the real entry point on arm64.
var cl = Module.findExportByName('libc.so', 'clone');
if (cl !== null) {
try {
Interceptor.attach(cl, {
onEnter: function (args) {
var extra = '';
try { extra = ' realEntry=' + args[3].add(96).readPointer(); } catch (e) {}
bump('thread_create', 'clone()' + extra + ' <- ' + ownerString(this.context));
}
});
} catch (e) { bump('hook_errors', 'clone: ' + e.message); }
}
}
function hookLoader() {
['dlopen', 'android_dlopen_ext', 'dlsym'].forEach(function (fn) {
var a = Module.findExportByName('libc.so', fn);
if (a === null) return;
try {
Interceptor.attach(a, {
onEnter: function (args) {
var s = null;
try { s = args[0].readUtf8String(); } catch (e) {}
bump('loader', fn + '("' + s + '") <- ' + ownerString(this.context));
}
});
} catch (e) { bump('hook_errors', fn + ': ' + e.message); }
});
}
function selfView() {
// What the process can see about itself right now. This is evidence about the
// *environment*, not about the detector: it says what is available to be found,
// which is the other half of attributing a death.
var out = {};
var files = ['/proc/self/status', '/proc/self/maps', '/proc/self/task',
'/proc/net/tcp', '/proc/net/tcp6', '/proc/self/cmdline'];
out.files = {};
files.forEach(function (f) {
try {
var content = File.readAllText(f);
if (f === '/proc/self/status') {
var m = content.match(/TracerPid:\s*(\d+)/);
out.tracerpid = m ? m[1] : 'absent';
// A detector may match any of these names; report which are present so a
// "frida is visible here" claim is not made from memory.
out.status_flags = (content.match(/^(Name|State|Seccomp):.*$/gm) || []).join(' ; ');
} else if (f === '/proc/net/tcp' || f === '/proc/net/tcp6') {
var listening = (content.match(/^[^\n]*\s0A\s/gm) || []).length;
out[f] = 'lines=' + content.split('\n').length + ' listening=' + listening;
} else if (f === '/proc/self/maps') {
var hits = (content.match(/frida|gum|gadget|linjector/gi) || []);
out.frida_named_maps = hits.length;
out.map_sample = hits.slice(0, 5);
} else {
out[f] = 'readable (' + content.length + ' B)';
}
} catch (e) {
out[f] = 'unreadable: ' + e.message;
}
});
try {
out.current_thread = Process.getCurrentThreadId();
out.threads = Process.enumerateThreads().map(function (t) { return t.id; }).slice(0, 24);
} catch (e) { out.threads = 'enumerate failed: ' + e.message; }
try {
out.modules_named = Process.enumerateModules()
.filter(function (m) { return /frida|gum|gadget|linjector/i.test(m.name); })
.map(function (m) { return m.name + '@' + m.base; });
} catch (e) { out.modules_named = []; }
return out;
}
function report() {
var out = {
kind: 'anti_detect_probe',
mode: 'observer-only (this script never changes the target)',
ranMs: Date.now() - started,
counts: counts,
details: lines,
selfView: selfView(),
};
send({ type: 'RESULT', payload: out });
console.log('\n=== RESULT (probe) ===');
console.log('ranMs=' + out.ranMs);
Object.keys(counts).sort().forEach(function (k) {
console.log(' ' + k + ' = ' + counts[k]);
});
var sv = out.selfView;
console.log(' tracerpid=' + sv.tracerpid + ' frida_named_maps=' + sv.frida_named_maps +
' frida_modules=' + JSON.stringify(sv.modules_named));
var interesting = ['termination', 'thread_create', 'env_probe', 'loader'];
interesting.forEach(function (cat) {
if (!lines[cat]) return;
console.log('\n=== ' + cat + ' ===');
lines[cat].forEach(function (l) { console.log(' ' + l); });
});
console.log('\n=== how to read this ===');
console.log(' a hook that reports nothing is a finding about the hook (is the export');
console.log(' really there? did the code run?) -- establish the pipeline on a control');
console.log(' target before concluding "no detection".');
}
function start() {
hookExport('libc.so', 'open', 'env_probe', 0);
hookExport('libc.so', 'openat', 'env_probe', 1);
hookExport('libc.so', 'fopen', 'env_probe', 0);
hookExport('libc.so', 'fopen64', 'env_probe', 0);
hookExport('libc.so', 'access', 'env_probe', 0);
hookExport('libc.so', 'stat', 'env_probe', 0);
hookExport('libc.so', 'stat64', 'env_probe', 0);
hookExport('libc.so', 'lstat', 'env_probe', 0);
hookExport('libc.so', 'readlink', 'env_probe', 0);
hookExport('libc.so', 'opendir', 'env_probe', 0);
hookExport('libc.so', 'scandir', 'env_probe', 0);
hookExport('libc.so', 'strstr', 'strstr_probe', 1);
hookExport('libc.so', 'strcmp', 'strcmp_probe', 1);
hookTerminators();
hookThreadCreation();
hookLoader();
console.log('=== anti_detect_probe armed (observer only) ===');
var sv0 = selfView();
console.log('selfView at arm time: ' + JSON.stringify(sv0));
send({ type: 'LIVE', cat: 'armed', t: 0, msg: 'script armed', selfView: sv0 });
if (CONFIG.heartbeatMs > 0) {
// The heartbeat is the liveness proof. Its absence at time T means the script
// (or the whole process) was gone before T, which is a different finding from
// "the target was quiet".
setInterval(function () {
send({ type: 'LIVE', cat: 'heartbeat', t: Date.now() - started,
counts: JSON.parse(JSON.stringify(counts)) });
}, CONFIG.heartbeatMs);
}
if (CONFIG.watchMs > 0) {
var t = setTimeout(report, CONFIG.watchMs);
setTimeout(function () { clearTimeout(t); }, CONFIG.watchMs + 1);
}
}
rpc.exports = {
config: function (patch) { Object.assign(CONFIG, patch); return CONFIG; },
status: function () { return { counts: counts, ranMs: Date.now() - started }; },
report: function () { report(); return true; },
// Kept for interface parity with the kit's other probes; nothing is patched.
patch: function () { return 'observed-only: nothing patched'; },
};
if (Java.available) {
// Only to record what the Java side is, not to hook it: an observer that hooks
// Java would change the timing it is there to measure.
try {
Java.perform(function () {
console.log('java classloader ready, app=' +
(Java.androidVersion ? 'Android ' + Java.androidVersion : 'unknown'));
});
} catch (e) { /* Java optional */ }
}
setImmediate(start);

Some files were not shown because too many files have changed in this diff Show More