Reconstruction pass: make the gates tell the truth, make the package self-contained, keep the one-hop symptom index

An external audit of this repository landed on four structural findings, and this pass is the
response to them. Everything below was re-measured on this tree before being acted on; two of the
audit's claims needed a correction of my own, and both corrections are noted in place.

**The gates were optimistic, and one of them did not read content at all.**

  doctor.py    the capability table was hard-coded (`repack + sign = bool(java) and python`), so a
               machine with a JDK and no zipalign/apksigner was told it could re-sign; the script
               inventory was a hand-written list of 28 against 51 files on disk, omitting the nine
               most important scripts. It now scans the directory (`registered/checked = 52 / 52`,
               unjudgeable entries counted as `unknown`, never skipped) and resolves capability as a
               real requires-closure, reporting BLOCKED with the missing piece and an installable
               next step. Measured: the old doctor reports `[OK] repack + sign` on a JDK-only PATH
               where keytool, jarsigner, zipalign, apksigner and all eight smali jars are absent.
               The report is allowed to look bad; that is the point.
  check_commands.py (new)
               anchor checks prove a section exists, never that a command runs. This parses each
               script's argparse table with `ast` and checks every documented invocation against it.
               146 commands / 41 interfaces, 0 drift. It caught `sig_probe.py --live --pkg`, which
               does not exist -- the reference had been wrong since it was written.
  check_routing.py (new)
               the symptom index now has one home (`SKILL.md`, because symptom -> file must stay one
               hop) and the inventory has another (`references/routing.md`). This gate proves the
               mirror agrees, that every reference file is named, and that every script is named.

**so_constpatch.py was corrupting APKs it rebuilt.** The writer copied filename and timestamp onto a
fresh ZipInfo and recompressed the whole archive: 435-entry real package, `extra` lost on 205 entries,
`external_attr` rewritten on all 435, 132 untouched entries recompressed, and `resources.arsc` pushed
to an unaligned offset. Android's own `zipalign -c -v 4` fails the old output and passes the new one;
a 26-case fixture suite pins it, and `extractNativeLibs="false"` is now refused by default with a
pointer at `repack.py`. Fixing it exposed the same defect in `repack.py`, where `^lib/[^/]+\.so$`
cannot match `lib/arm64-v8a/*.so` -- so its alignment handling had never once fired on a real APK.

**The package was not self-contained.** 59 references pointed at the repository root, which `npx
skills add` does not install. They now converge on `references/evidence-summary.md` (capability,
conclusion, strength, evidence that ships) plus `evidence/` with the machine-readable companions: 8
references remain and each is explicitly labelled "repository root, not shipped". A drill in which
the new files were copied to an isolated directory and asked three questions answered all three.

**SKILL.md: 572 lines / 15,733 tokens -> 398 lines / ~10,900 tokens**, without losing the two things
that had to stay. The symptom index remains inline; the reference and script indexes moved to the
on-demand routing file. The freed budget went into making the rules *stronger*, not shorter: the
constraints section is now a table of thirteen non-negotiable constraints each paired with the check
that catches a violation, and the tooling obligations are instructions whose violation is a defect
(hedging language in SKILL.md: 1 line, down from a paragraph of hedged advice). Long references gained
a navigation head so a reader can decide from the top whether to load one.

**New: an executable test layer.** `tests/` (unit, CLI contract, no-device integration) plus
`pytest.ini`, `tests/run_tests.py`, a minimal CI job, and `evals/evals.json` written but never run --
stated as such in the file. 608 passed / 158 skipped / 25 xfailed. The 25 xfails are a work order, not
a pass: thirteen scripts report a missing input by exiting 1 instead of 2, seven let a traceback
escape, four treat `--help` as an error, and `preflight --json` lacks a `status` field. A regression
row for `dex_patch_bytes.py` reproduces a previous artifact byte-for-byte
(`67ae1e58e7a44777e5ac8f9f823ab21e…`) and additionally pins the four bytes that may move.

**Two real defects the new tests found in the tooling itself.** `check_repo.py` decoded subprocess
output with the locale encoding, so on a GBK console a script printing an em dash in its usage line
was reported as crashed; it now decodes UTF-8 explicitly. And a CLI contract test drove a *real
attached device* through `coldstart.py` -- logcat, screenshots, five orphan `adb logcat` processes --
which is fixed twice over: child processes run in a private temporary cwd, and the fifteen
device-bound scripts are never executed beyond `--help`/argument parsing.

**Quoting on the device side is now one implementation.** `devsh.su` escaped only double quotes, so
`;`, `$( )` and backticks in a caller-supplied value still reached the device shell while
`coldstart.Device.shell` quoted correctly. Both use `scripts/device_shell.py` now: POSIX single-quote
escaping, argv arrays by default, and identifier validation that refuses a malformed package, path or
component with exit 2 instead of a traceback. The integration test that used to pin the *defect* (it
asserted `su -c "…"` with `\"` escapes) now asserts the property, across five metacharacter payloads.

Gates: check_repo 0 problems (including the content-reading identity scan over git ls-files),
check_refs 0 dangling / 0 warning, check_routing coherent (53 symptom / 45 reference / 54 script rows),
check_budget 0 failure / 0 warning with the always-loaded body now measured in tokens rather than in
"narrative lines with the index excluded", check_commands 146 checked / 0 drift, pytest 608 passed.

Not done, and stated rather than implied: CI has never run on a runner; the evals have never been
executed; the 25 xfails are open; no device-side install/launch was performed in this pass.
This commit is contained in:
inliver233
2026-09-22 01:37:51 +08:00
parent 6cffc3d2ac
commit 2beade67f2
81 changed files with 10332 additions and 708 deletions
+86
View File
@@ -0,0 +1,86 @@
# 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, pure parsers, CLI contracts and no-device flows. Nothing more is
# claimed.
name: ci
on:
push:
pull_request:
permissions:
contents: read
jobs:
test:
name: python ${{ matrix.python-version }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.13"]
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@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install test dependencies
run: python -m pip install --upgrade pip pytest
- 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
+34 -6
View File
@@ -206,6 +206,22 @@ references/ loaded on demand, one topic each
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
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
@@ -364,6 +380,11 @@ scripts/ parameterized, path-agnostic
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
@@ -488,14 +509,21 @@ 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)
(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_budget.py keep the always-loaded part from creeping: narrative lines in
SKILL.md (index lines counted separately, because one line per
bundled file is the price of discoverability), index-row length,
and conclusions restated outside their legitimate homes
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)
```
+161 -36
View File
@@ -1,31 +1,44 @@
#!/usr/bin/env python3
"""Budget gate for the apk-reverse skill: keep the always-loaded part from creeping.
"""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*. 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.
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. **Content lines vs index lines.** An index row is the price of discoverability: one line per
bundled file, and this skill ships 38 references and 48 scripts, so ~100 of its lines are
structural. Counting them against a prose budget would push the skill to drop files it needs,
so the budget applies to the *narrative* lines — rules, workflow, gates, classification —
which is the part that actually competes for attention. Anthropic's Agent Skills guidance
says "keep SKILL.md under 500 lines"; its own `skill-creator` ships 480 and `mcp-builder` 237.
Here the narrative budget is 460, and the total is reported for context, not as a verdict.
2. **Index row length.** The index tables are the largest part of SKILL.md and the easiest to
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 —
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. It is a note
rather than a failure precisely because the three legitimate homes make a high count normal.
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.
Usage:
python check_budget.py # report; exit 1 if a hard limit is exceeded
@@ -44,11 +57,25 @@ 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 # narrative budget
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
# 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 = {
@@ -59,16 +86,21 @@ RESTATED = {
'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 skill_lines():
with open(SKILL, encoding='utf-8') as fh:
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
@@ -94,20 +126,50 @@ def index_rows(lines):
return n, long_rows
def token_estimate(text):
"""Token count for the always-loaded body.
Uses `tiktoken` (cl100k_base) when it happens to be installed, because a real measurement beats
an estimate everywhere in this repository -- including in this file. Otherwise falls back to
`characters / 3.6`, which was *calibrated against that measurement* on this file (69,143 chars
-> 17,144 tokens) rather than guessed; markdown-heavy prose tokenizes worse per character than
the usual /4 rule of thumb.
"""
try:
import tiktoken
return len(tiktoken.get_encoding('cl100k_base').encode(text)), 'tiktoken/cl100k_base'
except Exception:
return int(len(text) / 3.6), 'estimate chars/3.6'
def check_size(lines):
n_idx, _ = index_rows(lines)
content = len(lines) - n_idx
if content <= CONTENT_LINES_OK:
lv, msg = 'ok', 'SKILL.md %d narrative lines + %d index lines = %d total (budget %d narrative)' % (
content, n_idx, len(lines), CONTENT_LINES_OK)
elif content <= CONTENT_LINES_HARD:
lv, msg = 'warn', ('SKILL.md narrative is %d lines, over the %d budget by %d (%d index lines '
'excluded). Move detail into references/ and leave a pointer.' %
(content, CONTENT_LINES_OK, content - CONTENT_LINES_OK, n_idx))
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:
lv, msg = 'fail', 'SKILL.md narrative is %d lines, past the hard limit of %d' % (
content, CONTENT_LINES_HARD)
return lv, msg, n_idx, content
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):
@@ -122,9 +184,57 @@ def check_index_rows(lines):
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)]
imperative = [ln for ln in body if re.match(r'^\s*[-*]? *(\*\*)?[A-Z]', 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 = {}
@@ -146,13 +256,23 @@ def check_restated():
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 never mentioned in SKILL.md -- nothing will '
'load it' % name))
out.append(('fail', 'references/%s is named neither in SKILL.md nor in '
'references/routing.md -- nothing will load it' % name))
return out
@@ -164,15 +284,20 @@ def main():
args = ap.parse_args()
lines = skill_lines()
lv, msg, n_idx, content = check_size(lines)
findings = [(lv, msg)]
findings, n_idx, content, toks = check_size(lines)
findings += check_index_rows(lines)
findings += check_discoverable()
notes = check_restated()
findings += check_navigable()
notes = check_restated() + check_hedging()
if args.json:
print(json.dumps({'findings': [{'level': l, 'message': m} for l, m in findings],
'notes': notes, 'index_lines': n_idx, 'content_lines': content}, indent=2))
print(json.dumps({
'findings': [{'level': l, 'message': m} for l, m in findings],
'notes': notes,
'index_lines': n_idx,
'total_lines': content,
'token_estimate': toks,
}, indent=2))
else:
for l, m in findings:
print('%s %s' % ({'ok': ' ok ', 'warn': ' WARN ', 'fail': ' FAIL '}[l], m))
+1020
View File
File diff suppressed because it is too large Load Diff
+10 -4
View File
@@ -151,8 +151,13 @@ def check_scripts(skill_dir):
print(' FAIL %s' % name)
continue
run = subprocess.run([sys.executable, '-B', path, '--help'],
capture_output=True, text=True, timeout=60,
cwd=ROOT)
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 '')
@@ -254,7 +259,8 @@ def check_leaks():
return
import subprocess
import tempfile
proc = subprocess.run(['git', 'ls-files'], cwd=ROOT, capture_output=True, text=True)
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
@@ -264,7 +270,7 @@ def check_leaks():
try:
run = subprocess.run([sys.executable, scanner, '--root', ROOT,
'--files-from', listing, '--max', '10'],
capture_output=True, text=True)
capture_output=True, encoding='utf-8', errors='replace')
finally:
os.unlink(listing)
token = (run.stdout or '').strip().splitlines()
+176
View File
@@ -0,0 +1,176 @@
#!/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 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')
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())
@@ -0,0 +1,521 @@
# 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 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.
@@ -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
SSBYPJKFNVEU6PGA 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.
+2
View File
@@ -68,6 +68,8 @@ those are called out in the matrix rather than buried here.
| `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-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
+27
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
+125 -288
View File
@@ -1,6 +1,14 @@
---
name: apk-reverse
description: "Reverse engineer, debloat, de-ad, patch, or re-sign Android APKs, and analyze their runtime and server-side behavior. Use when a task involves an .apk/.aab/.dex/.so sample, smali or dex patching, Frida/objection runtime hooking, repacking and re-signing, removing ads or SDK trackers, probing a mobile app's HTTP API, or deciding whether a client-side patch is even capable of achieving the goal. Covers recon, anti-tamper, ad removal, membership/paywall limits, dex-level surgical patching, repack pitfalls, device and emulator setup, and a hard-won failure catalogue. Load the body before planning any patch work: it opens with a symptom index and four gates that must be cleared first."
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
@@ -8,32 +16,30 @@ description: "Reverse engineer, debloat, de-ad, patch, or re-sign Android APKs,
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 — the four things to do before anything else
## Immediate — do these four things before anything else
Placed at the top on purpose: the longer this file gets, the less its middle is read, and everything
below is explanation for these four moves.
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** (§Start here's thirteen questions; **R4** decides whether you
are editing the right layer at all — a wrong branch 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 the machine's real capability, G3 the
code location, G4 baseline and control — "I understand the idea" is not clearing one). **3. A symptom
you cannot explain is a stop signal** — search §Symptom index for the shape *before your next attempt*
and load the row's file; those rows cost hours precisely because the answer was already written down.
**4. Two strikes on one shape of attempt → back to classification**, not a third variant
(§Stop conditions), and never report done without §What "done" means.
**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.
## How to use this file
**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.
This file is a **procedure with gates**, not background reading. Three things are mandatory:
**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.
- Before you patch anything, clear the **four gates** in §Gates. They are actions with pass criteria,
not attitudes.
- When anything fails in a way your current plan does not explain, **stop and check the symptom
index**. If a row matches, load that file before running another command.
- 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; your current intuition is
the intuition of someone who has not hit it yet.
**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
@@ -64,42 +70,29 @@ native, Dart/Unity, server). Patching the wrong layer either does nothing or bre
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 — what to reach for, in order, and how to notice what you are missing
## 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, or a full 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.
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.
Four obligations. These are instructions, not preferences:
**Five obligations. These are instructions; a violation is a defect, not a preference.**
- **Orient with an indexer, not with an export.** Before reading code, build the ability to *ask the
artifact questions* — `droidasc findrefs` (string/type/method → every reference site, sub-second) or
`ddc findrefs`. A full decompile is for reading a class you have **already located**; it is not the
way you locate it. Treat `jadx` as a readable viewer of last resort, never as the source of truth, and
never as the entry point of a recon. → `references/toolchain.md` §Tier 1 — dex and Java
- **When the hot layer has no working tool here, installing one is part of the task.** A missing arm64
decompiler is not a constraint to route around; it is the next step. Route around it and you pay in
hours for a result a decompiler gives in minutes. Ask a human only when installation is genuinely
impossible. → `references/toolchain.md` §Closing a capability gap
- **Name the gap before you spend against it.** State the capability the current blocker requires, and
whether this machine has it. This is a G2 item, not a note to yourself.
- **Reach for a script here before writing a new one.** The kit exists precisely so that parsing,
hashing, alignment and hot-plug probes are not re-derived per task; a bespoke script written in place
of `scripts/dex_find_insn.py` is how offsets get guessed instead of computed.
→ `references/toolchain.md` §Using the kit's scripts instead of writing your own
- **Pick the instrument for the layer the question lives on — the heavier one is not the safer one.**
A dex in memory wants `dex_mem_scan.py` (find and cut) plus `dex_dump_validate.py` (judge), not a
full decompiler pointed at a fragment; an algorithm you only have to *call* wants
`references/emulation-and-rpc.md`, not a week of reading a flattened function
(`references/native-dbi-and-deobfuscation.md`); "does this class ever get loaded at runtime" wants a
hook that fires or does not, not more reading. Reaching one layer up because the correct instrument
is unfamiliar is how a ten-minute answer becomes a day — and it usually produces a *plausible* result,
which is worse than none.
| 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 |
A capability you have not checked for is not a capability you lack. Run
`python skills/apk-reverse/scripts/doctor.py` and read what it finds **off-PATH** before concluding
that anything is unavailable.
**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
@@ -108,68 +101,36 @@ applying the nearest available procedure to a target it was never written for.**
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.
**Covered, by verified mechanisms:**
**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:
- Client-side ads, promos and splash/popup/tab configuration, including server-issued UI config that
has no SDK to find (`ad-removal.md`, `server-config-and-updates.md`).
- 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, dexlib2 method rewrites, and 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 — identification, the validation boundary, and the
routes that survive it (`packers.md`, `code-virtualization-and-custom-linkers.md`).
- **Java2C versus an extraction shell versus JNI sinking** — the misdiagnosis that sends an agent
hunting a decrypted DEX that never exists (`java2c-and-jni-sinking.md`, `scripts/java2c_probe.py`).
- The native layer: `.so` hosts, tamper-triggered self-termination, forged ELF structure, and
neutralising a terminate path without making it not return (`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 analysis with Frida, server-side API probing, feature-scoped TLS failures, update and
forced-upgrade neutralisation, and the verification discipline everything above rests on.
| 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 |
**Also covered, by documented routes — mostly *inferred* rather than measured:** module-side
delivery when a repack is refused (`lsposed-and-modules.md`) · extraction shells and the VMP boundary,
including how to *measure* that the bodies are empty (`advanced-unpacking.md`) · calling a routine
instead of reading it — emulation and Frida-RPC (`emulation-and-rpc.md`) · instruction-level tracing
and de-obfuscation (`native-dbi-and-deobfuscation.md`) · protocol reversing beyond REST
(`protocol-reverse.md`) · what to do when userspace hooking provably cannot reach the check
(`kernel-and-environment-hardening.md`) · split APK / App Bundle sets (`split-apk.md`) · schema-free
protobuf decoding (`protocol-reverse.md`) · Dex-VMP differential analysis
(`vmp-differential-analysis.md`) · kernel-module templates and their version gate
(`kernelsu_syscall_mask.py`) · working from the phone itself (`on-device-tooling.md`) · publishing what
you learned without publishing the target, and reading a graded precedent before repeating work
(`desensitization-and-leak-scans.md`, `scripts/scan_leaks.py`, `precedents/`).
**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".
**Dependencies this skill does not ship** — the Dart AOT workflow needs a snapshot dump this skill
cannot produce, and naming which front end you used matters. Details:
`references/coverage-and-limits.md`.
**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.
**How strong these claims are:** every item above carries one of three labels — `observed` (a command
and its output exist behind it), `inferred` (reasoned from mechanism or a neighbouring measurement),
`unverified` (assumed, or reported elsewhere). Treat the distinction as load-bearing rather than
cosmetic, and label your own results the same way. The evidence behind each label, the extension
pass's own footing (*the tool was measured, the route was not*), and the list of scripts that have
never been run all live in `references/coverage-and-limits.md`.
**Not covered — say so rather than improvise:** Unity / IL2CPP · React Native / Hermes / Cordova ·
iOS · defeating a server-side authority · an off-the-shelf unpacker or an anti-detection arms race ·
building and shipping a kernel module. Reasons and evidence boundaries are in
`references/coverage-and-limits.md`; **silence in that record is not support.**
**Not covered — say so rather than improvise:** 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, or an anti-detection arms race · building and shipping a kernel module · a
Stalker trace guaranteed to work on every device. Each of these has a reason and, where one exists,
an evidence boundary — both are in `references/coverage-and-limits.md`.
**What was never exercised, and which scripts have never been run**, is recorded in
`references/coverage-and-limits.md` — read it before treating "this was measured" as covering the
route you are about to take. Silence in the record is not support.
**The fallback, as an instruction:** if the target does not match that list, or no symptom-index row
matches, **stop and classify before choosing a branch.** Answer the thirteen questions first. 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, rather than taking the closest documented
route and applying it anyway. A wrong branch here does not fail loudly: it produces an artifact that
builds, runs, and does the wrong thing.
**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
@@ -250,7 +211,7 @@ unread in this repository.
| 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` §1. Protobuf on the wire (measured) — run `scripts/protobuf_decode_raw.py`; the candidate list and its `tie:` lines are the answer |
| 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 |
@@ -309,71 +270,31 @@ compare against, and every later measurement is unfalsifiable.
## Start here: classify the target in thirteen questions
Answer these before touching a tool. Every one of them changes the whole plan.
**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.
1. **Is the app packed/hardened?** → `references/recon.md`
Read the manifest's `application android:name`. If it is a third-party shell class rather than the app's own Application, you have a packer and must handle it first.
2. **Where does the behavior you want to change actually live?**
- Ad SDK (Pangle/GDT/AnyThink/Kuaishou/Baidu/Sigmob…) → usually **client-side and removable** → `references/ad-removal.md`
- **Server-issued config for UI the client renders** (launch screen, popup, announcement, tab set, sponsored card on a home feed) → **the client decides, the server supplies the data** → `references/server-config-and-updates.md` (this is the most common shape of "ad" in a modern app, and there is no SDK to find — decide this question early, because hunting an SDK that does not exist costs hours)
- Membership / VIP / paid content → **usually server-authorized, client patch is cosmetic** → `references/membership-and-limits.md` (read this *before* spending hours)
- Feature flag, UI gate, debug switch → usually client-side
- Anything decided by an API response → server-side → `references/server-api.md`
3. **Is the app's own code in plain dex, or moved to native/Flutter/Unity?**
Plain dex → you can patch. Flutter (`libflutter.so` + `libapp.so`) / Unity (`libil2cpp.so`) / pure native → different toolchain entirely. See `references/recon.md` §Where does the app's own code live and `references/framework-runtimes.md`.
**Runtime check (cheap -- do it before committing to a layer):** hook the obvious Java classes for the UI you care about, then reproduce that UI. If those hooks fire, the behavior is Java-owned. If they fire **zero times** while the UI is plainly on screen, the behavior is drawn by the runtime or by native code, and a dex-only plan will stall. Do not keep hunting in dex after a zero-hit probe -- that is the most expensive wrong turn in this skill's history.
4. **What must the deliverable be able to do?** Write the answer as a testable sentence before
planning anything, then re-read it at every checkpoint. This is the drift guard, and the drift it
guards against is the most expensive one in this skill: a runtime-only result (a data edit, a live
hook, a blocked hostname, a host proxy) can look like success while failing the actual requirement.
The axes that decide it: **privilege** (unrooted?), **modification form** (a rebuilt, installable
artifact, or is live instrumentation acceptable?), **ABI/device class**, **network** (must it work
online?), **persistence** (survives restart / upgrade / fresh install?), **distribution** (must the
shipped file be self-contained?). → `references/long-task-discipline.md` §the most expensive drift.
**If the evidence already shows a deep extraction shell, a VMP, or more than one independent
integrity check, re-answer this question against G1's four forms** — the repack column may be
blocked rather than expensive, and the answer may be a module, an RPC service, or a report.
5. **Does the app verify its own signature, or does the server?**
App-side → you must bypass it. Server-side → re-signing silently breaks the app later. See `references/repack-and-sign.md` and `references/server-api.md`.
6. **What is your device situation?** → `references/environment.md`
Rooted real device (best), emulator with root, or no device (static only). Also: this determines whether Frida is usable. **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 (`pitfalls.md` P9).
7. **Which architecture is actually executing?** → `references/native-and-so.md` §Cross-architecture
`getprop` reports what the device claims and `primaryCpuAbi` reports what the package manager chose — neither is what is running. Only the live mapping is ground truth (`scripts/lib_map.py`). If the library you meant to patch is not mapped, a translator is in play, or the ABI differs from your assumption, that changes the plan more than any patch will.
8. **Is one *specific feature* failing at runtime — login, registration, payment, an API-backed screen — while the rest of the app works?**
→ `references/tls-and-cert.md`. A feature-scoped network failure is very often a **TLS/certificate problem on one code path**, not a consequence of your patch. The app can even carry two independent trust chains, so "other requests work" proves nothing. Rule this out in minutes before hunting for a signature check.
9. **Was the input a build you did not produce** (a "cracked"/"modded" APK circulating online)?
→ `references/third-party-builds.md`. Audit it before adopting it: 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.
| # | 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:** if this is likely to run long, open `references/long-task-discipline.md` now
and keep its record updated as you go. Re-read the refuted-conclusions and dead-routes sections
before starting any new experiment. Losing earlier findings is the most expensive failure in this
skill, and it is entirely preventable.
10. **Does the client sign its requests with its own signing certificate?**
→ `references/signature-derived-keys.md`. 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. This is the single
most expensive silent failure in a repack, and 15 minutes of grep prevents it.
11. **Does the app die on its own after a while — with no Java stack trace, or with a native
crash that looks like a bug?**
→ `references/native-tamper-and-suicide.md`. A hardened library that decides the build is
tampered 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, and the system then reports it as an
app "crash" or "abnormal" dialog. Two rules before you touch anything: **enumerate which
mechanism actually fires** (the signal and the 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.
12. **Will this build still be usable in a week?** → `references/updates-and-forced-upgrade.md`
If the app has any version check, upgrade prompt, or self-update path, an unpatched build can be
turned off remotely or replaced by the official package. This is one or two edits and it decides
whether the work is durable — do it as part of the build, not as a follow-up. Also check for a
**hot-update / remote-config** channel, which can restore behaviour you removed without any version
change at all.
13. **Does the request touch sign-in or phone binding — "no login required", "skip binding", "guest ok"?**
→ `references/account-gates.md`. The whole difficulty here is 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.
**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
@@ -448,124 +369,40 @@ These are moments where continuing to push forward is the wrong move. Each has c
## Non-negotiable constraints
- **Read-only inputs.** Keep the original APK/dex untouched; work on copies. Always keep a known-good baseline to diff against.
- **One variable at a time.** If you change two things and it breaks, you learn nothing. Build a control (same pipeline, zero patches) and compare.
- **Verify structure after every dex edit.** `scripts/dex_classdiff.py` must report zero differences in class set and access flags for classes you did not intend to change.
- **Do not patch a method that is widely shared.** Before patching any helper, count its callers (`scripts/find_refs.py`). A `Long.valueOf` wrapper with 30 callers is not an ad-specific hook.
- **Do not make an API fail to suppress a UI element.** A 404/400 on an endpoint that other features depend on takes the whole screen down with it. Suppress at the data-consumption or render layer instead.
- **Neutralise a native terminate path by returning, never by making it not return.** A stub, stub patch, or function entry replaced with a spin or a self-branch does not suppress the check — it freezes the caller, holding whatever lock it had, and unrelated threads wedge behind it. The symptom (a hang, an external kill, a restart loop) looks nothing like the cause, and there is no crash record to explain it. Return a benign value, and prefer success (0) over failure (-1). Never touch the ordinary-path symbols (`pthread_exit`, `exit`, `abort`, `snprintf`, `closedir`). `references/native-tamper-and-suicide.md`
- **Look before you conclude — and look while you wait.** Execute, capture, and **inspect**; do not drive and sleep blind. A screen that is actually looked at answers in one step what coordinate-guessing cannot answer in five: the layout moved, a different dialog is up, a countdown is frozen, the text on screen says exactly why. Where the thing you are waiting on is visible, a sample you can inspect beats a duration you hoped was right, and byte-identical samples mean nothing is going to change. `scripts/snap.py`; `references/environment.md` §look at the screen.
- **Put a timeout on every command, and calibrate it from measurement.** An unbounded call turns a stall into "the task stopped making progress", which is indistinguishable from slow work and costs hours silently. Time the operation once, record it, then derive the bound from it — that is what makes slow and hung distinguishable. A deadline that passes is a measurement, not a verdict. `references/long-task-discipline.md` §bound every wait.
- **Every claim needs evidence.** "Probably", "should be", "in theory" are not findings. Either you observed it, or you label it unverified.
- **"Done" means the user-visible outcome**, not an internal signal. A blocking dialog still on screen means the task is not done, however many errors disappeared from the log. Absence of a log line is absence of evidence, never evidence of success.
- **Do not discard a route on compound evidence.** If a failure followed two simultaneous changes, the attribution is a hypothesis, not a finding. Re-run it single-variable before writing the route off -- mis-attributed failures have removed viable approaches for a long time.
- **Prove the device changed before measuring.** Install success describes the request, not the app on disk. Confirm the artifact actually advanced, or every following observation describes the previous build.
- **Attribute a failure to the right layer before patching again.** When something stops working after a rebuild, first check whether the **unmodified original** fails the same way on the same device and network. Feature-scoped network failures in particular are frequently the app's own TLS/certificate problem (`references/tls-and-cert.md`); chasing a signature check that does not exist burns hours.
**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.
## Reference index
| 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`) |
Load only what the current step needs.
## Reference index and script index
| 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/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/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 |
**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.
## Script index
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.
All scripts are parameterized and path-agnostic; pass paths explicitly. Run `--help` or read the header of each.
`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.
| Script | Purpose |
|---|---|
| `scripts/doctor.py` | **Run this first.** Which tools exist (including off-PATH and `java -jar` jars), which scripts can run here, and the environment facts that poison experiments: clock skew, leftover forwards/proxy, a device-side frida already running |
| `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` |
| `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/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 |
`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.
+56
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"
}
]
}
@@ -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."
}
]
}
@@ -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.
@@ -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."
]
}
@@ -2,6 +2,9 @@
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.
@@ -18,8 +18,8 @@ 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
`docs/tool-verification/EXTENSION-extraction-shell-bench.md`. The older surgical fixture
that first exercised the script is in `docs/tool-verification/EXTENSION-unpacking.md`.
`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
@@ -27,13 +27,13 @@ rooted device. The **root-side memory route** (§Dumping when frida is refused)
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
`docs/tool-verification/EXTENSION-rootdump.md`. The `frida-dexdump` refusal that
motivates that section is in `docs/tool-verification/EXTENSION-device-run.md`. A later
`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
`docs/tool-verification/EXTENSION-detection-pipeline.md`. The
`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"
@@ -90,7 +90,7 @@ 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 (`docs/tool-verification/EXTENSION-extraction-shell-bench.md`,
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 %) |
@@ -147,7 +147,7 @@ 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:
`docs/tool-verification/EXTENSION-extraction-shell-bench.md`.
`references/evidence-summary.md` §The capability matrix.
## The FART loop: skeleton, invocation, splice
@@ -424,7 +424,7 @@ 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
(`docs/tool-verification/EXTENSION-rootdump.md`). The correction this pass adds is
(`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.
@@ -524,7 +524,7 @@ desynchronisation is indistinguishable from the finding it fakes. Measured on th
`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 (`docs/tool-verification/EXTENSION-extraction-shell-bench.md` §3.4). The bodies
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`.
@@ -6,6 +6,9 @@ technique: rewriting **a few bytes in place**. Reach for it whenever the change
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):
@@ -182,7 +185,10 @@ Keep these properties; they are what make the edit auditable a month later:
- 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);
different-length values need `dex_strpatch`-class tooling or a rebuild.
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.
@@ -9,9 +9,12 @@ This file carries the part that is read **only when you need to weigh a claim**
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 `docs/tool-verification/`.
- **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.
@@ -48,7 +51,7 @@ but nobody here has paid for the counters yet.
### Added by the benchmark pass, and measured against public targets
Each of these has a row in `tests/benchmark.md` naming its target, its strength and the evidence
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
@@ -86,7 +89,7 @@ its own strength note at the top.
- **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` §6.
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,
@@ -131,7 +134,7 @@ its own strength note at the top.
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
(`docs/tool-verification/EXTENSION-stalker-exclude.md`). Treat the zero-event case as a boundary to
(`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
@@ -144,7 +147,7 @@ with no packer — the Dart work above, library mapping, and the environment fac
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
`docs/tool-verification/` is evidence either way about them.
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`,
@@ -157,10 +160,10 @@ a crash-reporter SDK — the exact situation that target presented — and was n
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
`tests/benchmark.md`.
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
`docs/tool-verification/EXTENSION-*.md` before relying on one: `dex_dump_validate.py` (measured
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),
@@ -169,12 +172,12 @@ started by hand), and `stalker_trace.js` / `stalker_report.py` (two boundary res
trace of a real target).
**The extension pass's claims are mostly `inferred`.** Its evidence lives in
`docs/tool-verification/EXTENSION-*.md`, one file per topic, each with its own strength note. The
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
`docs/tool-verification/EXTENSION-detection-pipeline.md`:
`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
@@ -210,7 +213,8 @@ any of the newer documents as a verified path.
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.** `docs/tool-verification/README.md` indexes it; `tests/benchmark.md` is
the public-target regression matrix. Neither ships inside the installed skill (they sit at the
repository root), so an installed copy of the skill carries the claim but not its evidence file —
which is why the strength label is stated here rather than left to be looked up.
**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.
@@ -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.
+6 -6
View File
@@ -38,7 +38,7 @@ snapshot hash or a structural probe (§2's Route A), rather than building a deco
## 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 §4
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).
@@ -114,7 +114,7 @@ Outputs of interest (blutter):
> 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 §7). Note this is a *format*
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
@@ -165,7 +165,7 @@ library has no code". If you need capstone here, start at a known function addre
`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 (§9): decode `B`/`BL` arithmetically
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
@@ -183,7 +183,7 @@ Stable across Dart 3.x arm64 AOT. Verify once on a known function, then rely on
| `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 §6) |
| `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) |
@@ -310,7 +310,7 @@ Use this to find function boundaries when you need to delimit one.
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 (§4).
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
@@ -382,7 +382,7 @@ Use this to find function boundaries when you need to delimit one.
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** (§4). Windowed disassembly is.
- **Full-file disassembly is not a debugging tool** (). Windowed disassembly is.
## 12. Verification
@@ -22,10 +22,13 @@ A rule that only handles the first side produces "a document about nothing". A r
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
`docs/tool-verification/`.
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.
@@ -90,7 +93,7 @@ above — this file stating the decision is itself the hit — and the fourth is
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
`docs/tool-verification/EXTENSION-desensitization.md`.
`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
@@ -107,7 +110,7 @@ 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 `docs/tool-verification/`. Context
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
@@ -91,7 +91,7 @@ anti-instrumentation pipeline published in `index-login/MobileRE-Skill`
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:
`docs/tool-verification/EXTENSION-detection-pipeline.md`). Stage 4's kill path was reached once and
`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.
@@ -2,6 +2,9 @@
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.
@@ -9,6 +9,9 @@ Use Frida when you need to answer:
- 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.
@@ -17,6 +17,9 @@ This file is about choosing between them and driving both. The unpacking itself
`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:
@@ -80,7 +83,7 @@ Default to **RPC first, emulation second**: the device already provides the envi
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 `EXTENSION-emulation-rpc.md` states.
commercial sample end to end, as `references/evidence-summary.md` §The capability matrix states.
## Part A — Unidbg
@@ -218,7 +221,7 @@ each is: inferred from community experience, not measured here.)
### Measured here
See `docs/tool-verification/EXTENSION-emulation-rpc.md` for the exact commands and outputs behind
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
@@ -297,7 +300,7 @@ 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 `docs/tool-verification/EXTENSION-emulation-rpc.md`):
(exact outputs in `references/evidence-summary.md` §The capability matrix):
| Failure | What it looks like | Handling |
|---|---|---|
@@ -1,5 +1,8 @@
# 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 |
@@ -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.
@@ -35,7 +35,7 @@ that performs the unpacking. `packers.md` owns the dex-side identification; the
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
(`tests/benchmark.md` row B3).
(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
@@ -67,4 +67,4 @@ 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.
`tests/benchmark.md` records which rows have one and which do not.
the benchmark matrix (`references/evidence-summary.md` §The capability matrix) records which rows have one and which do not.
@@ -14,7 +14,7 @@ file splits that row and owns both halves of it; the virtualization file stays w
**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
`docs/tool-verification/EXTENSION-java2c.md`. But no Java2C library exists in this repository and
`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
@@ -17,11 +17,11 @@ Division of labour, so the two files never duplicate:
**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 (§4) or the conflict (§5), not to teach kernel hacking.
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 §4. The generator, its userspace output and
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.
@@ -54,7 +54,7 @@ why the hook does not land):
| `/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 §6. |
| `/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
@@ -144,7 +144,7 @@ uname -r # on the device. The gate for most of this section is 5.10+
|---|---|---|---|
| **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 (§7) |
| **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
@@ -241,7 +241,7 @@ rather than to any tool:
- 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) — §4, `unverified`;
- 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`.
@@ -288,7 +288,7 @@ defeated with a custom kernel module is not a deliverable anyone can install.
## Checklist
- [ ] `detection-and-anti-analysis.md` read first; A/B/C chosen deliberately
- [ ] Check located on the timeline (§1) before choosing a counter-layer
- [ ] 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
@@ -13,10 +13,10 @@ the death", that is `native-and-so.md` and `native-tamper-and-suicide.md`; if it
this function really execute, and how often", it is here.
**Claim strength.** `measured` = an exact command and its output exist in
`docs/tool-verification/EXTENSION-native-dbi.md`. `inferred` = it follows from measured behaviour or
`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 §6.
result of this pass is that **the trace pipeline is unverified on the test device** — see.
## 1. Which obfuscation are you looking at
@@ -32,7 +32,7 @@ Obfuscator-LLVM ships three transforms, and one target usually carries all three
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 §5.
**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
@@ -56,8 +56,8 @@ script header in `scripts/stalker_trace.js`.
| 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 (§5) |
| What is the value at a specific point for a specific input? | Deterministic per-instruction replay with memory operands | QBDI (§4) |
| 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
@@ -173,14 +173,14 @@ Diagnostics the report prints, and what they mean:
| 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** (§5) | A trace is one path; a solver enumerates the condition |
| 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 §6).
in).
### The cost is not linear, and on arm64 it decides the tool choice
@@ -194,7 +194,7 @@ Two costs decide whether Stalker is the right tool, and neither is visible in th
- **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 (§6).
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
@@ -216,7 +216,7 @@ Exclusion is what keeps the target alive — now `observed`, with the no-follow
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:
`docs/tool-verification/EXTENSION-stalker-exclude.md`.
`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
@@ -313,6 +313,6 @@ because the cheap answer is usually to switch to static analysis with `elf_plt.p
- 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 `docs/tool-verification/`, with the exact command, the
- 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.
@@ -9,7 +9,10 @@ Evidence basis: the facts below come from a real configured environment (Android
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 §2).
(`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
@@ -115,7 +118,7 @@ The phone can run the whole dynamic stack alone: Termux (or a plain root shell)
- **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` §1.
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).
@@ -8,6 +8,9 @@ Two independent questions, and they fail independently:
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.
@@ -6,6 +6,9 @@ Each entry: **symptom → root cause → why it is hard to see → what to do in
---
**Load this when:** before building anything, and again when a failure looks familiar. It is the failure catalogue: symptom, root cause, why it is hard to see, and what to do instead. Skim it; do not read it linearly.
## P1. Stripping the whole `META-INF/` breaks the app at startup
**Symptom**
@@ -83,7 +83,7 @@ it belongs in the chain above instead.
## Rules
- **Grade every assertion, and grade it down when in doubt.** `observed` means a command was run
and its output is in `docs/tool-verification/`. "I reasoned it out" is `inferred`. This is the
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
@@ -6,7 +6,7 @@
| 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 | `docs/tool-verification/EXTENSION-device-run.md` (the pid-drift section and the frida-dexdump section); `docs/tool-verification/EXTENSION-lsposed.md` (the root-hiding layer) |
| 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
@@ -16,11 +16,11 @@
| 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 `EXTENSION-lsposed.md`) |
| 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 `EXTENSION-device-run.md` |
| 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)
@@ -6,8 +6,8 @@
| 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 | `docs/tool-verification/EXTENSION-stalker-exclude.md`; `docs/tool-verification/EXTENSION-native-dbi.md` (device attempts A and B) |
| Related | `references/native-dbi-and-deobfuscation.md` §6; `scripts/stalker_trace.js`, `scripts/stalker_report.py`; `pitfalls.md` P26 |
| 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 |
@@ -16,8 +16,8 @@
| 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 `EXTENSION-stalker-exclude.md`; 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 `EXTENSION-native-dbi.md` |
| 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` |
@@ -6,7 +6,7 @@
| 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 | `docs/tool-verification/EXTENSION-benchmark-l1-l3.md` (the B1 sections); `tests/benchmark.md` row B1 |
| 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
@@ -6,8 +6,8 @@
| 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 | `docs/tool-verification/EXTENSION-lsposed.md` (the injection-line, log-surfaces and scope sections) |
| Related | `docs/tool-verification/EXTENSION-device-run.md` (the framework-damage section); `references/lsposed-and-modules.md`; `references/environment.md` |
| 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 |
@@ -6,19 +6,19 @@
| 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 | `docs/tool-verification/EXTENSION-desensitization.md`; `docs/tool-verification/EXTENSION-lsposed.md` (its identifier-convention and injection-line sections); `docs/tool-verification/EXTENSION-device-run.md` (its environment section) |
| 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 `EXTENSION-device-run.md`: *"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 `EXTENSION-lsposed.md` normalises every occurrence of the package identifier and its sub-package prefixes, and states that the transcripts are otherwise verbatim |
| 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 | `docs/tool-verification/EXTENSION-desensitization.md`, the planted-corpus and repository-scan sections |
| 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 `docs/tool-verification/EXTENSION-desensitization.md`: 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 |
| 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 |
@@ -64,7 +64,9 @@
## 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.
- [ ] `docs/tool-verification/README.md` — the index entry for `EXTENSION-desensitization.md`.
- [ ] 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
@@ -75,7 +77,7 @@
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 `docs/tool-verification/EXTENSION-desensitization.md`.
"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.
@@ -10,7 +10,7 @@ that is a different problem with its own file: read `tls-and-cert.md` first —
attribution and the permissive-defaults template, and this file deliberately does not repeat them.
**Claim strength.** `measured` = an exact command and output exist in
`docs/tool-verification/EXTENSION-native-dbi.md`. `inferred` = documented behaviour or a step that
`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.
@@ -32,7 +32,7 @@ sequence". That is the whole format; the schema supplies meaning and nothing els
| 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 — `docs/tool-verification/EXTENSION-protobuf-raw.md` records that cross-check against
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:
```
@@ -136,7 +136,7 @@ are framing and not message data.
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
`docs/tool-verification/EXTENSION-protobuf-raw.md`.
`references/evidence-summary.md` §The capability matrix.
## 2. Recovering the schema from the APK
@@ -311,18 +311,18 @@ a system CA when a native verifier ignores it produces the same empty capture yo
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 §1, which caught a decoder bug in this
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 (§3). Handed a real gRPC frame, the
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 (§4), a raw socket, a native client bypassing Java's HTTP stack, or a certificate-pinned
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.
+4 -1
View File
@@ -2,6 +2,9 @@
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
@@ -136,7 +139,7 @@ For a **Kotlin/Java app**, note the module layout: app code often lives in a sma
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 (§2).
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
@@ -2,6 +2,9 @@
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.**
+192
View File
@@ -0,0 +1,192 @@
# 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/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/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._
@@ -2,6 +2,9 @@
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 |
@@ -13,6 +13,9 @@ Two consequences that change the whole plan:
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
@@ -54,7 +57,7 @@ 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 §4.
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
@@ -130,7 +133,7 @@ A config-driven behaviour needs more evidence than a log line:
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 §3). A patch that reads the flag and
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,
@@ -9,6 +9,9 @@ This is distinct from "the server checks the signature". Here the *client* uses
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
@@ -58,9 +61,13 @@ and every request signing parameter evaluating to `-1`.
**Route A — from the device (authoritative):**
```bash
python scripts/sig_probe.py --live --pkg <pkg> # needs Frida + a rooted device
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.
+12 -9
View File
@@ -21,7 +21,10 @@ which route is legal from what the members actually carry, prints the reason, an
**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 `docs/tool-verification/EXTENSION-split-apk.md`.
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
@@ -87,8 +90,8 @@ Two traps, both **observed**:
**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 (§6). Either work on a set you own, or rename the pulled one
(§7) to get an installable fixture.
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
@@ -115,7 +118,7 @@ Failure modes, one per route:
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`, §6). The attribute has to be cleared first, and that is a
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.
@@ -150,7 +153,7 @@ adb shell "su -c 'pm install-multiple -r /data/local/tmp/set/*.apk'" # when an
```
**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 §6).
at signing time — sign them all, let the installer pick (see).
### Toolchain: `uber-apk-signer` is optional, `apksigner` is not
@@ -196,7 +199,7 @@ If you genuinely need one standalone file for a bundle whose resources are split
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 (§4).
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
@@ -208,7 +211,7 @@ miss because they live in binary AXML:
| `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 (§3) |
| `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`,
@@ -297,7 +300,7 @@ can use. There is nothing to select on your side.
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** (§1). Blocking on "has an arsc" refuses
- **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.
@@ -316,7 +319,7 @@ can use. There is nothing to select on your side.
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** (§5). Feature-split *code* folds;
- **`--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.
@@ -1,5 +1,8 @@
# 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:
@@ -2,6 +2,9 @@
**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 -1
View File
@@ -433,7 +433,7 @@ script, and check the device architecture — the server binary is per-ABI.
**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` §1) and do not "try it and see".
Pin the version first (`dart-aot.md`) and do not "try it and see".
## Using the kit's scripts instead of writing your own
@@ -2,6 +2,9 @@
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**.
@@ -10,7 +10,7 @@ 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** (`docs/tool-verification/EXTENSION-vmp-diff.md`).
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
@@ -56,8 +56,8 @@ never once been run against a real target.
| 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 §4); 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 (§5) |
| 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
@@ -65,7 +65,7 @@ irreducibly manual — the platform has no interface a script can drive, and its
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** (§5) — the judgement stays human;
- 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`
@@ -139,13 +139,13 @@ decide whether the fourth is meaningful:
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 (§5).
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 (§3) or the dex slot is unallocated. They are **not** guessed at.
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
@@ -230,7 +230,7 @@ boundary hook answers in an hour.
| 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 | §3, and the `audit` report |
| 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` |
File diff suppressed because it is too large Load Diff
+302
View File
@@ -0,0 +1,302 @@
#!/usr/bin/env python3
"""Device-side command construction: quoting, argv, and identifier validation.
Why this module exists: `adb shell` hands your string to a **device shell** (toybox `sh`), and two of
this kit's scripts had different, partially-wrong ideas about that. One escaped only double quotes, so
a `;`, a `$(...)` or a backtick in a value still reached the shell as syntax; the other quoted
correctly but let a malformed value go through to fail later, far from the caller, as a confusing
`grep: no such file` or an empty result. Both are the same defect seen from two sides: **a value that
becomes shell syntax**.
None of this is exotic. The values are package names, component names, paths and sizes that the user
typed or that came out of a manifest, and the failure mode when one is malformed is a wasted round,
not an exploit. The rules are still worth enforcing in one place:
* **Validate what has a known shape.** An Android package name has a defined grammar; a value that
does not match it will never select a package, so rejecting it here is strictly better than
passing it to the device and reading an empty result as "not installed".
* **Quote what does not.** Paths and free-form text are escaped with POSIX single quotes -- the
only form that makes `$`, backticks, `;`, `|` and newlines literal to `sh`.
* **Prefer an argv array over a string** whenever the command is not being run through `su -c`.
`['adb', 'shell', 'cmd', arg]` sends `arg` as a separate argument; adb quotes it for the device
shell, and there is no string to get the quoting wrong in.
Usage as a module:
from device_shell import validate_package, validate_component, sh_quote, su_wrap, adb_shell
adb_shell(serial, ['dumpsys', 'package', validate_package(pkg)]) # argv, no string
su_wrap(['cat', validate_device_path(path)]) # -> one quoted string
Usage as a tool:
python device_shell.py --check com.example.app
python device_shell.py --quote "a b;c"
python device_shell.py --selftest
Exit codes: 0 success / 1 the value was refused / 2 usage error / 4 internal error.
"""
from __future__ import annotations
import argparse
import re
import sys
# Android's own grammar: one or more Java-package segments, each starting with a letter.
PACKAGE_RE = re.compile(r'^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$')
# A component is `pkg/Class`, `pkg/.Class` or `pkg/full.Class.Path`.
COMPONENT_RE = re.compile(
r'^(?P<pkg>[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+)'
r'/(?P<cls>\.?[A-Za-z][A-Za-z0-9_.$]*)$')
# Device paths we are willing to interpolate: absolute, no control characters, no shell metachars.
PATH_SAFE_RE = re.compile(r'^/[A-Za-z0-9_./@+:=~-]*$')
# A user id as `pm`/`dumpsys` reports it, or a process id.
PID_RE = re.compile(r'^[1-9][0-9]{0,6}$')
class Refused(ValueError):
"""A value failed validation. Callers translate this into exit code 1, never a traceback."""
def _reject(kind, value, why):
raise Refused('%s is not valid: %r -- %s' % (kind, value, why))
def validate_package(value):
"""A package name, or refuse. Raises `Refused`."""
if not isinstance(value, str) or not value:
_reject('package name', value, 'empty')
if len(value) > 255:
_reject('package name', value, 'longer than the platform maximum')
if not PACKAGE_RE.match(value):
_reject('package name', value,
'must be dot-separated segments each starting with a letter (Android grammar)')
return value
def validate_component(value, default_package=None):
"""`pkg/Class`, `pkg/.Class` or a bare `.Class` when `default_package` is given."""
if not isinstance(value, str) or not value:
_reject('component', value, 'empty')
if '/' not in value and default_package:
value = '%s/%s' % (validate_package(default_package), value)
m = COMPONENT_RE.match(value)
if not m:
_reject('component', value, 'expected package/Class with a valid package part')
validate_package(m.group('pkg'))
if m.group('cls').endswith('.'):
_reject('component', value, 'class part ends with a dot')
return value
def validate_device_path(value):
"""An absolute device path with no character that could be shell syntax."""
if not isinstance(value, str) or not value:
_reject('device path', value, 'empty')
if len(value) > 4096:
_reject('device path', value, 'unreasonably long')
if not PATH_SAFE_RE.match(value):
_reject('device path', value,
'must be absolute and free of whitespace, quotes and shell metacharacters')
if '..' in value.split('/'):
_reject('device path', value, 'contains a parent-directory segment')
return value
def validate_pid(value):
if isinstance(value, int):
value = str(value)
if not isinstance(value, str) or not PID_RE.match(value):
_reject('pid', value, 'expected a positive decimal pid')
return value
def sh_quote(value):
"""POSIX single-quote quoting: the one form that makes every metacharacter literal."""
if not isinstance(value, str):
value = str(value)
return "'" + value.replace("'", "'\\''") + "'"
def su_wrap(argv):
"""Wrap already-validated argv into a single `su -c '<cmd>'` string for the device shell."""
if isinstance(argv, str):
argv = [argv]
return 'su -c %s' % sh_quote(' '.join(argv))
def adb_shell(serial=None, argv=(), root=False, quote=True):
"""Build an adb argv list. `root=True` routes through `su -c`; `quote=False` sends argv directly."""
if isinstance(argv, str):
argv = [argv]
base = ['adb'] + (['-s', serial] if serial else []) + ['shell']
if root:
return base + [su_wrap(argv)]
if not quote:
return base + list(argv)
return base + [' '.join(sh_quote(a) for a in argv)]
def selftest():
"""Assert the properties this module claims. Returns a list of (name, ok, detail)."""
cases = []
payloads = ['a; rm -rf /sdcard/x', 'a$(id)b', 'a`id`b', 'a b', 'a\nb', 'a|b', "a'b"]
for p in payloads:
quoted = sh_quote(p)
# A single-quoted POSIX string must contain no unescaped quote and must round-trip.
ok = quoted.startswith("'") and quoted.endswith("'")
inner = quoted[1:-1]
ok = ok and re.fullmatch(r"[^']*('\\''[^']*)*", inner) is not None
cases.append(('sh_quote makes %r inert' % p, ok, quoted))
for good in ('com.example.app', 'a.b.c_d.e1'):
try:
validate_package(good)
cases.append(('accepts valid package %r' % good, True, ''))
except Refused as exc:
cases.append(('accepts valid package %r' % good, False, str(exc)))
for bad in ('', 'nodots', 'com..app', '1com.example', 'com.example.app;rm -rf /',
'com.example.app$(id)', 'com.example.app`id`'):
try:
validate_package(bad)
cases.append(('refuses invalid package %r' % bad, False, 'it was accepted'))
except Refused as exc:
cases.append(('refuses invalid package %r' % bad, True, str(exc)))
for good in ('/data/local/tmp/x', '/sdcard/Android/data/com.example.app/files'):
try:
validate_device_path(good)
cases.append(('accepts valid path %r' % good, True, ''))
except Refused as exc:
cases.append(('accepts valid path %r' % good, False, str(exc)))
for bad in ('relative/path', '/data/../etc/passwd', '/sdcard/x;id', '/sdcard/x$(id)',
'/sdcard/a b'):
try:
validate_device_path(bad)
cases.append(('refuses unsafe path %r' % bad, False, 'it was accepted'))
except Refused as exc:
cases.append(('refuses unsafe path %r' % bad, True, str(exc)))
try:
validate_component('com.example.app/.MainActivity')
validate_component('.MainActivity', default_package='com.example.app')
cases.append(('accepts component forms', True, ''))
except Refused as exc:
cases.append(('accepts component forms', False, str(exc)))
for bad in ('com.example.app', 'com.example.app/', 'nodots/Main'):
try:
validate_component(bad)
cases.append(('refuses bad component %r' % bad, False, 'it was accepted'))
except Refused as exc:
cases.append(('refuses bad component %r' % bad, True, str(exc)))
argv = adb_shell('SERIAL', ['dumpsys', 'package', validate_package('com.example.app')])
# With quote=True the adb argv is `adb -s SERIAL shell "<one string for the device shell>"`, so
# the package must appear as a *quoted* token inside that string; with quote=False it must be its
# own argv element. Both forms are asserted -- an assertion that accepts only one of them is how
# this selftest first failed, by hard-coding a length instead of checking the property.
quoted_form = argv[-1] if argv[-2] == 'shell' else ''
direct = adb_shell('SERIAL', ['dumpsys', 'package', 'com.example.app'], quote=False)
cases.append(('quoted form carries the package as one quoted token',
"'com.example.app'" in quoted_form, quoted_form))
cases.append(('direct form carries the package as one argv element',
direct[-1] == 'com.example.app' and direct[-2] == 'package', ' '.join(direct)))
cases.append(('root form routes through su -c as a single string',
adb_shell('SERIAL', ['id'], root=True)[-1].startswith('su -c '),
adb_shell('SERIAL', ['id'], root=True)[-1]))
wrapped = su_wrap(['cat', '/data/local/tmp/x'])
cases.append(('su wrapper is a single quoted string',
wrapped.startswith('su -c \'') and wrapped.endswith('\''), wrapped))
return cases
def exit_code_for(main_fn):
"""Run a script's `main()` and turn a refusal into exit 2 with one line, never a traceback.
Kept here so every script that validates device values reports the same way: a malformed
identifier is a *usage* error (2), distinguishable from "the device said no" (1) and from a
defect in the tool (4).
"""
try:
return main_fn()
except Refused as exc:
print('refused: %s' % exc, file=sys.stderr)
print('RESULT=refused')
return 2
def main(argv=None):
ap = argparse.ArgumentParser(
prog='device_shell.py',
description='Validate Android identifiers and quote values for the device shell. '
'Importable as a module; the CLI is for checking a value by hand.',
epilog='examples:\n'
' device_shell.py --check com.example.app\n'
' device_shell.py --check com.example.app/.MainActivity\n'
' device_shell.py --quote "a b;c"\n'
' device_shell.py --selftest\n')
ap.add_argument('--check', metavar='VALUE',
help='validate as a package name')
ap.add_argument('--check-component', metavar='VALUE',
help='validate as package/Class, or as .Class together with --package')
ap.add_argument('--package', metavar='PKG', help='default package for --check-component')
ap.add_argument('--check-path', metavar='PATH', help='validate a device path')
ap.add_argument('--quote', metavar='VALUE', help='print the POSIX-quoted form')
ap.add_argument('--su', metavar='ARGV', help='print a su -c wrapper for a space-separated argv')
ap.add_argument('--selftest', action='store_true', help='assert this module\'s own claims')
ap.add_argument('--json', action='store_true')
args = ap.parse_args(argv)
if args.selftest:
cases = selftest()
bad = [c for c in cases if not c[1]]
for name, ok, detail in cases:
print(' %-52s %s' % (name, 'ok' if ok else 'FAIL'))
print()
print('RESULT=%s' % ('selftest_ok' if not bad else 'selftest_failed'))
return 0 if not bad else 4
try:
if args.check:
print('accepted: %s' % validate_package(args.check))
print('RESULT=accepted')
return 0
if args.check_component:
print('accepted: %s' % validate_component(args.check_component, args.package))
print('RESULT=accepted')
return 0
if args.check_path:
print('accepted: %s' % validate_device_path(args.check_path))
print('RESULT=accepted')
return 0
if args.quote:
if args.su:
print(su_wrap([args.quote]))
else:
print(sh_quote(args.quote))
print('RESULT=quoted')
return 0
if args.su:
print(su_wrap(args.su.split()))
print('RESULT=quoted')
return 0
except Refused as exc:
print('refused: %s' % exc, file=sys.stderr)
print('RESULT=refused')
return 1
ap.print_help()
print('RESULT=usage_error')
return 2
if __name__ == '__main__':
if hasattr(sys.stdout, 'reconfigure'):
try:
sys.stdout.reconfigure(encoding='utf-8')
except (ValueError, OSError):
pass
sys.exit(main())
+37 -17
View File
@@ -31,6 +31,12 @@ import os
import subprocess
import sys
try:
import device_shell as _shell # same directory as this script
except ImportError: # run from the repository root instead
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import device_shell as _shell
DEFAULT_ADB = 'adb'
DEFAULT_SERIAL = None
@@ -82,23 +88,37 @@ def main():
mode = argv[0]
if mode == 'dev':
rc, out = run(adb, None, ['devices', '-l'])
elif mode in ('sh', 'shell'):
rc, out = run(adb, serial, ['shell', argv[1]])
elif mode == 'su':
cmd = argv[1].replace('"', '\\"')
rc, out = run(adb, serial, ['shell', 'su -c "%s"' % cmd])
elif mode == 'suf': # read a device text file
cmd = argv[1].replace('"', '\\"')
size = argv[2] if len(argv) > 2 else '6000'
rc, out = run(adb, serial, ['shell', 'su -c "head -c %s %s"' % (size, cmd)])
elif mode == 'pull':
rc, out = run(adb, serial, ['pull', argv[1], argv[2]])
elif mode == 'push':
rc, out = run(adb, serial, ['push', argv[1], argv[2]])
else:
print(__doc__)
try:
if mode == 'dev':
rc, out = run(adb, None, ['devices', '-l'])
elif mode in ('sh', 'shell'):
rc, out = run(adb, serial, ['shell', argv[1]])
elif mode == 'su':
# POSIX single-quote quoting, via the shared module. The previous form escaped only double
# quotes, so `;`, `$( )` and backticks still reached the device shell as syntax -- and this
# helper sits directly under an agent's hands. See scripts/device_shell.py.
rc, out = run(adb, serial, ['shell', _shell.su_wrap([argv[1]])])
elif mode == 'suf': # read a device text file
# `suf` interpolates a path and a byte count into a device command, so both are validated:
# a path with a space or a metacharacter is refused here rather than mangled on the device.
path = _shell.validate_device_path(argv[1])
size = argv[2] if len(argv) > 2 else '6000'
if not size.isdigit():
print('suf: size must be a decimal byte count, got %r' % size, file=sys.stderr)
return 2
rc, out = run(adb, serial, ['shell', _shell.su_wrap(['head', '-c', size, path])])
elif mode == 'pull':
rc, out = run(adb, serial, ['pull', argv[1], argv[2]])
elif mode == 'push':
rc, out = run(adb, serial, ['push', argv[1], argv[2]])
else:
print(__doc__)
return 2
except _shell.Refused as exc:
# A malformed device value is a usage error (2): distinguishable by a caller from "the device
# said no" (1) and from a defect in this tool (4). Never a traceback.
print('refused: %s' % exc, file=sys.stderr)
print('RESULT=refused')
return 2
sys.stdout.write(out)
+430 -183
View File
@@ -18,62 +18,104 @@ Run this once before a work block. It answers three questions:
It never installs anything and never touches a target. Read the report, then go.
What changed, and why the report got worse
------------------------------------------
This script used to answer question 1 from a hand-written table of 28 scripts (the
directory held 51) and from a formula that read
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`: signing needs a signer and `zipalign`, a smali
round-trip needs eight jars. On a host with only a JDK both reported OK, and every gate
downstream inherited an optimism nobody had measured. A capability verdict that is
wrong in the permissive direction is worse than no verdict at all, because it is used
to decide whether a claim is supportable.
So now:
* the script list is **scanned from this directory**, so it cannot go stale, and the
printed `registered/checked = N / N` counts every `.py` and `.js` file here;
* each script's third-party dependencies come from a **static AST pass** over its
imports, checked against `sys.stdlib_module_names` -- no script is imported or run
to find out what it needs;
* a script this pass cannot classify is printed as `unknown` and counted, never
dropped from the denominator;
* capability verdicts come from `capabilities.py`, the single registry, which probes
every atom of a capability's real closure (a missing input artifact included) and
prints an executable next step with a cost estimate.
A report that says BLOCKED on a host which genuinely cannot sign is the point. Fix the
named atom, or route the work through a capability that is ok.
Usage
-----
python doctor.py # short capability report
python doctor.py # capability + script summary
python doctor.py --scripts # per-script runnability table
python doctor.py --capabilities # every capability, with next actions
python doctor.py --device <serial> # include device checks
python doctor.py --json # machine-readable
Exit codes: 0 = nothing blocked, 3 = at least one capability blocked, 2 = usage error,
4 = internal error. The last line is `RESULT=<token>`.
"""
from __future__ import annotations
import argparse
import importlib.util
import ast
import json
import os
import platform
import shutil
import re
import subprocess
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
SKILL_DIR = os.path.dirname(HERE)
REPO_ROOT = os.path.dirname(os.path.dirname(SKILL_DIR))
if HERE not in sys.path:
sys.path.insert(0, HERE)
import capabilities as caps # noqa: E402 (path is set above on purpose)
EXIT_OK = 0
EXIT_USE = 2
EXIT_ENV = 3
EXIT_INTERNAL = 4
TOKENS = {
'ok': 'env_ok',
'partial': 'env_partial',
'blocked': 'env_blocked',
'use': 'usage_error',
'internal': 'internal_error',
}
_PROBE = caps.Probe()
# Host executables worth recognising inside a script's source. A literal outside this set
# is treated as data (an adb subcommand, a shell builtin, a path), never as a host tool:
# guessing too eagerly would report a missing tool that was never needed.
KNOWN_HOST_TOOLS = {
'java', 'javac', 'keytool', 'jarsigner', 'zipalign', 'apksigner', 'adb', 'aapt',
'aapt2', 'd8', 'dx', 'dexdump', 'frida', 'frida-ps', 'objection',
'node', 'npm', 'unzip', 'zip', '7z', 'git', 'sqlite3', 'tshark', 'tcpdump',
'mitmdump', 'rizin', 'rabin2', 'gdb', 'readelf', 'objdump', 'nm', 'strings',
'clang', 'ndk-build', 'cmake', 'make', 'python', 'python3', 'blutter', 'jadx',
'apktool', 'dex2jar', 'd2j-dex2jar',
}
# ---------------------------------------------------------------- probing
def extra_tool_dirs():
"""Directories to search beyond PATH.
Tools like jadx and apktool are frequently installed into a project-local or
versioned directory and invoked by full path, so a pure PATH probe reports them
missing and the capability table lies. Point APKREV_TOOLS at one or more
directories (os.pathsep-separated) and they are searched too.
"""
dirs = []
env = os.environ.get('APKREV_TOOLS')
if env:
dirs.extend(d for d in env.split(os.pathsep) if d)
# a tools/ dir beside the skill is a common convention and safe to probe
dirs.append(os.path.join(os.path.dirname(HERE), 'tools'))
return [d for d in dirs if os.path.isdir(d)]
"""Directories to search beyond PATH (delegated to the capability registry)."""
return caps.extra_tool_dirs()
def which(name: str):
hit = shutil.which(name)
if hit:
return hit
exts = ['']
if os.name == 'nt':
exts = ['.exe', '.bat', '.cmd', '.ps1', '']
for d in extra_tool_dirs():
for root, _dirs, files in os.walk(d):
if root.count(os.sep) - d.count(os.sep) > 3:
continue
for f in files:
stem, ext = os.path.splitext(f)
if stem.lower() == name.lower() and ext.lower() in exts:
return os.path.join(root, f)
return None
def which(name):
return _PROBE.which(name)
def run(cmd, timeout=15):
@@ -90,32 +132,25 @@ def run(cmd, timeout=15):
return 1, '%s: %s' % (type(e).__name__, e)
def have_module(name: str) -> bool:
try:
return importlib.util.find_spec(name) is not None
except Exception:
return False
def have_module(name):
"""Existence only. Importing a probe target can start threads and touch devices."""
return _PROBE.module(name)
# ---------------------------------------------------------------- capability matrix
def python_info():
v = sys.version_info
ok = (v.major, v.minor) >= (3, 9)
return {
'version': '%d.%d.%d' % (v.major, v.minor, v.micro),
'ok': ok,
'note': '' if ok else 'scripts target 3.9+; older interpreters may fail on syntax',
}
# ---------------------------------------------------------------- tools
def tool_info():
"""name -> (found_path, version_string)"""
"""name -> {path, version, note}. Version probes are bounded and failure is data."""
out = {}
probes = [
('java', ['java', '-version']),
('javac', ['javac', '-version']),
('keytool', ['keytool', '-help']),
('jarsigner', ['jarsigner', '-help']),
('adb', ['adb', 'version']),
('apksigner', ['apksigner', '--version']),
('zipalign', ['zipalign']),
('dexdump', ['dexdump']),
('frida', ['frida', '--version']),
('frida-ps', ['frida-ps', '--version']),
('objection', ['objection', '--version']),
@@ -135,27 +170,14 @@ def tool_info():
('adb-devices', ['adb', 'devices']),
]
for name, cmd in probes:
if which(cmd[0]) is None and cmd[0] != sys.executable:
# A tool that ships as a runnable .jar is not on PATH but is still usable
# via `java -jar`. Reporting it as missing makes the capability table lie.
jar_hit = None
for d in [HERE, os.path.dirname(HERE)] + extra_tool_dirs():
if not os.path.isdir(d):
continue
for root, _dirs, files in os.walk(d):
if root.count(os.sep) - d.count(os.sep) > 3:
continue
for f in files:
if f.lower() == (name + '.jar').lower():
jar_hit = os.path.join(root, f)
break
if jar_hit:
break
if jar_hit:
break
if cmd[0] != sys.executable and which(cmd[0]) is None:
# A tool that ships as a runnable .jar is not on PATH but is still usable via
# `java -jar`. Reporting it missing would make the report lie downward.
jar_hit = _PROBE.find_jar(name)
if jar_hit:
out[name] = {'path': jar_hit,
'version': 'runnable as: java -jar %s' % os.path.basename(jar_hit),
'version': 'runnable as: java -jar %s'
% os.path.basename(jar_hit),
'note': 'jar-only (not a PATH command)'}
else:
out[name] = {'path': None, 'version': None, 'note': 'not on PATH'}
@@ -175,91 +197,199 @@ def tool_info():
return out
# jar/libraries the dex tooling needs; kept as a list so doctor can name them
JAR_HINTS = ['baksmali', 'smali', 'dexlib2', 'apksigner', 'uber-apk-signer', 'd2j']
def java_toolchain():
"""Find the jars a smali round-trip needs, wherever they live."""
"""Find the jars a smali round-trip or a signer needs, wherever they live."""
found = {}
roots = [os.path.join(HERE, 'dexpatch'), HERE,
os.path.join(HERE, 'libs'), os.path.join(HERE, 'jar')]
extra = os.environ.get('APKREV_JARS')
if extra:
roots.extend(extra.split(os.pathsep))
for r in roots:
if not os.path.isdir(r):
continue
for dirpath, _dirs, files in os.walk(r):
for d in _PROBE.jar_dirs():
for dirpath, _dirs, files in os.walk(d):
if dirpath.count(os.sep) - d.count(os.sep) > 3:
continue
for f in files:
if f.lower().endswith('.jar'):
found.setdefault(f, os.path.join(dirpath, f))
return found
def script_dependencies():
"""Static, best-effort dependency map: script -> (required_modules, required_bins)."""
return {
'smtool.py': ([], ['java']),
'patch_smali.py': ([], []),
'find_refs.py': ([], []),
'dex_strings.py': ([], []),
'dex_classdiff.py': ([], []),
'dex_strpatch.py': ([], []),
'repack.py': ([], ['java']),
'install_test.py': ([], ['adb']),
'preflight.py': ([], ['adb']),
'lib_map.py': ([], ['adb']),
'apk_diff.py': ([], []),
'snap.py': ([], ['adb']),
'native_crash.py': ([], []),
'grab_crash.py': ([], ['adb']),
'blob_decode.py': ([], []),
'datastore_inject.py': ([], []),
'sig_probe.py': ([], ['java']),
'probe_api.py': ([], []),
'tls_check.py': ([], []),
'usb_net_proxy.py': ([], ['adb']),
'devsh.py': ([], ['adb']),
'elf_plt.py': ([], []),
'so_constpatch.py': ([], []),
'dart_pool_strings.py': ([], []),
'dart_pprefs.py': ([], []),
'dart_disasm.py': ([], []),
'run_probe.py': (['frida'], ['adb']),
'frida_probe.js': ([], []), # needs a frida host, checked separately
}
# ---------------------------------------------------------------- static script analysis
def _const_strings(tree):
"""Module- and function-level `NAME = 'literal'` bindings, best effort."""
consts = {}
for node in ast.walk(tree):
if isinstance(node, ast.Assign) and len(node.targets) == 1:
tgt, val = node.targets[0], node.value
if isinstance(tgt, ast.Name) and isinstance(val, ast.Constant) \
and isinstance(val.value, str):
consts.setdefault(tgt.id, val.value)
return consts
def script_report(tools, jars):
deps = script_dependencies()
def _as_name(node, consts):
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return node.value
if isinstance(node, ast.Name):
return consts.get(node.id)
return None
def _first_list_elem(call_node, consts):
"""`subprocess.run(['adb', ...])`: the first element of the first list argument."""
for arg in getattr(call_node, 'args', []):
if isinstance(arg, ast.List) and arg.elts:
return _as_name(arg.elts[0], consts)
return None
def static_python_deps(path):
"""Third-party modules and host tools a .py file needs, from its source alone.
Returns (modules, tools, error). `error` is set when the file could not be parsed,
which the caller must surface as `unknown` rather than as "no dependencies".
"""
try:
with open(path, encoding='utf-8', errors='replace') as fh:
src = fh.read()
tree = ast.parse(src, path)
except (OSError, SyntaxError) as exc:
return [], [], '%s: %s' % (type(exc).__name__, exc)
stdlib = set(sys.stdlib_module_names)
local = {f[:-3] for f in os.listdir(HERE) if f.endswith('.py')}
modules, tools = set(), set()
consts = _const_strings(tree)
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for a in node.names:
modules.add(a.name.split('.')[0])
elif isinstance(node, ast.ImportFrom):
if node.level == 0 and node.module:
modules.add(node.module.split('.')[0])
elif isinstance(node, ast.Call):
fn = node.func
attr = fn.attr if isinstance(fn, ast.Attribute) else (
fn.id if isinstance(fn, ast.Name) else '')
# A dependency is a tool the script *resolves in order to use*, or the
# program of a subprocess. A bare `shutil.which('x')` is only a probe -- the
# script usually handles the negative branch itself -- and counting it as a
# dependency reports doctor.py as needing frida-server, which is a device
# binary it merely looks for.
if attr in ('resolve_tool', 'find_executable'):
if node.args:
name = _as_name(node.args[0], consts)
if name in KNOWN_HOST_TOOLS:
tools.add(name)
elif attr in ('run', 'check_output', 'check_call', 'Popen', 'call', 'spawn',
'execvp', 'execv'):
name = _first_list_elem(node, consts)
if name in KNOWN_HOST_TOOLS:
tools.add(name)
third = sorted(m for m in modules
if m not in stdlib and m not in local and not m.startswith('_'))
return third, sorted(tools), None
def static_js_deps(path):
"""Modules a Frida JS script `require()`s. Frida's require is not a Python import,
so these are recorded as declarations, not resolved against the Python environment."""
try:
with open(path, encoding='utf-8', errors='replace') as fh:
src = fh.read()
except OSError as exc:
return [], 'OSError: %s' % exc
# strip line comments cheaply; a require() inside a string is not worth the parser
body = '\n'.join(l.split('//')[0] for l in src.splitlines())
return sorted(set(re.findall(r"""require\(\s*['"]([^'"]+)['"]\s*\)""", body))), None
def discover_scripts():
"""Every `.py` and `.js` in this directory. The list is the directory, not a table."""
names = []
for f in sorted(os.listdir(HERE)):
if f.endswith(('.py', '.js')) and os.path.isfile(os.path.join(HERE, f)):
names.append(f)
return names
def scan_scripts(cap_results):
"""Per-script facts: static deps, declared capability, and a verdict.
The verdict is the conjunction of the two independent facts about the script --
whether its own dependencies resolve, and whether the capability it serves is
available -- so a script is never reported runnable merely because it exists.
"""
rows = []
for name in sorted(deps):
for name in discover_scripts():
path = os.path.join(HERE, name)
mods, bins = deps[name]
missing_mods = [m for m in mods if not have_module(m)]
missing_bins = [b for b in bins if which(b) is None]
# scripts that shell out to the frida host
if name in ('run_probe.py', 'frida_probe.js') and which('frida') is None:
if 'frida' not in missing_bins:
missing_bins.append('frida')
ok = os.path.isfile(path) and not missing_mods and not missing_bins
rows.append({
'script': name,
'present': os.path.isfile(path),
'runnable': ok,
'missing': missing_mods + missing_bins,
})
return rows, deps
kind = 'js' if name.endswith('.js') else 'py'
cap_ids, cap_source = caps.capabilities_for_script(name, path)
row = {
'script': name, 'kind': kind, 'present': os.path.isfile(path),
'capabilities': cap_ids if cap_ids else [],
'capability_source': cap_source,
'modules': [], 'missing_modules': [], 'tools': [], 'missing_tools': [],
'js_requires': [], 'error': None, 'deps_source': '', 'verdict': 'ok',
}
if kind == 'py':
mods, tools, err = static_python_deps(path)
row['deps_source'] = 'ast-import-scan'
row['error'] = err
row['modules'] = mods
row['tools'] = tools
row['missing_modules'] = [m for m in mods if not have_module(m)]
row['missing_tools'] = [t for t in tools if which(t) is None]
else:
reqs, err = static_js_deps(path)
row['deps_source'] = 'js-require-scan'
row['error'] = err
row['js_requires'] = reqs
cap_states = [cap_results[c]['status'] for c in row['capabilities']
if c in cap_results]
missing_modules = row['missing_modules']
missing_tools = row['missing_tools']
cap_unknown = cap_ids is None
# A script's verdict is the conjunction of two independent facts: whether its own
# dependencies resolve, and whether the capability it serves is available.
#
# A missing *module* is fatal to the process (the import is at module level), so
# it blocks. A missing *tool* is not always fatal: repack.py resolves apksigner /
# keytool / zipalign only on the signing path, so on a host with no build-tools it
# is still fully usable with --no-sign, and calling that BLOCKED would be a
# pessimism of its own. When at least one capability the script serves is not
# blocked, a missing tool downgrades the script to PARTIAL and names the tool.
if cap_unknown or row['error']:
row['verdict'] = 'unknown'
elif cap_states and all(s == 'blocked' for s in cap_states):
row['verdict'] = 'blocked'
elif missing_modules:
row['verdict'] = 'blocked'
elif missing_tools and not cap_states:
row['verdict'] = 'blocked'
elif missing_tools or 'partial' in cap_states:
row['verdict'] = 'partial'
else:
row['verdict'] = 'ok'
row['runnable'] = row['verdict'] in ('ok', 'partial')
rows.append(row)
return rows
# ---------------------------------------------------------------- device
def device_report(serial=None):
if which('adb') is None:
return {'available': False, 'reason': 'adb not on PATH'}
return {'available': False, 'reason': 'adb not on PATH',
'devices': [], 'root': False, 'device_frida_processes': []}
cmd = ['adb'] + (['-s', serial] if serial else []) + ['devices', '-l']
rc, txt = run(cmd, timeout=20)
if rc != 0:
return {'available': False, 'reason': txt[:200]}
return {'available': False, 'reason': txt[:200], 'devices': [],
'root': False, 'device_frida_processes': []}
devices = []
for line in txt.splitlines()[1:]:
line = line.strip()
@@ -269,7 +399,8 @@ def device_report(serial=None):
if len(parts) >= 2:
devices.append({'serial': parts[0], 'state': parts[1],
'info': ' '.join(parts[2:])[:160]})
out = {'available': True, 'devices': devices, 'raw': txt[:400]}
out = {'available': True, 'devices': devices, 'raw': txt[:400], 'root': False,
'device_frida_processes': []}
if not devices:
out['reason'] = 'no device/emulator attached'
return out
@@ -313,50 +444,137 @@ def device_report(serial=None):
# ---------------------------------------------------------------- reporting
def capability_summary(tools, dev, py):
caps = {}
caps['static (dex/zip/strings)'] = py['ok']
caps['repack + sign'] = bool(tools['java']['path']) and py['ok']
caps['smali round-trip'] = bool(tools['java']['path']) and py['ok']
caps['native ELF patching'] = py['ok']
caps['jadx decompile'] = bool(tools['jadx']['path'])
caps['apktool unpack'] = bool(tools['apktool']['path'])
caps['device work'] = bool(dev.get('available') and dev.get('devices'))
caps['root on device'] = bool(dev.get('root'))
caps['frida dynamic'] = bool(tools['frida']['path'])
caps['emulator/home'] = bool(tools['adb']['path'])
return caps
def capability_section(results, verbose=True):
lines = caps.human_report(results, verbose=verbose)
blocked = [c for c, r in results.items() if r['status'] == 'blocked']
partial = [c for c, r in results.items() if r['status'] == 'partial']
lines.append('')
lines.append(' ok=%d partial=%d blocked=%d (of %d capabilities)'
% (len(results) - len(blocked) - len(partial), len(partial),
len(blocked), len(results)))
return lines
class _Parser(argparse.ArgumentParser):
"""argparse exits 2 on a usage error. Keep the RESULT= contract on that path too:
a caller that branches on the last line must not have to special-case bad usage."""
def error(self, message):
self.print_usage(sys.stderr)
sys.stderr.write('%s: error: %s\n' % (self.prog, message))
print('RESULT=%s' % TOKENS['use'])
raise SystemExit(EXIT_USE)
def main():
ap = argparse.ArgumentParser(description='Check what this environment can actually do.')
ap = _Parser(
description='Check what this environment can actually do. Exit 0 = nothing '
'blocked, 3 = a capability is blocked, 2 = usage, 4 = internal.')
ap.add_argument('--device', default=None, metavar='SERIAL', help='probe this device')
ap.add_argument('--scripts', action='store_true', help='per-script runnability table')
ap.add_argument('--json', action='store_true', help='emit JSON')
ap.add_argument('--capabilities', action='store_true',
help='every capability with its missing atoms and next actions '
'(the default prints the same list compactly)')
ap.add_argument('--json', action='store_true', dest='as_json', help='emit JSON')
args = ap.parse_args()
py = python_info()
tools = tool_info()
jars = java_toolchain()
dev = device_report(args.device)
rows, _deps = script_report(tools, jars)
caps = capability_summary(tools, dev, py)
try:
py = {'version': '%d.%d.%d' % sys.version_info[:3],
'ok': sys.version_info[:2] >= (3, 9),
'note': '' if sys.version_info[:2] >= (3, 9)
else 'scripts target 3.9+; older interpreters may fail on syntax'}
dev = device_report(args.device)
# Hand the device facts to the registry so it does not re-probe the device.
_PROBE.set_device({
'available': bool(dev.get('devices')),
'devices': [d['serial'] for d in dev.get('devices', [])],
'root': bool(dev.get('root')),
'frida_server': bool(dev.get('device_frida_processes')),
'reason': dev.get('reason', ''),
})
cap_results = caps.resolve_all(_PROBE)
tools = tool_info()
jars = java_toolchain()
rows = scan_scripts(cap_results)
except Exception as exc: # pragma: no cover - defensive
sys.stderr.write('internal error: %s: %s\n' % (type(exc).__name__, exc))
print('RESULT=%s' % TOKENS['internal'])
return EXIT_INTERNAL
if args.json:
print(json.dumps({'python': py, 'tools': tools, 'jars': jars,
'device': dev, 'capabilities': caps, 'scripts': rows},
indent=2, ensure_ascii=False))
return 0
status, code, token = caps.verdict(cap_results)
counts = {'ok': 0, 'partial': 0, 'blocked': 0, 'unknown': 0}
for r in rows:
counts[r['verdict']] = counts.get(r['verdict'], 0) + 1
unknown = [r['script'] for r in rows if r['verdict'] == 'unknown']
total = len(rows)
py_files = sum(1 for r in rows if r['kind'] == 'py')
js_files = total - py_files
warnings = []
for cid, r in cap_results.items():
for m in r['partial']:
warnings.append('%s: reduced -- %s' % (cid, m['atom']))
if unknown:
warnings.append('dependency verdict unknown for %d script(s): %s'
% (len(unknown), ', '.join(unknown)))
for r in rows:
for m in r['missing_modules']:
warnings.append('%s: missing python module %s' % (r['script'], m))
for t in r['missing_tools']:
warnings.append('%s: missing tool %s' % (r['script'], t))
# The next action is the first *blocked* capability's, not the first partial one's:
# a reduced capability is a caveat, a blocked one is what stops the work.
hints = [cap_results[c]['next_action'] for c in caps.CAPABILITIES
if c in cap_results and cap_results[c]['status'] == 'blocked'
and cap_results[c]['next_action']]
if args.as_json:
payload = {
'status': status,
'exit_code': code,
'capability': None,
'next_action': hints[0] if hints else '',
'warnings': warnings,
'evidence': [
{'kind': 'capability', 'capability': cid, 'atom': e['atom'],
'ok': e['ok'], 'detail': e['detail'], 'source': e['source']}
for cid, r in cap_results.items() for e in r['evidence']
] + [
{'kind': 'script', 'script': r['script'],
'detail': 'verdict=%s deps=%s capabilities=%s'
% (r['verdict'], r['deps_source'],
','.join(r['capabilities']) or 'unknown')}
for r in rows
],
# the pre-existing keys are kept so an existing consumer keeps working
'python': py,
'tools': tools,
'jars': jars,
'device': dev,
'capabilities': cap_results,
'capabilities_legacy': {c: r['status'] for c, r in cap_results.items()},
'scripts': rows,
'script_counts': {'registered': total, 'checked': total,
'py': py_files, 'js': js_files,
'ok': counts['ok'], 'partial': counts['partial'],
'blocked': counts['blocked'], 'unknown': counts['unknown']},
}
print(json.dumps(payload, indent=2, ensure_ascii=False))
print('RESULT=%s' % token)
return code
print('=' * 74)
print('apk-reverse environment doctor')
print('=' * 74)
print('platform : %s %s / %s' % (platform.system(), platform.release(), platform.machine()))
print('platform : %s %s / %s' % (platform.system(), platform.release(),
platform.machine()))
print('python : %s %s' % (py['version'], '' if py['ok'] else '<-- ' + py['note']))
print('\n--- capabilities ---')
for k, v in caps.items():
print(' [%s] %s' % ('OK ' if v else '-- ', k))
print('\n--- capabilities (%d) ---' % len(cap_results))
for line in capability_section(cap_results, verbose=args.capabilities):
print(line)
print('\n--- tools ---')
for name, info in tools.items():
@@ -368,11 +586,12 @@ def main():
print('\n--- java jars found near this skill ---')
if jars:
for k, v in sorted(jars.items()):
for k in sorted(jars):
print(' %s' % k)
else:
print(' (none) - a smali round-trip needs baksmali/smali/dexlib2 jars.')
print(' Set APKREV_JARS=<dir-with-jars> to point this script at them.')
print(' (none) - a smali round-trip needs the baksmali/smali/dexlib2 jar set.')
print(' smtool.py reads --cp, then APK_REVERSE_SMALI_CP, then scripts/smali_cp.txt.')
print(' doctor.py itself looks under APKREV_JARS and this skill directory.')
print('\n--- device ---')
if not dev.get('available'):
@@ -399,29 +618,57 @@ def main():
print(' If the target dies only while this is up, you are looking at')
print(' a probe aimed at YOU. Stop it before concluding anything.')
print('\n--- scripts ---')
print(' directory: %s' % HERE)
print(' registered/checked = %d / %d (.py=%d, .js=%d)'
% (total, total, py_files, js_files))
print(' ok=%d partial=%d blocked=%d unknown=%d'
% (counts['ok'], counts['partial'], counts['blocked'], counts['unknown']))
if args.scripts:
print('\n--- scripts ---')
ready = [r for r in rows if r['runnable']]
notready = [r for r in rows if not r['runnable']]
for r in ready:
print(' [OK ] %s' % r['script'])
for r in notready:
for r in rows:
mark = {'ok': 'OK ', 'partial': 'PARTIAL', 'blocked': 'BLOCKED',
'unknown': 'UNKNOWN'}[r['verdict']]
why = []
if not r['present']:
why.append('file missing')
if r['missing']:
why.append('missing: ' + ', '.join(r['missing']))
print(' [-- ] %-24s %s' % (r['script'], '; '.join(why)))
print('\n runnable: %d / %d' % (len(ready), len(rows)))
if r['error']:
why.append('unparsed: %s' % r['error'][:60])
if r['missing_modules']:
why.append('missing modules: ' + ', '.join(r['missing_modules']))
if r['missing_tools']:
why.append('missing tools: ' + ', '.join(r['missing_tools']))
if r['kind'] == 'js':
why.append('frida JS (requires scanned: %s)'
% (', '.join(r['js_requires']) or 'none'))
if r['capability_source'] == 'mapped' and not r['capabilities']:
why.append('no capability mapped')
if r['capability_source'] == 'self':
why.append('reports on this environment rather than using it')
caps_txt = ','.join(r['capabilities']) if r['capabilities'] else (
'self' if r['capability_source'] == 'self' else 'unknown')
print(' [%s] %-30s %-28s %s'
% (mark, r['script'], caps_txt, '; '.join(why)))
if counts['unknown']:
print('\n UNKNOWN means this pass could not decide the dependency set. It is '
'counted above,\n not skipped: read the file before trusting it.')
else:
ready = sum(1 for r in rows if r['runnable'])
print('\n scripts runnable here: %d / %d (use --scripts for the table)'
% (ready, len(rows)))
print(' use --scripts for the per-script table')
if counts['blocked']:
blocked_names = [r['script'] for r in rows if r['verdict'] == 'blocked']
print(' blocked scripts (%d): %s'
% (len(blocked_names), ', '.join(blocked_names[:8])
+ (' ...' if len(blocked_names) > 8 else '')))
if hints:
print('\n--- next action ---')
print(' %s' % hints[0])
print('\nRead references/long-task-discipline.md before a long block:')
print(' bound every wait, look at the screen while you wait, and record the '
'time-to-death before patching.')
return 0
print('RESULT=%s' % token)
return code
if __name__ == '__main__':
+4 -1
View File
@@ -723,7 +723,10 @@ def build_unsigned(apk, repl, out_apk, drop_signatures=True):
ALIGN = 4
# Entries Android requires to be STORED, and (for some) 4-byte aligned.
ALIGNED_STORED = ('resources.arsc',)
ALIGNED_PATTERNS = (re.compile(r'^lib/[^/]+\.so$'),)
# ABI-split libraries live at `lib/<abi>/*.so`, so the pattern must allow one more path segment:
# `^lib/[^/]+\.so$` matches nothing in a real APK and silently disabled STORED/alignment handling
# for every native library -- measured on a 435-entry real package (zip-safety pass, 2026-09).
ALIGNED_PATTERNS = (re.compile(r'^lib/(?:[^/]+/)*[^/]+\.so$'),)
FILLER_NAME = 'META-INF/ALIGN.RSV'
File diff suppressed because it is too large Load Diff
+75
View File
@@ -0,0 +1,75 @@
# tests/ - the executable half of this repository's evidence
`tests/benchmark.md` is a **record**: what happened when a route was run once, by hand,
against a real sample and a real device. This directory is the other half: checks that run
on every commit with nothing but the standard library and pytest.
The two are not substitutes. A benchmark row can establish that a route works on a hardened
target; it cannot tell you that yesterday's refactor broke the dex width table. A test in
here can tell you that every time, and it can never tell you whether the route achieves the
goal on a real app.
```
python -m pytest -q # whole suite
python tests/run_tests.py # same thing, with a clear message if pytest is missing
python -m pytest -q -m unit # one marker
python -m pytest -q -m cli # exit codes, flags, --json
python -m pytest -q -m integration # no-device flows
python -m pytest -q -ra # list every skip and xfail reason
```
## What the markers mean
- `unit` - pure functions and parsers of real shipped scripts: the dex width table and branch
arithmetic, the scheme-less protobuf decoder, the blob rotation search, the leak rules, and
the established `dex_patch_bytes` regression hash.
- `cli` - one subprocess run per script per invocation shape: `--help`, an unknown flag, no
arguments, and every input pointed at a file that does not exist.
- `integration` - flows that were made of several parts: adb argv construction under a fake
runner, logcat signal counting, tombstone parsing, the two-sample launch-health verdict.
## Nothing here needs a device
No test in this suite opens a frida session or touches the network. The device-dependent
behaviour is tested by intercepting the command that *would* have been run and asserting on the
argv list.
The exception is deliberate and enforced: `contract.DEVICE_OR_NETWORK_BOUND` names the 15
scripts that reach a device once their arguments parse, and those are **never executed** with
complete arguments — only `--help` and an unknown flag, which argparse answers before any
device call. That blocklist exists because a development host may well have a phone attached
(the machine this harness was written on did), and an earlier version of the CLI contract test
drove it for real. General safety net: every child runs in a private temporary working
directory, so a script that defaults an output path relative to cwd cannot write into the
shipped tree.
Anything that genuinely cannot be reached this way is not faked into a pass — it is listed as a
coverage gap in `docs/tool-verification/EXTENSION-test-harness.md`.
## Skipped is not passed
Two kinds of non-passing outcome are kept separate on purpose:
- **`skip`** - the check does not apply here. `tools/_work/bench/**` is git-ignored, so the
`dex_patch_bytes` regression hash self-skips on a fresh checkout and in CI, printing the
reason. A --json script that needs a sample to reach its JSON path skips too.
- **`xfail`** - the script is present and does **not** meet the repository contract. The
reason carries the measured value, for example
`contract says exit 2, measured 1`. These are work items, not flaky tests.
`pytest -q -ra` prints both lists. The xfail list for the CLI layer is the exit-code
unification work order; it is reproduced in `EXTENSION-test-harness.md` with owners.
## Ground rules
- **This suite never edits anything under `skills/`.** A defect found here is registered in
the harness report. Fixing a script's exit code or quoting strategy is its owner's change,
and doing it from a test would hide the drift the test exists to expose.
- **The bench tree is evidence and is read-only here.** The regression test copies its input
into `tmp_path` before patching, so `tools/_work/bench/l1l3/dex/classes.patched.dex` cannot
be overwritten by a test run.
- **No real target identity is committed.** The leak-scanner decoys are synthesised in
`tmp_path` at run time, and token-shaped decoys are assembled from fragments so that this
directory cannot itself become a finding.
- Fixtures in `tests/fixtures/` are small, synthetic sample files. `tests/fixtures/apk/**` is
owned by a different work item and is not touched from here.
+181
View File
@@ -0,0 +1,181 @@
"""Helpers for the CLI contract tests.
Everything here is derived at run time from the scripts' own `--help` output
rather than from a hard-coded list of argument names, so a new script is covered
automatically and a renamed flag does not silently stop being tested.
The contract under test is the repository-wide one recorded in
tools/_work/REBUILD-BRIEF.md section 4:
0 = success
1 = the target result failed
2 = bad arguments or input
3 = the environment lacks a capability
4 = internal tool error
A script that does not follow it yet is not a red test here: the test calls
``pytest.xfail`` with the measured code in the reason, so the summary line stays
green and the xfail list is exactly the work order for unifying exit codes.
"""
from __future__ import annotations
import json
from pathlib import Path
# A path that does not exist anywhere. Used to drive the "bad input" scenario.
NONEXISTENT = str(Path("__apkrev_missing__") / "does-not-exist.bin")
# The four contract codes, plus None for "did not return".
CONTRACT_EXIT_CODES = {0, 1, 2, 3, 4}
def parse_leading_json(text):
"""Parse the first JSON document in `text`, ignoring anything after it.
Several scripts print a `RESULT=<token>` line after their JSON document (the
repository's long-task discipline requires that token last), so a plain
json.loads on the whole stream fails. Only the first document is returned.
"""
starts = [i for i in (text.find("{"), text.find("[")) if i >= 0]
if not starts:
return None
try:
doc, _end = json.JSONDecoder().raw_decode(text[min(starts):])
except ValueError:
return None
return doc
def usage_block(help_text):
"""Return the joined `usage:` block of an argparse-rendered help text."""
lines = help_text.splitlines()
start = None
for i, line in enumerate(lines):
if line.lower().startswith("usage:"):
start = i
break
if start is None:
return None
block = [lines[start][len("usage:"):].strip()]
for line in lines[start + 1:]:
if not line.strip() or not line.startswith((" ", "\t")):
break
block.append(line.strip())
return " ".join(block)
def _strip_bracketed(block):
"""Split a usage line into tokens, dropping every bracketed (optional) group."""
tokens, depth, cur = [], 0, ""
for ch in block:
if ch in "[(":
depth += 1
continue
if ch in ")]":
depth -= 1
continue
if depth:
continue
if ch.isspace():
if cur:
tokens.append(cur)
cur = ""
else:
cur += ch
if cur:
tokens.append(cur)
return tokens
def usage_state(help_text):
"""Classify a script's usage line.
Returns one of:
"unreadable" - no argparse-style `usage:` block at all (hand-written usage)
"none-required" - a usage block exists but names no positional and no required option
"required" - at least one positional or required option is named
"""
block = usage_block(help_text)
if block is None:
return "unreadable"
return "required" if _strip_bracketed(block)[1:] else "none-required"
def missing_input_args(help_text):
"""Build an argv that points every positional and required option at a missing file.
Returns None when there is nothing to point at: either the usage block cannot
be recovered (the four scripts with a hand-written parser print their own
usage, not argparse's) or the script takes no required argument at all.
"""
if usage_state(help_text) != "required":
return None
tokens = _strip_bracketed(usage_block(help_text))
args, i = [], 1 # tokens[0] is the program name
while i < len(tokens):
token = tokens[i]
if token.startswith("-"):
args.append(token)
if i + 1 < len(tokens) and not tokens[i + 1].startswith("-"):
args.append(NONEXISTENT)
i += 1
else:
args.append(NONEXISTENT)
i += 1
return args or None
def has_option(help_text, option):
return option in help_text
# Scripts that reach a device, a live frida server or a network peer once their
# arguments parse. They are listed explicitly for one reason: this suite must not
# touch a device, and a development host may well have one attached. Measured on
# the machine this harness was written on, `adb devices` reported one online
# device, and running `coldstart.py --pkg <name>` really did start
# `adb logcat -v threadtime` and capture frames. So the exclusion is not
# theoretical: it is the difference between a unit test and a device test.
#
# `--help` and an unknown flag are still exercised for these scripts: argparse
# answers both before any device call is reached, which was verified for every
# one of them (`--help` returned in well under a second, an unknown flag returned
# 2). It is the argument-complete scenarios that are refused.
DEVICE_OR_NETWORK_BOUND = {
"coldstart.py", "datastore_inject.py", "devsh.py", "frida_rpc_serve.py",
"grab_crash.py", "install_test.py", "lib_map.py", "mt_mcp_probe.py",
"probe_api.py", "run_probe.py", "sig_probe.py", "snap.py",
"spawn_patch_detach.py", "tls_check.py", "usb_net_proxy.py",
}
def execution_refusal(name, scenario, help_text):
"""Why this script must not be executed for `scenario`, or None if it may be.
Returns a skip reason rather than a verdict: not executing a script is a
coverage limit, not a contract violation, and the two must not be reported
with the same word.
"""
if name in DEVICE_OR_NETWORK_BOUND:
return ("%s reaches a device or the network once its arguments parse; %s is not "
"executed because this suite does not touch a device" % (name, scenario))
state = usage_state(help_text)
if state == "unreadable":
return ("%s prints its own usage instead of argparse's, so %s cannot be derived "
"automatically" % (name, scenario))
if state == "none-required":
return ("%s requires no argument, so %s is legal and the contract does not apply"
% (name, scenario))
return None
# The scripts whose --json path is reachable on a bare checkout. Everything else
# that advertises --json needs a sample or a device to get that far; those are
# skipped with that reason rather than asserted against a guess.
JSON_RUNNABLE = {
"doctor.py": ["--json"],
"preflight.py": ["--json"],
}
# Keys the unified JSON contract requires at minimum.
JSON_REQUIRED_KEYS = ("status",)
JSON_CONTEXT_KEYS = ("exit_code", "capability", "evidence", "warnings", "next_action")
+262
View File
@@ -0,0 +1,262 @@
"""CLI contract tests: every shipped script, four invocation shapes each.
What is asserted, per the repository-wide contract in REBUILD-BRIEF section 4:
1. `--help` succeeds and never prints an uncaught traceback.
2. An unknown flag is a usage error: exit 2.
3. An invocation with required arguments missing, or with every input pointed
at a file that does not exist, is a usage error: exit 2.
4. A script that advertises `--json` emits a parseable JSON document that
states a `status`.
5. Every observed exit code is one of 0/1/2/3/4.
Two clean outcomes are distinguished, and the distinction is the whole point:
* `xfail` means the script is on the tree and does **not** meet the contract.
The reason carries the measured value. The xfail list this file produces is
the work order for exit-code unification -- it is not a defect to be fixed
here, because every one of those scripts has an owner.
* `skip` means the scenario is not applicable or not reachable on a machine
with no device, no network and no frida. Those are coverage gaps, and they
are reported as such in docs/tool-verification/EXTENSION-test-harness.md.
Each scenario runs once per script and is cached for the session, so the suite
spends its time on assertions rather than on re-spawning interpreters.
"""
from __future__ import annotations
import pytest
from conftest import all_scripts, run_script
from contract import (
CONTRACT_EXIT_CODES, DEVICE_OR_NETWORK_BOUND, JSON_REQUIRED_KEYS,
JSON_RUNNABLE, execution_refusal, has_option, missing_input_args,
parse_leading_json,
)
pytestmark = pytest.mark.cli
SCRIPTS = all_scripts()
BAD_FLAG = "--zzz-not-a-real-flag"
HELP_TIMEOUT = 60
SCENARIO_TIMEOUT = 12
def why(name):
if name in DEVICE_OR_NETWORK_BOUND:
return " (device/network bound: it may legitimately block)"
return ""
def require_returned(result, what):
if result.timed_out:
pytest.xfail("%s did not return within %ss%s; there is no exit code to check"
% (what, SCENARIO_TIMEOUT, why(result.argv[1])))
if result.returncode not in CONTRACT_EXIT_CODES:
pytest.fail("%s returned %r, outside the 0/1/2/3/4 contract vocabulary"
% (what, result.returncode))
def expect_exit(result, expected, what):
"""Pass when the measured code matches; otherwise record it as an xfail."""
require_returned(result, what)
if result.returncode != expected:
pytest.xfail("%s: contract says exit %d, measured %d%s"
% (what, expected, result.returncode, why(result.argv[1])))
# --------------------------------------------------------------------------- #
# one cached run per script per scenario
# --------------------------------------------------------------------------- #
@pytest.fixture(scope="session")
def help_results():
return {n: run_script(n, ["--help"], timeout=HELP_TIMEOUT) for n in SCRIPTS}
@pytest.fixture(scope="session")
def badflag_results():
return {n: run_script(n, [BAD_FLAG], timeout=SCENARIO_TIMEOUT) for n in SCRIPTS}
@pytest.fixture(scope="session")
def empty_results():
"""Bare invocations, for the scripts that may be run at all.
Device-bound scripts are excluded here too: their no-argument path is exactly
where several of them start doing device work (`usb_net_proxy.py` did not
return within 20 s when probed by hand), and this suite does not touch a device.
"""
return {n: run_script(n, [], timeout=SCENARIO_TIMEOUT) for n in SCRIPTS
if n not in DEVICE_OR_NETWORK_BOUND}
@pytest.fixture(scope="session")
def missing_results(help_results):
"""Argument-complete runs against a missing file, for the scripts that may be run at all.
Device-bound scripts are skipped here rather than executed and timed out: on
the host this harness was written on, `adb devices` reported an online device,
so running them would have touched real hardware.
"""
results = {}
for name in SCRIPTS:
if name in DEVICE_OR_NETWORK_BOUND:
continue
args = missing_input_args(help_results[name].output)
if args is None:
continue
results[name] = run_script(name, args, timeout=SCENARIO_TIMEOUT)
return results
# --------------------------------------------------------------------------- #
# 1. --help
# --------------------------------------------------------------------------- #
class TestHelp:
@pytest.mark.parametrize("name", SCRIPTS)
def test_help_never_crashes(self, name, help_results):
"""Hard assertion: an uncaught traceback from --help is a defect, not a drift."""
result = help_results[name]
assert not result.timed_out, "--help did not return for %s" % name
assert not result.has_traceback, result.output[-2000:]
@pytest.mark.parametrize("name", SCRIPTS)
def test_help_prints_something(self, name, help_results):
assert help_results[name].output.strip(), "--help printed nothing"
@pytest.mark.parametrize("name", SCRIPTS)
def test_help_exits_zero(self, name, help_results):
expect_exit(help_results[name], 0, "%s --help" % name)
# --------------------------------------------------------------------------- #
# 2. unknown flag
# --------------------------------------------------------------------------- #
class TestUnknownFlag:
@pytest.mark.parametrize("name", SCRIPTS)
def test_unknown_flag_is_never_success(self, name, badflag_results):
result = badflag_results[name]
require_returned(result, "%s %s" % (name, BAD_FLAG))
assert result.returncode != 0, (
"an unknown flag was reported as success:\n%s" % result.output[-2000:])
@pytest.mark.parametrize("name", SCRIPTS)
def test_unknown_flag_exits_two(self, name, badflag_results):
expect_exit(badflag_results[name], 2, "%s %s" % (name, BAD_FLAG))
# --------------------------------------------------------------------------- #
# 3a. empty invocation
# --------------------------------------------------------------------------- #
class TestEmptyInvocation:
@pytest.mark.parametrize("name", SCRIPTS)
def test_no_arguments_is_a_usage_error(self, name, help_results, empty_results):
"""Only applies when the script actually requires arguments.
A script whose usage block has no positional and no required option may
legitimately succeed with no arguments; demanding exit 2 from those would
invent a rule the contract does not state. Device-bound scripts are not run
at all -- see `contract.execution_refusal`.
"""
refusal = execution_refusal(name, "a bare invocation", help_results[name].output)
if refusal:
pytest.skip(refusal)
expect_exit(empty_results[name], 2, "%s with no arguments" % name)
# --------------------------------------------------------------------------- #
# 3b. bad input
# --------------------------------------------------------------------------- #
class TestMissingInput:
@pytest.mark.parametrize("name", SCRIPTS)
def test_missing_input_file_is_a_usage_error(self, name, help_results, missing_results):
if name not in missing_results:
pytest.skip(execution_refusal(name, "a missing input", help_results[name].output)
or "not executed for %s" % name)
expect_exit(missing_results[name], 2, "%s with a missing input" % name)
@pytest.mark.parametrize("name", SCRIPTS)
def test_missing_input_is_never_success(self, name, help_results, missing_results):
if name not in missing_results:
pytest.skip(execution_refusal(name, "a missing input", help_results[name].output)
or "not executed for %s" % name)
result = missing_results[name]
require_returned(result, "%s with a missing input" % name)
assert result.returncode != 0, (
"a missing input was reported as success:\n%s" % result.output[-2000:])
@pytest.mark.parametrize("name", SCRIPTS)
def test_bad_input_reports_cleanly_not_as_an_exception(self, name, help_results,
missing_results):
"""A missing file is an expected failure mode; it must not escape as an exception.
Several scripts currently let `FileNotFoundError` propagate. Each is
recorded here as an xfail naming the exception; the fix belongs to that
script's owner, not to this suite.
"""
if name not in missing_results:
pytest.skip(execution_refusal(name, "a missing input", help_results[name].output)
or "not executed for %s" % name)
result = missing_results[name]
require_returned(result, "%s with a missing input" % name)
if result.has_traceback:
tail = [l for l in result.output.splitlines() if l.strip()]
pytest.xfail("%s reports a missing input by letting an exception escape "
"instead of exiting 2 cleanly: %s"
% (name, (tail[-1] if tail else "uncaught exception")[:140]))
# --------------------------------------------------------------------------- #
# 4. the --json contract
# --------------------------------------------------------------------------- #
def json_capable(help_results):
return sorted(n for n, r in help_results.items() if has_option(r.output, "--json"))
def test_json_capable_scripts_are_discoverable(help_results):
found = json_capable(help_results)
assert found, "no script advertises --json; the discovery method is broken"
@pytest.mark.parametrize("name,args", sorted(JSON_RUNNABLE.items()))
def test_json_output_is_parseable_and_states_a_status(name, args):
result = run_script(name, args, timeout=120)
require_returned(result, "%s %s" % (name, " ".join(args)))
doc = parse_leading_json(result.stdout)
assert doc is not None, (
"%s %s printed no parseable JSON document:\n%s"
% (name, " ".join(args), result.output[-2000:]))
missing = [k for k in JSON_REQUIRED_KEYS if not isinstance(doc, dict) or k not in doc]
if missing:
present = sorted(doc) if isinstance(doc, dict) else type(doc).__name__
pytest.xfail("%s --json does not carry %s; top-level keys are %s"
% (name, missing, present))
@pytest.mark.parametrize("name", SCRIPTS)
def test_json_capable_but_unreachable_scripts_are_named(name, help_results):
"""Coverage honesty: a --json script is either exercised here or explicitly not."""
if not has_option(help_results[name].output, "--json"):
pytest.skip("%s does not advertise --json" % name)
if name in JSON_RUNNABLE:
return
pytest.skip("%s advertises --json, but reaching that path needs a device or a "
"sample; the gap is listed in EXTENSION-test-harness.md" % name)
# --------------------------------------------------------------------------- #
# 5. the exit-code vocabulary
# --------------------------------------------------------------------------- #
class TestExitCodeVocabulary:
@pytest.mark.parametrize("name", SCRIPTS)
def test_observed_codes_stay_inside_the_vocabulary(self, name, help_results,
badflag_results):
for label, result in (("--help", help_results[name]),
(BAD_FLAG, badflag_results[name])):
if result.returncode is not None:
assert result.returncode in CONTRACT_EXIT_CODES, (
"%s %s returned %r" % (name, label, result.returncode))
assert result.returncode is None or result.returncode >= 0, (
"a negative code is a crash signal, not an exit status: %s %s"
% (name, label))
+174
View File
@@ -0,0 +1,174 @@
"""Shared fixtures for the apk-reverse regression suite.
The rules this directory follows (they are the point of the directory, not a
style preference):
* Nothing here may need a device, adb, frida, or the network. Routes that do
are recorded as experiments in ``tests/benchmark.md``; their coverage gap is
stated in ``docs/tool-verification/EXTENSION-test-harness.md`` rather than
simulated into a green tick.
* A missing fixture is ``pytest.skip`` with the reason on stdout, never a red
test. ``tools/_work/bench/**`` is git-ignored, so CI legitimately does not
have it and the regression hash test must degrade to a skip there.
* This suite observes behaviour. It never edits anything under
``skills/apk-reverse/scripts/`` -- a defect found here is written into the
report as a work item, not patched in place.
"""
from __future__ import annotations
import importlib.util
import os
import subprocess
import sys
import tempfile
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
SCRIPTS_DIR = REPO_ROOT / "skills" / "apk-reverse" / "scripts"
FIXTURES_DIR = Path(__file__).resolve().parent / "fixtures"
BENCH_L1L3 = REPO_ROOT / "tools" / "_work" / "bench" / "l1l3"
# Some scripts import their siblings by bare name (`from dexutil import ...`);
# loading them as modules requires that directory on sys.path. The suite's own
# directory is added too, so `from conftest import load_script` works regardless
# of how pytest resolves the rootdir.
for _p in (SCRIPTS_DIR, Path(__file__).resolve().parent):
if str(_p) not in sys.path:
sys.path.insert(0, str(_p))
PYTHON = sys.executable
# Children run with this as their working directory, never the scripts directory.
# Measured reason: `coldstart.py` writes its capture under `--out` (default
# `coldstart/`), so a contract test that ran it with the scripts directory as cwd
# left a `skills/apk-reverse/scripts/coldstart/logcat.txt` behind. This suite must
# not write anywhere near the shipped tree, and a script that defaults an output
# path relative to cwd is exactly the case that catches a harness out.
ISOLATED_CWD = tempfile.mkdtemp(prefix="apkrev-suite-cwd-")
# The regression marker established before this harness existed. It is a hash,
# not a golden-file rewrite: scripts/dex_patch_bytes.py applied to the
# l1l3 fixture must still reproduce the file that the recorded benchmark row
# produced.
L1L3_PATCHED_SHA256 = "67ae1e58e7a44777e5ac8f9f823ab21e"
_loaded: dict[str, object] = {}
class ScriptResult:
"""Result of running one script as a subprocess."""
def __init__(self, argv, returncode, stdout, stderr, timed_out=False):
self.argv = argv
self.returncode = returncode
self.stdout = stdout
self.stderr = stderr
self.timed_out = timed_out
@property
def output(self) -> str:
return self.stdout + self.stderr
@property
def has_traceback(self) -> bool:
return "Traceback (most recent call last)" in self.output
def __repr__(self) -> str: # keeps assertion failures readable
return "ScriptResult(argv=%r, rc=%r, timed_out=%r)" % (
self.argv, self.returncode, self.timed_out)
def script_path(name: str) -> Path:
return SCRIPTS_DIR / name
def all_scripts() -> list[str]:
"""Every shipped script, sorted. Used to parameterise the CLI contract tests."""
return sorted(p.name for p in SCRIPTS_DIR.glob("*.py"))
def run_script(name, args=(), timeout=60, cwd=None, stdin=None, env=None):
"""Run one shipped script in a subprocess and capture everything.
The child runs with ``PYTHONIOENCODING=utf-8`` so that a non-UTF-8 console
(a Windows CP936 shell, a CI runner with C locale) does not turn a UTF-8
print into a UnicodeEncodeError and change the exit code under test, and with
an isolated working directory so that a script defaulting an output path
relative to cwd cannot write into the shipped tree.
"""
argv = [PYTHON, str(script_path(name))] + [str(a) for a in args]
child_env = dict(os.environ)
child_env["PYTHONIOENCODING"] = "utf-8"
child_env["PYTHONDONTWRITEBYTECODE"] = "1"
if env:
child_env.update(env)
try:
proc = subprocess.run(
argv, capture_output=True, text=True, encoding="utf-8", errors="replace",
timeout=timeout, cwd=str(cwd or ISOLATED_CWD), input=stdin, env=child_env,
)
except subprocess.TimeoutExpired as exc:
return ScriptResult(argv, None, _text(exc.stdout), _text(exc.stderr),
timed_out=True)
return ScriptResult(argv, proc.returncode, proc.stdout or "", proc.stderr or "")
def _text(blob):
if blob is None:
return ""
if isinstance(blob, bytes):
return blob.decode("utf-8", "replace")
return blob
def load_script(name):
"""Import a shipped script as a module, so pure functions can be unit-tested.
The module is registered under a namespaced name (``apkrev_<stem>``) because
several scripts have siblings with the same basename in other trees.
"""
if name in _loaded:
return _loaded[name]
path = script_path(name)
mod_name = "apkrev_" + path.stem
spec = importlib.util.spec_from_file_location(mod_name, str(path))
if spec is None or spec.loader is None:
raise ImportError("cannot load %s" % path)
module = importlib.util.module_from_spec(spec)
sys.modules[mod_name] = module
spec.loader.exec_module(module)
_loaded[name] = module
return module
def require_path(path: Path, why: str):
"""Skip with a printed reason when a git-ignored fixture is absent."""
if not Path(path).exists():
pytest.skip("fixture absent: %s (%s)" % (path, why))
return Path(path)
@pytest.fixture(scope="session")
def repo_root():
return REPO_ROOT
@pytest.fixture(scope="session")
def scripts_dir():
return SCRIPTS_DIR
@pytest.fixture(scope="session")
def fixtures_dir():
return FIXTURES_DIR
@pytest.fixture(scope="session")
def bench_l1l3():
return require_path(
BENCH_L1L3,
"tools/_work/bench is git-ignored; re-download the l1l3 sample to run the "
"dex_patch_bytes regression hash test",
)
+9
View File
@@ -0,0 +1,9 @@
# The APKs in this directory are committed on purpose.
#
# They are synthesised by ../make_fixture_apk.py (a few kilobytes each, no target data)
# and they are the input of tests/test_so_constpatch.py, which pins the contract that
# scripts/so_constpatch.py must keep when it rebuilds an archive: STORED stays STORED,
# extra / external_attr / create_system survive, and resources.arsc keeps its 4-byte
# alignment. The repository-wide `*.apk` rule exists to keep pulled samples out of the
# tree, which is a different thing from a generated fixture.
!*.apk
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+11
View File
@@ -0,0 +1,11 @@
--------- beginning of main
01-01 10:00:01.100 1234 1234 I ActivityManager: Start proc 1234:com.example.app/u0a123 for activity
01-01 10:00:01.500 1234 1234 D AndroidRuntime: Shutting down VM
01-01 10:00:01.501 1234 1234 E AndroidRuntime: FATAL EXCEPTION: main
01-01 10:00:01.501 1234 1234 E AndroidRuntime: Process: com.example.app, PID: 1234
01-01 10:00:01.502 1234 1234 E AndroidRuntime: java.lang.VerifyError: Rejecting class Lprobe/Synthetic;
01-01 10:00:01.503 1234 1234 W dalvikvm: Failure to verify dex file: Bad checksum (12345678, expected 9abcdef0)
01-01 10:00:01.504 1234 1234 E AndroidRuntime: Caused by: java.lang.UnsatisfiedLinkError: dlopen failed
01-01 10:00:02.000 1000 1500 I ActivityManager: Displayed com.example.app/.MainActivity: +842ms
01-01 10:00:03.100 1000 1500 W ActivityManager: ANR in com.example.app (com.example.app/.MainActivity)
01-01 10:00:03.200 1000 1500 E ActivityManager: Reason: Input dispatching timed out
+369
View File
@@ -0,0 +1,369 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Build the four tiny APKs that pin the APK-rebuild contract of `so_constpatch.py`.
WHY THIS EXISTS
---------------
`so_constpatch.py` rebuilt an APK through `zipfile.ZipInfo(filename, date_time)`, which
drops `extra`, `external_attr`, `create_system` and re-deflates every entry. An entry
that must stay STORED or 4-byte aligned (`resources.arsc`, an uncompressed `lib/*.so`)
can therefore come back compressed or unaligned, and the result is refused by the
installer with `[-124]` -- or simply not loadable when the manifest asks for
`extractNativeLibs="false"`. A regression test for that fix needs inputs that actually
carry the metadata which was being dropped, at a size worth keeping in the repository:
plain.apk DEFLATED entries, no `resources.arsc` -> an unsafe rebuild is not
in play, so this is the "rebuild is allowed" control
arsc_aligned.apk `resources.arsc` STORED and 4-byte aligned, plus a STORED and a
DEFLATED `lib/*.so` -> the storage/alignment contract must survive
noextract.apk the manifest declares `android:extractNativeLibs="false"` and both
`resources.arsc` and `lib/*.so` are STORED+aligned -> a rebuild must
be REFUSED unless `--unsafe-rebuild` is passed
services.apk `META-INF/services/**` entries, a UTF-8 entry name and a non-empty
archive comment -> untouched entries must come back byte-identical
Nothing here is target data: every fixture is synthesised from the constants below. Two
details exist so the fixtures are not weaker than real input:
* `AndroidManifest.xml` is a real binary AXML document (string pool + start element), so
the manifest check reads the same bytes a compiler-produced manifest would give it.
* `resources.arsc` is a valid `RES_TABLE_TYPE` header plus filler: enough to pin the
storage/alignment contract, not a renderable resource table.
* the `lib/*.so` payload is a real (tiny) aarch64 shared object with `.rodata` and
`.shstrtab` section headers, so `--section-aware` exercises the section map instead of
falling back to "no sections".
USAGE
-----
python make_fixture_apk.py --out tests/fixtures/apk
The generated files are committed. Regenerate only when the contract itself changes;
the output is deterministic (fixed timestamps, fixed compression level, no randomness).
"""
from __future__ import annotations
import argparse
import hashlib
import os
import struct
import zlib
ALIGN = 4
STAMP_UNIX = 1577836800 # 2020-01-01T00:00:00Z
FORCE_UTF8 = 0x800 # general purpose bit 11
# ------------------------------------------------------------------ small builders
def _dos(dt):
"""(time, date) MS-DOS words, the way a zip local header stores them."""
y, mo, d, h, mi, s = dt
return (h << 11) | (mi << 5) | (s // 2), ((y - 1980) << 9) | (mo << 5) | d
def _deflate(data):
"""Raw deflate (no zlib wrapper) -- what a method-8 zip entry holds."""
c = zlib.compressobj(9, zlib.DEFLATED, -15)
return c.compress(data) + c.flush()
def ext_stamp(unix=STAMP_UNIX):
"""An extended-timestamp extra field (id 0x5455), so `extra` is never empty.
Real APKs carry this on entries written by a toolchain that stamps mtimes, and it is
exactly the field a rebuilt archive used to lose.
"""
return b'\x55\x54' + struct.pack('<H', 5) + b'\x01' + struct.pack('<I', unix)
def align_record(pad):
"""A private extra record of `4 + pad` bytes, i.e. `pad` bytes of alignment.
A zip extra area is a sequence of (id, 2-byte size, payload) records, so the smallest
record is 4 bytes and a 1-3 byte pad cannot be expressed on its own. Appending a
record of length `4 + pad` shifts the data offset by `pad` (mod 4), which is exactly
what is needed, without inserting a filler entry.
"""
if pad <= 0:
return b''
return b'\xca\xca' + struct.pack('<H', pad) + b'\x00' * pad
def make_elf(const=b'apkhuan', decoy=b'libapkhuanx.so'):
"""A minimal aarch64 ET_DYN object: ELF header, `.rodata`, `.shstrtab`, shdrs.
`const` sits alone between NULs (an isolated constant, safe to rewrite in place);
`decoy` contains the same bytes as a substring, which is the case `--find` must
report as NOT isolated.
"""
rodata = b'\x00' + const + b'\x00' + decoy + b'\x00'
shstr = b'\x00.rodata\x00.shstrtab\x00'
ehsize, shentsize, shnum, shstrndx = 64, 64, 3, 2
ro_off = ehsize
shstr_off = ro_off + len(rodata)
sh_off = (shstr_off + len(shstr) + 7) & ~7
ident = b'\x7fELF' + bytes([2, 1, 1, 0]) + b'\x00' * 8
ehdr = ident + struct.pack('<HHIQQQIHHHHHH',
3, # e_type = ET_DYN
183, # e_machine= EM_AARCH64
1, # e_version
0, 0, # e_entry, e_phoff
sh_off, # e_shoff
0, # e_flags
ehsize, 0, 0, shentsize, shnum, shstrndx)
def shdr(name, typ, flags, addr, off, size, align=1):
return struct.pack('<IIQQQQIIQQ', name, typ, flags, addr, off, size,
0, 0, align, 0)
shdrs = (shdr(0, 0, 0, 0, 0, 0)
+ shdr(1, 1, 0x2, 0x1000, ro_off, len(rodata)) # .rodata PROGBITS
+ shdr(9, 3, 0, 0, shstr_off, len(shstr))) # .shstrtab STRTAB
pad = b'\x00' * (sh_off - (shstr_off + len(shstr)))
return ehdr + rodata + shstr + pad + shdrs
def _string_pool(strings):
"""A UTF-8 (flag 0x100) RES_STRING_POOL chunk."""
offsets, blob = [], b''
for s in strings:
raw = s.encode('utf-8')
assert len(raw) < 0x80 and len(s) < 0x80, 'fixture pool is ASCII/short on purpose'
offsets.append(len(blob))
blob += bytes([len(raw), len(s)]) + raw + b'\x00'
strings_start = 28 + 4 * len(strings)
header = struct.pack('<HHI', 0x0001, 28, strings_start + len(blob))
header += struct.pack('<IIIII', len(strings), 0, 0x100, strings_start, 0)
return header + b''.join(struct.pack('<I', o) for o in offsets) + blob
def make_axml(package='com.example.fixture', extract_native_libs=None):
"""A minimal but structurally valid binary AndroidManifest.xml.
Layout: file header, one string pool, one START_ELEMENT chunk for <manifest>. The
attribute shape matches what aapt2 emits: `extractNativeLibs` is a boolean
(dataType 0x12) with no raw string, namespaced to the android URI.
"""
android_uri = 'http://schemas.android.com/apk/res/android'
strings = ['android', android_uri, 'manifest', 'package', 'extractNativeLibs', package]
s = {v: i for i, v in enumerate(strings)}
attrs = [(0xFFFFFFFF, s['package'], 0xFFFFFFFF, 0x03, s[package])]
if extract_native_libs is not None:
attrs.append((s[android_uri], s['extractNativeLibs'], 0xFFFFFFFF, 0x12,
1 if extract_native_libs else 0))
pool = _string_pool(strings)
def chunk(ctype, node, tail):
# chunk size covers the 8-byte chunk header as well as `node` and `tail`.
body = node + tail
return struct.pack('<HHI', ctype, 16, 8 + len(body)) + body
node = struct.pack('<II', 1, 0xFFFFFFFF) # lineNumber, comment
body = struct.pack('<II', 0xFFFFFFFF, s['manifest']) # ns, name
body += struct.pack('<HHHHHH', 20, 20, len(attrs), 0, 0, 0)
for ns, name, raw, dtype, data in attrs:
body += struct.pack('<III', ns, name, raw)
body += struct.pack('<HBBI', 8, 0, dtype, data)
element = chunk(0x0102, node, body)
end = chunk(0x0103, node, struct.pack('<II', 0xFFFFFFFF, s['manifest']))
total = 8 + len(pool) + len(element) + len(end)
return struct.pack('<HHI', 0x0003, 8, total) + pool + element + end
def make_arsc(filler=60):
"""A `RES_TABLE_TYPE` header with zero packages, plus filler bytes."""
body = struct.pack('<I', 0) + b'\x00' * filler
return struct.pack('<HHI', 0x0002, 12, 12 + len(body)) + body
def make_dex(classes=2):
"""A dex header whose file_size matches, so a reader does not reject it outright.
Only the header is written: the fixtures exist to pin zip behaviour, not dex
semantics, and a full dex would be the largest file in the repository's test tree.
"""
header_size, endian = 0x70, 0x12345678
size = header_size + 4 * classes
h = bytearray(header_size)
h[0:8] = b'dex\n035\x00'
h[32:36] = struct.pack('<I', size)
h[36:40] = struct.pack('<I', header_size)
h[40:44] = struct.pack('<I', endian)
h[104:108] = struct.pack('<I', 0) # data_size
h[108:112] = struct.pack('<I', header_size)
return bytes(h) + b'\x00' * (size - header_size)
# ------------------------------------------------------------------ zip writer
class Entry(object):
"""One fixture entry, with the metadata the rebuild must carry over."""
def __init__(self, name, data, method=8, date_time=(2020, 1, 1, 0, 0, 0),
extra=None, external_attr=0o100644 << 16, internal_attr=0,
create_system=3, comment=b'', align=False, utf8=False):
self.name = name
self.data = data
self.method = method
self.date_time = date_time
self.extra = ext_stamp() if extra is None else extra
self.external_attr = external_attr
self.internal_attr = internal_attr
self.create_system = create_system
self.comment = comment
self.align = align
self.utf8 = utf8
@property
def flags(self):
return FORCE_UTF8 if self.utf8 else 0
def write_apk(path, entries, archive_comment=b''):
"""Write a zip the way zipalign does: STORED stays STORED, aligned stays aligned.
Written by hand rather than through `zipfile` because alignment is a property of the
local header length, which `zipfile` cannot express -- the same reason `repack.py`
hand-rolls its writer.
"""
out = bytearray()
cd = []
for e in entries:
name_b = e.name.encode('utf-8')
payload = e.data if e.method == 0 else _deflate(e.data)
crc = zlib.crc32(e.data) & 0xFFFFFFFF
extra = e.extra
if e.align:
head = len(out) + 30 + len(name_b) + len(extra)
extra += align_record((ALIGN - head % ALIGN) % ALIGN)
t, d = _dos(e.date_time)
local_off = len(out)
out += struct.pack('<IHHHHHIIIHH', 0x04034B50, 20, e.flags, e.method, t, d,
crc, len(payload), len(e.data), len(name_b), len(extra))
out += name_b + extra + payload
data_off = local_off + 30 + len(name_b) + len(extra)
if e.align and data_off % ALIGN:
raise RuntimeError('%s is not %d-byte aligned' % (e.name, ALIGN))
cd.append((e, name_b, crc, len(payload), len(extra), local_off))
cd_off = len(out)
for e, name_b, crc, csize, elen, local_off in cd:
t, d = _dos(e.date_time)
out += struct.pack('<IHHHHHHIIIHHHHHII', 0x02014B50,
(e.create_system << 8) | 20, 20, e.flags, e.method, t, d,
crc, csize, len(e.data), len(name_b), len(e.extra),
len(e.comment), 0, e.internal_attr, e.external_attr,
local_off)
out += name_b + e.extra + e.comment
cd_size = len(out) - cd_off
out += struct.pack('<IHHHHIIH', 0x06054B50, 0, 0, len(cd), len(cd), cd_size,
cd_off, len(archive_comment))
out += archive_comment
folder = os.path.dirname(os.path.abspath(path))
if folder:
os.makedirs(folder, exist_ok=True)
with open(path, 'wb') as fh:
fh.write(bytes(out))
return bytes(out)
# ------------------------------------------------------------------ the fixtures
def definitions():
"""name -> (entries, archive comment). Deterministic; no timestamps from the clock.
Every STORED entry is 4-byte aligned, the way `zipalign` lays a real APK out: that is
what makes "the rebuild did not break alignment" a checkable claim, and what keeps a
rebuilt fixture acceptable to `zipalign -c`.
"""
elf = make_elf()
dex = make_dex()
strings_xml = b'<?xml version="1.0" encoding="utf-8"?>\n<resources/>\n'
plain_manifest = make_axml('com.example.plain')
noextract_manifest = make_axml('com.example.noextract', extract_native_libs=False)
st = dict(method=0, align=True, date_time=(2021, 6, 15, 12, 34, 56))
plain = [
Entry('AndroidManifest.xml', plain_manifest, **st),
Entry('classes.dex', dex, external_attr=0o100600 << 16),
Entry('res/values/strings.xml', strings_xml),
Entry('lib/arm64-v8a/libfoo.so', elf),
Entry('META-INF/MANIFEST.MF', b'Manifest-Version: 1.0\r\n\r\n'),
]
arsc_aligned = [
Entry('AndroidManifest.xml', plain_manifest, **st),
Entry('classes.dex', dex),
# DEFLATED and patched: its compressed size moves, so resources.arsc has to be
# re-aligned on output. This is the case the old writer silently broke.
Entry('lib/arm64-v8a/libfoo.so', elf),
# STORED: must stay STORED, must not be re-compressed, must stay aligned.
Entry('lib/arm64-v8a/libbar.so', elf, method=0, align=True,
external_attr=0o100755 << 16),
Entry('resources.arsc', make_arsc(), method=0, align=True),
Entry('res/values/strings.xml', strings_xml),
Entry('META-INF/services/com.example.Provider',
b'com.example.ProviderImpl\n'),
]
noextract = [
Entry('AndroidManifest.xml', noextract_manifest, **st),
Entry('classes.dex', dex),
Entry('lib/arm64-v8a/libfoo.so', elf, method=0, align=True),
Entry('resources.arsc', make_arsc(), method=0, align=True),
]
services = [
Entry('AndroidManifest.xml', plain_manifest, **st),
Entry('META-INF/services/com.example.Provider', b'com.example.ProviderImpl\n'),
Entry('META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory',
b'kotlinx.coroutines.internal.MainDispatcherFactoryImpl\n', method=0,
align=True),
Entry('lib/x86_64/libfoo.so', elf, external_attr=0),
# A non-ASCII name with the UTF-8 flag (bit 11) set: the raw name bytes and the
# flag must both survive a rebuild, and this entry must not be touched at all.
Entry('res/raw/\u6d4b\u8bd5.bin', b'fixture-payload\x00', utf8=True,
create_system=0, external_attr=0, internal_attr=1,
comment=b'fixture comment'),
]
return {
'plain.apk': (plain, b''),
'arsc_aligned.apk': (arsc_aligned, b''),
'noextract.apk': (noextract, b''),
'services.apk': (services, b'fixture-archive-comment'),
}
def main():
ap = argparse.ArgumentParser(description=__doc__.split('\n')[0])
ap.add_argument('--out', default=os.path.join(os.path.dirname(os.path.abspath(__file__)),
'apk'),
help='directory to write the fixtures into')
ap.add_argument('--list', action='store_true', help='print names and exit')
a = ap.parse_args()
defs = definitions()
if a.list:
for name in sorted(defs):
print(name)
return 0
for name in sorted(defs):
entries, comment = defs[name]
path = os.path.join(a.out, name)
blob = write_apk(path, entries, comment)
print('%-18s %6d bytes %2d entries sha256=%s'
% (name, len(blob), len(entries), hashlib.sha256(blob).hexdigest()[:16]))
print('wrote %d fixture(s) to %s' % (len(defs), os.path.abspath(a.out)))
return 0
if __name__ == '__main__':
raise SystemExit(main())
+20
View File
@@ -0,0 +1,20 @@
*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
Build fingerprint: 'google/sdk_gphone64_arm64/sdk_gphone64_arm64:14/UE1A.230829.036/11228888:userdebug/dev-keys'
Revision: '0'
ABI: 'arm64'
Timestamp: 2026-01-01 10:00:01.500000000+0000
Process uptime: 5
Cmdline: com.example.app
pid: 12345, tid: 12345, name: com.example.app >>> com.example.app <<<
uid: 10123
signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0000000000000010
x0 0000000000000010 x1 0000007a12345678 x2 0000000000000000 x3 0000000000000020
x4 0000007a98765432 x5 0000000000000001 x6 0000000000000000 x7 0000000000000008
x8 0000000000000098 x9 0000000000000000 x10 0000007ffabc1234 x11 0000000000000000
sp 0000007ffabc1110 lr 0000007a11112222 pc 0000007a111128cc pst 0000000060001000
backtrace:
#00 pc 00000000000128cc /data/app/~~AbCdEf==/com.example.app-1/lib/arm64/libfoo.so
#01 pc 0000000000020f40 /data/app/~~AbCdEf==/com.example.app-1/lib/arm64/libfoo.so (Java_com_example_app_Native_check+120)
#02 pc 00000000000cccd0 /apex/com.android.runtime/lib64/bionic/libc.so (__pthread_start+256)
#03 pc 0000000000055488 /apex/com.android.runtime/lib64/bionic/libc.so (__start_thread+68)
Cause: null pointer dereference? no -- read of a small integer address
@@ -0,0 +1,334 @@
"""Device-side command construction, exercised without a device.
The behaviour under test is the one the repository's own documentation says is
the reason these helpers exist: a device command is built as an **argv list** and
handed to `subprocess` directly, so the host shell never expands `$`, `|`, `>`,
`;` or quotes before adb sees them. Every assertion here inspects the argv list
that would have been executed; none of them launches adb.
"""
from __future__ import annotations
import re
import subprocess
import sys
import pytest
from conftest import load_script
devsh = load_script("devsh.py")
coldstart = load_script("coldstart.py")
install_test = load_script("install_test.py")
pytestmark = pytest.mark.integration
class Recorder:
"""Captures the argv lists handed to a patched subprocess runner."""
def __init__(self, stdout="", stderr="", returncode=0):
self.calls = []
self.stdout = stdout
self.stderr = stderr
self.returncode = returncode
def __call__(self, cmd, *args, **kwargs):
self.calls.append((list(cmd), kwargs))
return subprocess.CompletedProcess(cmd, self.returncode, self.stdout, self.stderr)
@property
def last(self):
return self.calls[-1][0]
# --------------------------------------------------------------------------- #
# devsh.py
# --------------------------------------------------------------------------- #
@pytest.fixture
def devsh_recorder(monkeypatch):
rec = Recorder(stdout="ok\n")
monkeypatch.setattr(devsh.subprocess, "run", rec)
monkeypatch.delenv("ADB_PATH", raising=False)
monkeypatch.delenv("ADB_SERIAL", raising=False)
return rec
def run_devsh(monkeypatch, rec, argv):
monkeypatch.setattr(sys, "argv", ["devsh.py"] + argv)
return devsh.main()
class TestDevshCommandConstruction:
def test_shell_command_is_one_argv_element_not_a_shell_string(self, monkeypatch, devsh_recorder):
"""`|` and `grep` must survive as literal text inside a single argument."""
assert run_devsh(monkeypatch, devsh_recorder, ["sh", "pm list packages | grep myapp"]) == 0
assert devsh_recorder.last == ["adb", "shell", "pm list packages | grep myapp"]
def test_serial_is_injected_as_dash_s(self, monkeypatch, devsh_recorder):
run_devsh(monkeypatch, devsh_recorder, ["--serial", "SERIAL123", "sh", "id"])
assert devsh_recorder.last == ["adb", "-s", "SERIAL123", "shell", "id"]
def test_serial_equals_form_is_accepted(self, monkeypatch, devsh_recorder):
run_devsh(monkeypatch, devsh_recorder, ["--adb=x", "--serial=SERIAL123", "sh", "id"])
assert devsh_recorder.last == ["x", "-s", "SERIAL123", "shell", "id"]
def test_adb_path_comes_from_the_environment_when_not_given(self, monkeypatch, devsh_recorder):
monkeypatch.setenv("ADB_PATH", "/opt/platform-tools/adb")
monkeypatch.setenv("ADB_SERIAL", "ENVSERIAL")
run_devsh(monkeypatch, devsh_recorder, ["sh", "id"])
assert devsh_recorder.last == ["/opt/platform-tools/adb", "-s", "ENVSERIAL", "shell", "id"]
def test_device_listing_explicitly_ignores_the_serial(self, monkeypatch, devsh_recorder):
run_devsh(monkeypatch, devsh_recorder, ["dev"])
assert devsh_recorder.last == ["adb", "devices", "-l"]
def test_pull_and_push_keep_their_arguments_positional(self, monkeypatch, devsh_recorder):
run_devsh(monkeypatch, devsh_recorder, ["pull", "/data/local/tmp/x.apk", "./x.apk"])
assert devsh_recorder.last == ["adb", "pull", "/data/local/tmp/x.apk", "./x.apk"]
run_devsh(monkeypatch, devsh_recorder, ["push", "./x.apk", "/data/local/tmp/x.apk"])
assert devsh_recorder.last == ["adb", "push", "./x.apk", "/data/local/tmp/x.apk"]
def test_a_nonzero_device_status_is_reported_as_one(self, monkeypatch, devsh_recorder):
devsh_recorder.returncode = 1
assert run_devsh(monkeypatch, devsh_recorder, ["sh", "false"]) == 1
def test_the_su_wrapper_is_posix_single_quoted(self, monkeypatch, devsh_recorder):
# Assert the *property*, not the representation. The previous version of this test pinned
# `su -c "..."` with `\"` escapes, which is exactly the form that let `;`, `$( )` and
# backticks through to the device shell -- the test was guarding the defect.
run_devsh(monkeypatch, devsh_recorder, ["su", 'pm grant com.example.app "android.permission.X"'])
payload = devsh_recorder.last[-1]
assert payload.startswith("su -c '") and payload.endswith("'")
inner = payload[len("su -c '"):-1]
# A POSIX single-quoted string: no bare quote may appear except as the '\'' escape.
assert re.fullmatch(r"[^']*('\\''[^']*)*", inner), payload
assert '"' in inner # the caller's double quotes survive as literal characters
def test_an_unknown_mode_prints_usage_and_exits_two(self, monkeypatch, devsh_recorder):
assert run_devsh(monkeypatch, devsh_recorder, ["frobnicate", "x"]) == 2
def test_no_arguments_prints_usage_and_exits_two(self, monkeypatch, devsh_recorder):
assert run_devsh(monkeypatch, devsh_recorder, []) == 2
class TestDevshSuQuotingGap:
"""The quoting contract, now unified through `scripts/device_shell.py`.
This class used to record a *gap*: `devsh.su` wrapped the command in double quotes and escaped
only `"`, so `$`, backticks, `;` and `|` from a caller-supplied value still reached the device
shell, while `coldstart.Device.shell` used the POSIX single-quote escape and did neutralise them.
Both now go through the shared module; the tests below pin the property from both sides, so a
future edit that reintroduces double-quote escaping fails here instead of on a device.
"""
@pytest.mark.parametrize("payload", [
"id; rm -rf /sdcard/x",
"id$(touch /data/local/tmp/pwned)",
"id`touch /data/local/tmp/pwned`",
"id | tee /data/local/tmp/pwned",
"id 'quoted'",
])
def test_su_wrapper_neutralises_shell_metacharacters(self, monkeypatch, devsh_recorder, payload):
run_devsh(monkeypatch, devsh_recorder, ["su", payload])
wrapped = devsh_recorder.last[-1]
assert wrapped.startswith("su -c '"), wrapped
inner = wrapped[len("su -c '"):-1]
# The payload must be present verbatim, with only the '\'' sequence able to break out --
# i.e. the whole payload is one argument to sh, not syntax.
assert re.fullmatch(r"[^']*('\\''[^']*)*", inner), wrapped
assert payload.replace("'", "'\\''") == inner
def test_a_malformed_path_is_refused_before_the_device_shell(self, monkeypatch, devsh_recorder):
"""`suf` interpolates a path, so it validates rather than quotes-and-hopes."""
rc = run_devsh(monkeypatch, devsh_recorder, ["suf", "/data/local/tmp/x;id", "200"])
assert rc == 2
assert not devsh_recorder.calls, "the refused command must never reach adb"
def test_coldstart_uses_the_posix_single_quote_escape(self, monkeypatch):
"""The contrasting implementation: this one does neutralise metacharacters."""
rec = Recorder()
monkeypatch.setattr(coldstart, "run", rec)
dev = coldstart.Device(None, use_su=False)
dev.su = True
dev.shell("echo 'hi'; rm -rf /sdcard/x")
payload = rec.last[-1]
assert payload.startswith("su -c '")
assert payload.endswith("'")
assert "'\\''" in payload, "the POSIX single-quote escape is missing"
# --------------------------------------------------------------------------- #
# coldstart.Device: command shape and screenshot handling
# --------------------------------------------------------------------------- #
class TestDeviceHelperCommands:
def make_device(self, monkeypatch, serial=None):
rec = Recorder()
monkeypatch.setattr(coldstart, "run", rec)
dev = coldstart.Device(serial, use_su=False)
return dev, rec
def test_shell_without_root_passes_the_command_as_one_argument(self, monkeypatch):
dev, rec = self.make_device(monkeypatch, serial="SERIAL123")
dev.shell("dumpsys window | grep mCurrentFocus")
assert rec.last == ["adb", "-s", "SERIAL123", "shell", "dumpsys window | grep mCurrentFocus"]
def test_shell_without_a_serial_omits_dash_s(self, monkeypatch):
dev, rec = self.make_device(monkeypatch)
dev.shell("id")
assert rec.last == ["adb", "shell", "id"]
def test_foreground_activity_parses_the_resumed_component(self, monkeypatch):
dev, rec = self.make_device(monkeypatch, serial="S")
rec.stdout = ("mResumedActivity: ActivityRecord{9f2c1 u0 "
"com.example.app/.MainActivity t42}\n")
assert coldstart.foreground_activity(dev) == "com.example.app/.MainActivity"
def test_foreground_activity_returns_none_when_absent(self, monkeypatch):
dev, rec = self.make_device(monkeypatch, serial="S")
rec.stdout = "no matches\n"
assert coldstart.foreground_activity(dev) is None
def test_installed_facts_extracts_version_and_uid(self, monkeypatch):
dev, rec = self.make_device(monkeypatch, serial="S")
def respond(cmd, *args, **kwargs):
rec.calls.append((list(cmd), kwargs))
if "versionCode" in cmd[-1]:
out = " versionCode=42 minSdk=24 targetSdk=34\n versionName=1.2.3\n"
elif "sha256sum" in cmd[-1]:
out = "a" * 64 + " /data/app/base.apk\n"
elif "base.apk" in cmd[-1]:
out = "/data/app/~~x==/com.example.app-1/base.apk\n"
elif "userId=" in cmd[-1]:
out = " userId=10123\n"
else:
out = ""
return subprocess.CompletedProcess(cmd, 0, out, "")
monkeypatch.setattr(coldstart, "run", respond)
facts = coldstart.installed_facts(dev, "com.example.app")
assert facts["versionName"] == "1.2.3"
assert facts["uid"] == "10123"
assert facts["apk_sha256"] == "a" * 64
def test_version_code_swallows_the_rest_of_the_line(self, monkeypatch):
"""Measured sub-string behaviour of `installed_facts`, recorded as a work item.
The extraction is `r'\\s*(versionCode|...)=(.*)'`, so on a real
`dumpsys package` line -- `versionCode=42 minSdk=24 targetSdk=34` --
`facts['versionCode']` is the whole remainder, not `42`. It is still a
usable before/after fingerprint, which is how the caller uses it, but the
field name overstates what it holds.
"""
dev = coldstart.Device(None, use_su=False)
def respond(cmd, *args, **kwargs):
if "versionCode" in cmd[-1]:
out = " versionCode=42 minSdk=24 targetSdk=34\n"
else:
out = ""
return subprocess.CompletedProcess(cmd, 0, out, "")
monkeypatch.setattr(coldstart, "run", respond)
facts = coldstart.installed_facts(dev, "com.example.app")
assert facts["versionCode"] == "42 minSdk=24 targetSdk=34"
class TestScreenshotValidation:
"""`screencap` is judged by the bytes on disk, not by the command's exit status."""
PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
def make_device(self, monkeypatch):
dev = coldstart.Device(None, use_su=False)
return dev
def test_a_png_header_is_accepted_and_the_size_returned(self, monkeypatch, tmp_path):
dev = self.make_device(monkeypatch)
payload = self.PNG_MAGIC + b"\x00" * 100
def fake_run(cmd, *args, **kwargs):
kwargs["stdout"].write(payload)
return subprocess.CompletedProcess(cmd, 0, "", "")
monkeypatch.setattr(coldstart.subprocess, "run", fake_run)
out = tmp_path / "shot.png"
assert dev.screencap(str(out)) == len(payload)
assert out.read_bytes() == payload
def test_the_capture_uses_exec_out_not_shell_redirect(self, monkeypatch, tmp_path):
dev = self.make_device(monkeypatch)
seen = []
def fake_run(cmd, *args, **kwargs):
seen.append(list(cmd))
kwargs["stdout"].write(self.PNG_MAGIC + b"\x00" * 100)
return subprocess.CompletedProcess(cmd, 0, "", "")
monkeypatch.setattr(coldstart.subprocess, "run", fake_run)
dev.screencap(str(tmp_path / "shot.png"))
assert seen[0] == ["adb", "exec-out", "screencap", "-p"]
def test_a_non_png_body_is_rejected(self, monkeypatch, tmp_path):
"""A device that printed an error into the stream must not be read as a frame."""
dev = self.make_device(monkeypatch)
def fake_run(cmd, *args, **kwargs):
kwargs["stdout"].write(b"error: device offline\n")
return subprocess.CompletedProcess(cmd, 0, "", "")
monkeypatch.setattr(coldstart.subprocess, "run", fake_run)
assert dev.screencap(str(tmp_path / "shot.png")) == -1
def test_a_truncated_body_is_rejected(self, monkeypatch, tmp_path):
dev = self.make_device(monkeypatch)
def fake_run(cmd, *args, **kwargs):
kwargs["stdout"].write(b"\x89PNG")
return subprocess.CompletedProcess(cmd, 0, "", "")
monkeypatch.setattr(coldstart.subprocess, "run", fake_run)
assert dev.screencap(str(tmp_path / "shot.png")) == -1
def test_a_timeout_is_reported_as_a_failed_capture(self, monkeypatch, tmp_path):
dev = self.make_device(monkeypatch)
def fake_run(cmd, *args, **kwargs):
raise subprocess.TimeoutExpired(cmd, 30)
monkeypatch.setattr(coldstart.subprocess, "run", fake_run)
assert dev.screencap(str(tmp_path / "shot.png")) == -1
# --------------------------------------------------------------------------- #
# install_test.py: argument plumbing
# --------------------------------------------------------------------------- #
class TestInstallTestAdbWrappers:
def test_serial_is_injected_as_dash_s(self, monkeypatch):
rec = Recorder()
monkeypatch.setattr(install_test.subprocess, "run", rec)
install_test.adb(["shell", "id"], "SERIAL")
assert rec.last == ["adb", "-s", "SERIAL", "shell", "id"]
def test_without_a_serial_no_dash_s_is_added(self, monkeypatch):
rec = Recorder()
monkeypatch.setattr(install_test.subprocess, "run", rec)
install_test.adb(["devices", "-l"])
assert rec.last == ["adb", "devices", "-l"]
def test_stderr_is_appended_so_device_errors_are_not_silently_dropped(self, monkeypatch):
rec = Recorder(stdout="out", stderr="error: device offline")
monkeypatch.setattr(install_test.subprocess, "run", rec)
combined = install_test.adb(["devices"])
assert "out" in combined
assert "device offline" in combined
def test_su_wrapper_keeps_the_command_as_one_argument(self, monkeypatch):
rec = Recorder()
monkeypatch.setattr(install_test.subprocess, "run", rec)
install_test.su('pm grant com.example.app "android.permission.X"', "S")
assert rec.last == [
"adb", "-s", "S", "shell",
'su -c "pm grant com.example.app \\"android.permission.X\\""',
]
+252
View File
@@ -0,0 +1,252 @@
"""No-device flows: launch-health verdicts, log parsing, tombstone parsing.
Everything here runs on a bare checkout. The device commands are intercepted, so
what is being tested is the *decision logic* -- which is exactly the part that a
device test would otherwise hide behind "it looked fine on screen".
"""
from __future__ import annotations
import sys
import pytest
from conftest import load_script, run_script
install_test = load_script("install_test.py")
coldstart = load_script("coldstart.py")
native_crash = load_script("native_crash.py")
pytestmark = pytest.mark.integration
TOMBSTONE = "tombstone_sample.txt"
LOGCAT = "logcat_sample.txt"
# --------------------------------------------------------------------------- #
# install_test.py: the two-sample pid verdict
# --------------------------------------------------------------------------- #
def drive(monkeypatch, pids, log="", capsys=None):
"""Run install_test.main() with the device replaced by a scripted responder.
`pids` is consumed in order by successive `pidof` calls, which is the whole
point of the check: one sample cannot tell a running app from a crash loop.
"""
pids = list(pids)
calls = []
def fake_adb(args, serial=None, timeout=600):
calls.append(list(args))
if list(args)[:1] == ["logcat"] and "-d" in args:
return log
return ""
def fake_sh(cmd, serial=None):
calls.append(cmd)
if cmd.startswith("pidof"):
return pids.pop(0) if pids else ""
if "mCurrentFocus" in cmd:
return "mCurrentFocus=Window{a1b2 u0 com.example.app/.MainActivity}"
return ""
monkeypatch.setattr(install_test, "adb", fake_adb)
monkeypatch.setattr(install_test, "sh", fake_sh)
monkeypatch.setattr(install_test.time, "sleep", lambda _s: None)
monkeypatch.setattr(sys, "argv", [
"install_test.py", "--apk", "out/app.apk", "--pkg", "com.example.app",
"--activity", ".MainActivity",
])
return install_test.main(), calls
class TestLaunchHealthVerdict:
def test_a_stable_pid_with_no_fatal_signature_is_ok(self, monkeypatch, capsys):
code, calls = drive(monkeypatch, ["1234", "1234"])
assert code == 0, capsys.readouterr().out
def test_a_changed_pid_is_a_failure_even_though_the_app_is_running(self, monkeypatch, capsys):
"""The measured shape of a crash loop: alive at the first sample, restarted by the second."""
code, _calls = drive(monkeypatch, ["1234", "5678"])
out = capsys.readouterr().out
assert code == 1
assert "pid @+6s : 1234" in out
assert "5678" in out
def test_a_dead_app_is_a_failure(self, monkeypatch, capsys):
code, _calls = drive(monkeypatch, ["", ""])
assert code == 1
assert "(dead)" in capsys.readouterr().out
def test_a_fatal_signature_fails_even_with_a_stable_pid(self, monkeypatch, capsys):
"""Stability is not health: a caught-but-fatal error still fails the check."""
code, _calls = drive(monkeypatch, ["1234", "1234"],
log="01-01 10:00:01 E AndroidRuntime: FATAL EXCEPTION: main\n")
assert code == 1
assert "FATAL EXCEPTION" in capsys.readouterr().out
def test_both_pid_samples_are_actually_taken(self, monkeypatch, capsys):
_code, calls = drive(monkeypatch, ["1234", "1234"])
capsys.readouterr()
pidof_calls = [c for c in calls if isinstance(c, str) and c.startswith("pidof")]
assert len(pidof_calls) == 2, "the second sample is what catches a crash loop"
def test_the_launch_uses_the_package_slash_activity_component(self, monkeypatch, capsys):
_code, calls = drive(monkeypatch, ["1234", "1234"])
capsys.readouterr()
assert any("am start -n com.example.app/.MainActivity" == c for c in calls
if isinstance(c, str))
# --------------------------------------------------------------------------- #
# coldstart.py: logcat signal counting
# --------------------------------------------------------------------------- #
class TestLogcatSignalCounting:
@pytest.fixture
def counts(self, fixtures_dir):
path = fixtures_dir / LOGCAT
assert path.exists(), "the logcat fixture is missing from tests/fixtures"
return coldstart.signal_counts(str(path), coldstart.SIGNAL_PATTERNS)
@pytest.mark.parametrize("name,expected", [
("FATAL EXCEPTION", 1),
("VerifyError", 1),
("Bad checksum", 1),
("ANR", 1),
("Displayed", 1),
("IncompatibleClassChangeError", 0),
("ClassNotFoundException", 0),
("uncaughtException", 0),
("am_crash", 0),
])
def test_counts(self, counts, name, expected):
assert counts[name] == expected
def test_every_pattern_in_the_table_is_reported(self, counts):
assert set(counts) == set(coldstart.SIGNAL_PATTERNS)
def test_a_missing_log_yields_no_counts_rather_than_raising(self, tmp_path):
assert coldstart.signal_counts(str(tmp_path / "nope.txt"),
coldstart.SIGNAL_PATTERNS) == {}
def test_the_displayed_pattern_captures_the_milliseconds(self):
import re
match = re.search(coldstart.SIGNAL_PATTERNS["Displayed"],
"I ActivityManager: Displayed com.example.app/.MainActivity: +842ms")
assert match is not None
assert match.group(1) == "842"
# --------------------------------------------------------------------------- #
# native_crash.py: tombstone parsing
# --------------------------------------------------------------------------- #
@pytest.fixture
def crash(fixtures_dir):
text = (fixtures_dir / TOMBSTONE).read_text(encoding="utf-8")
crashes = native_crash.parse(text)
assert len(crashes) == 1, "the fixture holds exactly one crash block"
return crashes[0]
class TestTombstoneParsing:
def test_signal_and_code(self, crash):
assert crash["signal"] == 11
assert crash["signame"] == "SIGSEGV"
assert crash["si_code"] == "SEGV_MAPERR"
assert crash["fault"] == "0x0000000000000010"
def test_process_identity_is_picked_up(self, crash):
assert crash["pid"] == "12345"
assert crash["tid"] == "12345"
assert crash["proc"] == "com.example.app"
def test_uptime_and_cause(self, crash):
assert crash["uptime"] == "5"
assert "null pointer" in crash["cause"]
def test_backtrace_frames_are_complete(self, crash):
assert len(crash["frames"]) == 4
assert crash["frames"][0]["path"].endswith("libfoo.so")
assert crash["frames"][0]["symbol"] == ""
assert crash["frames"][1]["symbol"] == "Java_com_example_app_Native_check+120"
assert crash["frames"][2]["path"].startswith("/apex/")
assert crash["frames"][2]["symbol"] == "__pthread_start+256"
def test_registers_are_collected_from_the_dump_line(self, crash):
assert crash["regs"]["x0"] == 0x10
assert crash["regs"]["x1"] == 0x7A12345678
assert crash["regs"]["sp"] == 0x7FFABC1110
assert crash["regs"]["lr"] == 0x7A11112222
def test_the_pc_key_is_overwritten_by_the_last_backtrace_frame(self, crash):
"""Measured behaviour, recorded rather than smoothed over.
Register extraction scans every line of the block with one permissive
pattern, so a `#NN pc <hex>` backtrace line also matches and the last one
wins. The register dump's own `pc 0000007a111128cc` is therefore not what
`regs['pc']` holds. It is a reporting wart, not a wrong verdict -- but it
is a wart, so it is pinned here and listed in the harness report.
"""
assert crash["regs"]["pc"] == 0x55488, "the last frame's pc"
assert crash["regs"]["pc"] != 0x7A111128CC, "the register dump's pc"
def test_empty_input_produces_no_crashes(self):
assert native_crash.parse("") == []
def test_a_log_without_a_signal_line_produces_no_crashes(self):
assert native_crash.parse("I/whatever: nothing to see\n") == []
class TestLibraryClassification:
@pytest.mark.parametrize("path,expected", [
("/apex/com.android.runtime/lib64/bionic/libc.so", True),
("/system/lib64/libart.so", True),
("/vendor/lib64/libqcom.so", True),
("/data/dalvik-cache/arm64/system@framework@boot.oat", True),
("/data/app/~~AbCdEf==/com.example.app-1/lib/arm64/libfoo.so", False),
("/data/local/tmp/libpayload.so", False),
])
def test_classification(self, path, expected):
assert native_crash.is_system_lib(path) is expected
def test_a_bare_name_is_treated_as_system(self):
"""Measured behaviour: a path that is not absolute cannot be attributed to the app."""
assert native_crash.is_system_lib("libfoo.so") is True
class TestVerdictHint:
def test_a_small_fault_address_held_in_a_register_is_flagged_as_arranged(self, crash):
hints = " ".join(native_crash.verdict_hint(crash))
assert "small integer" in hints
assert "ARRANGED" in hints
assert "x0" in hints
def test_an_ordinary_null_dereference_gets_no_arranged_hint(self):
ordinary = {"fault": "0x0", "regs": {"x1": 0x7A111128CC}, "signame": "SIGSEGV"}
hints = native_crash.verdict_hint(ordinary)
assert any("small integer" in h for h in hints)
assert not any("ARRANGED" in h for h in hints)
def test_sigkill_is_called_out_as_a_deliberate_termination(self):
hints = native_crash.verdict_hint({"fault": "0x0", "regs": {}, "signame": "SIGKILL"})
assert any("deliberate termination" in h for h in hints)
class TestNativeCrashCli:
def test_the_fixture_tombstone_is_parsed_by_the_cli(self, fixtures_dir):
result = run_script("native_crash.py", [str(fixtures_dir / TOMBSTONE)], timeout=120)
assert not result.timed_out
assert not result.has_traceback, result.output
assert result.returncode == 0, result.output
assert "libfoo.so" in result.stdout
assert "SIGSEGV" in result.stdout
def test_a_missing_log_is_reported_without_a_traceback(self, tmp_path):
result = run_script("native_crash.py", [str(tmp_path / "absent.txt")])
assert not result.has_traceback, result.output
assert result.returncode != 0
assert "no such file" in result.output
def test_a_malformed_lib_spec_is_refused(self, fixtures_dir):
result = run_script("native_crash.py",
[str(fixtures_dir / TOMBSTONE), "--lib", "notapair"])
assert result.returncode != 0
assert "NAME=PATH" in result.output
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env python3
"""Single entry point for the regression suite.
Why this file exists in addition to ``pytest.ini``: a fresh checkout without
pytest should say so in one line, with the install command, instead of dying on
an import error. It exits 3 (environment capability insufficient) per the
repository's exit-code policy, so a wrapper script can tell "no pytest here"
apart from "tests failed" (1).
Usage:
python tests/run_tests.py # whole suite, quiet summary
python tests/run_tests.py -m unit # one marker
python tests/run_tests.py -k dexutil -vv # anything pytest accepts
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
def main(argv):
try:
import pytest
except ImportError:
sys.stderr.write(
"pytest is not installed; this suite cannot run.\n"
" install: python -m pip install pytest\n"
" the suite is stdlib-only otherwise (no device, no network, no adb)\n")
return 3
# pytest.ini lives at the repository root and is found from there.
os.chdir(REPO_ROOT)
args = list(argv[1:]) or ["-q"]
return int(pytest.main(args))
if __name__ == "__main__":
sys.exit(main(sys.argv))
+686
View File
@@ -0,0 +1,686 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Regression tests for `skills/apk-reverse/scripts/so_constpatch.py`.
What is being locked down
-------------------------
The script rewrites one zip entry inside an APK. The first implementation rebuilt the
whole archive through `zipfile.ZipInfo(filename, date_time)` and re-deflated everything,
which dropped `extra`, `external_attr` and `create_system`, and could push
`resources.arsc` off its STORED/4-byte-aligned layout -- an APK the installer refuses
with `[-124]`, and one that may not load at all when the manifest asks for
`extractNativeLibs="false"`.
These tests run the CLI the way a caller does (subprocess, exit code, RESULT token, JSON)
and re-read the written archive to check the contract, so they cannot pass by trusting
the writer's own bookkeeping. No device, no Android toolchain, no network.
The fixtures in `tests/fixtures/apk/` are committed; `tests/fixtures/make_fixture_apk.py`
regenerates them and is exercised too, so a fixture that stops carrying the metadata
under test fails here rather than silently weakening the suite.
"""
from __future__ import annotations
import hashlib
import importlib.util
import json
import os
import struct
import subprocess
import sys
import zipfile
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
REPO = HERE.parent
SCRIPT = REPO / 'skills' / 'apk-reverse' / 'scripts' / 'so_constpatch.py'
SCRIPTS_DIR = SCRIPT.parent
FIXTURES = HERE / 'fixtures' / 'apk'
MAKE_FIXTURES = HERE / 'fixtures' / 'make_fixture_apk.py'
APKHUNAN = b'apkhuan'
ANDROID = b'android'
SO_ENTRY = 'lib/arm64-v8a/libfoo.so'
STORE_ENTRY = 'lib/arm64-v8a/libbar.so'
X64_SO_ENTRY = 'lib/x86_64/libfoo.so'
SERVICES_ENTRIES = (
'META-INF/services/com.example.Provider',
'META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory',
)
UTF8_ENTRY = 'res/raw/\u6d4b\u8bd5.bin'
# ------------------------------------------------------------------ helpers
def run_cli(*args, cwd=None):
return subprocess.run([sys.executable, str(SCRIPT)] + [str(a) for a in args],
capture_output=True, text=True, encoding='utf-8',
errors='replace', cwd=cwd)
def token_of(proc):
for line in reversed(proc.stdout.splitlines()):
if line.startswith('RESULT='):
return line.split('=', 1)[1].strip()
return None
def json_of(proc):
lines = proc.stdout.strip().splitlines()
assert lines and lines[-1].startswith('RESULT='), proc.stdout
return json.loads('\n'.join(lines[:-1]))
def load_named(name, path):
"""Import a module by path, so the tests do not depend on sys.path state."""
spec = importlib.util.spec_from_file_location(name, path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def load_module():
return load_named('so_constpatch_under_test', SCRIPT)
def local_extra(path, name):
"""The local-header extra field, which is what alignment lives in."""
with zipfile.ZipFile(path) as z, open(path, 'rb') as fh:
info = z.getinfo(name)
fh.seek(info.header_offset)
head = fh.read(30)
nlen, elen = struct.unpack_from('<HH', head, 26)
return fh.read(nlen + elen)[nlen:]
def data_offset(path, name):
with zipfile.ZipFile(path) as z, open(path, 'rb') as fh:
info = z.getinfo(name)
fh.seek(info.header_offset)
head = fh.read(30)
nlen, elen = struct.unpack_from('<HH', head, 26)
return info.header_offset + 30 + nlen + elen
def zip_dump(path):
"""Everything a rebuild must preserve, keyed by entry name."""
out = {}
with zipfile.ZipFile(path) as z, open(path, 'rb') as fh:
for info in z.infolist():
fh.seek(info.header_offset)
head = fh.read(30)
nlen, elen = struct.unpack_from('<HH', head, 26)
raw_name = fh.read(nlen)
local_extra_bytes = fh.read(elen)
out[info.filename] = {
'info': info,
'raw_name': raw_name,
'local_extra': local_extra_bytes,
'method': info.compress_type,
'extra': info.extra,
'external_attr': info.external_attr,
'internal_attr': info.internal_attr,
'create_system': info.create_system,
'comment': info.comment,
'flags': info.flag_bits,
'date_time': info.date_time,
'crc': info.CRC,
'compress_size': info.compress_size,
'file_size': info.file_size,
'data_offset': info.header_offset + 30 + nlen + elen,
'sha256': hashlib.sha256(z.read(info.filename)).hexdigest(),
}
return out
def fixture(name):
p = FIXTURES / name
assert p.is_file(), 'missing fixture %s; run tests/fixtures/make_fixture_apk.py' % p
return p
def so_bytes(apk, entry=SO_ENTRY):
with zipfile.ZipFile(apk) as z:
return z.read(entry)
# ------------------------------------------------------------------ fixtures themselves
def test_fixtures_carry_the_metadata_under_test():
"""A fixture that lost its `extra`/`external_attr` would make the suite vacuous."""
for name in ('plain.apk', 'arsc_aligned.apk', 'noextract.apk', 'services.apk'):
dump = zip_dump(fixture(name))
assert dump, name
for entry, row in dump.items():
assert row['extra'], '%s!%s has no extra field to preserve' % (name, entry)
assert any(row['external_attr'] for row in dump.values()), \
'%s has no non-zero external_attr to preserve' % name
# And one entry that must keep a zero external_attr / create_system=0 too.
zeroed = zip_dump(fixture('services.apk'))[UTF8_ENTRY]
assert zeroed['external_attr'] == 0 and zeroed['create_system'] == 0
arsc = zip_dump(fixture('arsc_aligned.apk'))['resources.arsc']
assert arsc['method'] == 0
assert arsc['data_offset'] % 4 == 0
noextract = zip_dump(fixture('noextract.apk'))
assert noextract[SO_ENTRY]['method'] == 0
assert noextract[SO_ENTRY]['data_offset'] % 4 == 0
def test_fixture_manifest_is_binary_axml_with_the_declaration():
"""The manifest is real AXML, and the script's reader agrees with repack.py's.
Two independent readers on the same bytes: if either drifts, this fails instead of
one tool quietly disagreeing with the other about `extractNativeLibs`.
"""
sys.path.insert(0, str(SCRIPTS_DIR))
try:
import repack
finally:
sys.path.pop(0)
with zipfile.ZipFile(fixture('noextract.apk')) as z:
blob = z.read('AndroidManifest.xml')
assert blob[:2] == b'\x03\x00', 'not a binary AXML document'
attrs = repack.axml_root_attrs(blob)
assert attrs['extractNativeLibs'] == 'false'
assert attrs['package'] == 'com.example.noextract'
mod = load_module()
assert mod.axml_root_attributes(blob)['extractNativeLibs'] == 'false'
assert mod.manifest_extract_native_libs(blob) == 'false'
with zipfile.ZipFile(fixture('plain.apk')) as z:
assert mod.manifest_extract_native_libs(z.read('AndroidManifest.xml')) is None
assert mod.manifest_extract_native_libs(
b'<manifest xmlns:android="x" android:extractNativeLibs="false">') == 'false'
assert mod.manifest_extract_native_libs(b'</bad>') == 'unreadable'
def test_alignment_predicate_matches_a_real_abi_directory():
"""`lib/<abi>/x.so` has two slashes; a `[^/]+` pattern would match nothing."""
mod = load_module()
for name in ('lib/arm64-v8a/libfoo.so', 'lib/x86/libfoo.so', 'lib/x86_64/a.so'):
assert mod.needs_alignment(name, 0, 8) is True, name
# `resources.arsc` is mandatory whatever its input offset is.
assert mod.needs_alignment('resources.arsc', 0, 10) is True
assert mod.needs_alignment('resources.arsc', 8, 10) is True
# An uncompressed .so is mandatory too (extractNativeLibs=false maps it in place).
assert mod.needs_alignment('lib/arm64-v8a/libfoo.so', 0, 10) is True
# Any other STORED entry keeps the alignment it arrived with, and is not re-laid out.
assert mod.needs_alignment('assets/blob.bin', 0, 8) is True
assert mod.needs_alignment('assets/blob.bin', 0, 10) is False
# Deflated entries have no alignment requirement.
assert mod.needs_alignment('classes.dex', 8, 8) is False
assert mod.needs_alignment('lib/arm64-v8a/libfoo.so', 8, 8) is False
def test_fixture_generator_reproduces_the_committed_archives(tmp_path):
proc = subprocess.run([sys.executable, str(MAKE_FIXTURES), '--out', str(tmp_path)],
capture_output=True, text=True)
assert proc.returncode == 0, proc.stderr
for name in ('plain.apk', 'arsc_aligned.apk', 'noextract.apk', 'services.apk'):
regenerated = tmp_path / name
assert regenerated.is_file()
assert list(zip_dump(regenerated)) == list(zip_dump(fixture(name))), name
# Structure must match even if a different zlib produced different bytes.
assert os.path.getsize(regenerated) < 8192, 'fixtures must stay tiny'
# ------------------------------------------------------------------ the bare-.so path
def test_bare_so_rewrite_is_byte_exact(tmp_path):
"""The `.so` path predates the container work and must not move a single byte.
The expected output is constructed here from the input, so this is not a
self-consistency check: patching `apkhuan` at its reported offset is the whole
contract. The sha256 of this same output was measured identical under the
pre-change script (see tools/_work/zip-safety-EVIDENCE.md).
"""
src = so_bytes(fixture('plain.apk'))
so_path = tmp_path / 'libfoo.so'
so_path.write_bytes(src)
out = tmp_path / 'libfoo.patched.so'
proc = run_cli(so_path, '--replace', 'apkhuan=android', '-o', out)
assert proc.returncode == 0, proc.stdout + proc.stderr
assert token_of(proc) == 'patched'
got = out.read_bytes()
expected = bytearray(src)
expected[0x41:0x41 + len(APKHUNAN)] = ANDROID
assert got == bytes(expected)
assert len(got) == len(src)
assert hashlib.sha256(got).hexdigest() == \
hashlib.sha256(bytes(expected)).hexdigest()
def test_bare_so_reports_the_bytes_actually_changed(tmp_path):
"""`N byte(s) actually changed`: the window is 7, the changed bytes are 6."""
so_path = tmp_path / 'libfoo.so'
so_path.write_bytes(so_bytes(fixture('plain.apk')))
proc = run_cli(so_path, '--replace', 'apkhuan=android', '-o', tmp_path / 'o.so')
assert '6 byte(s) actually changed' in proc.stdout, proc.stdout
# ------------------------------------------------------------------ APK rebuild: metadata
def test_deflated_entry_rebuild_preserves_every_other_entry(tmp_path):
src = fixture('plain.apk')
out = tmp_path / 'plain.patched.apk'
before = zip_dump(src)
proc = run_cli(src, '--entry', SO_ENTRY, '--replace', 'apkhuan=android', '-o', out,
'--verbose')
assert proc.returncode == 0, proc.stdout + proc.stderr
after = zip_dump(out)
assert list(after) == list(before), 'entry order changed'
assert len(after) == len(before)
changed = after[SO_ENTRY]
assert changed['sha256'] != before[SO_ENTRY]['sha256'], 'the patched entry did not change'
assert changed['method'] == 8
for name, row in before.items():
if name == SO_ENTRY:
continue
got = after[name]
assert got['sha256'] == row['sha256'], '%s payload changed' % name
assert got['crc'] == row['crc'], '%s CRC changed' % name
assert got['compress_size'] == row['compress_size'], '%s compress_size changed' % name
assert got['extra'] == row['extra'], '%s central-directory extra changed' % name
assert got['local_extra'] == row['local_extra'], '%s local extra changed' % name
assert got['external_attr'] == row['external_attr'], '%s external_attr changed' % name
assert got['internal_attr'] == row['internal_attr']
assert got['create_system'] == row['create_system']
assert got['comment'] == row['comment']
assert got['date_time'] == row['date_time']
assert got['flags'] == row['flags']
def test_store_stays_store_and_is_not_recompressed(tmp_path):
"""A STORED entry must come back STORED with the same layout, not re-deflated."""
src = fixture('arsc_aligned.apk')
out = tmp_path / 'arsc_store.patched.apk'
before = zip_dump(src)
proc = run_cli(src, '--entry', STORE_ENTRY, '--replace', 'apkhuan=android', '-o', out)
assert proc.returncode == 0, proc.stdout + proc.stderr
after = zip_dump(out)
got, want = after[STORE_ENTRY], before[STORE_ENTRY]
assert want['method'] == 0
assert got['method'] == 0, 'a STORED entry was re-compressed'
assert got['compress_size'] == want['compress_size'] == want['file_size']
assert got['file_size'] == want['file_size']
assert got['sha256'] != want['sha256']
# Nothing moved, so alignment needed no new padding and every extra field is
# byte-identical -- the strongest form of "metadata preserved".
assert got['data_offset'] == want['data_offset']
assert got['extra'] == want['extra']
assert got['local_extra'] == want['local_extra']
for name, row in before.items():
assert after[name]['data_offset'] == row['data_offset'], name
assert after[name]['local_extra'] == row['local_extra'], name
def test_deflated_change_keeps_arsc_and_stored_so_aligned(tmp_path):
"""The case the old writer broke: a size change before `resources.arsc`."""
src = fixture('arsc_aligned.apk')
out = tmp_path / 'arsc_deflate.patched.apk'
before = zip_dump(src)
proc = run_cli(src, '--entry', SO_ENTRY, '--replace', 'apkhuan=android', '-o', out)
assert proc.returncode == 0, proc.stdout + proc.stderr
after = zip_dump(out)
assert after[SO_ENTRY]['compress_size'] != before[SO_ENTRY]['compress_size'], \
'this fixture must make the patched entry change size, or padding is not exercised'
for name, row in before.items():
got = after[name]
if row['method'] != 0:
continue
# Every uncompressed entry that was aligned stays aligned (zipalign semantics),
# and `resources.arsc` is aligned even if it had not been.
assert got['data_offset'] % 4 == 0, '%s lost 4-byte alignment' % name
assert got['method'] == 0, '%s is no longer STORED' % name
if name != SO_ENTRY:
# The entry itself is untouched...
assert got['sha256'] == row['sha256'], name
assert got['crc'] == row['crc'], name
assert got['extra'] == row['extra'], name
assert got['external_attr'] == row['external_attr'], name
# ...and its original local extra survives as a prefix of the new one:
# padding is appended, never substituted.
assert got['local_extra'].startswith(row['local_extra']), name
# `libbar.so` is STORED and sits before `resources.arsc`, so the size change shifts
# it by an unaligned amount: the writer must restore its alignment with a pad record.
bar_pad = len(after[STORE_ENTRY]['local_extra']) \
- len(before[STORE_ENTRY]['local_extra'])
assert bar_pad > 0, 'a shifted STORED .so was left unaligned without a pad record'
pad = bar_pad - 4
assert 1 <= pad <= 3, 'the appended record claims a pad of %d bytes' % pad
assert bar_pad % 4 == pad, 'the appended record does not land on the boundary'
appended = after[STORE_ENTRY]['local_extra'][
len(before[STORE_ENTRY]['local_extra']):]
assert struct.unpack_from('<H', appended, 2)[0] == pad, 'malformed extra record'
assert 'alignment padding' in proc.stdout
assert 'resources.arsc' in proc.stdout
assert '4-byte aligned' in proc.stdout
def test_an_unaligned_stored_entry_is_not_silently_relaid(tmp_path):
"""This tool preserves alignment; it does not re-lay-out an archive it was not asked
to fix. A STORED entry that arrived unaligned stays that way, with its extra and
offsets untouched -- silently rewriting it would be the same class of change the
STORED/alignment work exists to prevent."""
fx = load_named('make_fixture_apk_under_test', MAKE_FIXTURES)
src = tmp_path / 'handmade.apk'
entries = [
fx.Entry('classes.dex', fx.make_dex(), method=8),
fx.Entry('lib/arm64-v8a/libfoo.so', fx.make_elf(), method=8),
# STORED, deliberately NOT aligned (the generator aligns only when asked).
fx.Entry('AndroidManifest.xml', fx.make_axml('com.example.unaligned'), method=0),
]
fx.write_apk(str(src), entries)
before = zip_dump(src)
assert before['AndroidManifest.xml']['data_offset'] % 4 != 0, \
'the handmade archive is supposed to be unaligned here'
out = tmp_path / 'handmade.patched.apk'
proc = run_cli(src, '--entry', SO_ENTRY, '--replace', 'apkhuan=android', '-o', out)
assert proc.returncode == 0, proc.stdout + proc.stderr
after = zip_dump(out)
assert after[SO_ENTRY]['compress_size'] != before[SO_ENTRY]['compress_size']
got = after['AndroidManifest.xml']
assert got['data_offset'] % 4 != 0, 'an unaligned STORED entry was silently moved'
assert got['local_extra'] == before['AndroidManifest.xml']['local_extra']
assert got['sha256'] == before['AndroidManifest.xml']['sha256']
assert got['extra'] == before['AndroidManifest.xml']['extra']
assert 'NOT 4-byte aligned in the input' in proc.stdout
def test_self_check_is_printed_and_reads_back_the_written_file(tmp_path):
src = fixture('arsc_aligned.apk')
out = tmp_path / 'selfcheck.apk'
proc = run_cli(src, '--entry', STORE_ENTRY, '--replace', 'apkhuan=android', '-o', out)
assert proc.returncode == 0, proc.stdout + proc.stderr
text = proc.stdout
assert 'rebuild self-check' in text
assert 'entry count' in text and 'identical' in text
assert 'CRC+size identical' in text
assert 'sha256 identical' in text
assert 'header_offset' in text
def test_services_and_utf8_entries_are_untouched(tmp_path):
src = fixture('services.apk')
out = tmp_path / 'services.patched.apk'
before = zip_dump(src)
proc = run_cli(src, '--entry', X64_SO_ENTRY, '--replace', 'apkhuan=android', '-o', out,
'--verbose')
assert proc.returncode == 0, proc.stdout + proc.stderr
after = zip_dump(out)
for name in SERVICES_ENTRIES + (UTF8_ENTRY, 'AndroidManifest.xml'):
assert after[name]['sha256'] == before[name]['sha256'], '%s changed' % name
assert after[name]['extra'] == before[name]['extra'], '%s extra changed' % name
assert after[name]['external_attr'] == before[name]['external_attr']
assert after[name]['create_system'] == before[name]['create_system']
assert after[name]['comment'] == before[name]['comment']
utf8 = after[UTF8_ENTRY]
assert utf8['info'].filename == UTF8_ENTRY
assert utf8['flags'] & 0x800, 'the UTF-8 name flag was dropped'
assert utf8['create_system'] == 0
assert utf8['internal_attr'] == 1
assert utf8['comment'] == b'fixture comment'
with zipfile.ZipFile(src) as a, zipfile.ZipFile(out) as b:
assert a.comment == b.comment == b'fixture-archive-comment'
# ------------------------------------------------------------------ the refusal gate
def test_extract_native_libs_false_is_refused_by_default(tmp_path):
src = fixture('noextract.apk')
out = tmp_path / 'noextract.patched.apk'
proc = run_cli(src, '--entry', SO_ENTRY, '--replace', 'apkhuan=android', '-o', out)
assert proc.returncode == 1, proc.stdout + proc.stderr
assert token_of(proc) == 'refused_unsafe_rebuild'
assert not out.exists(), 'a refused rebuild must write nothing'
assert 'REFUSED' in proc.stdout
assert 'extractNativeLibs' in proc.stdout
assert 'scripts/repack.py' in proc.stdout, 'the refusal must name the right tool'
assert '--unsafe-rebuild' in proc.stdout
def test_unsafe_rebuild_is_explicit_and_reported(tmp_path):
src = fixture('noextract.apk')
out = tmp_path / 'noextract.unsafe.apk'
proc = run_cli(src, '--entry', SO_ENTRY, '--replace', 'apkhuan=android', '-o', out,
'--unsafe-rebuild')
assert proc.returncode == 0, proc.stdout + proc.stderr
assert token_of(proc) == 'patched'
assert 'WARNING: --unsafe-rebuild accepted' in proc.stdout
after = zip_dump(out)
assert after[SO_ENTRY]['method'] == 0
assert after[SO_ENTRY]['data_offset'] % 4 == 0
assert after['resources.arsc']['method'] == 0
assert after['resources.arsc']['data_offset'] % 4 == 0
def test_a_compressed_arsc_is_refused(tmp_path):
"""An arsc that is not STORED+aligned cannot be made compliant by this writer."""
mod = load_module()
broken = tmp_path / 'compressed_arsc.apk'
with zipfile.ZipFile(fixture('arsc_aligned.apk')) as src_z:
plan = [(i.filename, src_z.read(i.filename), i.compress_type, i.extra,
i.external_attr, i.create_system, i.date_time)
for i in src_z.infolist()]
with zipfile.ZipFile(broken, 'w') as dst:
for name, blob, method, extra, eattr, csys, dt in plan:
zi = zipfile.ZipInfo(name, date_time=dt)
zi.compress_type = zipfile.ZIP_DEFLATED if name == 'resources.arsc' else method
zi.extra = extra
zi.external_attr = eattr
zi.create_system = csys
dst.writestr(zi, blob)
out = tmp_path / 'x.apk'
proc = run_cli(broken, '--entry', STORE_ENTRY, '--replace', 'apkhuan=android',
'-o', out)
assert proc.returncode == 1, proc.stdout + proc.stderr
assert token_of(proc) == 'refused_unsafe_rebuild'
assert 'resources.arsc' in proc.stdout
assert not out.exists()
# The audit reads the storage state without needing a rebuild at all.
facts = mod.audit_apk_for_rebuild(str(broken), mod.Reporter(as_json=True))
assert facts['arsc']['stored'] is False
assert facts['unsafe']
# ------------------------------------------------------------------ find / usage errors
def test_find_reports_isolated_and_non_isolated(tmp_path):
src = fixture('plain.apk')
proc = run_cli(src, '--entry', SO_ENTRY, '--find', 'apkhuan')
assert proc.returncode == 0
assert token_of(proc) == 'found'
assert 'isolated=True' in proc.stdout and 'isolated=False' in proc.stdout
assert 'isolated (safe to rewrite in place): 1 / 2' in proc.stdout
def test_find_without_hits_is_a_negative_finding():
proc = run_cli(fixture('plain.apk'), '--entry', SO_ENTRY, '--find', 'nosuchthing')
assert proc.returncode == 1
assert token_of(proc) == 'not_found'
def test_equal_length_is_mandatory():
proc = run_cli(fixture('plain.apk'), '--entry', SO_ENTRY, '--replace', 'apkhuan=and',
'-o', 'out.apk')
assert proc.returncode == 2
assert 'Equal length is mandatory' in proc.stdout
def test_non_isolated_and_multi_hits_need_an_explicit_flag():
# The decoy `libapkhuanx.so` makes a second, non-isolated hit.
proc = run_cli(fixture('plain.apk'), '--entry', SO_ENTRY, '--replace', 'apkhuan=android',
'-o', 'out.apk', '--allow-nonisolated')
assert proc.returncode == 2
assert '--force' in proc.stdout
assert token_of(proc) == 'usage_error'
def test_missing_entry_and_missing_out_are_usage_errors(tmp_path):
p = run_cli(fixture('plain.apk'), '--entry', 'lib/arm64-v8a/nope.so',
'--replace', 'apkhuan=android', '-o', tmp_path / 'x.apk')
assert p.returncode == 2
assert 'available .so entries' in p.stdout
p = run_cli(fixture('plain.apk'), '--replace', 'apkhuan=android')
assert p.returncode == 2
p = run_cli(fixture('plain.apk'), '--entry', SO_ENTRY, '--replace', 'apkhuan=android')
assert p.returncode == 2, 'patching an APK without -o must be rejected'
p = run_cli(fixture('plain.apk'), '--entry', SO_ENTRY, '--replace', 'apkhuan=android',
'-o', fixture('plain.apk'))
assert p.returncode == 2, 'overwriting the input in place must be rejected'
# ------------------------------------------------------------------ json contract
def test_json_contract_on_success(tmp_path):
out = tmp_path / 'j.apk'
proc = run_cli(fixture('arsc_aligned.apk'), '--entry', STORE_ENTRY, '--replace',
'apkhuan=android', '-o', out, '--json')
assert proc.returncode == 0, proc.stdout + proc.stderr
payload = json_of(proc)
assert payload['status'] == 'ok'
assert payload['exit_code'] == 0
assert payload['capability'] == 'native_const_patch'
assert payload['result'] == 'patched'
for key in ('evidence', 'warnings', 'next_action'):
assert key in payload
assert isinstance(payload['evidence'], list) and payload['evidence']
assert payload['verify']['entries'] == payload['verify']['untouched'] + 1
assert payload['verify']['sha256_identical'] == payload['verify']['untouched']
assert payload['audit']['arsc']['stored'] is True
assert payload['audit']['unsafe'] == []
def test_json_contract_on_refusal(tmp_path):
proc = run_cli(fixture('noextract.apk'), '--entry', SO_ENTRY, '--replace',
'apkhuan=android', '-o', tmp_path / 'r.apk', '--json')
assert proc.returncode == 1
payload = json_of(proc)
assert payload['status'] == 'refused'
assert payload['exit_code'] == 1
assert payload['result'] == 'refused_unsafe_rebuild'
assert payload['audit']['extract_native_libs'] == 'false'
assert payload['audit']['unsafe']
assert 'repack.py' in payload['next_action']
def test_json_contract_on_negative_find():
proc = run_cli(fixture('plain.apk'), '--entry', SO_ENTRY, '--find', 'nope', '--json')
assert proc.returncode == 1
payload = json_of(proc)
assert payload['status'] == 'negative'
assert payload['result'] == 'not_found'
assert payload['exit_code'] == 1
# ------------------------------------------------------------------ streaming archives
def test_data_descriptors_are_resolved_into_the_local_header(tmp_path):
"""An entry written while streaming carries flag bit 3 and no CRC in its local
header. The rebuild resolves it from the central directory, so the output is
readable by tools that never look at the central directory."""
class _Sink(object):
"""A file object without seek/tell, which is what makes zipfile stream."""
def __init__(self, fh):
self._fh = fh
def write(self, data):
return self._fh.write(data)
def flush(self):
self._fh.flush()
def close(self):
self._fh.close()
streaming = tmp_path / 'streaming.apk'
with zipfile.ZipFile(fixture('plain.apk')) as zin:
with open(streaming, 'wb') as raw:
with zipfile.ZipFile(_Sink(raw), 'w', zipfile.ZIP_DEFLATED) as zout:
for info in zin.infolist():
zi = zipfile.ZipInfo(info.filename, date_time=info.date_time)
zi.compress_type = info.compress_type
with zout.open(zi, 'w') as fh:
fh.write(zin.read(info.filename))
before = zip_dump(streaming)
assert all(row['flags'] & 0x08 for row in before.values()), \
'this fixture must carry data descriptors, or the branch is not covered'
assert all(row['local_extra'] == b'' or True for row in before.values())
out = tmp_path / 'streaming.patched.apk'
proc = run_cli(streaming, '--entry', SO_ENTRY, '--replace', 'apkhuan=android',
'-o', out)
assert proc.returncode == 0, proc.stdout + proc.stderr
after = zip_dump(out)
assert 'data descriptors' in proc.stdout
for name, row in before.items():
got = after[name]
assert not (got['flags'] & 0x08), '%s still declares a data descriptor' % name
if name != SO_ENTRY:
assert got['sha256'] == row['sha256']
assert got['crc'] == row['crc']
with zipfile.ZipFile(out) as z:
assert z.testzip() is None
# ------------------------------------------------------------------ internal invariants
def test_padding_record_is_four_byte_expressible():
"""A zip extra record is (id, size, payload) and its minimum length is 4 bytes."""
mod = load_module()
assert mod._pad_extra(0) == b''
for pad in (1, 2, 3):
rec = mod._pad_extra(pad)
assert len(rec) == 4 + pad
assert len(rec) % 4 == pad
assert struct.unpack_from('<H', rec, 2)[0] == pad
def test_eocd_and_unsupported_containers_are_reported(tmp_path):
mod = load_module()
junk = tmp_path / 'not-a-zip.apk'
junk.write_bytes(b'not a zip at all')
with pytest.raises(mod.Refused) as exc:
mod.audit_apk_for_rebuild(str(junk), mod.Reporter(as_json=True))
assert exc.value.exit_code == 3
assert exc.value.token == 'unsupported_container'
proc = run_cli(junk, '--entry', 'anything', '--replace', 'a=b', '-o', tmp_path / 'o.apk')
assert proc.returncode == 3
assert token_of(proc) == 'unsupported_container'
+186
View File
@@ -0,0 +1,186 @@
"""Unit tests for skills/apk-reverse/scripts/blob_decode.py.
The encoded-blob search is the part worth pinning: the whole method exists
because a single un-rotated inflate attempt fails and the failure looks like
"this is encrypted". The tests build blobs with known parameters and require the
search to recover them.
"""
from __future__ import annotations
import gzip
import json
import zlib
import pytest
from conftest import load_script, run_script
blob_decode = load_script("blob_decode.py")
pytestmark = pytest.mark.unit
PAYLOAD = json.dumps({
"flags": {"newCheckout": True, "minVersion": 4210},
"endpoints": ["https://api.example.invalid/v3/config"],
"notes": "synthetic payload for the rotation search regression" * 3,
}).encode("utf-8")
# --------------------------------------------------------------------------- #
# outer encodings
# --------------------------------------------------------------------------- #
class TestOuterEncodings:
@pytest.mark.parametrize("which", ["base64", "base64url", "hex"])
def test_round_trip(self, which):
data = bytes(range(64))
encoded = blob_decode.outer_encode(data, which)
assert blob_decode.outer_decode(encoded, which) == data
def test_unknown_encoding_raises(self):
with pytest.raises(ValueError):
blob_decode.outer_encode(b"x", "rot13")
def test_undecodable_hex_returns_empty_not_an_exception(self):
"""The search calls this on candidate encodings; a raise would abort it."""
assert blob_decode.outer_decode("zzzz", "hex") == b""
def test_whitespace_and_newlines_are_stripped_before_decoding(self):
encoded = blob_decode.outer_encode(b"hello world", "base64")
wrapped = "\n".join([encoded[:4], encoded[4:]])
assert blob_decode.outer_decode(wrapped, "base64") == b"hello world"
def test_sniff_offers_all_three_encodings(self):
guesses = blob_decode.sniff_outer("aGVsbG8gd29ybGQgdGhpcyBpcyBhIHRlc3Q=")
assert set(guesses) >= {"base64", "base64url", "hex"}
def test_sniff_puts_hex_first_for_hex_shaped_text(self):
assert blob_decode.sniff_outer("deadbeef" * 8)[0] == "hex"
# --------------------------------------------------------------------------- #
# inner transforms
# --------------------------------------------------------------------------- #
class TestInnerTransforms:
def test_inflate_raw_round_trips_through_raw_deflate(self):
body = blob_decode.inflate_raw(PAYLOAD)
assert zlib.decompress(body, -15) == PAYLOAD
def test_inflate_raw_output_has_no_zlib_header(self):
"""A raw stream is what the rotation search needs: no header to mis-align."""
body = blob_decode.inflate_raw(b"x" * 200)
with pytest.raises(zlib.error):
zlib.decompress(body) # default wbits expects a zlib header
@pytest.mark.parametrize("name,blob", [
("raw", zlib.compressobj(9, zlib.DEFLATED, -15)),
("zlib", zlib.compressobj(9, zlib.DEFLATED, 15)),
("gzip", zlib.compressobj(9, zlib.DEFLATED, 31)),
])
def test_try_inflate_names_each_inner_transform(self, name, blob):
body = blob.compress(PAYLOAD) + blob.flush()
got = blob_decode.try_inflate(body)
assert got is not None
assert got[0] == name
assert got[1] == PAYLOAD
def test_try_inflate_returns_none_for_noise(self):
assert blob_decode.try_inflate(bytes(range(256))) is None
def test_gzip_is_accepted_via_the_stdlib_helper(self):
got = blob_decode.try_inflate(gzip.compress(PAYLOAD))
assert got is not None and got[0] == "gzip" and got[1] == PAYLOAD
# --------------------------------------------------------------------------- #
# scoring
# --------------------------------------------------------------------------- #
class TestScoring:
def test_structured_text_scores_above_random_bytes(self):
structured = blob_decode.score(PAYLOAD)
noise = blob_decode.score(bytes(range(256)) * 4)
assert structured > noise
def test_a_json_header_is_rewarded(self):
assert blob_decode.score(b'{"a": 1}') > blob_decode.score(b"aaaaaaaa")
def test_empty_output_scores_negative(self):
assert blob_decode.score(b"") < 0
# --------------------------------------------------------------------------- #
# the rotation search
# --------------------------------------------------------------------------- #
def rotate(body, cut):
return body[-cut:] + body[:-cut] if cut else body
class TestRotationSearch:
def _hits(self, blob, max_skip=0):
return blob_decode.search(blob, max_skip, lambda _msg: None)
def test_search_recovers_a_rotated_raw_stream(self):
body = blob_decode.inflate_raw(PAYLOAD)
cut = 7
hits = self._hits(rotate(body, cut))
matching = [h for h in hits if h["data"] == PAYLOAD]
assert matching, "the rotation with cut=%d was not recovered" % cut
assert matching[0]["cut"] == cut
assert matching[0]["inner"] == "raw"
assert matching[0]["size"] == len(PAYLOAD)
def test_search_recovers_a_prefixed_and_rotated_stream(self):
"""The `skip` arm: a short header in front of the compressed stream."""
body = blob_decode.inflate_raw(PAYLOAD)
blob = b"\x01\x02" + rotate(body, 5)
hits = self._hits(blob, max_skip=4)
matching = [h for h in hits if h["data"] == PAYLOAD]
assert matching, "skip=2 + cut=5 was not recovered"
assert matching[0]["skip"] == 2
def test_search_on_noise_returns_no_hits(self):
assert self._hits(bytes(range(64))) == []
def test_scores_are_reported_rounded(self):
body = blob_decode.inflate_raw(PAYLOAD)
hits = self._hits(body)
assert hits
assert all(isinstance(h["score"], float) for h in hits)
def test_the_shortest_accepted_body_is_eight_bytes(self):
"""Documented floor: `m < 8` breaks the loop, so tiny blobs yield nothing."""
assert self._hits(b"\x01\x02\x03") == []
# --------------------------------------------------------------------------- #
# the CLI-level write-back contract
# --------------------------------------------------------------------------- #
class TestEncodePath:
def test_encode_then_decode_recovers_the_payload(self, tmp_path):
"""The write-back line the tool prints must actually round-trip."""
payload_file = tmp_path / "payload.bin"
payload_file.write_bytes(PAYLOAD)
encoded_file = tmp_path / "value.txt"
enc = run_script("blob_decode.py",
["--encode", "--file", str(payload_file), "--inner", "raw",
"--outer", "base64", "--cut", "0", "--out", str(encoded_file)])
assert enc.returncode == 0, enc.output
back = run_script("blob_decode.py",
["--file", str(encoded_file), "--outer", "base64",
"--min-size", "1", "--out", str(tmp_path / "back.bin")])
assert back.returncode == 0, back.output
assert (tmp_path / "back.bin").read_bytes() == PAYLOAD
def test_an_undecodable_blob_exits_one(self, tmp_path):
value = tmp_path / "value.txt"
value.write_text("not-a-blob-at-all-just-prose-text\n", encoding="utf-8")
result = run_script("blob_decode.py", ["--file", str(value), "--outer", "hex"])
assert result.returncode == 1, result.output
assert not result.has_traceback
def test_empty_stdin_is_reported_as_invalid_input(self):
"""Exit code 2, not 1: an empty input is invalid, not a failed decode."""
result = run_script("blob_decode.py", [], stdin=" ")
assert result.returncode == 2, result.output
assert "empty input" in result.output
@@ -0,0 +1,216 @@
"""The established regression for scripts/dex_patch_bytes.py.
This is the repository's pre-existing red line, not a test invented here: the
l1l3 benchmark row recorded that patching the first root check in
`MainActivity.onCreate` produces a specific file, and any refactor of the dex
reader, the spec matcher, the header fixer or the writer must reproduce it
byte for byte.
`tools/_work/bench/**` is git-ignored, so these tests skip with a printed reason
on a fresh checkout. That is deliberate: a regression marker that turns red
because a sample is absent is a marker people learn to ignore.
"""
from __future__ import annotations
import hashlib
import json
import pytest
from conftest import L1L3_PATCHED_SHA256, load_script, require_path, run_script
dexutil = load_script("dexutil.py")
pytestmark = pytest.mark.unit
SPEC_NAME = "l1_root.spec.json"
SITE_OFF = 0x9D0
OLD_BYTES = "39000e00"
NEW_BYTES = "00000000"
@pytest.fixture
def sample(bench_l1l3):
"""Paths to the recorded input, spec and expected output."""
dex = require_path(bench_l1l3 / "dex" / "classes.dex",
"git-ignored bench tree")
spec = require_path(bench_l1l3 / SPEC_NAME, "git-ignored bench tree")
expected = require_path(bench_l1l3 / "dex" / "classes.patched.dex",
"git-ignored bench tree")
return {"dex": dex, "spec": spec, "expected": expected}
def patch(sample, tmp_path, extra=()):
out = tmp_path / "out.patched.dex"
report = tmp_path / "report.json"
result = run_script("dex_patch_bytes.py",
[str(sample["dex"]), "--spec", str(sample["spec"]),
"-o", str(out), "--report", str(report)] + list(extra))
return result, out, report
# --------------------------------------------------------------------------- #
# the hash marker
# --------------------------------------------------------------------------- #
class TestRecordedRegression:
def test_the_patch_runs_and_exits_zero(self, sample, tmp_path):
result, _out, _report = patch(sample, tmp_path)
assert not result.timed_out, repr(result)
assert not result.has_traceback, result.output
assert result.returncode == 0, result.output
def test_output_matches_the_recorded_sha256(self, sample, tmp_path):
_result, out, _report = patch(sample, tmp_path)
digest = hashlib.sha256(out.read_bytes()).hexdigest()
assert digest.startswith(L1L3_PATCHED_SHA256), (
"the recorded marker moved: got %s, expected prefix %s"
% (digest, L1L3_PATCHED_SHA256))
def test_output_is_byte_identical_to_the_stored_expectation(self, sample, tmp_path):
_result, out, _report = patch(sample, tmp_path)
assert out.read_bytes() == sample["expected"].read_bytes()
def test_only_the_four_patch_bytes_and_the_header_change(self, sample, tmp_path):
"""Pin *where* the difference is, so a stray write cannot pass as a hash match.
The allowed window is bytes 8..32 (checksum + signature, recomputed) plus
the four bytes of the patched instruction. Two of those four keep their
original value (`0e00` -> `0000` clears one byte and leaves the zero
byte), and a recomputed checksum may keep a byte by coincidence, so the
assertion is containment plus the changes that must occur.
"""
_result, out, _report = patch(sample, tmp_path)
before = sample["dex"].read_bytes()
after = out.read_bytes()
assert len(before) == len(after), "an equal-length patch must not resize the file"
diffs = {i for i, (a, b) in enumerate(zip(before, after)) if a != b}
window = set(range(8, 32)) | {SITE_OFF, SITE_OFF + 1, SITE_OFF + 2, SITE_OFF + 3}
assert diffs <= window, "bytes changed outside the owned window: %r" % sorted(
diffs - window)
# The two bytes that differ between 39000e00 and 00000000 must have moved.
assert {SITE_OFF, SITE_OFF + 2} <= diffs
# Both header fields are recomputed; each must have changed somewhere.
assert diffs & set(range(8, 12)), "no checksum byte changed"
assert diffs & set(range(12, 32)), "no signature byte changed"
def test_the_written_dex_self_verifies(self, sample, tmp_path):
_result, out, _report = patch(sample, tmp_path)
data = bytearray(out.read_bytes())
assert dexutil.verify_dex_header(data) == (True, True)
def test_the_result_still_parses_and_decodes_cleanly(self, sample, tmp_path):
_result, out, _report = patch(sample, tmp_path)
dex, _entry = dexutil.load_dex(str(out))
assert dex.check() == []
found = dex.find_method("Lsg/vantagepoint/uncrackable1/MainActivity;",
"onCreate", "(Landroid/os/Bundle;)V")
assert found
_section, _idx, code_off = found
_insns, clean, expected = dex.decode_all(code_off)
assert clean, "the patched method must still decode to exactly insns_size units"
# --------------------------------------------------------------------------- #
# the report the tool writes
# --------------------------------------------------------------------------- #
class TestPatchReport:
def test_report_records_the_site_and_the_polarity_check(self, sample, tmp_path):
_result, _out, report = patch(sample, tmp_path)
reports = json.loads(report.read_text(encoding="utf-8"))
assert len(reports) == 1
entry = reports[0]
assert entry["ok"] is True
assert entry["site_off"] == SITE_OFF
assert entry["old_bytes"] == OLD_BYTES
assert entry["new_bytes"] == NEW_BYTES
assert entry["expect_next_ok"] is True
assert entry["size_delta"] == 0
def test_report_confirms_the_post_edit_instruction_boundary(self, sample, tmp_path):
_result, _out, report = patch(sample, tmp_path)
entry = json.loads(report.read_text(encoding="utf-8"))[0]
assert entry["post_decode_clean"] is True
assert entry["verifier_edges"] == 0
def test_stdout_states_the_polarity_check_passed(self, sample, tmp_path):
result, _out, _report = patch(sample, tmp_path)
assert "polarity: expect_next satisfied" in result.stdout
def test_dry_run_reports_without_writing(self, sample, tmp_path):
out = tmp_path / "should-not-exist.dex"
result = run_script("dex_patch_bytes.py",
[str(sample["dex"]), "--spec", str(sample["spec"]),
"-o", str(out), "--dry-run"])
assert result.returncode == 0, result.output
assert "dry run: nothing written" in result.stdout
assert not out.exists()
def test_default_output_name_is_derived_from_the_input(self, sample, tmp_path):
"""Run on a copy: the bench tree is evidence and is never written by this suite."""
local = tmp_path / "classes.dex"
local.write_bytes(sample["dex"].read_bytes())
result = run_script("dex_patch_bytes.py",
[str(local), "--spec", str(sample["spec"])])
assert result.returncode == 0, result.output
derived = tmp_path / "classes.patched.dex"
assert derived.exists(), result.output
assert hashlib.sha256(derived.read_bytes()).hexdigest() == \
hashlib.sha256(sample["expected"].read_bytes()).hexdigest()
# --------------------------------------------------------------------------- #
# refusal paths: a wrong spec must fail loudly, not write a plausible file
# --------------------------------------------------------------------------- #
class TestRefusals:
def test_a_dry_run_against_a_missing_match_fails(self, sample, tmp_path):
spec = tmp_path / "bad.spec.json"
spec.write_text(json.dumps([{
"name": "no such site",
"class": "Lsg/vantagepoint/uncrackable1/MainActivity;",
"method": "onCreate",
"desc": "(Landroid/os/Bundle;)V",
"match": {"kind": "if-testz", "bytes": "deadbeef"},
"replace": {"kind": "nops"},
}]), encoding="utf-8")
result = run_script("dex_patch_bytes.py",
[str(sample["dex"]), "--spec", str(spec), "--dry-run"])
assert result.returncode == 1, result.output
assert "no instruction matched" in result.output
def test_a_wrong_polarity_pin_is_refused(self, sample, tmp_path):
"""expect_next exists to make the wrong side of a branch impossible to patch."""
spec = tmp_path / "bad-polarity.spec.json"
spec.write_text(json.dumps([{
"name": "wrong expected neighbour",
"class": "Lsg/vantagepoint/uncrackable1/MainActivity;",
"method": "onCreate",
"desc": "(Landroid/os/Bundle;)V",
"match": {"kind": "if-testz", "bytes": OLD_BYTES},
"expect_next": {"kind": "return"},
"replace": {"kind": "nops"},
}]), encoding="utf-8")
result = run_script("dex_patch_bytes.py",
[str(sample["dex"]), "--spec", str(spec), "--dry-run"])
assert result.returncode == 1, result.output
assert "expect_next not satisfied" in result.output
def test_a_missing_dex_is_a_usage_error(self, tmp_path, sample):
result = run_script("dex_patch_bytes.py",
[str(tmp_path / "nope.dex"), "--spec", str(sample["spec"])])
assert result.returncode == 2, result.output
assert "no such file" in result.output
def test_a_missing_spec_argument_is_a_usage_error(self, sample):
result = run_script("dex_patch_bytes.py", [str(sample["dex"])])
assert result.returncode == 2, result.output
def test_a_non_dex_input_is_refused_with_code_1(self, tmp_path, sample):
"""Structurally invalid input is a target-side failure (1), not a usage error (2)."""
bogus = tmp_path / "bogus.dex"
bogus.write_bytes(b"not a dex at all" * 8)
result = run_script("dex_patch_bytes.py",
[str(bogus), "--spec", str(sample["spec"]), "--dry-run"])
assert result.returncode == 1, result.output
assert "not a dex" in result.output
+393
View File
@@ -0,0 +1,393 @@
"""Unit tests for skills/apk-reverse/scripts/dexutil.py.
The dex format table is the one place where a wrong entry silently shifts every
offset after it, so widths are asserted opcode by opcode against the cases the
script's own docstring calls out. No device and no sample file are needed for
the table itself; the sample-backed assertions skip when the git-ignored bench
tree is absent.
"""
from __future__ import annotations
import hashlib
import zlib
import pytest
from conftest import load_script, require_path
dexutil = load_script("dexutil.py")
pytestmark = pytest.mark.unit
# --------------------------------------------------------------------------- #
# little-endian scalar readers
# --------------------------------------------------------------------------- #
class TestScalarReaders:
def test_u16_and_u32_are_little_endian(self):
assert dexutil.u16(b"\x01\x02", 0) == 0x0201
assert dexutil.u32(b"\x01\x02\x03\x04", 0) == 0x04030201
def test_u16_and_u32_read_from_an_offset(self):
buf = b"\xff\xff\x34\x12\x78\x56\x34\x12"
assert dexutil.u16(buf, 2) == 0x1234
assert dexutil.u32(buf, 4) == 0x12345678
@pytest.mark.parametrize("value,expected", [
(0x0000, 0),
(0x7FFF, 0x7FFF),
(0x8000, -0x8000),
(0xFFFF, -1),
])
def test_s16_boundaries(self, value, expected):
assert dexutil.s16(value) == expected
def test_read_uleb_single_byte(self):
assert dexutil.read_uleb(b"\x7f", 0) == (0x7F, 1)
def test_read_uleb_two_bytes_and_offset(self):
value, off = dexutil.read_uleb(b"\x00\x80\x01", 1)
assert value == 128
assert off == 3
def test_read_uleb_multi_byte(self):
# 0xE5 0x8E 0x26 -> 624485, the example from the format specification
value, off = dexutil.read_uleb(b"\xe5\x8e\x26", 0)
assert value == 624485
assert off == 3
# --------------------------------------------------------------------------- #
# instruction width table
# --------------------------------------------------------------------------- #
# Each case is a trap the module docstring names explicitly.
WIDTH_CASES = [
(0x00, 1, "plain nop is one unit"),
(0x02, 2, "move/from16"),
(0x03, 3, "move/16"),
(0x13, 2, "const/16"),
(0x14, 3, "const"),
(0x16, 2, "const-wide/16"),
(0x17, 3, "const-wide/32"),
(0x18, 5, "const-wide"),
(0x1A, 2, "const-string (21c) carries a 16-bit index"),
(0x1B, 3, "const-string/jumbo (31c) carries a 32-bit index"),
(0x1C, 2, "const-class"),
(0x1E, 1, "monitor-exit breaks the same-family reading"),
(0x20, 2, "instance-of"),
(0x21, 1, "array-length breaks the same-family reading"),
(0x27, 1, "throw"),
(0x28, 1, "goto (10t) is one unit"),
(0x29, 2, "goto/16 (20t)"),
(0x2A, 3, "goto/32 (30t)"),
(0x32, 2, "if-test (22t) is two units, not one"),
(0x37, 2, "last if-test"),
(0x38, 2, "if-testz (21t) is two units, not one"),
(0x3D, 2, "last if-testz"),
(0x6E, 3, "invoke-kind (35c)"),
(0x74, 3, "invoke-kind/range (3rc)"),
(0x7B, 1, "neg-int"),
(0x90, 2, "add-int (23x)"),
(0xD0, 2, "add-int/lit16 (22s)"),
(0xFA, 4, "invoke-polymorphic (45cc)"),
(0xFC, 3, "invoke-custom (35c)"),
]
class TestInstructionWidths:
@pytest.mark.parametrize("op,units,why", WIDTH_CASES,
ids=["%02x" % c[0] for c in WIDTH_CASES])
def test_width(self, op, units, why):
assert dexutil.insn_units(op, b"\x00" * 32, 0, 32) == units, why
def test_table_and_accessor_agree_for_every_listed_opcode(self):
"""insn_units must not diverge from OP_UNITS for any allocated opcode."""
buf = b"\x00" * 32
drifted = []
for op, units in sorted(dexutil.OP_UNITS.items()):
got = dexutil.insn_units(op, buf, 0, len(buf))
if got != units:
drifted.append((hex(op), units, got))
assert drifted == []
@pytest.mark.parametrize("op", [0x3E, 0x43, 0x73, 0x79, 0x7A, 0xE3, 0xF9])
def test_unallocated_opcodes_still_terminate(self, op):
"""An unlisted opcode must advance by one unit, not run off the end."""
assert dexutil.insn_units(op, b"\x00" * 8, 0, 8) == 1
def test_packed_switch_payload_width(self):
# 00 01 | size=3 -> 4 + 3*2 = 10 units
buf = bytes([0x00, 0x01]) + (3).to_bytes(2, "little") + b"\x00" * 12
assert dexutil.insn_units(0x00, buf, 0, len(buf)) == 10
def test_sparse_switch_payload_width(self):
# 00 02 | size=3 -> 2 + 3*4 = 14 units
buf = bytes([0x00, 0x02]) + (3).to_bytes(2, "little") + b"\x00" * 24
assert dexutil.insn_units(0x00, buf, 0, len(buf)) == 14
def test_fill_array_data_payload_width(self):
# 00 03 | element_width=4 | size=30 -> 4 + ceil(30*4/2) = 64 units.
# The measured case named in the docstring: counting this as 5 units is
# what desynchronises the rest of the walk.
buf = bytes([0x00, 0x03]) + (4).to_bytes(2, "little") + (30).to_bytes(4, "little")
assert dexutil.insn_units(0x00, buf, 0, len(buf)) == 64
def test_fill_array_data_payload_pads_to_an_even_byte_count(self):
# element_width=1, size=3 -> 3 bytes pads to 4 -> 4 + 2 = 6 units
buf = bytes([0x00, 0x03]) + (1).to_bytes(2, "little") + (3).to_bytes(4, "little")
assert dexutil.insn_units(0x00, buf, 0, len(buf)) == 6
def test_plain_nop_is_not_treated_as_a_payload(self):
assert dexutil.insn_units(0x00, b"\x00\x00\x00\x00", 0, 4) == 1
# --------------------------------------------------------------------------- #
# header parsing and the structural check
# --------------------------------------------------------------------------- #
# Verified dex header offsets, as declared by dexutil.OFFSETS. Asserted rather
# than assumed: these are the values a hand-written reader most often gets wrong.
HEADER_OFFSETS = {
"file_size": 0x20, "header_size": 0x24, "endian_tag": 0x28,
"map_off": 0x34, "string_ids_off": 0x3C, "type_ids_off": 0x44,
"proto_ids_off": 0x4C, "field_ids_off": 0x54, "method_ids_off": 0x5C,
"class_defs_off": 0x64,
}
# The six table pointers dex.check() requires to be inside the file.
TABLE_POINTERS = ("string_ids_off", "type_ids_off", "proto_ids_off",
"field_ids_off", "method_ids_off", "class_defs_off")
def synthetic_dex(size=512):
"""A dex-shaped buffer whose header is internally consistent.
Enough for the structural reader: magic, a matching file_size, and the six
table pointers inside the file. It is deliberately not a runnable dex, so
nothing here claims parse-level validity of a real image.
"""
buf = bytearray(size)
def put(offset, value, width=4):
buf[offset:offset + width] = value.to_bytes(width, "little")
buf[0:8] = b"dex\n035\x00"
put(HEADER_OFFSETS["file_size"], size)
put(HEADER_OFFSETS["header_size"], 0x70)
put(HEADER_OFFSETS["endian_tag"], 0x12345678)
put(HEADER_OFFSETS["map_off"], 0x70)
for key in TABLE_POINTERS:
# size 0, pointer 0x70: every pointer lands inside the file.
put(HEADER_OFFSETS[key] - 4, 0)
put(HEADER_OFFSETS[key], 0x70)
return buf
class TestHeaderParsing:
def test_the_offset_table_matches_the_format_specification(self):
for key, expected in HEADER_OFFSETS.items():
assert dexutil.OFFSETS[key] == expected, key
def test_header_fields_are_parsed_out_of_the_buffer(self):
dex = dexutil.Dex(bytes(synthetic_dex(512)))
assert dex.header["file_size"] == 512
assert dex.header["header_size"] == 0x70
assert dex.header["string_ids_off"] == 0x70
def test_magic_is_parsed(self):
dex = dexutil.Dex(bytes(synthetic_dex(256)))
assert dex.d[:4] == b"dex\n"
def test_consistent_header_reports_no_problems(self):
dex = dexutil.Dex(bytes(synthetic_dex(512)))
assert dex.check() == []
def test_bad_magic_is_reported(self):
buf = synthetic_dex(512)
buf[0:4] = b"zip\n"
assert any("not a dex" in p for p in dexutil.Dex(bytes(buf)).check())
def test_file_size_mismatch_is_reported(self):
buf = synthetic_dex(512)
buf[HEADER_OFFSETS["file_size"]:HEADER_OFFSETS["file_size"] + 4] = \
(999).to_bytes(4, "little")
assert any("file_size" in p for p in dexutil.Dex(bytes(buf)).check())
def test_out_of_range_table_pointer_is_reported(self):
buf = synthetic_dex(512)
at = HEADER_OFFSETS["string_ids_off"]
buf[at:at + 4] = (0).to_bytes(4, "little")
assert any("string_ids_off" in p for p in dexutil.Dex(bytes(buf)).check())
def test_string_safe_never_raises_on_a_junk_index(self):
"""A desynced decode passes junk indices; the reader must not throw."""
dex = dexutil.Dex(bytes(synthetic_dex(512)))
assert dex.string_safe(10**6).startswith("<string_idx")
# --------------------------------------------------------------------------- #
# branch targets
# --------------------------------------------------------------------------- #
def fake_dex(buf):
"""A Dex instance over raw bytes, without header parsing.
branch_target needs only self.d plus the instruction dict, so this keeps the
displacement arithmetic testable without a whole valid dex image.
"""
dex = dexutil.Dex.__new__(dexutil.Dex)
dex.d = bytes(buf)
dex.name = "<synthetic>"
return dex
def insn_at_zero(buf, op, raw_len):
return {"off": 0, "op": op, "raw": bytes(buf[:raw_len]),
"units": raw_len // 2, "name": "synthetic"}
class TestBranchTargets:
def test_if_testz_negative_displacement(self):
buf = bytes([0x38, 0x00]) + (-1).to_bytes(2, "little", signed=True)
assert fake_dex(buf).branch_target(insn_at_zero(buf, 0x38, 4)) == -2
def test_if_testz_positive_displacement(self):
buf = bytes([0x3D, 0x01]) + (2).to_bytes(2, "little", signed=True)
assert fake_dex(buf).branch_target(insn_at_zero(buf, 0x3D, 4)) == 4
def test_if_test_two_registers(self):
buf = bytes([0x32, 0x10]) + (-4).to_bytes(2, "little", signed=True)
assert fake_dex(buf).branch_target(insn_at_zero(buf, 0x32, 4)) == -8
def test_goto_signed_byte(self):
# 10t: op, int8. 0xFE -> -2 units -> -4 bytes from the instruction.
buf = bytes([0x28, 0xFE])
assert fake_dex(buf).branch_target(insn_at_zero(buf, 0x28, 2)) == -4
def test_goto16(self):
buf = bytes([0x29, 0x00]) + (3).to_bytes(2, "little", signed=True)
assert fake_dex(buf).branch_target(insn_at_zero(buf, 0x29, 4)) == 6
def test_goto32_negative(self):
buf = bytes([0x2A, 0x00]) + (-1).to_bytes(4, "little", signed=True)
assert fake_dex(buf).branch_target(insn_at_zero(buf, 0x2A, 6)) == -2
def test_non_branch_returns_none(self):
buf = bytes([0x0E, 0x00])
assert fake_dex(buf).branch_target(insn_at_zero(buf, 0x0E, 2)) is None
def test_branch_regs_if_testz_reads_one_register(self):
buf = bytes([0x38, 0x07]) + (-1).to_bytes(2, "little", signed=True)
regs, kind = fake_dex(buf).branch_regs(insn_at_zero(buf, 0x38, 4))
assert regs == [7]
assert kind == "if-testz"
def test_branch_regs_if_test_reads_two_registers(self):
buf = bytes([0x32, 0x21]) + (-1).to_bytes(2, "little", signed=True)
regs, kind = fake_dex(buf).branch_regs(insn_at_zero(buf, 0x32, 4))
assert regs == [2, 1]
assert kind == "if-test"
def test_branch_regs_is_empty_for_a_non_branch(self):
buf = bytes([0x0E, 0x00])
assert fake_dex(buf).branch_regs(insn_at_zero(buf, 0x0E, 2)) == ([], None)
# --------------------------------------------------------------------------- #
# header integrity: signature before checksum
# --------------------------------------------------------------------------- #
class TestHeaderIntegrity:
def test_fix_then_verify_round_trips(self):
data = bytearray(synthetic_dex(512))
dexutil.fix_dex_header(data)
assert dexutil.verify_dex_header(data) == (True, True)
def test_signature_covers_the_bytes_after_the_signature_field(self):
data = bytearray(synthetic_dex(512))
dexutil.fix_dex_header(data)
assert bytes(data[12:32]) == hashlib.sha1(bytes(data[32:])).digest()
def test_checksum_covers_the_signature_field(self):
"""The order requirement: adler32 over d[12:] includes the written signature."""
data = bytearray(synthetic_dex(512))
dexutil.fix_dex_header(data)
stored = int.from_bytes(data[8:12], "little")
assert stored == zlib.adler32(bytes(data[12:])) & 0xFFFFFFFF
def test_checksum_taken_over_a_zeroed_signature_does_not_verify(self):
"""The failure mode the module docstring names: the header never verifies."""
data = bytearray(synthetic_dex(512))
data[12:32] = b"\x00" * 20
data[8:12] = (zlib.adler32(bytes(data[12:])) & 0xFFFFFFFF).to_bytes(4, "little")
data[12:32] = hashlib.sha1(bytes(data[32:])).digest()
checksum_ok, _signature_ok = dexutil.verify_dex_header(data)
assert checksum_ok is False
def test_a_tampered_body_fails_verification(self):
data = bytearray(synthetic_dex(512))
dexutil.fix_dex_header(data)
data[-1] ^= 0xFF
assert dexutil.verify_dex_header(data) == (False, False)
def test_fix_reports_before_and_after_values(self):
data = bytearray(synthetic_dex(512))
report = dexutil.fix_dex_header(data)
assert set(report) == {"before_checksum", "after_checksum",
"before_signature", "after_signature"}
assert report["before_checksum"] != report["after_checksum"]
# --------------------------------------------------------------------------- #
# sample-backed assertions (skip when the git-ignored bench tree is absent)
# --------------------------------------------------------------------------- #
SAMPLE_CLASS = "Lsg/vantagepoint/uncrackable1/MainActivity;"
SAMPLE_METHOD = ("onCreate", "(Landroid/os/Bundle;)V")
SAMPLE_SITE = 0x9D0
SAMPLE_SITE_BYTES = "39000e00"
class TestAgainstTheRecordedSample:
"""The l1l3 fixture is git-ignored, so these skip in CI by design."""
@pytest.fixture(autouse=True)
def _need_sample(self, bench_l1l3):
self.dex_path = require_path(
bench_l1l3 / "dex" / "classes.dex",
"the l1l3 sample is a download into the git-ignored bench tree")
def _method(self):
dex, _entry = dexutil.load_dex(str(self.dex_path))
found = dex.find_method(SAMPLE_CLASS, *SAMPLE_METHOD)
assert found, "the recorded fixture must still contain MainActivity.onCreate"
_section, _idx, code_off = found
return dex, code_off
def test_sample_dex_parses_cleanly(self):
dex, _entry = dexutil.load_dex(str(self.dex_path))
assert dex.check() == []
def test_decode_walks_exactly_insns_size_units(self):
dex, code_off = self._method()
insns, clean, expected = dex.decode_all(code_off)
assert clean, "decode did not land on 0x%x" % expected
assert insns
def test_the_recorded_root_check_branch_is_at_0x9d0(self):
dex, code_off = self._method()
target = dex.insn_at(code_off, SAMPLE_SITE)
assert target is not None, "instruction boundary at 0x%x disappeared" % SAMPLE_SITE
assert target["raw"].hex() == SAMPLE_SITE_BYTES
assert target["op"] in dexutil.IF_TESTZ
def test_branch_target_resolves_to_an_instruction_boundary(self):
dex, code_off = self._method()
target = dex.insn_at(code_off, SAMPLE_SITE)
dest = dex.branch_target(target)
assert dest is not None
assert dex.insn_at(code_off, dest) is not None, (
"branch target 0x%x is not an instruction boundary" % dest)
def test_every_branch_target_is_an_instruction_boundary(self):
"""A decode desync shows up here first: a branch into the middle of an insn."""
dex, code_off = self._method()
boundaries = {i["off"] for i in dex.decode(code_off)}
stray = sorted(t for t in dex.all_targets(code_off) if t not in boundaries)
assert stray == [], "branch targets outside the instruction stream: %r" % stray
+192
View File
@@ -0,0 +1,192 @@
"""Unit tests for skills/apk-reverse/scripts/protobuf_decode_raw.py.
Two properties are pinned here, because both are claims the reference material
makes and neither is visible from reading the decoder:
* the built-in known-answer fixture set passes in full (26/26), and
* a decode -> re-encode round trip is byte-identical for every fixture except
the one fixture that is deliberately non-canonical, which must instead
report *why* it differs rather than silently normalising.
"""
from __future__ import annotations
import pytest
from conftest import load_script, run_script
pb = load_script("protobuf_decode_raw.py")
pytestmark = pytest.mark.unit
# 12 fixtures x (fixture check + round trip) + 2 wire-format trap checks.
EXPECTED_FIXTURE_COUNT = 12
EXPECTED_TOTAL_CHECKS = 26
@pytest.fixture(scope="module")
def fixtures():
return pb._fixtures()
# --------------------------------------------------------------------------- #
# varint primitives
# --------------------------------------------------------------------------- #
class TestVarintPrimitives:
def test_canonical_example(self):
assert pb.encode_varint(150) == bytes.fromhex("9601")
def test_read_varint_returns_info_next_index_and_no_error(self):
info, nxt, err = pb.read_varint(bytes.fromhex("9601"), 0, 2)
assert err is None
assert info["value"] == 150
assert info["bytes"] == 2
assert nxt == 2
def test_a_truncated_varint_is_an_error_not_a_value(self):
info, nxt, err = pb.read_varint(bytes.fromhex("96"), 0, 1)
assert info is None
assert err and "truncated" in err
assert nxt == 0
def test_a_non_canonical_encoding_is_flagged(self):
"""A padded varint must be reported: it is why re-encode cannot be assumed equal."""
info, _nxt, _err = pb.read_varint(bytes.fromhex("8000"), 0, 2)
assert info["value"] == 0
assert info["non_canonical"] is True
assert info["canonical_bytes"] == 1
def test_varint_len_matches_the_encoding(self):
for value in (0, 1, 127, 128, 300, 2 ** 31, 2 ** 63 - 1):
assert pb.varint_len(value) == len(pb.encode_varint(value))
def test_zigzag(self):
assert pb.zigzag_decode(0) == 0
assert pb.zigzag_decode(1) == -1
assert pb.zigzag_decode(2) == 1
assert pb.zigzag_decode(4294967294) == 2147483647
def test_int_views_wrap(self):
assert pb.int32_view(0xFFFFFFFF) == -1
assert pb.int64_view(0xFFFFFFFFFFFFFFFF) == -1
# --------------------------------------------------------------------------- #
# the built-in fixture set
# --------------------------------------------------------------------------- #
class TestFixtureSet:
def test_the_selftest_function_passes_end_to_end(self, capsys):
assert pb.selftest(None) == 0
out = capsys.readouterr().out
assert "selftest: %d/%d PASS" % (EXPECTED_TOTAL_CHECKS, EXPECTED_TOTAL_CHECKS) in out
assert "FAIL" not in out
def test_fixture_count_is_stable(self, fixtures):
"""The 26/26 headline is 12 fixtures x 2 + 2; a silent drop would hide."""
assert len(fixtures) == EXPECTED_FIXTURE_COUNT
def test_every_fixture_has_a_name_payload_and_expectations(self, fixtures):
for name, payload, expects, _rt in fixtures:
assert isinstance(name, str) and name
assert isinstance(payload, (bytes, bytearray)) and payload
assert expects, "fixture %s has no assertions" % name
def test_each_declared_expectation_holds(self, fixtures):
failures = []
for name, payload, expects, _rt in fixtures:
ctx = pb.Ctx(max_depth=6)
frames = pb._fixture_frames(name, payload)
fields = []
for start, end, _p in frames:
found, _n, _s = pb.walk(payload, start, end, 0, ctx, "f", analyse=True)
fields.extend(found)
for label, num, expected in expects:
got = pb._selftest_lookup(fields, frames, payload, label, num)
if got != expected:
failures.append((name, label, expected, got))
assert failures == []
# --------------------------------------------------------------------------- #
# round trip
# --------------------------------------------------------------------------- #
class TestRoundTrip:
def _reencode(self, name, payload):
frames = pb._fixture_frames(name, payload)
tree = {"frames": [{"fields": pb.walk(payload, s, e, 0, pb.Ctx(6), "f",
analyse=True)[0]}
for s, e, _p in frames]}
report = {"fields": 0, "identical": 0, "mismatched": []}
out = pb.reencode_tree(tree, report)
expected = b"".join(payload[s:e] for s, e, _p in frames)
return out, expected, report
def test_canonical_fixtures_reencode_byte_identically(self, fixtures):
differing = []
for name, payload, _expects, rt in fixtures:
if rt == "non-canonical":
continue
out, expected, _report = self._reencode(name, payload)
if out != expected:
differing.append((name, expected.hex(), out.hex()))
assert differing == []
def test_the_non_canonical_fixture_reports_why_it_differs(self, fixtures):
"""A normalising re-encode must say so, not return different bytes quietly."""
targets = [f for f in fixtures if f[3] == "non-canonical"]
assert targets, "the non-canonical fixture disappeared from the set"
for name, payload, _expects, _rt in targets:
out, expected, report = self._reencode(name, payload)
assert out != expected
assert out.hex() == "089601"
assert report["mismatched"], "the mismatch must be recorded"
assert "non-canonical" in report["mismatched"][0]["reason"]
def test_reencode_accounts_for_every_field(self, fixtures):
for name, payload, _expects, rt in fixtures:
_out, _expected, report = self._reencode(name, payload)
assert report["fields"] > 0, name
# --------------------------------------------------------------------------- #
# the two wire-format traps, asserted rather than narrated
# --------------------------------------------------------------------------- #
class TestWireFormatTraps:
def test_explicit_zero_is_not_the_same_bytes_as_absent(self):
zero = pb.tag_0(5, 0) + pb.encode_varint(0)
assert zero != b""
def test_explicit_zero_decodes_to_value_zero(self):
zero = pb.tag_0(5, 0) + pb.encode_varint(0)
fields = pb.walk(zero, 0, len(zero), 0, pb.Ctx(6), "f")[0]
assert fields[0]["value"] == 0
def test_absent_field_decodes_to_no_fields(self):
assert pb.walk(b"", 0, 0, 0, pb.Ctx(6), "f")[0] == []
def test_a_packed_payload_offers_both_readings(self):
"""Three varints are also a valid nested message; only a schema decides."""
packed = b"".join(pb.encode_varint(v) for v in (3, 270, 86942))
cands, view = pb.analyse_payload(packed, 0, pb.Ctx(6), "f", 0)
live = sorted(c["kind"] for c in cands if c["confidence"] > 0.0)
all_kinds = sorted(c["kind"] for c in cands)
assert "packed_varint" in live
assert "nested_message" in all_kinds
assert view == "packed_varint"
# --------------------------------------------------------------------------- #
# the subprocess contract
# --------------------------------------------------------------------------- #
class TestSelftestCli:
def test_selftest_subcommand_exits_zero_and_prints_the_tally(self):
result = run_script("protobuf_decode_raw.py", ["--selftest"], timeout=180)
assert not result.timed_out
assert not result.has_traceback, result.output
assert result.returncode == 0, result.output
assert "selftest: %d/%d PASS" % (EXPECTED_TOTAL_CHECKS, EXPECTED_TOTAL_CHECKS) \
in result.stdout
def test_help_lists_the_selftest_flag(self):
result = run_script("protobuf_decode_raw.py", ["--help"])
assert result.returncode == 0
assert "--selftest" in result.stdout
+198
View File
@@ -0,0 +1,198 @@
"""Unit tests for skills/apk-reverse/scripts/scan_leaks.py.
Decoys are built in a temporary directory at run time. No real target identity
is committed here, and the token-shaped decoys are assembled from fragments so
that this test file itself cannot become a finding if the scanner is ever
pointed at the tests tree.
"""
from __future__ import annotations
import json
import pytest
from conftest import load_script, run_script
scan_leaks = load_script("scan_leaks.py")
pytestmark = pytest.mark.unit
# Synthetic identifiers: reverse-domain-shaped, not derived from any real target.
DECOY_PACKAGE = "com." + "acme" + ".targetapp"
DECOY_TOKEN = "AbCdEf" + "0123456789" + "XyZ"
DECOY_USER = "mkessler"
def write(root, name, body):
path = root / name
path.write_text(body, encoding="utf-8")
return path
def scan(root, *extra, timeout=120):
"""Run the scanner over a temporary root and parse its JSON report."""
result = run_script("scan_leaks.py",
["--root", str(root), "--format", "json"] + list(extra),
timeout=timeout)
assert not result.timed_out, "scan_leaks.py did not finish: %r" % result
assert not result.has_traceback, result.output
# The JSON document is printed first, then the RESULT token line.
lines = result.stdout.splitlines()
token = lines[-1] if lines else ""
doc = json.loads(result.stdout[:result.stdout.rindex("}") + 1])
return result, doc, token
# --------------------------------------------------------------------------- #
# the exemption list as data
# --------------------------------------------------------------------------- #
class TestExemptions:
@pytest.mark.parametrize("value", [
"com.android.internal",
"androidx.core.app",
"com.google.firebase",
"org.example.demo",
"<pkg>",
"${PKG}",
"sg.vantagepoint.uncrackable1",
])
def test_benign_values_carry_an_exemption_reason(self, value):
assert scan_leaks._benign_package(value) != "", value
@pytest.mark.parametrize("value", [
DECOY_PACKAGE,
"com.unknownvendor.someapp",
])
def test_unknown_reverse_domain_values_are_reportable(self, value):
assert scan_leaks._benign_package(value) == "", value
def test_loopback_and_documentation_addresses_are_distinguished(self):
"""Loopback is exempt; an RFC 5737 documentation address is reported on purpose."""
assert scan_leaks._benign_ip("127.0.0.1:8080") != ""
assert scan_leaks._benign_ip("192.0.2.10:8080") == ""
def test_placeholder_user_paths_are_exempt(self):
assert scan_leaks._benign_path("C:\\Users\\<user>\\work") != ""
assert scan_leaks._benign_path("/home/" + DECOY_USER + "/work") == ""
# --------------------------------------------------------------------------- #
# decoys that must be reported
# --------------------------------------------------------------------------- #
class TestDecoyDetection:
def test_strong_decoys_are_reported(self, tmp_path):
write(tmp_path, "notes.md", "\n".join([
'package="%s"' % DECOY_PACKAGE,
"Authorization: Bearer " + DECOY_TOKEN,
"/home/%s/work/build/classes.dex" % DECOY_USER,
]) + "\n")
result, doc, token = scan(tmp_path)
rules = sorted({f["rule"] for f in doc["findings"]})
assert "bundle_pkg_attr" in rules
assert "token_authorization_header" in rules
assert "user_path_posix" in rules
assert all(f["strength"] in ("strong", "certain") for f in doc["findings"])
assert token == "RESULT=leaks_found"
assert result.returncode == 1, result.output
def test_findings_carry_context_not_just_a_line_number(self, tmp_path):
write(tmp_path, "notes.md", 'package="%s"\n' % DECOY_PACKAGE)
_result, doc, _token = scan(tmp_path)
finding = doc["findings"][0]
assert finding["context"].strip()
assert finding["match"] == DECOY_PACKAGE
assert finding["line"] == 1
assert finding["file"] == "notes.md"
def test_device_serial_shaped_token_is_reported(self, tmp_path):
# 16 uppercase alphanumerics with at least one letter, next to a device word.
serial = "R58M" + "12ABCDEF" + "9K7Q"
assert len(serial) == 16
write(tmp_path, "adb.txt", "device %s\n" % serial)
_result, doc, token = scan(tmp_path)
assert any(f["rule"].startswith("device_serial") for f in doc["findings"]), doc
assert token == "RESULT=leaks_found"
# --------------------------------------------------------------------------- #
# decoys that must not be reported
# --------------------------------------------------------------------------- #
class TestExemptDecoysStaySilent:
def test_benign_identifiers_produce_no_findings(self, tmp_path):
write(tmp_path, "notes.md", "\n".join([
'package="com.android.internal"',
'package="com.google.firebase.messaging"',
'package="<pkg>"',
"endpoint 127.0.0.1:8080",
"path C:\\Users\\<user>\\work\\x.dex",
"public crackme sg.vantagepoint.uncrackable1",
]) + "\n")
result, doc, token = scan(tmp_path)
assert doc["findings"] == [], doc["findings"]
assert token == "RESULT=clean"
assert result.returncode == 0
def test_exempted_hits_are_kept_and_auditable_with_show_exempt(self, tmp_path):
"""The exemption list must be inspectable, not just silent."""
write(tmp_path, "notes.md", 'package="com.android.internal"\n')
_result, doc, _token = scan(tmp_path, "--show-exempt")
assert doc["findings"] == []
assert doc["exempted"], "an exempted hit must still be recorded"
assert doc["exempted"][0]["exempt_reason"]
def test_a_weak_only_hit_keeps_the_default_gate_green(self, tmp_path):
"""Documented behaviour: an RFC 5737 address is reported but does not fail --fail-on strong.
Note the measured detail: `ip:port` satisfies both endpoint rules, so one
address is reported twice (once as the `ip:port` form, once as the bare
literal). Recorded here rather than smoothed over -- it is a report-volume
question, not a missed or invented finding.
"""
write(tmp_path, "notes.md", "endpoint 192.0.2.10:8080\n")
result, doc, token = scan(tmp_path)
assert {f["strength"] for f in doc["findings"]} == {"weak"}
assert {f["rule"] for f in doc["findings"]} == {"plain_addr", "plain_addr_ipliteral"}
assert token == "RESULT=leaks_found_strong_only"
assert result.returncode == 0
strict, _doc2, strict_token = scan(tmp_path, "--fail-on", "any")
assert strict_token == "RESULT=leaks_found"
assert strict.returncode == 1
# --------------------------------------------------------------------------- #
# the machine-readable contract
# --------------------------------------------------------------------------- #
class TestReportContract:
def test_json_report_has_the_documented_keys(self, tmp_path):
write(tmp_path, "notes.md", 'package="%s"\n' % DECOY_PACKAGE)
_result, doc, _token = scan(tmp_path)
assert set(doc) >= {"root", "files_scanned", "findings", "exempted",
"errors", "result", "fail_on"}
assert doc["result"] == "leaks_found"
assert doc["fail_on"] == "strong"
def test_result_token_is_always_the_last_line(self, tmp_path):
write(tmp_path, "notes.md", 'package="%s"\n' % DECOY_PACKAGE)
result, _doc, token = scan(tmp_path)
assert result.stdout.splitlines()[-1] == token
assert token.startswith("RESULT=")
def test_list_rules_prints_the_table_and_exits_zero(self):
result = run_script("scan_leaks.py", ["--list-rules"])
assert result.returncode == 0, result.output
assert not result.has_traceback
for rule_id in ("bundle_pkg_attr", "device_serial", "token_github_pat",
"appkey_assignment", "user_path_posix"):
assert rule_id in result.stdout
def test_unknown_category_is_a_usage_error(self, tmp_path):
result = run_script("scan_leaks.py",
["--root", str(tmp_path), "--only", "nosuchcategory"])
assert result.returncode == 2, result.output