reverse-skills-max: initial public release (restored, redacted, hardened)
This commit is contained in:
34
vendor-new/apk-reverse/.flake8
Normal file
34
vendor-new/apk-reverse/.flake8
Normal file
@@ -0,0 +1,34 @@
|
||||
# flake8 configuration for this repository.
|
||||
#
|
||||
# Three settings, each with the measurement behind it rather than a preference.
|
||||
#
|
||||
# max-line-length = 160
|
||||
# 120 was tried first and two lines still exceeded it: the secret-scanner rule table in
|
||||
# scan_leaks.py holds raw regex source of 148 and 156 characters. Splitting a regex inside a raw
|
||||
# string either changes the pattern or forces concatenation that is harder to read than the long
|
||||
# line, so the limit is set from what a real string in this repository needs (156) with a little
|
||||
# room, and a line over 160 is a genuine defect rather than a style disagreement.
|
||||
#
|
||||
# max-complexity is not set, and C901 is excluded below with the measurement: this repository's
|
||||
# parsers and dispatchers reach 21-41 (check_commands.command_findings is 41, a flat if/elif chain
|
||||
# over finding kinds). Setting a threshold above the maximum would be a number chosen to pass rather
|
||||
# than a standard; splitting them into one function per case would move the shape of the format away
|
||||
# from the code that reads it, and looking like the specification is this repository's argument.
|
||||
# The numbers are recorded here so a future pass can decide to refactor with them in hand.
|
||||
#
|
||||
# extend-ignore:
|
||||
# C901 see above -- reported here as a measurement, not enforced
|
||||
# E203 slice whitespace (`data[start : end]`); flake8 and every autoformatter disagree, and the
|
||||
# repository has no autoformatter
|
||||
#
|
||||
# Run from the repository root:
|
||||
# flake8 *.py skills/apk-reverse/scripts/*.py
|
||||
#
|
||||
# Enforced in CI: the `lint` job in .github/workflows/ci.yml runs this exact command alongside mypy,
|
||||
# on a bare interpreter, so the configuration is exercised in the poorest environment it must keep
|
||||
# working in.
|
||||
|
||||
[flake8]
|
||||
max-line-length = 160
|
||||
extend-ignore = C901,E203
|
||||
exclude = .git,__pycache__,tools,build,dist
|
||||
21
vendor-new/apk-reverse/.gitattributes
vendored
Normal file
21
vendor-new/apk-reverse/.gitattributes
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Normalize line endings so a Windows checkout does not rewrite committed files.
|
||||
# Scripts stay LF (they are consumed by python/node and by shell on POSIX hosts).
|
||||
* text=auto eol=lf
|
||||
|
||||
*.py text eol=lf
|
||||
*.js text eol=lf
|
||||
*.sh text eol=lf
|
||||
*.md text eol=lf
|
||||
*.java text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
|
||||
# PowerShell is the one thing that is happier with CRLF on Windows.
|
||||
*.ps1 text eol=crlf
|
||||
|
||||
# Never normalize binary-ish artifacts.
|
||||
*.jar binary
|
||||
*.png binary
|
||||
*.apk binary
|
||||
*.dex binary
|
||||
*.so binary
|
||||
132
vendor-new/apk-reverse/.github/workflows/ci.yml
vendored
Normal file
132
vendor-new/apk-reverse/.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,132 @@
|
||||
# CI for the apk-reverse skill repository.
|
||||
#
|
||||
# WHAT THIS RUNS
|
||||
# * the repository's own structural gates: check_repo.py, check_refs.py,
|
||||
# check_budget.py, check_routing.py, and check_commands.py when it is present
|
||||
# * the pytest suite: tests/unit, tests/cli, tests/integration
|
||||
#
|
||||
# WHAT THIS DOES NOT RUN, ON PURPOSE
|
||||
# * anything needing a device, adb, frida, a rooted phone, a network peer or a
|
||||
# real sample APK. Those routes are recorded experiments in tests/benchmark.md
|
||||
# and their status is documented in
|
||||
# docs/tool-verification/EXTENSION-test-harness.md.
|
||||
# * anything about agent behaviour. Whether a model routes, classifies or
|
||||
# refuses correctly is not observable from a unit test; skills/apk-reverse/
|
||||
# evals/evals.json is the skeleton for that, and it has never been executed.
|
||||
# * the dex_patch_bytes regression hash. Its input lives in git-ignored
|
||||
# tools/_work/bench/, so that test self-skips here with a printed reason
|
||||
# rather than failing. Run it locally after downloading the sample.
|
||||
#
|
||||
# Coverage is therefore: static structure, documentation anchors, budget, command
|
||||
# references, routing coherence, pure parsers, CLI contracts, typing/style lint and
|
||||
# no-device flows. Nothing more is claimed.
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
#
|
||||
# NOTE ON TRIGGERS: no `paths:` filter, deliberately. A filter that watches only
|
||||
# `*.py` and the lint configuration would skip the structural gates on exactly the
|
||||
# changes they exist to catch -- a reference index or a documented command edited
|
||||
# without touching a script. check_refs.py and check_routing.py are documentation
|
||||
# gates, so they have to run on documentation commits.
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: python ${{ matrix.python-version }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.11", "3.12", "3.13", "3.14"]
|
||||
|
||||
env:
|
||||
PYTHONIOENCODING: utf-8
|
||||
PYTHONDONTWRITEBYTECODE: "1"
|
||||
# The gates spawn sibling scripts and decode their output. On a Windows
|
||||
# runner the default codec is the console's (GBK here), and one script's
|
||||
# --help text is not GBK-decodable, which is reported as a crash in that
|
||||
# script rather than as a harness decoding problem. Pinning UTF-8 removes
|
||||
# the whole class of failure on every platform.
|
||||
PYTHONUTF8: "1"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install test dependencies
|
||||
# `tiktoken` is optional for the suite but not for the budget gate's *measurement*: without
|
||||
# it, `check_budget.py` falls back to characters/constant, and the two paths must agree.
|
||||
# Installing it here means CI exercises the real tokenizer path, and the unit test comparing
|
||||
# both paths runs instead of skipping.
|
||||
# Not upgrading pip itself: it is not a goal here, and a pip self-upgrade failing would be
|
||||
# reported as this repository's regression.
|
||||
run: python -m pip install --upgrade pytest tiktoken
|
||||
|
||||
- name: Structural gate - repository layout
|
||||
run: python check_repo.py
|
||||
|
||||
- name: Structural gate - documentation anchors
|
||||
run: python check_refs.py
|
||||
|
||||
- name: Structural gate - context budget
|
||||
run: python check_budget.py
|
||||
|
||||
- name: Structural gate - documented commands (if present)
|
||||
run: |
|
||||
if [ -f check_commands.py ]; then
|
||||
python check_commands.py
|
||||
else
|
||||
echo "check_commands.py is not present in this revision; skipping."
|
||||
fi
|
||||
|
||||
- name: Structural gate - routing inventory matches the entry point
|
||||
run: python check_routing.py
|
||||
|
||||
- name: Regression suite
|
||||
run: python -m pytest -q
|
||||
|
||||
- name: Summarise skipped and expected-failure coverage
|
||||
if: always()
|
||||
run: python -m pytest -q -ra --no-header -p no:cacheprovider 2>&1 | tail -n 40
|
||||
|
||||
lint:
|
||||
# Static checks over the same tracked surface the local commands cover, so what passes locally
|
||||
# cannot drift from what CI enforces. Both tools read their configuration (mypy.ini, .flake8)
|
||||
# from the repository root automatically.
|
||||
#
|
||||
# The environment is deliberately bare -- only mypy and flake8, none of PyYAML, capstone, tiktoken
|
||||
# or frida. That is the poorest environment the configuration must keep working in, which is what
|
||||
# mypy.ini's ignore_missing_imports overrides exist for: CI exercises the fallbacks instead of a
|
||||
# machine-local install hiding a regression.
|
||||
#
|
||||
# One interpreter, not the matrix: the checks are static, and the matrix's job is runtime
|
||||
# behaviour, which lint does not observe. `check_routing.py` -- a gate the test job also runs --
|
||||
# is deliberately not repeated here.
|
||||
#
|
||||
# Verified locally before being added: on a venv with only mypy and flake8, `flake8` reports 0 and
|
||||
# `mypy` reports "Success: no issues found in 55 source files".
|
||||
name: lint (mypy + flake8)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install lint dependencies
|
||||
run: python -m pip install --upgrade mypy flake8
|
||||
|
||||
- name: mypy
|
||||
run: mypy *.py skills/apk-reverse/scripts/*.py
|
||||
|
||||
- name: flake8
|
||||
run: flake8 *.py skills/apk-reverse/scripts/*.py
|
||||
54
vendor-new/apk-reverse/.gitignore
vendored
Normal file
54
vendor-new/apk-reverse/.gitignore
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
# build artifacts
|
||||
*.class
|
||||
*.jar
|
||||
!skills/apk-reverse/scripts/dexpatch/*.java
|
||||
|
||||
# working data -- never commit samples or outputs
|
||||
*.apk
|
||||
*.aab
|
||||
*.dex
|
||||
*.odex
|
||||
*.vdex
|
||||
*.oat
|
||||
*.so
|
||||
*.jks
|
||||
*.keystore
|
||||
*.pk8
|
||||
*.pem
|
||||
*.p12
|
||||
|
||||
# local scratch
|
||||
_tmp/
|
||||
out/
|
||||
work/
|
||||
audit_out/
|
||||
|
||||
# local extension-pass workspace: toolchains, device archives, dumped images, scratch notes.
|
||||
# Nothing under tools/ belongs to the repository -- the skill's own evidence record lives in
|
||||
# docs/tool-verification/ and carries no target identity or vendored binaries.
|
||||
tools/
|
||||
|
||||
# local input material (the reporting sample and the analysis notes it came with)
|
||||
存在问题和例子/
|
||||
|
||||
# sandbox installs made while testing `npx skills add .` against this repo
|
||||
.claude/
|
||||
.agents/
|
||||
*.log
|
||||
*.jsonl
|
||||
*.pcap
|
||||
*.png
|
||||
*.jpg
|
||||
|
||||
# python / editors
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
|
||||
# local input material -- the same rule as 存在问题和例子/: the reports these passes are based on
|
||||
# are the user's material, not content of this repository
|
||||
新的建议和思路/
|
||||
朋友全面审查和建议/
|
||||
21
vendor-new/apk-reverse/LICENSE
Normal file
21
vendor-new/apk-reverse/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 apk-reverse contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
612
vendor-new/apk-reverse/README.md
Normal file
612
vendor-new/apk-reverse/README.md
Normal file
@@ -0,0 +1,612 @@
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="assets/banner-dark.svg">
|
||||
<img src="assets/banner-light.svg" alt="apk-reverse" width="100%">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<b>English</b> · <a href="README.zh-CN.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/newliver666/apk-reverse/stargazers"><img src="https://img.shields.io/github/stars/newliver666/apk-reverse?style=flat-square&label=stars&color=49454F" alt="stars"></a>
|
||||
<a href="https://github.com/newliver666/apk-reverse/network/members"><img src="https://img.shields.io/github/forks/newliver666/apk-reverse?style=flat-square&label=forks&color=49454F" alt="forks"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/github/license/newliver666/apk-reverse?style=flat-square&color=49454F" alt="license"></a>
|
||||
<img src="https://img.shields.io/badge/python-3.9%2B-49454F?style=flat-square&logo=python&logoColor=white" alt="python">
|
||||
<img src="https://img.shields.io/badge/platform-android-49454F?style=flat-square&logo=android&logoColor=white" alt="android">
|
||||
<a href="https://github.com/newliver666/apk-reverse/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/newliver666/apk-reverse/ci.yml?style=flat-square&label=ci&color=49454F" alt="ci"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#what-it-is-good-at">Capabilities</a> · <a href="#structure">Structure</a> · <a href="#install">Install</a> · <a href="#requirements">Requirements</a> · <a href="#read-this-first">Failure catalogue</a> · <a href="#scope">Scope</a> · <a href="#repository-maintenance">Maintenance</a> · <a href="#disclaimer">Disclaimer</a>
|
||||
</p>
|
||||
|
||||
# apk-reverse
|
||||
|
||||
An Agent Skill for Android APK reverse engineering, debloating, ad removal, surgical
|
||||
dex patching, repacking, and runtime/server analysis.
|
||||
|
||||
It is a **skill**, not a tutorial: it is written to be loaded by an agent (Claude Code,
|
||||
Codex, or any harness that supports the Agent Skills format) while it works, so it is
|
||||
organized for progressive disclosure — a short decision-oriented `SKILL.md`, detailed
|
||||
references loaded only when a step needs them, and parameterized scripts you can run
|
||||
directly.
|
||||
|
||||
## How an agent is expected to consume this
|
||||
|
||||
`SKILL.md` is deliberately written as a **procedure with gates** rather than as advice, because the
|
||||
observed failure mode is not ignorance — it is a model reading the whole thing, agreeing with it, and
|
||||
then reasoning from first principles anyway.
|
||||
|
||||
So there are four things in the body that are meant to be *acted on*, not read:
|
||||
|
||||
- **Four override rules (R1–R4).** Where they conflict with the current plan, they win until evidence
|
||||
overrides them.
|
||||
- **A symptom index.** Each row is a failure that has already been paid for. **A matching row is a
|
||||
stop signal**: load that file before running another command, rather than after a few more attempts.
|
||||
Reasoning past a known symptom is how the same hours get spent twice.
|
||||
- **Four gates (G1–G4),** each an action with a pass criterion. "I understand the idea" does not clear
|
||||
a gate. They exist so that classification, environment truth and a control build happen *before*
|
||||
the first patch, not after the third failure.
|
||||
- **A two-strike rule and stop conditions.** Two failures of the same shape mean the model is wrong,
|
||||
not the parameters. The third variant of a hypothesis that already failed twice is where rounds go
|
||||
to die.
|
||||
|
||||
And one thing at the end that is meant to be *withheld*: **"done"** has a definition (six items). A
|
||||
clean log is not one of them. Anything short of all six is a checkpoint, and should be reported as a
|
||||
checkpoint with what remains.
|
||||
|
||||
If you are an agent reading this: the cheapest possible first command is
|
||||
`python skills/apk-reverse/scripts/doctor.py`. It tells you which of these tools exist here, which
|
||||
scripts can actually run, and whether something in the environment is already poisoning your
|
||||
measurements.
|
||||
|
||||
## What it is good at
|
||||
|
||||
- Deciding **fast** whether a request is even achievable client-side, instead of
|
||||
burning hours on a paywall that is enforced by a server.
|
||||
- Deciding **what form the deliverable must take** before any work starts — an
|
||||
unrooted, self-contained artifact is a different problem from "make it work on this
|
||||
machine", and confusing the two is the most expensive drift in this domain.
|
||||
- Choosing the **safest patch layer** for a given change, and avoiding the layers that
|
||||
break the app.
|
||||
- Catching the repack failure that looks like success: an app that installs, launches and
|
||||
renders perfectly while **every signed request is rejected**, because the client derives its
|
||||
request-signing key from its own signing certificate.
|
||||
- Separating **your own mistakes from the app's or the server's problems** — a
|
||||
feature-scoped failure (login, registration, payment) is often a TLS/certificate issue on
|
||||
one code path, not a consequence of the patch you just built. Device state, a dead device
|
||||
server and clock drift masquerade the same way.
|
||||
- Establishing **which architecture and which library are actually executing**, rather
|
||||
than trusting what the manifest ships or what the device claims.
|
||||
- Working through **packed/hardened targets**: identifying the packer, unpacking, and turning a
|
||||
memory dump back into a patched, installable APK.
|
||||
- Handling a **hardened library that terminates the process on purpose** — including the
|
||||
deliberate-crash shape (`fault addr 0x4`) that looks exactly like an ordinary null-dereference
|
||||
bug, and the "neutralise it, but never by making it *not return*" rule that decides whether the
|
||||
fix works or freezes the whole app in a way that looks nothing like the cause.
|
||||
- Knowing **which tools to reach for and where each one lies** — including the ones that only
|
||||
exist as a GUI, so you ask for a human instead of silently substituting a weaker method.
|
||||
- Making a patched build **stay** patched: neutralising version checks, forced-upgrade dialogs and
|
||||
self-update installers so the work cannot be switched off remotely — and recognising the
|
||||
hot-update/remote-config channel that can quietly undo it without any version change.
|
||||
- Separating a **client-side sign-in gate** (patchable) from an **account-scoped resource** (empty
|
||||
because the server has nothing to answer with), and knowing that forging a session produces a state
|
||||
worse than being signed out.
|
||||
- Keeping a **long task honest**: a live record, graded conclusions, calibrated timeouts, and
|
||||
bounded waits, so progress is not lost and the same mistake is not made twice.
|
||||
- Avoiding the specific mistakes that produce an APK that builds perfectly and dies at
|
||||
runtime.
|
||||
- Deciding **what the deliverable should be when an APK is not an option** — a repack refused by
|
||||
several independent checks is *blocked*, not expensive, and the fallback ladder is a system-level
|
||||
module, a local RPC service, or an honest report with a stated boundary.
|
||||
- Telling a **real memory dump from an extraction-shell skeleton**, and knowing which recovery route
|
||||
applies — including the root-side dump for when `frida` itself is refused. What the measurement can
|
||||
and cannot see is in `skills/apk-reverse/references/advanced-unpacking.md`.
|
||||
- **Calling a routine instead of reversing it** when reversing costs more than invoking: emulated
|
||||
execution on the host, or a live function service-ified over Frida RPC.
|
||||
- Reading **instruction-level execution evidence** when a native function has been flattened into a
|
||||
state machine by OLLVM — including the two ways Stalker was measured to bite back on a real device.
|
||||
- Recognising when **userspace hooking cannot reach the check at all** (raw `svc` syscalls,
|
||||
`init_array`-early detection), what the next layer up and down can actually do, and when escalating
|
||||
is the wrong answer.
|
||||
- Working **protocols that are not REST** — protobuf without a schema, gRPC, QUIC/HTTP3 — and
|
||||
native-side certificate pinning that ignores the system trust store.
|
||||
- Working **from the phone itself**: MT Manager's edit/repack/sign flow and its APK MCP surface,
|
||||
LSPosed Manager, and on-device data inspection, alongside the PC toolchain rather than instead of it.
|
||||
- Telling **Java2C apart from an extraction shell** before spending hours hunting a decrypted DEX that
|
||||
does not exist at any point in the process lifetime — the code was compiled into a `.so`.
|
||||
- Handling a build that arrives as a **split APK / App Bundle set**: reading the set off a device,
|
||||
signing every member with one keystore for `pm install-multiple`, or merging code/native members
|
||||
into a standalone APK when that is legal.
|
||||
- Working a **real Dex VMP** with the known-plaintext differential — which links can be automated and
|
||||
which cannot, what a compiled fixture can and cannot reach, and how to *prove* a derived
|
||||
private-opcode table instead of asserting one.
|
||||
- **Publishing what it learns without publishing the target** — a scanner that reports identity
|
||||
shapes with their context, an explicit list of what must *not* be redacted (tools, libraries,
|
||||
protocol fields, CVEs, hardening products, public crackmes) because redacting those destroys the
|
||||
reusable part, and exit codes that gate a commit.
|
||||
- Reading a **precedent** before repeating work this repository already converged on: the positive
|
||||
half of the record, with the route including its dead ends, a grade on every assertion, and the
|
||||
files the case says to write back to.
|
||||
|
||||
## Structure
|
||||
|
||||
`SKILL.md`, `references/` and `scripts/` are all inside the skill directory, `skills/apk-reverse/`.
|
||||
Everything at the repository root is maintenance tooling shared across skills, not part of an
|
||||
installed skill.
|
||||
|
||||
```
|
||||
SKILL.md a procedure with gates, not background reading:
|
||||
how-to-use -> four override rules (R1-R4)
|
||||
symptom index (a matching row is a stop signal)
|
||||
four gates (G1-G4, actions with pass criteria)
|
||||
thirteen classification questions
|
||||
the workflow, with a per-step skip condition and a two-strike rule
|
||||
what "done" means -> stop conditions -> constraints -> indexes
|
||||
references/ loaded on demand, one topic each
|
||||
recon.md identify packer, SDKs, code location, tamper checks; unpacking
|
||||
server-config-and-updates.md
|
||||
the most common shape of "ad" and the one usually mis-diagnosed:
|
||||
the server supplies UI the client renders (launch screen, popup,
|
||||
announcement, tab set). The two-layer fetch that proves it, how to
|
||||
find the config DTOs by the field names data classes keep, why you
|
||||
patch the decision and not the data, deciding the scope of "remove",
|
||||
and remote re-enable / cached config durability
|
||||
byte-level-patching.md equal-length byte edits: why they beat method rebuilding (measured),
|
||||
locating an instruction's exact offset without scraping listings,
|
||||
the instruction width traps that desynchronise a decode, neutralise
|
||||
a branch vs redirect it, dex header integrity field order, and the
|
||||
verifier's move-result rule
|
||||
packers.md hardened targets: rejection signals, measuring the validation
|
||||
boundary with single-variable tests, choosing a native host
|
||||
code-virtualization-and-custom-linkers.md
|
||||
the layer between "packed" and "clean": whole classes turned into
|
||||
`native` declarations, a private loader whose SONAME does not match
|
||||
its filename, an embedded self-decrypting payload, a Java-layer
|
||||
"signature killer" that logs success while a native check kills you.
|
||||
The keep-it/drop-it deadlock, how to separate the *checker* from the
|
||||
*implementation*, and the string-redirect technique that ends it
|
||||
without neutralizing anything
|
||||
framework-runtimes.md Flutter / React Native / Unity: which layer owns the UI, and how to
|
||||
find logic when there are no symbols (string encoding traps)
|
||||
dart-aot.md Dart AOT in depth: version pinning and building a matching decompiler,
|
||||
the object pool and reference indexes, register/boolean conventions,
|
||||
the three signatures that identify business logic, locating, patching.
|
||||
Begins with the snapshot-decoding front end it depends on (aotopsy or
|
||||
blutter) because the pool listing is an input, not something this skill
|
||||
produces itself
|
||||
native-and-so.md .so hosts, DT_NEEDED vs JNI_OnLoad, relocation limits,
|
||||
relocation-free bootstrapping, replacing Java methods natively,
|
||||
and which ABI/library is *actually loaded and executing*
|
||||
native-tamper-and-suicide.md how a hardened library kills its own process: the visible
|
||||
mechanisms, how to tell which one actually fires, how to find the
|
||||
site, forged section headers, function boundaries from
|
||||
PT_GNU_EH_FRAME, scanner traps, and neutralising safely
|
||||
detection-and-anti-analysis.md when the app fights back or the tool cannot run here: telling
|
||||
detection apart from a broken environment, deciding by cost instead
|
||||
of escalating, recognising an environment where dynamic analysis
|
||||
simply does not work, and keeping the "blocks my analysis" question
|
||||
separate from "blocks the deliverable"
|
||||
toolchain.md what to install, how to invoke it non-interactively, which tools
|
||||
are GUI-only, version-alignment traps, working offline,
|
||||
**"not on PATH" is not "not installed"**, and which signer to use
|
||||
long-task-discipline.md live record, conclusion grading, drift control, timeout and
|
||||
wait calibration, deliverable-form drift, captures-you-never-looked-at,
|
||||
long-context decay, handover
|
||||
ad-removal.md ad taxonomy, wrapper mapping, callback trap, global gates, verification
|
||||
updates-and-forced-upgrade.md keeping a patched build alive: locating the version check, the
|
||||
two-layer patch (no-op the routine, neutralise the comparison), what not
|
||||
to touch (manifest version, installer permission, host blocking),
|
||||
self-update and hot-update/remote-config channels, verifying that no
|
||||
version request is issued at all
|
||||
account-gates.md sign-in walls, forced phone binding, guest mode: telling a client-side
|
||||
gate (patchable) apart from an account-scoped resource (not), why
|
||||
fabricating a session is worse than staying signed out, and the
|
||||
unavoidable session loss after a reinstall
|
||||
signature-derived-keys.md when the app's own signing certificate is used as key material:
|
||||
detection greps, why offline extraction is unreliable, the
|
||||
hardcode-then-verify procedure
|
||||
membership-and-limits.md server vs client authority; what is and is not patchable
|
||||
server-api.md probe an app's API; prove who owns the gate
|
||||
tls-and-cert.md feature-scoped network failures: expired certs, dual trust chains
|
||||
third-party-builds.md auditing a "cracked"/"modded" APK before trusting it
|
||||
dex-patching.md patch-layer table + dexlib2 technique in depth
|
||||
patch-audit.md proving a patch *landed* and is *legal*: length-vs-bytes
|
||||
comparison, the equal-length-replacement blind spot, verifier-level
|
||||
legality (move-result adjacency) checked statically, text-matching
|
||||
patch traps, and reporting a missing patch
|
||||
repack-and-sign.md repack rules, unpack-and-repack, signing, post-install hazards
|
||||
runtime-data.md DataStore / SharedPreferences / SQLite / protobuf; when the app
|
||||
rewrites your edit, and decoding a value that looks encrypted
|
||||
dynamic-frida.md Frida setup, version pinning, the four-layer probe, hook strategy
|
||||
environment.md device/emulator setup, root, ADB, offline devices, log signals,
|
||||
emulator console control and recovery, preflight, look-at-the-screen
|
||||
verification.md the claim ladder; what "done" means
|
||||
desensitization-and-leak-scans.md
|
||||
publishing discipline: what must be desensitized and what must stay,
|
||||
the do-not-anonymize list, the leak scanner and its exit states, and
|
||||
the entry-point file as a prompt surface
|
||||
precedents/ the positive case library: route including dead ends, a grade per
|
||||
assertion, measured pit-falls, and the write-back checklist
|
||||
routing.md the on-demand inventory: every reference with when to load it, every
|
||||
script with what it does, and a mirror of the symptom index
|
||||
rasc-and-droidsaw.md the Rust re-implementation of the ASC indexer: measured speedup and
|
||||
identical class sets, the enum shape where it silently drops bodies,
|
||||
and how to build and verify it
|
||||
evidence-summary.md the condensation that ships with the skill: capability, one-line
|
||||
conclusion, strength, and the evidence you can actually open in an
|
||||
installed copy
|
||||
|
||||
../evals/ NOT a spec directory either, but the location the Agent
|
||||
Skills guidance recommends: `evals.json` holds the
|
||||
with-skill / without-skill cases this skill has **not** run,
|
||||
with the method for running them written into the file
|
||||
../evidence/ NOT a spec directory: the machine-readable companions to the evidence
|
||||
summary reference above -- `capability-matrix.json` (the same rows with
|
||||
more fields), `tested-tool-versions.json` (versions and the probe behind
|
||||
each), `known-limitations.md` (the installer-facing limit list). Shipped
|
||||
inside the skill so an installed copy can answer "was this verified, and
|
||||
how strongly" without the repository
|
||||
pitfalls.md the failure catalogue -- read before building
|
||||
advanced-unpacking.md the dump landed but the bodies are empty: extraction-shell diagnosis by
|
||||
trivial-body ratio, FART-style active invocation and why its classic hooks
|
||||
died on Android 12-16, code_item splicing, the root-side dump for when
|
||||
frida itself is refused, and the honest VMP boundary
|
||||
lsposed-and-modules.md the repack is refused, so deliver a system-level hook module instead:
|
||||
module anatomy, a gradle-free build chain, scope configuration and how to
|
||||
verify injection, and the layer a Java module cannot reach
|
||||
emulation-and-rpc.md call the routine instead of reading it: Unidbg/Unicorn emulation and its
|
||||
environment-filling cost, versus service-ifying a live function over Frida RPC
|
||||
native-dbi-and-deobfuscation.md
|
||||
OLLVM shapes, Frida-Stalker traces, the trace-to-CFG route, the
|
||||
Stalker/QBDI/emulation decision, and two measured boundaries (a follow that
|
||||
delivers no events, and a crash from following a hot libc export)
|
||||
protocol-reverse.md protobuf without a schema, schema recovery from decompiled code, gRPC frame
|
||||
capture, the QUIC/HTTP3 limit, and native-side certificate pinning
|
||||
kernel-and-environment-hardening.md
|
||||
userspace hooking provably cannot reach the check: raw `svc`, init_array-early
|
||||
detection, what each root scheme hides, the kernel-route map with its version
|
||||
gate, and when to stop escalating
|
||||
on-device-tooling.md working from the phone itself: MT Manager edit/repack/sign and its APK MCP,
|
||||
LSPosed Manager, Termux+frida, on-device data inspection
|
||||
java2c-and-jni-sinking.md Java2C and JNI sinking, the two hardening shapes most easily confused
|
||||
with an extraction shell: the table that separates landing shell /
|
||||
extraction shell / VMP / Java2C / JNI sinking, why the code is in the
|
||||
`.so` and *never* in a dumped dex, and why a `Java_*` symbol search
|
||||
comes back empty (dynamic registration, `-fvisibility=hidden`)
|
||||
split-apk.md App Bundle / split APK sets: what the set is, pulling it off a device,
|
||||
merging into one APK vs signing the set as a unit, the install refusals
|
||||
and what each means, and making an installable fixture from a pulled set
|
||||
vmp-differential-analysis.md
|
||||
the known-plaintext differential for a real Dex VMP: which links can be
|
||||
automated and which cannot (the upload is the bottleneck), the coverage a
|
||||
compiled fixture can reach, how to *prove* a derived private-opcode
|
||||
table, smali generation, and when the route is closed
|
||||
coverage-and-limits.md the claim ladder applied to the skill itself: the evidence behind each
|
||||
covered item, the dependencies this skill does not ship, and what was
|
||||
never exercised
|
||||
handoff-boundaries.md where this skill ends and another discipline begins: the JNI form
|
||||
table, the packer-versus-loader split, and what "verified" means for
|
||||
each of the four deliverable forms
|
||||
scripts/ parameterized, path-agnostic
|
||||
doctor.py run this first: capability report + per-script runnability, finds
|
||||
tools installed off-PATH or as runnable jars, and surfaces the
|
||||
environment facts that poison experiments (clock skew, leftover
|
||||
adb forward / proxy, a device-side frida process already running)
|
||||
dexutil.py dependency-free dex reader: structural walk + exact instruction
|
||||
decode, dex header recompute/verify (correct checksum/signature
|
||||
order), branch-target and operand helpers. Library shared by the
|
||||
dex scripts, also runs standalone to dump one method with offsets
|
||||
dex_find_insn.py locate an instruction by decoded semantics and print its exact byte
|
||||
offset with context and both sides of any branch -- how you find a
|
||||
patch site instead of guessing offsets
|
||||
dex_patch_bytes.py equal-length byte patches from a JSON spec: semantic match, polarity
|
||||
pin via expect_next, equal-length enforcement, verifier check, dex
|
||||
header recompute, re-decode to prove it landed (--dry-run first)
|
||||
dex_check_verifier.py tier-3 check: does any conditional branch target a move-result
|
||||
(bypassing its producer)? Compares two builds and separates
|
||||
pre-existing findings from regressions your patch introduced
|
||||
coldstart.py cold-launch capture: timed screenshot burst + logcat signals +
|
||||
installed-build facts + launch timing, and warns when the foreground
|
||||
activity is not your app
|
||||
so_constpatch.py same-length in-place rewrite of an isolated string constant, for
|
||||
redirecting a library load instead of defeating a check
|
||||
smtool.py baksmali/smali wrapper with a configurable classpath
|
||||
dexpatch/ dexlib2 method-level rewriter (for changes that need new instructions)
|
||||
patch_smali.py method-body replacement in a smali tree
|
||||
dex_strpatch.py byte-level string patch with a string_ids ordering guard
|
||||
dex_classdiff.py prove a dex edit was surgical
|
||||
dex_strings.py strings/URLs/SDK markers without a decompiler
|
||||
dart_pool_strings.py recover literals from a Dart AOT snapshot (framed entries, the
|
||||
one-byte vs UTF-16 split, file offsets, run-length noise filter)
|
||||
dart_pprefs.py build/query the object-pool -> code-site index for a Dart snapshot
|
||||
dart_disasm.py annotated windowed disassembly of Dart AOT code + B/BL caller index
|
||||
find_refs.py count callers of a method before patching it
|
||||
repack.py rebuild APK, strip only signatures, keep META-INF/services/, write a
|
||||
4-byte-aligned archive (resources.arsc STORED+aligned), sign, verify;
|
||||
also split APK / App Bundle sets: inventory, sign every member with one
|
||||
keystore, or merge code/native members into a standalone APK
|
||||
devsh.py quoting-safe ADB shell helper
|
||||
usb_net_proxy.py give an offline device network over USB
|
||||
datastore_inject.py encode/inject AndroidX DataStore preferences safely
|
||||
probe_api.py probe an HTTP API with the right headers
|
||||
grab_crash.py recover stacks hidden by a crash-reporter SDK
|
||||
install_test.py install + launch health check with logcat signal scan
|
||||
frida_probe.js four-layer runtime probe (app net layer + OkHttp + java.net + exceptions)
|
||||
run_probe.py inject the probe, stream it to a log file, stay resident
|
||||
tls_check.py strict certificate check for one or more hosts
|
||||
preflight.py environment check before every experiment block (device, root,
|
||||
ABI/translation, clock skew, leftover proxy/forwards, dead server)
|
||||
lib_map.py what is *actually mapped* into a live process: per-library path,
|
||||
base, architecture, and whether it came from the APK or was
|
||||
materialized at runtime
|
||||
elf_plt.py resolve a PLT stub to its imported symbol (x86_64 + aarch64) from
|
||||
the relocation table; list a symbol's callers; byte-diff two
|
||||
libraries and name the symbol each changed stub belongs to
|
||||
apk_diff.py entry-level diff of two builds: changed / added / removed, by
|
||||
content hash so same-size replacements are caught
|
||||
native_crash.py locate a native death from a log or tombstone: signal, fault
|
||||
address, registers, frames split app vs system, the faulting
|
||||
instruction, and a flag when the fault looks *arranged*
|
||||
blob_decode.py search, don't guess, the framing of a stored value
|
||||
(base64/hex x rotation x deflate); re-encode the edited payload
|
||||
snap.py bounded burst screenshots + control-tree capture with a stall
|
||||
detector, and a verdict on whether the tree is usable at all
|
||||
sig_probe.py find the exact signatures[0].toCharsString() value — offline
|
||||
candidates from an APK, or the authoritative read from a device
|
||||
spawn_patch_detach.py spawn under a Frida probe, detach, then launch and capture: under
|
||||
spawn mode the Activity stack often never comes up, and memory
|
||||
writes survive detach while hooks do not
|
||||
hook_patch_only.js the minimal probe for spawn_patch_detach.py — neutralise one native
|
||||
death site by offset and report PATCHED
|
||||
dex_dump_validate.py dedupe, validate and rank a directory of dumped dex images: sha256
|
||||
grouping, header integrity, the trivial-body ratio that separates a real
|
||||
dump from an extraction-shell skeleton, and a most-likely-original ranking
|
||||
(--trim for page-aligned /proc/<pid>/mem captures)
|
||||
dex_mem_scan.py search memory captures for embedded dex images and extract each at the
|
||||
size its own header declares -- for a decrypted dex sitting in an
|
||||
anonymous mapping no maps entry names
|
||||
lsposed_scaffold.py generate a minimal LSPosed/Xposed module project (manifest with the
|
||||
xposed meta-data, assets/xposed_init, hook class, gradle-free build notes)
|
||||
frida_rpc_serve.py bridge a Frida script's rpc.exports to a local caller with reconnect
|
||||
handling, so a live native function can be called rather than reversed
|
||||
rpc_template.js the editable companion to frida_rpc_serve.py
|
||||
stalker_trace.js instruction-level tracing with Frida Stalker: configurable targets,
|
||||
trigger selection, the event stream, and output-size rules
|
||||
stalker_report.py reduce a stalker_trace.js log to block histograms and call sequences,
|
||||
with an explicit diagnostic for the measured zero-event case
|
||||
mt_mcp_probe.py probe MT Manager's on-device APK MCP (Streamable HTTP, port 8787):
|
||||
JSON-RPC handshake plus the grouped tool inventory
|
||||
java2c_probe.py collect the evidence that separates Java2C from an extraction shell, a
|
||||
VMP and ordinary JNI sinking: native density and stub ratio from the dex,
|
||||
JNI_OnLoad / dynamic registration / toolchain strings from the `.so`,
|
||||
each item labelled strong/medium/weak
|
||||
protobuf_decode_raw.py schema-free protobuf decode: hex / file / stdin to a JSON tree, every
|
||||
length-delimited field kept as a candidate set with ties labelled rather
|
||||
than guessed, plus a byte-exact re-encode to check a round trip
|
||||
vmp_diff_harness.py build a labelled opcode-coverage fixture, derive a candidate private-
|
||||
opcode map from an original/hardened dex pair, verify the comparison in a
|
||||
closed loop, and render a restored stream as a smali skeleton
|
||||
kernelsu_syscall_mask.py generate a KernelSU/APatch syscall-masking scaffold: an installable
|
||||
userspace module skeleton plus KPM/LKM/eBPF kernel-side templates, each
|
||||
with its version gate and an explicit unverified label
|
||||
rasc_build.py build and verify rasc, the Rust ASC re-implementation:
|
||||
--check what is present, --build clone plus cargo, --verify an APK
|
||||
against droidasc and fail on any class-set difference
|
||||
scan_leaks.py scan a repository for target identity before publishing it: bundle ids
|
||||
in manifest / `pm` / `ps` contexts, serial-shaped tokens, PATs, inline
|
||||
appkey assignments, literal endpoints, host user paths. Exemptions for
|
||||
everything that must stay (tools, libraries, CVEs, hardening products,
|
||||
public crackmes, placeholders), findings carry their context,
|
||||
`--show-exempt` prints why a hit was suppressed, exit 0/1/2
|
||||
svc_scan.py name the syscall behind an inline `svc` and the segment it sits in,
|
||||
which decides whether a libc-level hook can observe the call at all;
|
||||
`--context` shows neighbours because a byte scan also matches data
|
||||
anti_detect_probe.js observer-only Frida probe (patches nothing): path/loader/thread/kill
|
||||
hooks with caller module + offset, an environment self-report
|
||||
(`TracerPid`, frida-named mappings), and live streaming so a sub-second
|
||||
self-destructing target still yields evidence
|
||||
```
|
||||
|
||||
The repository also carries an **executable** test layer, which is a different thing from the
|
||||
evidence record: `tests/` asserts what the scripts do (unit, CLI contract, no-device
|
||||
integration) and `tests/benchmark.md` records what a route did on a real target. `tests/README.md`
|
||||
states the split, and `.github/workflows/ci.yml` runs the gates plus the suite.
|
||||
|
||||
## Install
|
||||
|
||||
This repository is a **skills repository**: the skill lives at `skills/apk-reverse/`, which is the
|
||||
layout the `skills` CLI resolves, and it is installed by name rather than by copying a directory:
|
||||
|
||||
```
|
||||
npx skills add newliver666/apk-reverse # install every skill in the repo
|
||||
npx skills add newliver666/apk-reverse --list # list what is here, install nothing
|
||||
npx skills add newliver666/apk-reverse --skill apk-reverse -y
|
||||
npx skills use newliver666/apk-reverse@apk-reverse # use it once, without installing
|
||||
```
|
||||
|
||||
The CLI symlinks the skill into your agent's skills directory by default (`--copy` makes independent
|
||||
copies instead), and `-g` installs for every project rather than the current one. With one skill in
|
||||
the repository, `--skill apk-reverse` is redundant today; it is written out here because it is what
|
||||
selects a single skill once a second one exists.
|
||||
|
||||
Once installed, the agent loads `SKILL.md` when a task matches its description, and pulls in
|
||||
`references/*` only as needed. No global state, no machine-specific paths, and no build step.
|
||||
|
||||
## Requirements
|
||||
|
||||
Nothing is mandatory; each script checks what it needs. `skills/apk-reverse/scripts/doctor.py` reports
|
||||
which of these are present here, which scripts can therefore run, and — usefully — which tools exist
|
||||
somewhere other than PATH.
|
||||
|
||||
If your toolchain lives outside PATH (a project-local `tools/` directory, a versioned SDK folder, a
|
||||
runnable `.jar` instead of a command), set `APKREV_TOOLS` to one or more directories and `doctor.py`
|
||||
will find them:
|
||||
|
||||
```
|
||||
set APKREV_TOOLS=<dir>;<dir> # Windows, e.g. an SDK or project-local tools dir
|
||||
export APKREV_TOOLS=<dir>:<dir> # POSIX
|
||||
```
|
||||
|
||||
The scripts themselves are plain `python3` and are intended to work identically on Windows, macOS and
|
||||
Linux; where a snippet is POSIX-only it is labelled. Nothing here assumes a Unix shell.
|
||||
|
||||
| Tool | Used for |
|
||||
|---|---|
|
||||
| Python 3.9+ | all scripts |
|
||||
| **`droidasc`** (ASC) (**optional but strongly recommended — install this first**) | whole-APK cross-reference index: `findrefs` / `listclass` / `getclass` / `getmanifest`. One `pip install droidasc`, no JVM, no SDK, no index build. Turns "which of N thousand classes mentions this string" into a sub-second query, and it is the only route to a class whose name R8 mangled. **This is the tool an agent should reach for before any full decompile** — see `skills/apk-reverse/references/toolchain.md` §droidasc (ASC) — ask an APK "who references this?", in one query |
|
||||
| `ddc` (optional but strongly recommended) | single-binary dex→Java decompiler with query subcommands (`info`, `findrefs`, `strings --with-locations`, per-class decompile). No JVM. **Reads** what ASC **locates**; also reports package identity reliably — see `skills/apk-reverse/references/toolchain.md` §ddc — dex-to-Java with query subcommands (worth adopting) |
|
||||
| `baksmali` / `smali` + `dexlib2` jars | disassembly, assembly, surgical patching |
|
||||
| JDK (`javac`, `java`) | building/running the dexlib2 patcher; also provides `keytool`/`jarsigner` |
|
||||
| Android SDK build-tools (`aapt`, `zipalign`, `apksigner`) | manifest info, alignment, signing. **`apksigner` is the signer to use** — `jarsigner` rewrites the archive and breaks the alignment Android R+ requires |
|
||||
| `uber-apk-signer` (optional) | one-step align + sign |
|
||||
| ADB | device work |
|
||||
| Frida (host package + matching on-device server) | dynamic analysis |
|
||||
| a rooted device or emulator | anything beyond static analysis |
|
||||
|
||||
None of these need to be on `PATH`: every script accepts an explicit path for the
|
||||
tools it shells out to, and `skills/apk-reverse/references/toolchain.md` covers finding
|
||||
an install that `PATH` does not know about (the common case for `apksigner` and
|
||||
`keytool`).
|
||||
|
||||
## Read this first
|
||||
|
||||
**This project is published for learning, research and authorized security testing only.** It ships
|
||||
no exploit payloads, no target data and no third-party binaries — it is a method, a set of scripts
|
||||
and an evidence record. You are responsible for having the right to analyze whatever you point it at;
|
||||
see **Disclaimer** at the end of this file.
|
||||
|
||||
`skills/apk-reverse/references/pitfalls.md`. It is the most valuable file here — every entry is a
|
||||
failure that produced a broken artifact while looking completely healthy.
|
||||
|
||||
The four that hurt most:
|
||||
|
||||
1. Stripping the whole `META-INF/` during a repack deletes ServiceLoader registrations
|
||||
and the app dies at startup with an error that names an unrelated library.
|
||||
2. Patching a byte-level string without preserving `string_ids` ordering gets the whole
|
||||
dex rejected, while checksums and signatures verify perfectly.
|
||||
3. Rebuilding a dex with a whole-tree smali round-trip damages R8 output invisibly —
|
||||
class tables compare clean, and it only blows up at runtime.
|
||||
4. Neutralising a native terminate path by making it **not return**. A spinning stub does not
|
||||
suppress the check; it freezes the caller and every thread behind it. The app hangs with *no
|
||||
crash record at all*, and the eventual death gets blamed on whatever killed the frozen process.
|
||||
|
||||
## Scope
|
||||
|
||||
Built for working on your own applications, on samples you are authorized to analyze,
|
||||
and in CTF/competition sandboxes. It contains no vendored third-party binaries and no
|
||||
target-specific data.
|
||||
|
||||
What it covers, and what it deliberately does not, is stated at the top of `SKILL.md`
|
||||
under **Coverage**. The short version: Android only (no iOS), and deep on the layers
|
||||
that have been worked through for real — dex patching, repacking, packers and custom
|
||||
loaders, native tamper response, and Flutter/Dart AOT. An extension pass added a second
|
||||
tier of documented routes: **module-side delivery** when a repack is blocked,
|
||||
**extraction-shell recovery** and its VMP boundary, **emulation and live RPC** for
|
||||
calling rather than reading, **instruction-level tracing** against OLLVM, **protocol
|
||||
reversing** beyond REST, the **kernel-side route** map for when userspace hooking is
|
||||
provably out of reach, and **on-device tooling**. A **benchmark pass** then put public
|
||||
targets under those routes (`tests/benchmark.md`): it added **Java2C discrimination** (the
|
||||
misdiagnosis that sends an agent hunting a decrypted DEX that never exists), **split APK /
|
||||
App Bundle handling**, **schema-free protobuf decoding**, a **Dex-VMP differential** harness,
|
||||
and **kernel-module templates with their version gates** — and it corrected two earlier claims
|
||||
whose measurements disagreed with them. Unity/IL2CPP logic recovery,
|
||||
React Native/Hermes bytecode internals, and defeating a server-side authority are **not**
|
||||
covered, and the skill is written to say so and stop rather than apply the nearest
|
||||
documented procedure to a target it was not written for.
|
||||
|
||||
Four qualifications that the Coverage section states in full and that belong here too:
|
||||
|
||||
- **Flutter/Dart AOT analysis has a dependency.** The workflow begins at a pool listing
|
||||
(`pp.txt`-class output). Producing that needs a snapshot-decoding decompiler — aotopsy (a static
|
||||
binary, no toolchain) or blutter (built from source, ~80 s) — and this repository does not contain
|
||||
one. It is named as a prerequisite rather than left implicit.
|
||||
- **Not every claim in this repository has a run behind it.** `docs/tool-verification/` records
|
||||
what was actually measured, on which target, and with which independent cross-check; anything not
|
||||
covered there is documented from experience and should be read as *inferred*, per this skill's own
|
||||
claim ladder.
|
||||
- **The extension pass is recorded separately and is mostly *inferred*.** Its evidence lives in
|
||||
`docs/tool-verification/EXTENSION-*.md`, one file per topic, with its own strength note. The
|
||||
common shape there is *the tool was measured, the route was not* — so read those files before
|
||||
treating any of the newer documents as a verified path.
|
||||
- **The benchmark pass is recorded per row, with that row's own strength.** `tests/benchmark.md`
|
||||
names each public target, the scripts the row exercises, what actually happened (including the
|
||||
rows that failed and the rows nobody ran), and how strong the evidence is. Rows marked
|
||||
`unverified` are statements about the evidence in this repository, not about the mechanism.
|
||||
|
||||
## Repository maintenance
|
||||
|
||||
Four tools live at the root and are not part of the installed skill:
|
||||
|
||||
```
|
||||
check_repo.py every skill discovered, frontmatter valid, scripts runnable,
|
||||
documented paths resolve, README paths explicit and existing,
|
||||
and -- on the tracked surface only -- no target identity
|
||||
(delegates the rules to skills/apk-reverse/scripts/scan_leaks.py
|
||||
so there is one place to argue with the exemption list)
|
||||
check_refs.py every cross-reference that names a section of another
|
||||
document reaches a real heading in that document
|
||||
check_routing.py the on-demand inventory still matches the entry point: the
|
||||
symptom mirror agrees with SKILL.md, every reference file is
|
||||
named in skills/apk-reverse/references/routing.md, and every
|
||||
script is too
|
||||
check_commands.py every command a document tells you to run is checked against
|
||||
the script's own argparse table -- a documented flag that does
|
||||
not exist is a drift the anchor checks cannot see
|
||||
check_budget.py keep the always-loaded part from creeping: SKILL.md's whole
|
||||
body (index lines included, because they load too) measured in
|
||||
lines and tokens, index-row length, long files with no
|
||||
navigable head, and hedged rules reported as a trend
|
||||
build_scripts.py audit for machine-specific leftovers (absolute paths, credentials)
|
||||
```
|
||||
|
||||
Consistency has a natural counter-pressure -- a broken path fails loudly, and someone fixes it.
|
||||
Bloat has none, which is why the third tool exists: every pass adds a reference, an index row and
|
||||
a coverage claim, and without a measurement nothing in the repository notices.
|
||||
|
||||
`tests/benchmark.md` holds the **regression matrix**: dimension -> public target -> the scripts the
|
||||
row exercises -> measured result -> strength label. It is the checklist to re-run before trusting
|
||||
any claim under `docs/tool-verification/`. Samples are downloaded into `tools/_work/` and are never
|
||||
committed, so each row names its public source and records the hash it was run against.
|
||||
|
||||
`docs/tool-verification/` is not part of the installed skill either. It is the evidence record
|
||||
for one measurement pass against a real target: what each script actually did, which independent
|
||||
method confirmed it, which defects were found, and which scenarios the target could not exercise.
|
||||
It exists so the **Coverage** claims in `SKILL.md` can be checked against runs instead of trusted,
|
||||
and so the gaps are written down where the next person will find them.
|
||||
|
||||
---
|
||||
|
||||
Proudly supported by the [LINUX DO](https://linux.do) community.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
**For learning, research and authorized security testing only.** Every script, reference and
|
||||
recorded result in this repository exists to explain *how* Android application analysis works, so
|
||||
that practitioners can reason about the tools they already own. Nothing here is a service, a
|
||||
product, or an endorsement of any particular use.
|
||||
|
||||
- **Authorized targets only.** Use this on applications you own or have been explicitly permitted to
|
||||
analyze, on public CTF/challenge material, or in a sandbox you control. Analyzing software you have
|
||||
no right to analyze may be unlawful where you live, and that determination is yours to make, not
|
||||
this repository's.
|
||||
- **No warranty, no fitness for any purpose.** The material is provided *as is*, without warranty of
|
||||
any kind. Results are recorded as they were measured on one machine at one time; nothing here
|
||||
promises that a route will work on your target, your device, your toolchain or today's app version.
|
||||
- **Verify before you trust; back up before you act.** Several scripts modify artifacts (dex, APK,
|
||||
`.so`, stored app data) and some operate on a rooted device. Keep your own copies, work on
|
||||
duplicates, and read `SKILL.md`'s gates before running anything against something you care about.
|
||||
- **Your use is your responsibility.** The authors and contributors accept no liability for any loss,
|
||||
damage, legal consequence or service interruption arising from the use or misuse of this
|
||||
repository, and are not affiliated with, endorsed by, or acting on behalf of any application,
|
||||
vendor or platform it may be used to examine.
|
||||
- **Test data is not distributed here.** Samples, dumps and device artifacts are deliberately absent
|
||||
from the tree (`.gitignore` excludes them) and live only in a local, ignored workspace. Anything you
|
||||
obtain to follow along is yours to keep safe and to delete when you are done with it — follow your
|
||||
local rules and the terms that came with the sample. What this repository *does* publish is the
|
||||
method and the evidence, with all target identity removed.
|
||||
- **No affiliation.** Names of tools, libraries, hardening products and public challenge targets
|
||||
appear only to make the material reusable; they belong to their respective owners and this project
|
||||
is not connected to them.
|
||||
457
vendor-new/apk-reverse/README.zh-CN.md
Normal file
457
vendor-new/apk-reverse/README.zh-CN.md
Normal file
@@ -0,0 +1,457 @@
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="assets/banner-dark.svg">
|
||||
<img src="assets/banner-light.svg" alt="apk-reverse" width="100%">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> · <b>简体中文</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/newliver666/apk-reverse/stargazers"><img src="https://img.shields.io/github/stars/newliver666/apk-reverse?style=flat-square&label=stars&color=49454F" alt="stars"></a>
|
||||
<a href="https://github.com/newliver666/apk-reverse/network/members"><img src="https://img.shields.io/github/forks/newliver666/apk-reverse?style=flat-square&label=forks&color=49454F" alt="forks"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/github/license/newliver666/apk-reverse?style=flat-square&color=49454F" alt="license"></a>
|
||||
<img src="https://img.shields.io/badge/python-3.9%2B-49454F?style=flat-square&logo=python&logoColor=white" alt="python">
|
||||
<img src="https://img.shields.io/badge/platform-android-49454F?style=flat-square&logo=android&logoColor=white" alt="android">
|
||||
<a href="https://github.com/newliver666/apk-reverse/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/newliver666/apk-reverse/ci.yml?style=flat-square&label=ci&color=49454F" alt="ci"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#它擅长什么">能力</a> · <a href="#结构">结构</a> · <a href="#安装">安装</a> · <a href="#环境要求">环境要求</a> · <a href="#先读这个">失败目录</a> · <a href="#范围">范围</a> · <a href="#仓库维护">维护</a> · <a href="#免责声明">免责声明</a>
|
||||
</p>
|
||||
|
||||
# apk-reverse
|
||||
|
||||
面向 Android APK 逆向工程、去臃肿、去广告、外科式 dex 补丁、重打包,以及运行时/服务端分析的 Agent Skill。
|
||||
|
||||
它是一个 **skill**,不是教程:它是写给 agent(Claude Code、Codex,或任何支持 Agent Skills 格式的 harness)在工作过程中加载的,因此按 progressive disclosure(渐进披露)来组织——一份简短、面向决策的 `SKILL.md`,只在某一步真正需要时才加载的详细 references,以及可直接运行的参数化脚本。
|
||||
|
||||
## Agent 应当如何使用它
|
||||
|
||||
`SKILL.md` 刻意写成一份**带 gate 的流程**,而不是建议,因为观察到的失败模式并不是无知——而是模型把整份文档读完、表示认同,然后照样从第一性原理重新推理一遍。
|
||||
|
||||
所以正文里有四样东西是用来**执行**的,不是用来读的:
|
||||
|
||||
- **四条覆盖规则(R1–R4)。** 当它们与当前计划冲突时,在证据推翻它们之前,以它们为准。
|
||||
- **一份症状索引。** 每一行都是已经付过代价的失败。**命中一行就是停止信号**:在你跑下一条命令之前加载那个文件,而不是在又试了几次之后。绕过已经记录在案的已知症状继续推理,就是同一批小时被花掉两次的方式。
|
||||
- **四个 gate(G1–G4)**,每个都是一个动作加一条通过标准。「我理解这个思路」并不能通过 gate。它们存在的意义是让分类、环境真相和对照构建发生在第一次打补丁**之前**,而不是在第三次失败之后。
|
||||
- **一条两击规则和若干停止条件。** 同一形态的失败出现两次,说明错的是模型,不是参数。一个已经失败过两次的假设再出第三个变体,就是回合葬身之处。
|
||||
|
||||
另外还有一样在结尾、应当被**扣住**的东西:**「done」**有定义(六项)。一份干净的日志不在其中。少于六项全部达成,就只是一个检查点,应当作为检查点汇报,并说明还剩什么。
|
||||
|
||||
如果你是正在读这段的 agent:成本最低的第一条命令是 `python skills/apk-reverse/scripts/doctor.py`。它会告诉你这里存在哪些工具、哪些脚本真的能跑,以及环境里是否已经有什么东西在污染你的测量结果。
|
||||
|
||||
## 它擅长什么
|
||||
|
||||
- **快速**判断一个需求在客户端是否本来就做不到,而不是把几小时烧在一个由服务端强制执行的付费墙(paywall)上。
|
||||
- 在任何工作开始之前先确定**交付物必须是什么形态**——一个免 root、自包含的产物与「让它在你这台机器上能跑」是两个不同的问题,混淆这两者是这个领域里最昂贵的漂移。
|
||||
- 为给定改动选择**最安全的补丁层**,并避开那些会把 app 弄坏的层。
|
||||
- 抓住那种「看起来成功」的重打包失败:app 安装、启动、渲染都完美,但**每一个签名请求都被拒绝**,因为客户端是从自己的签名证书推导请求签名密钥的。
|
||||
- 把**你自己的错误与 app 或服务端的问题区分开**——限定在某个功能上的失败(登录、注册、支付)常常是某一条代码路径上的 TLS/证书问题,而不是你刚做出来的补丁的后果。设备状态、失联的 device server 和时钟漂移会以同样的方式伪装成同类问题。
|
||||
- 确认**实际执行的是哪个架构、哪个库**,而不是相信 manifest 里带了什么或设备自称什么。
|
||||
- 处理**加壳/加固目标**:识别壳、脱壳,并把内存 dump 还原成一个打过补丁、可安装的 APK。
|
||||
- 处理一个**故意终止进程的加固库**——包括那种刻意崩溃的形态(`fault addr 0x4`),它看起来和普通的空指针解引用 bug 一模一样;以及「把它失效化,但绝不要让它*不返回*」这条规则,它决定修法是生效,还是以完全看不出与原因有关的方式冻住整个 app。
|
||||
- 知道**该伸手去拿哪些工具,以及每个工具在哪里说谎**——包括那些只以 GUI 形式存在的工具,此时应当请求人类介入,而不是悄悄换用一个更弱的方法。
|
||||
- 让打过补丁的构建**保持**打过补丁:失效化版本检查、强制升级弹窗和自更新安装器,使这份成果无法被远端一键关掉——同时认出那个能在版本号毫无变化的情况下悄悄把它撤销的热更新/远程配置通道。
|
||||
- 把**客户端侧的登录闸门**(可打补丁)与**账号维度的资源**(为空,因为服务端没有东西可答)区分开;并且知道伪造一个 session 所产生的状态比保持未登录更糟。
|
||||
- 让**长时间任务保持诚实**:一份实时记录、分级结论、校准过的超时和有界等待,使进展不会丢失、同一个错误不会被犯两次。
|
||||
- 避开那些会产出「构建完美、运行即死」的 APK 的具体错误。
|
||||
- 当 APK 不是可选项时,决定**交付物应该是什么**——被多个独立检查拒绝的重打包是*受阻*,不是*昂贵*;回落阶梯是系统级模块、本地 RPC 服务,或一份明确写出边界的诚实报告。
|
||||
- 分辨真实内存 dump 与**抽取壳骨架**,并知道哪条恢复路线适用——包括当 `frida` 本身被拒绝时走 root 侧 dump。这次测量能看见什么、看不见什么,写在 `skills/apk-reverse/references/advanced-unpacking.md`。
|
||||
- 当逆向的成本高于调用时,**调用例程而不是逆向它**:在宿主上做模拟执行,或把一个活函数通过 Frida RPC 服务化。
|
||||
- 当一个 native 函数被 OLLVM 碾平成状态机时,去读**指令级执行证据**——包括在真实设备上实测到的 Stalker 反咬的两种方式。
|
||||
- 认出**用户态 hooking 根本够不到那个检查**的情况(裸 `svc` 系统调用、`init_array` 早期检测)、上下相邻层实际能做什么,以及什么时候继续升级是错误答案。
|
||||
- 处理**非 REST 的协议**——无 schema 的 protobuf、gRPC、QUIC/HTTP3——以及无视系统信任库的 native 侧证书固定(pinning)。
|
||||
- **直接在手机上工作**:MT Manager 的编辑/重打包/签名流程及其 APK MCP 接口、LSPosed Manager、设备上的数据检查,与 PC 工具链并用,而不是用它来替代 PC 工具链。
|
||||
- 在花几小时去追一个在进程生命周期内任何时刻都不存在的「解密 DEX」之前,先把 **Java2C 与抽取壳区分开**——代码已经被编译进 `.so`。
|
||||
- 处理以 **split APK / App Bundle 集合**形式到来的构建:从设备上取出该集合、用一个 keystore 为每个成员签名以便 `pm install-multiple`,或在合法时把 code/native 成员合并成一个独立 APK。
|
||||
- 用已知明文差分处理一个**真实的 Dex VMP**——哪些环节可以自动化、哪些不能,一个编译出来的 fixture 能触达什么、不能触达什么,以及如何*证明*一张推导出的私有 opcode 表,而不是断言它。
|
||||
- **在发表所学的同时不发表目标**——一个带上下文报告目标身份形态的扫描器、一份明确列出*不得*脱敏内容(工具、库、协议字段、CVE、加固产品、公开 crackme)的清单,因为把这些脱敏会毁掉可复用的那部分;以及能 gate 住一次提交的退出码。
|
||||
- 在重复本仓库已经收敛过的劳动之前先读**先例**:记录中正向的那一半,含路线及其死路、对每条断言的分级,以及该案例指明要回写的文件。
|
||||
|
||||
## 结构
|
||||
|
||||
`SKILL.md`、`references/` 与 `scripts/` 都在 skill 目录 `skills/apk-reverse/` 内。仓库根目录下的一切是跨 skill 共享的维护工具,不属于已安装的 skill。
|
||||
|
||||
```
|
||||
SKILL.md 带 gate 的流程,不是背景阅读材料:
|
||||
how-to-use -> 四条覆盖规则(R1-R4)
|
||||
症状索引(命中一行即为停止信号)
|
||||
四个 gate(G1-G4,带通过标准的动作)
|
||||
十三个分类问题
|
||||
工作流,带每一步的跳过条件和一条两击规则
|
||||
「done」的含义 -> 停止条件 -> 约束 -> 索引
|
||||
references/ 按需加载,每个文件一个主题
|
||||
recon.md 识别壳、SDK、代码位置、篡改检查;脱壳
|
||||
server-config-and-updates.md
|
||||
「广告」最常见的那种形态,也是通常被误诊的那种:
|
||||
服务端提供 UI,客户端负责渲染(启动屏、弹窗、
|
||||
公告、tab 集合)。证明它的两层抓取、如何按
|
||||
data class 保留的字段名找到配置 DTO、为什么
|
||||
你要补的是决策而不是数据、「移除」的范围如何界定,
|
||||
以及远端重新启用 / 配置缓存持久性
|
||||
byte-level-patching.md 等长字节编辑:为什么它优于方法重建(实测)、
|
||||
如何在不刮 listing 的情况下定位一条指令的精确偏移、
|
||||
会让解码失步的指令宽度陷阱、把分支失效化还是重定向它、
|
||||
dex 头完整性字段的顺序,以及校验器的 move-result 规则
|
||||
packers.md 加固目标:拒绝信号、用单变量测试测量校验边界、
|
||||
选择 native 宿主
|
||||
code-virtualization-and-custom-linkers.md
|
||||
「已加壳」与「干净」之间的那一层:整个类被变成
|
||||
`native` 声明、SONAME 与文件名不匹配的私有加载器、
|
||||
内嵌的自解密 payload、一个 Java 层「签名杀手」在日志里
|
||||
报告成功而 native 检查却在杀你。留还是删的死锁、
|
||||
如何把*检查器*与*实现*分开,以及那条不做任何
|
||||
失效化就终结它的字符串重定向技术
|
||||
framework-runtimes.md Flutter / React Native / Unity:UI 归哪一层所有,
|
||||
以及在没有符号时怎么找逻辑(字符串编码陷阱)
|
||||
dart-aot.md Dart AOT 深入:版本钉定与构建一个匹配的反编译器、
|
||||
对象池与引用索引、寄存器/布尔约定、
|
||||
识别业务逻辑的三种签名、定位、打补丁。
|
||||
开篇先讲它所依赖的 snapshot 解码前端(aotopsy 或
|
||||
blutter),因为 pool 列表是输入,不是本 skill 自己产出的东西
|
||||
native-and-so.md .so 宿主、DT_NEEDED vs JNI_OnLoad、重定位限制、
|
||||
免重定位的 bootstrap、用 native 替换 Java 方法,
|
||||
以及*实际加载并执行*的是哪个 ABI/库
|
||||
native-tamper-and-suicide.md 加固库如何杀掉自己的进程:可见的机制、
|
||||
如何判断实际触发的是哪一个、如何定位现场、
|
||||
伪造的 section header、由 PT_GNU_EH_FRAME 推出的
|
||||
函数边界、扫描器陷阱,以及安全地失效化
|
||||
detection-and-anti-analysis.md 当 app 反击,或工具在这里跑不起来时:把检测与
|
||||
环境损坏区分开、按成本而不是按升级来决策、认出
|
||||
动态分析根本无效的环境,并把「挡住我的分析」这个问题
|
||||
与「挡住交付物」分开
|
||||
toolchain.md 该装什么、如何非交互地调用它、哪些工具只有 GUI、
|
||||
版本对齐陷阱、离线工作、**「不在 PATH 上」不等于「没装」**,
|
||||
以及该用哪个签名器
|
||||
long-task-discipline.md 实时记录、结论分级、漂移控制、超时与等待校准、
|
||||
交付物形态漂移、你从未看过的截图、
|
||||
长上下文衰减、交接
|
||||
ad-removal.md 广告分类学、包装层映射、回调陷阱、全局闸门、验证
|
||||
updates-and-forced-upgrade.md 让打过补丁的构建活下去:定位版本检查、
|
||||
两层补丁(把例程 no-op、把比较失效化)、不能碰的东西
|
||||
(manifest 版本、安装器权限、host 阻断)、
|
||||
自更新与热更新/远程配置通道、验证根本没有发出任何版本请求
|
||||
account-gates.md 登录墙、强制手机绑定、游客模式:把客户端侧闸门
|
||||
(可补)与账号维度资源(不可补)区分开、为什么
|
||||
伪造 session 比保持未登录更糟,以及重装后
|
||||
无法避免的登录态丢失
|
||||
signature-derived-keys.md 当 app 自己的签名证书被当作密钥材料时:
|
||||
检测用 grep、为什么离线提取不可靠、
|
||||
先硬编码再验证的流程
|
||||
membership-and-limits.md 服务端 vs 客户端权威;什么可补、什么不可补
|
||||
server-api.md 探测 app 的 API;证明闸门归谁所有
|
||||
tls-and-cert.md 功能维度的网络失败:过期证书、双信任链
|
||||
third-party-builds.md 在信任一个「破解版」/「魔改版」APK 之前审计它
|
||||
dex-patching.md 补丁层对照表 + dexlib2 技术深入
|
||||
patch-audit.md 证明一个补丁*落地*了并且*合法*:长度 vs 字节
|
||||
比较、等长替换的盲区、校验器层面的合法性
|
||||
(move-result 相邻性)静态检查、文本匹配式
|
||||
补丁陷阱,以及如何报告一个缺失的补丁
|
||||
repack-and-sign.md 重打包规则、解包与重打包、签名、安装后隐患
|
||||
runtime-data.md DataStore / SharedPreferences / SQLite / protobuf;app 何时
|
||||
会改写你的编辑,以及解码一个看起来像加密的值
|
||||
dynamic-frida.md Frida 配置、版本钉定、四层探测、hook 策略
|
||||
environment.md 设备/模拟器配置、root、ADB、离线设备、日志信号、
|
||||
模拟器 console 控制与恢复、preflight、看一眼屏幕
|
||||
verification.md 主张阶梯;「done」的含义
|
||||
desensitization-and-leak-scans.md
|
||||
发表纪律:什么必须脱敏、什么必须保留、
|
||||
不得匿名化的清单、泄漏扫描器及其退出状态,以及
|
||||
入口文件本身就是一处提示词暴露面
|
||||
precedents/ 正向案例库:含死路的路线、每条断言的分级、
|
||||
实测到的坑,以及回写清单
|
||||
routing.md 按需清单:每个 reference 及其加载时机、每个
|
||||
script 及其用途,以及症状索引的镜像
|
||||
rasc-and-droidsaw.md ASC 索引器的 Rust 重实现:实测加速比与
|
||||
完全一致的类集合、它会静默丢掉方法体的 enum 形态,
|
||||
以及如何构建与验证它
|
||||
evidence-summary.md 随 skill 一起发布的浓缩版:能力、一行结论、
|
||||
强度,以及你在已安装副本里实际能打开的证据
|
||||
|
||||
../evals/ 也不是 spec 目录,而是 Agent Skills 指南推荐的
|
||||
位置:`evals.json` 存放本 skill **尚未**运行过的
|
||||
带 skill / 不带 skill 用例,运行方法写在文件里
|
||||
../evidence/ 不是 spec 目录:上面 evidence summary reference 的
|
||||
机器可读配套文件 —— `capability-matrix.json`(同样的行、
|
||||
更多字段)、`tested-tool-versions.json`(版本及每个版本背后的
|
||||
探测)、`known-limitations.md`(面向安装者的限制清单)。随 skill
|
||||
一起发布,使已安装的副本不需要仓库就能回答「这一条验证过吗、
|
||||
强度如何」
|
||||
pitfalls.md 失败目录 —— 构建之前先读
|
||||
advanced-unpacking.md dump 拿到了但方法体是空的:按平凡方法体比例做
|
||||
抽取壳诊断、FART 式主动调用以及为什么它的经典 hook
|
||||
在 Android 12-16 上失效、code_item 拼接、当 frida
|
||||
本身被拒绝时的 root 侧 dump,以及诚实的 VMP 边界
|
||||
lsposed-and-modules.md 重打包被拒绝,那就交付一个系统级 hook 模块:模块解剖、
|
||||
无 gradle 的构建链、作用域配置与如何验证注入,
|
||||
以及 Java 模块够不到的那一层
|
||||
emulation-and-rpc.md 调用例程而不是读它:Unidbg/Unicorn 模拟及其
|
||||
环境填充成本,对比把活函数通过 Frida RPC 服务化
|
||||
native-dbi-and-deobfuscation.md
|
||||
OLLVM 形态、Frida-Stalker trace、trace 到 CFG 的路线、
|
||||
Stalker/QBDI/模拟的取舍,以及两条实测边界(一次
|
||||
不产生任何事件的 follow,和一次因 follow 热 libc 导出而崩溃)
|
||||
protocol-reverse.md 无 schema 的 protobuf、从反编译代码恢复 schema、gRPC 帧
|
||||
抓取、QUIC/HTTP3 的限制,以及 native 侧证书固定
|
||||
kernel-and-environment-hardening.md
|
||||
用户态 hooking 可证明够不到检查:裸 `svc`、init_array 早期
|
||||
检测、每种 root 方案隐藏了什么、带版本闸门的
|
||||
内核路线图,以及什么时候停止升级
|
||||
on-device-tooling.md 直接在手机上工作:MT Manager 的编辑/重打包/签名及其 APK MCP、
|
||||
LSPosed Manager、Termux+frida、设备上数据检查
|
||||
java2c-and-jni-sinking.md Java2C 与 JNI sinking,最容易与抽取壳混淆的两种加固
|
||||
形态:区分落地壳 / 抽取壳 / VMP / Java2C / JNI sinking
|
||||
的那张表、为什么代码在 `.so` 里而*永远*不在 dump 出的 dex 里,
|
||||
以及为什么 `Java_*` 符号搜索会一无所获(动态注册、
|
||||
`-fvisibility=hidden`)
|
||||
split-apk.md App Bundle / split APK 集合:集合是什么、如何从设备拉取、
|
||||
合并成一个 APK 还是把整个集合作为一个单元签名、安装拒绝
|
||||
及其各自含义,以及如何从拉取的集合做出一个可安装的 fixture
|
||||
vmp-differential-analysis.md
|
||||
针对真实 Dex VMP 的已知明文差分:哪些环节可以
|
||||
自动化、哪些不能(上传是瓶颈)、一个编译出的
|
||||
fixture 能覆盖到什么程度、如何*证明*一张推导出的私有
|
||||
opcode 表、smali 生成,以及这条路线何时关闭
|
||||
coverage-and-limits.md 把主张阶梯用在 skill 自己身上:每个已覆盖条目背后的
|
||||
证据、本 skill 不随附的依赖,以及从未实际演练过的东西
|
||||
handoff-boundaries.md 本 skill 在哪里结束、另一门学科在哪里开始:JNI 形态
|
||||
对照表、壳与加载器的分界,以及「已核实」对四种
|
||||
交付物形态各自意味着什么
|
||||
scripts/ 参数化、与路径无关
|
||||
doctor.py 先跑这个:能力报告 + 每个脚本的可运行性,发现
|
||||
安装在 PATH 之外或作为可运行 jar 存在的工具,并暴露
|
||||
会污染实验的环境事实(时钟偏移、残留的
|
||||
adb forward / proxy、设备侧已在运行的 frida 进程)
|
||||
dexutil.py 无依赖的 dex 读取器:结构遍历 + 精确指令
|
||||
解码、dex 头重算/校验(正确的 checksum/signature
|
||||
顺序)、分支目标与操作数辅助函数。被
|
||||
dex 脚本共享的库,也可独立运行以带偏移 dump 单个方法
|
||||
dex_find_insn.py 按解码语义定位一条指令,并打印其精确字节
|
||||
偏移,附上下文与任一分支的两侧 —— 你靠这个找到
|
||||
补丁点,而不是猜偏移
|
||||
dex_patch_bytes.py 按 JSON 规格做等长字节补丁:语义匹配、通过
|
||||
expect_next 钉定极性、等长强制、校验器检查、dex
|
||||
头重算、重新解码以证明它已落地(先 --dry-run)
|
||||
dex_check_verifier.py 三级检查:是否有任何条件分支以 move-result 为目标
|
||||
(绕过它的生产者)?比较两个构建,并把
|
||||
既存发现与你补丁引入的回归分开
|
||||
coldstart.py 冷启动捕获:定时截图连拍 + logcat 信号 +
|
||||
已安装构建事实 + 启动计时,并在前台
|
||||
activity 不是你的 app 时发出警告
|
||||
so_constpatch.py 对一个孤立的字符串常量做等长原地改写,用于
|
||||
重定向一次库加载,而不是击败一个检查
|
||||
smtool.py 带可配置 classpath 的 baksmali/smali 包装器
|
||||
dexpatch/ dexlib2 方法级重写器(用于需要新指令的改动)
|
||||
patch_smali.py smali 树中的方法体替换
|
||||
dex_strpatch.py 带 string_ids 顺序守卫的字节级字符串补丁
|
||||
dex_classdiff.py 证明一次 dex 编辑是外科式的
|
||||
dex_strings.py 不需要反编译器的 strings/URL/SDK 标记提取
|
||||
dart_pool_strings.py 从 Dart AOT snapshot 恢复字面量(带帧条目、
|
||||
单字节 vs UTF-16 的分岔、文件偏移、游程噪声过滤)
|
||||
dart_pprefs.py 为 Dart snapshot 构建/查询 对象池 -> 代码位置 索引
|
||||
dart_disasm.py Dart AOT 代码的带注释窗口式反汇编 + B/BL 调用者索引
|
||||
find_refs.py 在给一个方法打补丁之前统计它的调用者
|
||||
repack.py 重建 APK、只剥离签名、保留 META-INF/services/、写出
|
||||
4 字节对齐的归档(resources.arsc STORED+对齐)、签名、校验;
|
||||
也处理 split APK / App Bundle 集合:清点、用一个
|
||||
keystore 为每个成员签名,或把 code/native 成员合并成独立 APK
|
||||
devsh.py 引号安全的 ADB shell 辅助工具
|
||||
usb_net_proxy.py 通过 USB 给离线设备提供网络
|
||||
datastore_inject.py 安全地编码/注入 AndroidX DataStore preferences
|
||||
probe_api.py 带正确请求头探测一个 HTTP API
|
||||
grab_crash.py 恢复被崩溃上报 SDK 藏起来的栈
|
||||
install_test.py 安装 + 启动健康检查,带 logcat 信号扫描
|
||||
frida_probe.js 四层运行时探测(app 网络层 + OkHttp + java.net + 异常)
|
||||
run_probe.py 注入探测、把它流式写入日志文件、保持常驻
|
||||
tls_check.py 对一个或多个 host 做严格证书检查
|
||||
preflight.py 每个实验块之前的环境检查(设备、root、
|
||||
ABI/翻译层、时钟偏移、残留 proxy/forward、失联的 server)
|
||||
lib_map.py 一个活进程里*实际映射*了什么:每库路径、
|
||||
基址、架构,以及它来自 APK 还是
|
||||
运行时物化出来的
|
||||
elf_plt.py 从重定位表把 PLT stub 解析到它导入的符号(x86_64 + aarch64);
|
||||
列出某个符号的调用者;对两个
|
||||
库做字节级 diff,并指出每个变更 stub 属于哪个符号
|
||||
apk_diff.py 两个构建的条目级 diff:变更 / 新增 / 移除,按
|
||||
内容哈希,使等长替换也能被抓住
|
||||
native_crash.py 从日志或 tombstone 定位一次 native 死亡:signal、故障
|
||||
地址、寄存器、把帧拆成 app 与 system、故障
|
||||
指令本身,以及当故障看起来是*被安排好的*时给出标记
|
||||
blob_decode.py 搜索、而不是猜测一个存储值的分帧
|
||||
(base64/hex x 旋转 x deflate);把编辑后的 payload 重新编码
|
||||
snap.py 有界连拍截图 + 带停滞检测的控件树捕获,
|
||||
并对这棵控件树是否可用给出结论
|
||||
sig_probe.py 找到确切的 signatures[0].toCharsString() 值 —— 从 APK 得到的
|
||||
离线候选,或从设备读取的权威值
|
||||
spawn_patch_detach.py 在 Frida 探测下 spawn、detach,然后启动并捕获:在
|
||||
spawn 模式下 Activity 栈常常起不来,而内存
|
||||
写入能在 detach 后存活,hook 不能
|
||||
hook_patch_only.js spawn_patch_detach.py 的最小探测 —— 按偏移把一个 native
|
||||
死亡点失效化并报告 PATCHED
|
||||
dex_dump_validate.py 对一目录的 dump dex 镜像去重、校验并排序:sha256
|
||||
分组、头完整性、区分真实 dump 与抽取壳骨架的
|
||||
平凡方法体比例,以及一个最可能原件的排序
|
||||
(对页对齐的 /proc/<pid>/mem 捕获用 --trim)
|
||||
dex_mem_scan.py 在内存捕获中搜索内嵌的 dex 镜像,并按每个镜像自己头部
|
||||
声明的尺寸抽取出来 —— 用于躺在匿名映射里、
|
||||
没有任何 maps 条目为它命名的解密 dex
|
||||
lsposed_scaffold.py 生成一个最小 LSPosed/Xposed 模块工程(带
|
||||
xposed meta-data 的 manifest、assets/xposed_init、hook 类、无 gradle 的构建说明)
|
||||
frida_rpc_serve.py 把 Frida 脚本的 rpc.exports 桥接到本地调用方,带断线重连
|
||||
处理,从而可以调用一个活的 native 函数,而不是逆向它
|
||||
rpc_template.js frida_rpc_serve.py 的可编辑配套脚本
|
||||
stalker_trace.js 用 Frida Stalker 做指令级追踪:可配置目标、
|
||||
触发选择、事件流,以及输出体积规则
|
||||
stalker_report.py 把 stalker_trace.js 的日志归约为基本块直方图与调用序列,
|
||||
并对实测到的零事件情形给出明确诊断
|
||||
mt_mcp_probe.py 探测 MT Manager 的设备端 APK MCP(Streamable HTTP,端口 8787):
|
||||
JSON-RPC 握手加上分组后的工具清单
|
||||
java2c_probe.py 收集能区分 Java2C 与抽取壳、VMP 及普通 JNI sinking 的
|
||||
证据:从 dex 得到 native 密度与 stub 比例、
|
||||
从 `.so` 得到 JNI_OnLoad / 动态注册 / 工具链字符串,
|
||||
每一项都标注为 强/中/弱
|
||||
protobuf_decode_raw.py 无 schema 的 protobuf 解码:hex / 文件 / stdin 到 JSON 树,每个
|
||||
长度定界字段都保留为一个候选集合、并列项予以标注而
|
||||
不是猜测,另含字节级精确重编码以检查一次往返
|
||||
vmp_diff_harness.py 构建带标注的 opcode 覆盖 fixture、从原始/加固 dex 对
|
||||
推导候选私有 opcode 映射、在闭环中验证该比较,
|
||||
并把还原出的指令流渲染成 smali 骨架
|
||||
kernelsu_syscall_mask.py 生成 KernelSU/APatch 系统调用遮蔽脚手架:一个可安装的
|
||||
用户态模块骨架加上 KPM/LKM/eBPF 内核侧模板,每个
|
||||
都带自己的版本闸门和明确的未核实标注
|
||||
rasc_build.py 构建并验证 rasc,即 ASC 索引器的 Rust 重实现:
|
||||
--check 检查已有什么、--build 克隆并 cargo、--verify 用
|
||||
droidasc 校验一个 APK,并在任何类集合差异上失败
|
||||
scan_leaks.py 在发布一个仓库之前扫描其中的目标身份:manifest / `pm` / `ps`
|
||||
上下文里的 bundle id、序列号形态的 token、PAT、内联
|
||||
appkey 赋值、字面 endpoint、宿主用户路径。对
|
||||
必须保留的一切给予豁免(工具、库、CVE、加固产品、
|
||||
公开 crackme、占位符),发现项带上下文,
|
||||
`--show-exempt` 打印某个命中项为什么被抑制,退出码 0/1/2
|
||||
svc_scan.py 指出某条内联 `svc` 背后的系统调用以及它所在的段,
|
||||
这决定 libc 层面的 hook 究竟能否观察到该调用;
|
||||
`--context` 展示邻近字节,因为字节扫描也会匹配到数据
|
||||
anti_detect_probe.js 仅观察的 Frida 探测(不打任何补丁):path/loader/thread/kill
|
||||
hook 附调用者模块 + 偏移、一份环境自述
|
||||
(`TracerPid`、以 frida 命名的映射),以及实时流式输出,使一个
|
||||
亚秒级自毁目标仍能产出证据
|
||||
```
|
||||
|
||||
仓库另外带有一个**可执行**的测试层,它与证据记录是两回事:`tests/` 断言脚本的行为(单元、CLI 契约、无设备集成),`tests/benchmark.md` 记录某条路线在真实目标上的表现。`tests/README.md` 说明了这个划分,`.github/workflows/ci.yml` 运行各闸门加测试套件。
|
||||
|
||||
## 安装
|
||||
|
||||
这个仓库是一个 **skills 仓库**:skill 位于 `skills/apk-reverse/`,这正是 `skills` CLI 解析的布局;安装是按名字进行的,而不是复制一个目录:
|
||||
|
||||
```
|
||||
npx skills add newliver666/apk-reverse # install every skill in the repo
|
||||
npx skills add newliver666/apk-reverse --list # list what is here, install nothing
|
||||
npx skills add newliver666/apk-reverse --skill apk-reverse -y
|
||||
npx skills use newliver666/apk-reverse@apk-reverse # use it once, without installing
|
||||
```
|
||||
|
||||
CLI 默认把 skill 软链进你的 agent 的 skills 目录(`--copy` 则生成独立副本),`-g` 为所有项目安装而不是仅当前项目。仓库里只有一个 skill 时,`--skill apk-reverse` 今天是多余的;这里把它写出来,是因为等第二个 skill 存在时,正是它用来选中单个 skill。
|
||||
|
||||
安装后,agent 在任务匹配其描述时加载 `SKILL.md`,并且只在需要时拉入 `references/*`。没有全局状态,没有与机器绑定的路径,也没有构建步骤。
|
||||
|
||||
## 环境要求
|
||||
|
||||
没有什么是强制的;每个脚本都自己检查所需。`skills/apk-reverse/scripts/doctor.py` 报告这里哪些工具存在、因此哪些脚本能跑,以及——这很有用——哪些工具存在于 PATH 之外。
|
||||
|
||||
如果你的工具链不在 PATH 上(项目本地的 `tools/` 目录、带版本号的 SDK 目录、可运行的 `.jar` 而不是一条命令),把 `APKREV_TOOLS` 设为一个或多个目录,`doctor.py` 就会找到它们:
|
||||
|
||||
```
|
||||
set APKREV_TOOLS=<dir>;<dir> # Windows, e.g. an SDK or project-local tools dir
|
||||
export APKREV_TOOLS=<dir>:<dir> # POSIX
|
||||
```
|
||||
|
||||
脚本本身是纯 `python3`,并且意图在 Windows、macOS 和 Linux 上行为一致;凡有片段仅限 POSIX,都会标注出来。这里没有任何东西假定你用的是 Unix shell。
|
||||
|
||||
| 工具 | 用途 |
|
||||
|---|---|
|
||||
| Python 3.9+ | 所有脚本 |
|
||||
| **`droidasc`**(ASC)(**可选,但强烈建议——先装这个**) | 整 APK 交叉引用索引:`findrefs` / `listclass` / `getclass` / `getmanifest`。一句 `pip install droidasc`,无 JVM、无 SDK、无需建索引。把「这几千个类里哪一个提到过这个字符串」变成亚秒级查询,也是找到名字被 R8 混淆过的类的唯一路线。**这是 agent 在做任何完整反编译之前应当伸手去拿的工具** —— 见 `skills/apk-reverse/references/toolchain.md` §droidasc (ASC) —— 一次查询就能问一个 APK「谁引用了这个?」 |
|
||||
| `ddc`(可选,但强烈建议) | 单文件 dex→Java 反编译器,带查询子命令(`info`、`findrefs`、`strings --with-locations`、按类反编译)。无 JVM。**读** ASC 所**定位**的东西;也能可靠地报告包身份 —— 见 `skills/apk-reverse/references/toolchain.md` §ddc —— 带查询子命令的 dex 转 Java(值得采用) |
|
||||
| `baksmali` / `smali` + `dexlib2` jars | 反汇编、汇编、外科式打补丁 |
|
||||
| JDK(`javac`、`java`) | 构建/运行 dexlib2 补丁器;同时提供 `keytool`/`jarsigner` |
|
||||
| Android SDK build-tools(`aapt`、`zipalign`、`apksigner`) | manifest 信息、对齐、签名。**`apksigner` 才是该用的签名器** —— `jarsigner` 会重写归档并破坏 Android R+ 所要求的对齐 |
|
||||
| `uber-apk-signer`(可选) | 一步完成对齐 + 签名 |
|
||||
| ADB | 设备工作 |
|
||||
| Frida(宿主包 + 与之匹配的设备端 server) | 动态分析 |
|
||||
| 一台已 root 的设备或模拟器 | 静态分析之外的任何事 |
|
||||
|
||||
这些都不需要在 `PATH` 上:每个脚本都为它外壳调用的工具接受显式路径,`skills/apk-reverse/references/toolchain.md` 覆盖了如何找到一个 `PATH` 不认识的安装(对 `apksigner` 和 `keytool` 来说这是常见情况)。
|
||||
|
||||
## 先读这个
|
||||
|
||||
**本项目仅为学习、研究和经授权的安全测试而发布。** 它不附带任何 exploit payload、任何目标数据、任何第三方二进制 —— 它是一套方法、一组脚本和一份证据记录。你有责任确保自己对所分析的对象拥有相应权利;见文末**免责声明**。
|
||||
|
||||
`skills/apk-reverse/references/pitfalls.md`。这是这里最有价值的文件 —— 每一条都是在看起来完全健康的情况下产出了损坏产物的失败。
|
||||
|
||||
最痛的四个:
|
||||
|
||||
1. 重打包时把整个 `META-INF/` 剥离,会删掉 ServiceLoader 注册,app 在启动时死掉,而报错信息指向一个毫不相干的库。
|
||||
2. 对一个字节级字符串打补丁却不保持 `string_ids` 顺序,整个 dex 会被拒绝,而 checksum 和签名却完美通过校验。
|
||||
3. 用整棵 smali 树往返来重建 dex,会不可见地损坏 R8 的输出 —— 类表对比是干净的,它只在运行时炸开。
|
||||
4. 通过让它**不返回**来失效化一条 native 终止路径。一个自旋 stub 并不能抑制检查;它冻住调用者和它身后的每一个线程。app 挂死,且*完全没有崩溃记录*,最终的死亡会被归咎于那个杀掉这个冻结进程的任意东西。
|
||||
|
||||
## 范围
|
||||
|
||||
为处理你自己的应用、你被授权分析的样本,以及 CTF/竞赛沙箱而构建。它不含任何内置的第三方二进制,也不含任何目标特定数据。
|
||||
|
||||
它覆盖什么、刻意不覆盖什么,写在 `SKILL.md` 顶部 **Coverage** 一节。简版:仅 Android(不含 iOS),并且在经过真实打磨的层上深入 —— dex 补丁、重打包、壳与自定义加载器、native 篡改响应,以及 Flutter/Dart AOT。一次扩展迭代加入了第二梯队的已记录路线:重打包受阻时的**模块侧交付**、**抽取壳恢复**及其 VMP 边界、用于「调用而不是阅读」的**模拟执行与实时 RPC**、针对 OLLVM 的**指令级追踪**、REST 之外的**协议逆向**、当用户态 hooking 可证明够不到时用的**内核路线**图,以及**设备上工具**。随后一次**基准迭代**把公开目标放到这些路线之下(`tests/benchmark.md`):它加入了 **Java2C 判别**(那种会让 agent 去追一个从不存在的解密 DEX 的误诊)、**split APK / App Bundle 处理**、**无 schema 的 protobuf 解码**、一个 **Dex-VMP 差分**测试台,以及**带版本闸门的内核模块模板** —— 并纠正了先前两条与自身测量结果不一致的说法。Unity/IL2CPP 逻辑恢复、React Native/Hermes 字节码内部,以及击败服务端权威**不**在覆盖范围内,skill 的写法就是明说这一点并停下,而不是把最近的一条已记录流程套用到一个它并非为之编写的目标上。
|
||||
|
||||
Coverage 一节完整陈述、同时也应当放在这里的四条限定:
|
||||
|
||||
- **Flutter/Dart AOT 分析有一个依赖。** 工作流始于一份 pool 列表(`pp.txt` 一类输出)。产出它需要一个 snapshot 解码反编译器 —— aotopsy(静态二进制,不需要工具链)或 blutter(从源码构建,约 80 秒)—— 而本仓库不含其中之一。它被点名为前置条件,而不是留在那里让人自己推。
|
||||
- **本仓库里并非每条主张背后都有一次运行。** `docs/tool-verification/` 记录了实际测量了什么、在哪个目标上、用哪个独立交叉检查确认;那里未覆盖的内容都是凭经验记录的,按本 skill 自己的主张阶梯,应当读作*推断*。
|
||||
- **扩展迭代被单独记录,并且大部分是*推断*。** 其证据在 `docs/tool-verification/EXTENSION-*.md`,一个主题一个文件,各自带强度说明。那里常见的形态是*工具被测量过,路线没有* —— 所以在把任何较新的文档当作已验证路径之前,先读那些文件。
|
||||
- **基准迭代按行记录,并带有该行自己的强度。** `tests/benchmark.md` 列出每个公开目标、该行演练的脚本、实际发生了什么(包括失败的行和没人跑的行),以及证据有多强。标为 `unverified` 的行是在陈述本仓库证据的状况,不是在陈述机制本身。
|
||||
|
||||
## 仓库维护
|
||||
|
||||
根目录下住着四个工具,它们不属于已安装的 skill:
|
||||
|
||||
```
|
||||
check_repo.py 发现的每个 skill、frontmatter 有效、脚本可运行、
|
||||
文档中的路径可解析、README 路径显式且存在,
|
||||
并且 —— 仅在受跟踪的表面上 —— 没有目标身份
|
||||
(把规则委派给 skills/apk-reverse/scripts/scan_leaks.py,
|
||||
这样要争论豁免清单时就只有一个地方)
|
||||
check_refs.py 每一处指向另一文档某节的交叉引用,
|
||||
都能到达该文档里真实存在的标题
|
||||
check_routing.py 按需清单仍与入口点一致:症状镜像与 SKILL.md 一致、
|
||||
每个 reference 文件都在 skills/apk-reverse/references/routing.md 中
|
||||
被点名,每个脚本也是
|
||||
check_commands.py 文档让你运行的每条命令都会与脚本自己的 argparse 表
|
||||
核对 —— 一个不存在却被写进文档的 flag,是锚点检查
|
||||
看不见的漂移
|
||||
check_budget.py 阻止常驻加载的部分悄悄膨胀:按行和 token 测量 SKILL.md
|
||||
的整个主体(含索引行,因为它们也会被加载)、索引行长度、
|
||||
没有可导航头部的长文件,并把含糊其辞的规则作为趋势报告
|
||||
build_scripts.py 审计机器特定残留物(绝对路径、凭据)
|
||||
```
|
||||
|
||||
一致性有一种天然的反向压力 —— 一条断掉的路径会大声失败,然后有人修它。膨胀没有,这正是第三个工具存在的原因:每次迭代都会加一个 reference、一行索引和一条覆盖主张,而没有测量的话,仓库里没有任何东西会注意到。
|
||||
|
||||
`tests/benchmark.md` 保存**回归矩阵**:维度 -> 公开目标 -> 该行演练的脚本 -> 实测结果 -> 强度标注。它是你在信任 `docs/tool-verification/` 下任何主张之前要重跑的检查表。样本会下载进 `tools/_work/` 且从不提交,因此每一行都点名它的公开来源并记录它所针对的哈希。
|
||||
|
||||
`docs/tool-verification/` 也不属于已安装的 skill。它是针对一个真实目标、一次测量迭代的证据记录:每个脚本实际做了什么、哪个独立方法确认了它、发现了哪些缺陷,以及该目标未能演练哪些场景。它存在的意义,是让 `SKILL.md` 里的 **Coverage** 主张可以被对照运行来检查,而不是被信任;也让缺口被写在下一个人能找到它们的地方。
|
||||
|
||||
---
|
||||
|
||||
Proudly supported by the [LINUX DO](https://linux.do) community.
|
||||
|
||||
## 免责声明
|
||||
|
||||
**仅为学习、研究和经授权的安全测试。** 本仓库中的每个脚本、reference 和已记录结果,都是为了解释 Android 应用分析**如何**工作,使从业者能对自己已经拥有的工具进行推理。这里没有任何东西是一项服务、一个产品,也不构成对任何特定用途的背书。
|
||||
|
||||
- **仅限经授权的目标。** 请把它用在你自己拥有的应用、你被明确许可分析的应用、公开的 CTF/挑战材料,或你掌控的沙箱中。分析你无权分析的软件,在你所在的地区可能违法,而这个判断要由你作出,不是由本仓库作出。
|
||||
- **不提供担保,不保证适用于任何目的。** 材料按*现状*提供,不附带任何形式的担保。结果是某台机器在某个时刻的测量记录;这里没有任何东西承诺某条路线会在你的目标、你的设备、你的工具链或今天的 app 版本上生效。
|
||||
- **先验证再信任;先备份再行动。** 多个脚本会修改产物(dex、APK、`.so`、已存储的 app 数据),有些会在已 root 的设备上操作。保留你自己的副本、在副本上作业,并在对你在意的东西跑任何东西之前先读 `SKILL.md` 的 gate。
|
||||
- **你的使用由你自己负责。** 作者与贡献者不对因使用或误用本仓库而产生的任何损失、损害、法律后果或服务中断承担任何责任,也不与任何可能被用于检查的应用、厂商或平台存在隶属、背书或代理关系。
|
||||
- **测试数据不在此分发。** 样本、dump 和设备产物被刻意排除在这棵树之外(`.gitignore` 排除了它们),只存在于本地被忽略的工作区中。你为了跟做而取得的任何东西,都由你自己负责保管,并在用完后删除 —— 遵守你本地的规则以及样本自带的条款。本仓库*确实*发表的是方法与证据,且已移除全部目标身份。
|
||||
- **无隶属关系。** 工具、库、加固产品和公开挑战目标的名字出现,只是为了让材料可复用;它们属于各自的拥有者,本项目与它们没有关联。
|
||||
46
vendor-new/apk-reverse/assets/banner-dark.svg
Normal file
46
vendor-new/apk-reverse/assets/banner-dark.svg
Normal file
@@ -0,0 +1,46 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 260" width="1200" height="260" role="img" aria-label="apk-reverse">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#141218"/>
|
||||
<stop offset="0.55" stop-color="#1C1B1F"/>
|
||||
<stop offset="1" stop-color="#232026"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glow" cx="0.5" cy="0.3" r="0.7">
|
||||
<stop offset="0" stop-color="#CAC4D0" stop-opacity="0.10"/>
|
||||
<stop offset="1" stop-color="#CAC4D0" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="rule" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0" stop-color="#49454F"/>
|
||||
<stop offset="0.5" stop-color="#9A93A3"/>
|
||||
<stop offset="1" stop-color="#49454F"/>
|
||||
</linearGradient>
|
||||
<pattern id="grid" width="24" height="24" patternUnits="userSpaceOnUse">
|
||||
<path d="M24 0H0V24" fill="none" stroke="#E6E0E9" stroke-opacity="0.04" stroke-width="1"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
<rect width="1200" height="260" fill="url(#bg)"/>
|
||||
<rect width="1200" height="260" fill="url(#grid)"/>
|
||||
<rect width="1200" height="260" fill="url(#glow)"/>
|
||||
|
||||
<g transform="translate(600 52)" stroke-linejoin="round">
|
||||
<path d="M0 -26 L24 -13.5 L24 13.5 L0 27 L-24 13.5 L-24 -13.5 Z"
|
||||
fill="#E6E0E9" fill-opacity="0.06" stroke="#CAC4D0" stroke-width="2.6"/>
|
||||
<g stroke="#938F99" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M0 -11 L-11 8"/>
|
||||
<path d="M0 -11 L11 8"/>
|
||||
<path d="M-11 8 L11 8"/>
|
||||
</g>
|
||||
<circle cx="0" cy="-11" r="4.2" fill="#E6E0E9"/>
|
||||
<circle cx="-11" cy="8" r="3.4" fill="#938F99"/>
|
||||
<circle cx="11" cy="8" r="3.4" fill="#938F99"/>
|
||||
</g>
|
||||
|
||||
<g font-family="system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif" text-anchor="middle">
|
||||
<text x="600" y="152" font-size="60" font-weight="700" letter-spacing="-1.5" fill="#E6E0E9">apk-reverse</text>
|
||||
<text x="600" y="192" font-size="20" fill="#CAC4D0">Agent Skill for Android APK Reverse Engineering</text>
|
||||
<text x="600" y="224" font-size="16" fill="#938F99">dex patching · repacking & signing · packers & loaders · native tamper · Dart AOT</text>
|
||||
</g>
|
||||
|
||||
<rect x="0" y="257" width="1200" height="3" fill="url(#rule)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
47
vendor-new/apk-reverse/assets/banner-light.svg
Normal file
47
vendor-new/apk-reverse/assets/banner-light.svg
Normal file
@@ -0,0 +1,47 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 260" width="1200" height="260" role="img" aria-label="apk-reverse">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#FFFFFF"/>
|
||||
<stop offset="0.55" stop-color="#F5F4F7"/>
|
||||
<stop offset="1" stop-color="#EDECF1"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glow" cx="0.5" cy="0.3" r="0.7">
|
||||
<stop offset="0" stop-color="#79747E" stop-opacity="0.09"/>
|
||||
<stop offset="1" stop-color="#79747E" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="rule" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0" stop-color="#CAC4D0"/>
|
||||
<stop offset="0.5" stop-color="#79747E"/>
|
||||
<stop offset="1" stop-color="#CAC4D0"/>
|
||||
</linearGradient>
|
||||
<pattern id="grid" width="24" height="24" patternUnits="userSpaceOnUse">
|
||||
<path d="M24 0H0V24" fill="none" stroke="#1D1B20" stroke-opacity="0.035" stroke-width="1"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
<rect width="1200" height="260" fill="url(#bg)"/>
|
||||
<rect width="1200" height="260" fill="url(#grid)"/>
|
||||
<rect width="1200" height="260" fill="url(#glow)"/>
|
||||
<rect x="0.75" y="0.75" width="1198.5" height="258.5" fill="none" stroke="#CAC4D0" stroke-width="1.5"/>
|
||||
|
||||
<g transform="translate(600 52)" stroke-linejoin="round">
|
||||
<path d="M0 -26 L24 -13.5 L24 13.5 L0 27 L-24 13.5 L-24 -13.5 Z"
|
||||
fill="#49454F" fill-opacity="0.05" stroke="#49454F" stroke-width="2.6"/>
|
||||
<g stroke="#79747E" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M0 -11 L-11 8"/>
|
||||
<path d="M0 -11 L11 8"/>
|
||||
<path d="M-11 8 L11 8"/>
|
||||
</g>
|
||||
<circle cx="0" cy="-11" r="4.2" fill="#1D1B20"/>
|
||||
<circle cx="-11" cy="8" r="3.4" fill="#79747E"/>
|
||||
<circle cx="11" cy="8" r="3.4" fill="#79747E"/>
|
||||
</g>
|
||||
|
||||
<g font-family="system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif" text-anchor="middle">
|
||||
<text x="600" y="152" font-size="60" font-weight="700" letter-spacing="-1.5" fill="#1D1B20">apk-reverse</text>
|
||||
<text x="600" y="192" font-size="20" fill="#49454F">Agent Skill for Android APK Reverse Engineering</text>
|
||||
<text x="600" y="224" font-size="16" fill="#79747E">dex patching · repacking & signing · packers & loaders · native tamper · Dart AOT</text>
|
||||
</g>
|
||||
|
||||
<rect x="0" y="257" width="1200" height="3" fill="url(#rule)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
286
vendor-new/apk-reverse/build_scripts.py
Normal file
286
vendor-new/apk-reverse/build_scripts.py
Normal file
@@ -0,0 +1,286 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Sync a script directory to a publish location, and audit it for machine-specific leftovers.
|
||||
|
||||
WHY THIS EXISTS
|
||||
---------------
|
||||
An Agent Skill gets copied across machines and across runtimes (Claude Code, Codex,
|
||||
DeepSeek Harness). Anything machine-specific that survives the copy becomes a
|
||||
landmine: a baked-in JDK path, a keystore password, a home directory that only
|
||||
exists on the author's box. This tool does two things:
|
||||
|
||||
1. AUDIT (default, read-only): report every absolute-path literal that names
|
||||
*this* machine, and every credential-looking constant, with file and line
|
||||
number. Generic install locations (`Program Files` on Windows, `/usr/bin`
|
||||
on POSIX) and already-redacted paths (carrying a `<placeholder>` or an
|
||||
ellipsis) are exempt -- see `GENERIC_PATH_PREFIXES` and `_leaks_nothing`,
|
||||
which exist because a gate whose output is mostly noise stops being read.
|
||||
2. SYNC (--apply): copy matching files to the destination, optionally after
|
||||
neutralising the absolute-path literals it found (--scrub).
|
||||
|
||||
The audit is the default on purpose: a silent copy is exactly how the leftovers
|
||||
travel from the author's machine into someone else's first run.
|
||||
|
||||
USAGE
|
||||
-----
|
||||
python build_scripts.py scripts2 out # audit only, nothing written
|
||||
python build_scripts.py scripts out --apply # copy
|
||||
python build_scripts.py scripts out --apply --scrub # copy, neutralising abs paths
|
||||
python build_scripts.py scripts out --pattern '*.py' --pattern '*.js'
|
||||
|
||||
Exit codes: 0 = clean, 2 = findings reported (audit mode), 1 = usage or IO error.
|
||||
|
||||
NOTES
|
||||
-----
|
||||
* A finding is a heuristic, not a verdict: `/data/local/tmp/...` is a legitimate
|
||||
Android device path and is deliberately NOT reported; a drive-letter path is.
|
||||
* --scrub rewrites `'<abs path>'` literals into `''`, which keeps the file
|
||||
syntactically valid but leaves the decision (and the fix) to a human. It never
|
||||
runs unless --apply is also given.
|
||||
"""
|
||||
import argparse
|
||||
import fnmatch
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
DEFAULT_PATTERNS = ('*.py', '*.js', '*.java', '*.md', '*.txt')
|
||||
|
||||
# Absolute-path literals that belong to one particular machine.
|
||||
# Deliberately excluded: /data/... (an Android device path, not a host path).
|
||||
WINDOWS_LITERAL = re.compile(r"(?P<prefix>(?:r|u|b)?r?)(?P<quote>['\"])"
|
||||
r"(?P<path>[A-Za-z]:[\\/][^'\"]*)(?P=quote)")
|
||||
POSIX_LITERAL = re.compile(r"(?P<prefix>(?:r|u|b)?r?)(?P<quote>['\"])"
|
||||
r"(?P<path>/(?:home|Users|mnt)/[^'\"]*)(?P=quote)")
|
||||
|
||||
# Any drive-letter or foreign-home path outside a quoted literal as well.
|
||||
# Tuned to avoid the two false-positive families that made an earlier version of
|
||||
# this audit useless: a URL scheme ("https://" contains "s:" + "/") and a Python
|
||||
# escape sequence ("is:\n" contains "s:" + "\"). Hence: the previous character must
|
||||
# not be alphanumeric, a drive letter must be followed by a real backslash (not an
|
||||
# escape letter, not a second separator), and the POSIX form must start a path.
|
||||
BARE_ABS = re.compile(
|
||||
r"(?<![A-Za-z0-9])(?:"
|
||||
r"[A-Za-z]:\\(?![ntr0bxu'\"\\])\S{2,}"
|
||||
r"|/(?:home|Users|mnt)/[A-Za-z0-9_.\-]{2,}"
|
||||
r")")
|
||||
|
||||
CREDENTIAL_HINT = re.compile(
|
||||
r"(?i)(BEGIN [A-Z ]*PRIVATE KEY|storepass\s*=\s*['\"][^'\"]|"
|
||||
r"keypass\s*=\s*['\"][^'\"]|password\s*=\s*['\"][^'\"]|"
|
||||
r"api[_-]?key\s*=\s*['\"][^'\"]|secret\s*=\s*['\"][^'\"]{6,})")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# What is NOT a machine-specific leftover.
|
||||
#
|
||||
# This audit exists to catch paths that leak *this* machine's layout. Two
|
||||
# families look like absolute paths but leak nothing, and reporting them is
|
||||
# what made an earlier version of this gate unreadable: 37 findings on the pass
|
||||
# that introduced the evidence record, of which the large majority were these
|
||||
# two families. A gate whose output is mostly noise is a gate people stop
|
||||
# reading, so the exemptions are explicit and auditable rather than silent.
|
||||
#
|
||||
# 1. Generic install locations. `C:\Program Files\...` exists on every Windows
|
||||
# host and `/usr/bin/...` on every POSIX host; naming one names the
|
||||
# platform, not the machine. Same for a distribution's own paths.
|
||||
# `/data/...` is already excluded above for the same reason -- it is a
|
||||
# device path, not a host path.
|
||||
#
|
||||
# 2. Already-redacted paths. A path carrying a `<placeholder>` or an ellipsis
|
||||
# has been through a normalisation pass deliberately; re-reporting it
|
||||
# punishes the correct behaviour. `scan_leaks.py` reads the same signal as
|
||||
# PLACEHOLDER_TOKENS for target identity.
|
||||
#
|
||||
# What remains is a drive or home path with no placeholder: a real workbench
|
||||
# location. That is the thing worth failing on, and the thing to fix in the
|
||||
# document rather than in this list.
|
||||
GENERIC_PATH_PREFIXES = (
|
||||
# Windows, generic
|
||||
'c:\\program files', 'c:\\program files (x86)', 'c:\\windows',
|
||||
'c:\\python', 'c:\\mingw', 'c:\\msys', 'c:\\cygwin', 'c:\\strawberry',
|
||||
'c:\\android', 'c:\\android-sdk', 'c:\\users\\public', 'c:\\users\\default',
|
||||
# POSIX, generic
|
||||
'/usr/', '/opt/', '/etc/', '/var/', '/tmp/', '/bin/', '/sbin/',
|
||||
)
|
||||
|
||||
# `<user>`, `<name>`, `<path>`, `<serial>` -- a placeholder is the mark of a path
|
||||
# that has already been made portable on purpose.
|
||||
PLACEHOLDER_RE = re.compile(r'<[A-Za-z_][A-Za-z0-9_.\-]*>')
|
||||
# A literal ellipsis in a path means the middle was deliberately elided.
|
||||
ELLIPSIS_RE = re.compile(r'\.\.\.|\u2026')
|
||||
|
||||
|
||||
def _leaks_nothing(path):
|
||||
"""True when an absolute path says nothing about this particular machine.
|
||||
|
||||
Kept separate from the matching rules so the exemption can be tested and
|
||||
argued with on its own, rather than being folded into the regex.
|
||||
|
||||
The comparison is **bidirectional on purpose**. `BARE_ABS` stops at
|
||||
whitespace (it cannot know where a path ends), so a real
|
||||
`C:\\Program Files\\...` arrives here as the truncated `C:\\Program`. A
|
||||
one-directional `startswith` then misses the exemption and reports a path
|
||||
that exists on every Windows host. Requiring a minimum length keeps the
|
||||
reverse direction from turning a short drive prefix into a blanket pass.
|
||||
"""
|
||||
low = path.lower().replace('/', '\\')
|
||||
for prefix in GENERIC_PATH_PREFIXES:
|
||||
p = prefix.replace('/', '\\')
|
||||
if low.startswith(p):
|
||||
return True
|
||||
if len(low) >= 8 and p.startswith(low):
|
||||
return True
|
||||
# A bare user-directory root names no one -- only the segment after it does.
|
||||
# `C:\Users\` appears legitimately when a document describes a rule rather
|
||||
# than quoting a location, so it is exempt, while a personal name in that
|
||||
# position still reports. Phrased without a placeholder on purpose:
|
||||
# `scan_leaks.py` recognises a fixed token list, and an invented one reads
|
||||
# to it as the very name this rule is trying to catch.
|
||||
#
|
||||
# The lookahead must exclude a following separator as well as alphanumerics.
|
||||
# With only `[a-z0-9]` the optional `\\?` backtracks: it matches the empty
|
||||
# string, the next character is the separator, that passes the lookahead, and
|
||||
# a real personal path is swallowed. Excluding both is what separates a
|
||||
# harmless root from a leak. `BARE_ABS` can also append punctuation (a
|
||||
# trailing backtick), which is why the rule is a lookahead and not `$`.
|
||||
if re.match(r'^c:\\users\\?(?![\\a-z0-9])', low):
|
||||
return True
|
||||
if PLACEHOLDER_RE.search(path) or ELLIPSIS_RE.search(path):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def collect(src, patterns):
|
||||
"""Return [(absolute_path, relative_path)] for every matching file."""
|
||||
out = []
|
||||
for root, _dirs, files in os.walk(src):
|
||||
for name in sorted(files):
|
||||
if any(fnmatch.fnmatch(name, p) for p in patterns):
|
||||
full = os.path.join(root, name)
|
||||
out.append((full, os.path.relpath(full, src)))
|
||||
return out
|
||||
|
||||
|
||||
def audit(path, rel):
|
||||
"""Return [(kind, lineno, detail)] for one file."""
|
||||
findings = []
|
||||
try:
|
||||
with open(path, encoding='utf-8', errors='replace') as fh:
|
||||
lines = fh.readlines()
|
||||
except OSError as exc:
|
||||
return [('io-error', 0, str(exc))]
|
||||
|
||||
for lineno, line in enumerate(lines, 1):
|
||||
for match in BARE_ABS.finditer(line):
|
||||
hit = match.group(0)
|
||||
# Report only paths that actually name this machine; a generic
|
||||
# install location or an already-redacted path is not a leftover.
|
||||
if _leaks_nothing(hit):
|
||||
continue
|
||||
findings.append(('machine-path', lineno, hit[:90]))
|
||||
if CREDENTIAL_HINT.search(line):
|
||||
findings.append(('credential', lineno, line.strip()[:110]))
|
||||
return findings
|
||||
|
||||
|
||||
def scrub(text):
|
||||
"""Neutralise absolute-path literals; returns (new_text, count)."""
|
||||
total = 0
|
||||
|
||||
def repl(_match):
|
||||
return "''"
|
||||
|
||||
for pattern in (WINDOWS_LITERAL, POSIX_LITERAL):
|
||||
text, count = pattern.subn(repl, text)
|
||||
total += count
|
||||
return text, total
|
||||
|
||||
|
||||
def main():
|
||||
# A finding can quote a byte or a path the console's code page cannot
|
||||
# represent -- a Windows console defaults to a legacy encoding such as
|
||||
# cp936 or cp1252. Without this, the audit dies with UnicodeEncodeError
|
||||
# *while reporting its own result*, which reads as "the tool is broken"
|
||||
# instead of "here is what it found".
|
||||
try:
|
||||
sys.stdout.reconfigure(errors='replace')
|
||||
except (AttributeError, ValueError):
|
||||
pass
|
||||
|
||||
ap = argparse.ArgumentParser(
|
||||
description='Sync a script directory to a publish location and audit it for '
|
||||
'machine-specific leftovers (absolute paths, hardcoded credentials).')
|
||||
ap.add_argument('src', help='source directory to publish from')
|
||||
ap.add_argument('dst', help='destination directory to publish into')
|
||||
ap.add_argument('--pattern', action='append', default=list(DEFAULT_PATTERNS),
|
||||
help='glob to include, repeatable (default: %s)'
|
||||
% ' '.join(DEFAULT_PATTERNS))
|
||||
ap.add_argument('--apply', action='store_true',
|
||||
help='actually copy files (default is audit only)')
|
||||
ap.add_argument('--scrub', action='store_true',
|
||||
help='with --apply, neutralise absolute-path literals in the copies')
|
||||
args = ap.parse_args()
|
||||
|
||||
if not os.path.isdir(args.src):
|
||||
print('error: source directory not found: %s' % args.src)
|
||||
return 1
|
||||
|
||||
files = collect(args.src, args.pattern)
|
||||
if not files:
|
||||
print('error: no files matched %s in %s'
|
||||
% (', '.join(args.pattern), args.src))
|
||||
return 1
|
||||
|
||||
total_findings = 0
|
||||
scrubbed = 0
|
||||
copied = 0
|
||||
|
||||
for full, rel in files:
|
||||
findings = audit(full, rel)
|
||||
path_findings = [f for f in findings if f[0] == 'machine-path']
|
||||
cred_findings = [f for f in findings if f[0] == 'credential']
|
||||
other = [f for f in findings if f[0] not in ('machine-path', 'credential')]
|
||||
|
||||
if findings:
|
||||
print('-- %s' % rel)
|
||||
for kind, lineno, detail in path_findings + cred_findings + other:
|
||||
print(' [%s] line %d: %s' % (kind, lineno, detail))
|
||||
total_findings += len(findings)
|
||||
|
||||
if not args.apply:
|
||||
continue
|
||||
|
||||
target = os.path.join(args.dst, rel)
|
||||
os.makedirs(os.path.dirname(target) or '.', exist_ok=True)
|
||||
|
||||
if args.scrub and (path_findings or cred_findings):
|
||||
with open(full, encoding='utf-8', errors='replace') as fh:
|
||||
text = fh.read()
|
||||
new_text, count = scrub(text)
|
||||
if count:
|
||||
with open(target, 'w', encoding='utf-8', newline='\n') as fh:
|
||||
fh.write(new_text)
|
||||
scrubbed += count
|
||||
print(' -> copied with %d absolute-path literal(s) neutralised' % count)
|
||||
copied += 1
|
||||
continue
|
||||
|
||||
shutil.copy2(full, target)
|
||||
copied += 1
|
||||
|
||||
print('')
|
||||
if not args.apply:
|
||||
print('== audit: %d file(s) checked, %d finding(s). Nothing was written.'
|
||||
% (len(files), total_findings))
|
||||
print(' re-run with --apply to copy, and with --scrub to neutralise '
|
||||
'absolute-path literals.')
|
||||
return 2 if total_findings else 0
|
||||
|
||||
print('== sync: %d file(s) copied to %s (%d literal(s) scrubbed), %d finding(s) reported.'
|
||||
% (copied, args.dst, scrubbed, total_findings))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
372
vendor-new/apk-reverse/check_budget.py
Normal file
372
vendor-new/apk-reverse/check_budget.py
Normal file
@@ -0,0 +1,372 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Budget and shape gate for the apk-reverse skill: keep the always-loaded part from creeping,
|
||||
and keep the long parts navigable.
|
||||
|
||||
`check_repo.py` proves the repository is *consistent* (paths resolve, indexes are complete).
|
||||
This proves it is not *bloating*, and that a reader can find their way into a long file. Those are
|
||||
different failures, and the second has no natural counter-pressure: every pass adds a reference, an
|
||||
index row and a coverage claim, and nothing in the repository notices.
|
||||
|
||||
What is measured, and why each measure is shaped the way it is:
|
||||
|
||||
1. **The always-loaded part, in tokens rather than lines.** A skill's whole `SKILL.md` body loads
|
||||
into context when the skill activates (agentskills.io/specification, "Progressive disclosure"),
|
||||
so every line of it competes for attention -- **including the index tables**. An earlier version
|
||||
of this gate subtracted the index lines from the budget, which made the number look better than
|
||||
the context actually is: 150 index lines are still 150 lines the model reads. The budget is now
|
||||
measured on the whole file, reported in lines *and* estimated tokens, with the index share
|
||||
reported for context rather than credited against the budget.
|
||||
Line counts are kept because they are the number a human can check by hand; the token estimate
|
||||
is `characters / 4`, which is crude and stated as crude -- it is a trend indicator, not a
|
||||
measurement of a specific tokenizer.
|
||||
2. **Index row length.** The index tables are the largest part of `SKILL.md` and the easiest to
|
||||
grow by accident: each new entry is written by someone who knows the detail, and the detail
|
||||
lands in the row. An index row says *when to load the file*; the file says what is in it.
|
||||
3. **Restated conclusions** (reported as notes, never as failures). A conclusion legitimately
|
||||
appears in the benchmark matrix, in the evidence record, and in the coverage statement --
|
||||
those three exist to state results. The check flags a concept that has spread *beyond* those
|
||||
homes, because that is when a future correction has to hunt for every copy.
|
||||
4. **Reference discoverability.** Every reference must be reachable from SKILL.md or nothing will
|
||||
ever load it. (`check_repo.py` also enforces this; the duplication is deliberate, because a
|
||||
dead reference is invisible in a way a broken path is not.)
|
||||
5. **Long-file navigability.** A file over `NAV_LINES` that a reader cannot skim is a file they
|
||||
will not load. Each one must open with a navigable head -- a "what this answers" line and
|
||||
either a table of contents or a "when to load" line -- so the decision to read further can be
|
||||
made from the top. This is the check that keeps "file-level progressive disclosure" from
|
||||
becoming a claim the repository makes but does not implement.
|
||||
6. **Instruction strength** (a note, never a failure). The repository's rules are meant to be
|
||||
followed, not weighed: a rule phrased as "consider X" reads as optional and is skipped under
|
||||
load. This reports the ratio of hedging phrases to imperative ones as a *trend*, and lists the
|
||||
hedged lines it found, so a human can decide -- the gate cannot judge whether a given sentence
|
||||
should be optional.
|
||||
7. **Corpus size** (a note, never a failure). The checks above govern what every task *loads*; this
|
||||
reports what the repository *carries*. The first cannot see the second: a new reference costs
|
||||
exactly one index line, so the corpus can double while the always-loaded count stays green. A
|
||||
large corpus is a choice; a growth nobody measured is the defect.
|
||||
|
||||
Usage:
|
||||
python check_budget.py # report; exit 1 if a hard limit is exceeded
|
||||
python check_budget.py --strict # treat every warning as a failure
|
||||
python check_budget.py --json
|
||||
"""
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
SKILL_DIR = os.path.join(ROOT, 'skills', 'apk-reverse')
|
||||
SKILL = os.path.join(SKILL_DIR, 'SKILL.md')
|
||||
REFS = os.path.join(SKILL_DIR, 'references')
|
||||
|
||||
CONTENT_LINES_OK = 460 # always-loaded lines: soft ceiling, warns above this
|
||||
CONTENT_LINES_HARD = 560 # past this, something is structurally wrong
|
||||
# Token budget for the always-loaded body. Calibrated here rather than copied: the Agent Skills
|
||||
# spec recommends roughly 5,000 tokens of *instructions*, and this skill deliberately exceeds it,
|
||||
# because the two things it will not move out of the always-loaded body are the symptom index
|
||||
# (~54 rows; "symptom -> file" is the one lookup that must stay one hop) and the four gates with
|
||||
# their pass criteria. The budget therefore tracks whether the body is *growing past what the
|
||||
# design requires*, and the number is reviewed against a real tokenizer (see token_estimate).
|
||||
TOKEN_SOFT = 13000
|
||||
TOKEN_HARD = 16000
|
||||
INDEX_ROW_SOFT = 250 # chars of description per row
|
||||
INDEX_ROW_HARD = 400 # a row longer than this is a paragraph, not an index entry
|
||||
INDEXES = ('Symptom index', 'Reference index', 'Script index')
|
||||
NAV_LINES = 100 # above this, a reference must open navigably
|
||||
CORPUS_FILES_NOTE = 110 # references + scripts, reported as a note past this
|
||||
CORPUS_LINES_NOTE = 26000 # total lines across the same corpus, same treatment
|
||||
# Characters per token, for the no-tiktoken fallback. **Calibrated on SKILL.md against
|
||||
# tiktoken/cl100k_base** (47,629 chars -> 11,114 tokens = 4.285), so both measurement paths reach
|
||||
# the same verdict. Getting this wrong made the gate answer differently on CI than locally.
|
||||
TOKENS_PER_CHAR = 4.285
|
||||
# A navigable head: one of these must appear in the first NAV_HEAD_LINES lines.
|
||||
NAV_HEAD_LINES = 40
|
||||
HEDGE_RE = re.compile(
|
||||
r'\b(consider|considering|you may|you might|might want|optionally|prefer(?:ably)?|'
|
||||
r'it is worth|feel free|if you like|tends to be better)\b', re.I)
|
||||
|
||||
# Concepts that have been restated once already. Reported, not enforced -- see the docstring.
|
||||
RESTATED = {
|
||||
'trivial-body is bimodal': [r'bimodal'],
|
||||
'Java2C native density (2000x)': [r'82\.76', r'2,?000x'],
|
||||
'Stalker exclusion keeps the process alive': [r'keeps? the (?:target|process) alive'],
|
||||
'Dex-VMP coverage fixture (218/224)': [r'218 of 224', r'218/224'],
|
||||
'ezAndroid is JNI sinking, not a VMP': [r'JNI sinking'],
|
||||
'KernelSU userspace cannot change a syscall': [r'userspace module.{0,60}cannot change'],
|
||||
'protobuf proto3 explicit zero': [r'proto3.{0,40}(?:explicit )?zero'],
|
||||
'armv7 AOT strings are UTF-8, not UTF-16': [r'armv7.{0,80}UTF-8', r'UTF-8.{0,80}armv7'],
|
||||
}
|
||||
# Documents whose job is to state results; a concept appearing here is not drift.
|
||||
LEGITIMATE_HOMES = ('tests/benchmark.md', 'docs/tool-verification/')
|
||||
|
||||
|
||||
def read_lines(path):
|
||||
with open(path, encoding='utf-8') as fh:
|
||||
return fh.read().splitlines()
|
||||
|
||||
|
||||
def skill_lines():
|
||||
return read_lines(SKILL)
|
||||
|
||||
|
||||
def index_rows(lines):
|
||||
"""(index line count, [(section, first-cell, desc-length)] for over-long rows)."""
|
||||
n = 0
|
||||
long_rows = []
|
||||
cur = None
|
||||
for ln in lines:
|
||||
if ln.startswith('## '):
|
||||
cur = ln[3:].strip()
|
||||
continue
|
||||
if not (cur and any(cur.startswith(i) for i in INDEXES) and ln.startswith('|')):
|
||||
continue
|
||||
if set(ln) <= set('|-: '):
|
||||
continue
|
||||
cells = [c.strip() for c in ln.strip().strip('|').split('|')]
|
||||
if len(cells) < 2:
|
||||
continue
|
||||
n += 1
|
||||
if cells[0].lower() in ('file', 'script', 'what you observe', 'status'):
|
||||
continue
|
||||
desc = cells[-1]
|
||||
if len(desc) > INDEX_ROW_SOFT:
|
||||
long_rows.append((cur, cells[0][:44], len(desc)))
|
||||
return n, long_rows
|
||||
|
||||
|
||||
def token_estimate(text):
|
||||
"""Token count for the always-loaded body -- deterministic, not installation-dependent.
|
||||
|
||||
Uses `tiktoken` (cl100k_base) when it is available, because a real measurement beats an estimate
|
||||
everywhere in this repository, including in this file. When it is not, it falls back to
|
||||
`characters / TOKENS_PER_CHAR`.
|
||||
|
||||
The fallback constant is **calibrated against that measurement** rather than guessed: a
|
||||
markdown-heavy document tokenizes worse per character than the usual /4 rule of thumb, and this
|
||||
file measured 0.30 characters-to-tokens on SKILL.md (67,011 chars -> 19,929 tokens).
|
||||
|
||||
Why the value matters more than the method: the first version used 3.6 and the gate therefore
|
||||
reached *different verdicts on different machines* -- `WARN over budget` where tiktoken was
|
||||
absent (CI's 3.11 job, and any contributor's box) and `ok` where it was installed. A budget gate
|
||||
whose answer depends on what the runner happens to have installed is not a gate. Calibrating the
|
||||
fallback to the same ballpark makes the verdict agree, and the printed `how` field still says
|
||||
which number a reader is looking at.
|
||||
"""
|
||||
try:
|
||||
import tiktoken
|
||||
return len(tiktoken.get_encoding('cl100k_base').encode(text)), 'tiktoken/cl100k_base'
|
||||
except Exception:
|
||||
return int(len(text) / TOKENS_PER_CHAR), 'estimate chars/%.1f' % TOKENS_PER_CHAR
|
||||
|
||||
|
||||
def check_size(lines):
|
||||
n_idx, _ = index_rows(lines)
|
||||
content = len(lines)
|
||||
text = '\n'.join(lines)
|
||||
toks, how = token_estimate(text)
|
||||
pct_idx = (100.0 * n_idx / content) if content else 0.0
|
||||
|
||||
findings = []
|
||||
if content <= CONTENT_LINES_OK and toks <= TOKEN_SOFT:
|
||||
msg = ('SKILL.md %d lines (%d index = %.0f%% of the file, and it is still loaded: the budget '
|
||||
'is measured on the whole body), ~%d tokens (%s; soft %d)'
|
||||
% (content, n_idx, pct_idx, toks, how, TOKEN_SOFT))
|
||||
findings.append(('ok', msg))
|
||||
elif content <= CONTENT_LINES_HARD and toks <= TOKEN_HARD:
|
||||
findings.append((
|
||||
'warn',
|
||||
'SKILL.md is %d lines / ~%d tokens (%s), over the soft budget (%d lines / %d tokens) -- '
|
||||
'the always-loaded body is what the model reads on activation. Move detail into '
|
||||
'references/ and leave a pointer; %d of those lines are the index tables, which are '
|
||||
'loaded too. Total references: %d files.'
|
||||
% (content, toks, how, CONTENT_LINES_OK, TOKEN_SOFT, n_idx,
|
||||
len(glob.glob(os.path.join(REFS, '*.md'))))))
|
||||
else:
|
||||
findings.append((
|
||||
'fail',
|
||||
'SKILL.md is %d lines / ~%d tokens (%s), past the hard limit (%d lines / %d tokens)'
|
||||
% (content, toks, how, CONTENT_LINES_HARD, TOKEN_HARD)))
|
||||
return findings, n_idx, content, toks
|
||||
|
||||
|
||||
def check_index_rows(lines):
|
||||
_, long_rows = index_rows(lines)
|
||||
out = []
|
||||
for section, key, size in long_rows:
|
||||
hard = size > INDEX_ROW_HARD
|
||||
out.append(('warn' if hard else 'warn',
|
||||
'%s: row for %s is %d chars (soft limit %d)%s' %
|
||||
(section, key, size, INDEX_ROW_SOFT,
|
||||
' -- that is a paragraph, not an index entry' if hard else '')))
|
||||
return out
|
||||
|
||||
|
||||
def check_navigable():
|
||||
"""A long reference must be skimmable from its first screen.
|
||||
|
||||
The head is what lets a reader decide *not* to read the rest, which is the only thing that makes
|
||||
a 400-line reference cheaper than a 40-line one. Two shapes are accepted, because both are in
|
||||
use here: an explicit table of contents, or an explicit "when to load / what this answers" line.
|
||||
"""
|
||||
out = []
|
||||
for path in sorted(glob.glob(os.path.join(REFS, '*.md'))):
|
||||
lines = read_lines(path)
|
||||
if len(lines) < NAV_LINES:
|
||||
continue
|
||||
head = '\n'.join(lines[:NAV_HEAD_LINES]).lower()
|
||||
has_toc = bool(re.search(r'(?m)^#+ *(table of contents|contents)\s*$', head))
|
||||
has_when = ('when to load' in head or 'what this answers' in head
|
||||
or 'load this when' in head or 'when this applies' in head)
|
||||
if not (has_toc or has_when):
|
||||
out.append(('warn', 'references/%s is %d lines with neither a table of contents nor a '
|
||||
'"when to load"/"what this answers" line in its first %d lines -- '
|
||||
'a reader cannot decide from the top whether to load it'
|
||||
% (os.path.basename(path), len(lines), NAV_HEAD_LINES)))
|
||||
return out
|
||||
|
||||
|
||||
def check_hedging():
|
||||
"""Report hedged instruction lines; never fail on them.
|
||||
|
||||
A rule that reads as optional gets skipped when the context is under load. This cannot be
|
||||
decided by regex -- some sentences *should* be optional -- so it reports the lines and a ratio,
|
||||
and a human decides. The ratio is the trend: if it climbs, the rules are getting softer.
|
||||
"""
|
||||
lines = skill_lines()
|
||||
body = [ln for ln in lines
|
||||
if ln.strip() and not ln.startswith('|') and not ln.startswith('#')
|
||||
and not ln.startswith('---')]
|
||||
hedged = [(i + 1, ln.strip()) for i, ln in enumerate(lines) if HEDGE_RE.search(ln)]
|
||||
ratio = (len(hedged) / len(body)) if body else 0.0
|
||||
notes = []
|
||||
if hedged:
|
||||
notes.append('hedged instruction lines: %d of %d prose lines (%.1f%%; report-only -- a '
|
||||
'hedged rule reads as optional). First few: %s'
|
||||
% (len(hedged), len(body), 100.0 * ratio,
|
||||
'; '.join('L%d %s' % (n, t[:52]) for n, t in hedged[:3])))
|
||||
return notes
|
||||
|
||||
|
||||
def check_restated():
|
||||
notes = []
|
||||
files = (glob.glob(os.path.join(REFS, '*.md')) +
|
||||
glob.glob(os.path.join(REFS, '*', '*.md')) +
|
||||
glob.glob(os.path.join(ROOT, 'docs', 'tool-verification', '*.md')) +
|
||||
[SKILL, os.path.join(ROOT, 'README.md'), os.path.join(ROOT, 'tests', 'benchmark.md')])
|
||||
corpus = {}
|
||||
for f in files:
|
||||
if os.path.isfile(f):
|
||||
try:
|
||||
rel = os.path.relpath(f, ROOT).replace('\\', '/')
|
||||
corpus[rel] = open(f, encoding='utf-8').read()
|
||||
except OSError:
|
||||
pass
|
||||
for label, pats in RESTATED.items():
|
||||
hits = [p for p, t in corpus.items() if any(re.search(x, t, re.I) for x in pats)]
|
||||
# files whose job is to state results do not count as drift
|
||||
drift = [h for h in hits if not any(h.startswith(p) for p in LEGITIMATE_HOMES)]
|
||||
if len(drift) > 3:
|
||||
notes.append('restated in %d non-record files: %s -- %s' %
|
||||
(len(drift), label, ', '.join(sorted(d.split('/')[-1] for d in drift))))
|
||||
return notes
|
||||
|
||||
|
||||
def check_discoverable():
|
||||
"""Every reference must be reachable from the entry point or from the routing file it points to.
|
||||
|
||||
`SKILL.md` is the entry point; since this pass it carries the symptom index and delegates the
|
||||
inventory to `references/routing.md` (which `check_routing.py` in turn proves names every
|
||||
reference and every script). Counting the routing file here is what keeps the delegation honest
|
||||
instead of letting a file become unreachable the moment its index row moves.
|
||||
"""
|
||||
body = open(SKILL, encoding='utf-8').read()
|
||||
routing = os.path.join(REFS, 'routing.md')
|
||||
if os.path.isfile(routing):
|
||||
body += open(routing, encoding='utf-8').read()
|
||||
out = []
|
||||
for f in sorted(glob.glob(os.path.join(REFS, '*.md'))):
|
||||
name = os.path.basename(f)
|
||||
if name not in body:
|
||||
out.append(('fail', 'references/%s is named neither in SKILL.md nor in '
|
||||
'references/routing.md -- nothing will load it' % name))
|
||||
return out
|
||||
|
||||
|
||||
def check_corpus():
|
||||
"""Report corpus size so growth is visible instead of discovered late.
|
||||
|
||||
Contributed on this repository while the checks above were being rewritten. Kept as its own
|
||||
function and its own note: it measures what the repository *carries*, which is a different
|
||||
quantity from what a task loads, and collapsing the two would let a growing corpus hide behind a
|
||||
green always-loaded count.
|
||||
"""
|
||||
refs = sorted(glob.glob(os.path.join(REFS, '**', '*.md'), recursive=True))
|
||||
scripts = sorted(p for p in glob.glob(os.path.join(SKILL_DIR, 'scripts', '**', '*'), recursive=True)
|
||||
if os.path.isfile(p))
|
||||
|
||||
def count(paths):
|
||||
total = 0
|
||||
for p in paths:
|
||||
try:
|
||||
with open(p, encoding='utf-8', errors='replace') as fh:
|
||||
total += sum(1 for _ in fh)
|
||||
except OSError:
|
||||
pass
|
||||
return total
|
||||
|
||||
ref_lines, script_lines = count(refs), count(scripts)
|
||||
n_files = len(refs) + len(scripts)
|
||||
lines_total = ref_lines + script_lines
|
||||
|
||||
notes = ['corpus: %d references + %d scripts = %d files, %d lines total'
|
||||
% (len(refs), len(scripts), n_files, lines_total)]
|
||||
if n_files > CORPUS_FILES_NOTE:
|
||||
notes.append('corpus is %d files, past the %d-file note threshold -- every entry costs an '
|
||||
'index line and a share of the maintenance surface, so confirm the new ones are '
|
||||
'load-bearing rather than merely adjacent' % (n_files, CORPUS_FILES_NOTE))
|
||||
if lines_total > CORPUS_LINES_NOTE:
|
||||
notes.append('corpus is %d lines, past the %d-line note threshold'
|
||||
% (lines_total, CORPUS_LINES_NOTE))
|
||||
return notes
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument('--strict', action='store_true', help='treat warnings as failures')
|
||||
ap.add_argument('--json', action='store_true')
|
||||
args = ap.parse_args()
|
||||
|
||||
lines = skill_lines()
|
||||
findings, n_idx, content, toks = check_size(lines)
|
||||
findings += check_index_rows(lines)
|
||||
findings += check_discoverable()
|
||||
findings += check_navigable()
|
||||
notes = check_restated() + check_hedging() + check_corpus()
|
||||
|
||||
if args.json:
|
||||
print(json.dumps({
|
||||
'findings': [{'level': ln, 'message': m} for ln, m in findings],
|
||||
'notes': notes,
|
||||
'index_lines': n_idx,
|
||||
'total_lines': content,
|
||||
'token_estimate': toks,
|
||||
}, indent=2))
|
||||
else:
|
||||
for ln, m in findings:
|
||||
print('%s %s' % ({'ok': ' ok ', 'warn': ' WARN ', 'fail': ' FAIL '}[ln], m))
|
||||
for m in notes:
|
||||
print(' note %s' % m)
|
||||
n_fail = sum(1 for ln, _ in findings if ln == 'fail')
|
||||
n_warn = sum(1 for ln, _ in findings if ln == 'warn')
|
||||
print()
|
||||
print('== budget: %d failure(s), %d warning(s), %d note(s) ==' % (n_fail, n_warn, len(notes)))
|
||||
|
||||
n_bad = sum(1 for ln, _ in findings if ln == 'fail' or (args.strict and ln == 'warn'))
|
||||
return 1 if n_bad else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
1123
vendor-new/apk-reverse/check_commands.py
Normal file
1123
vendor-new/apk-reverse/check_commands.py
Normal file
File diff suppressed because it is too large
Load Diff
252
vendor-new/apk-reverse/check_refs.py
Normal file
252
vendor-new/apk-reverse/check_refs.py
Normal file
@@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Audit cross-references of the form `file.md` §section, and `file.md` P12.
|
||||
|
||||
A reference like `references/toolchain.md` §"not on PATH" is a promise that the
|
||||
target file has a section by that name. When a section is renamed or removed the
|
||||
promise silently breaks: the reader arrives at a file that does not contain the
|
||||
thing they were sent for, and the most likely outcome is that they conclude the
|
||||
material is missing rather than that the link is stale. Prose links do not fail
|
||||
loudly the way a missing file does, which is why this script exists separately
|
||||
from `check_repo.py` (that one only proves the *file* resolves).
|
||||
|
||||
A section reference is reported as:
|
||||
* OK -- normalised, it matches a heading (or bold numbered rule) in the target
|
||||
* WARN -- its words appear in the target body, but no heading carries them;
|
||||
usually a stale or paraphrased link, worth a human look
|
||||
* FAIL -- nothing in the target file corresponds; the link is dangling
|
||||
|
||||
Layout is discovered the same way as in `check_repo.py`, so a second skill under
|
||||
skills/ is audited with no change here.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
SKILLS_DIR = os.path.join(ROOT, 'skills')
|
||||
|
||||
# A section reference. The optional leading file may be separated from the
|
||||
# section mark by the closing backtick and a line break, since references are
|
||||
# often wrapped mid-sentence.
|
||||
REF_RE = re.compile(
|
||||
r'(?:(?P<file>[A-Za-z0-9_\-]+\.md)`?[\s`]{0,40})?'
|
||||
r'§\s*(?P<sec>[^\n|`();,.\u2014\u3002]{2,90})')
|
||||
# A failure-catalogue reference, e.g. `pitfalls.md` P31, or a bare (P18).
|
||||
PITFALL_RE = re.compile(
|
||||
r'(?:(?P<file>[A-Za-z0-9_\-]+\.md)`?[\s`]{0,40})?'
|
||||
r'\(?\bP(?P<num>\d{1,2})\b')
|
||||
|
||||
HEADING_RE = re.compile(r'^#{1,6}\s+(?P<title>.+?)\s*$')
|
||||
BOLD_RE = re.compile(r'^\*\*(?P<title>.+?)\*\*\s*$')
|
||||
|
||||
|
||||
def norm(text):
|
||||
"""Fold a heading or a reference into comparable tokens.
|
||||
|
||||
Hyphens and slashes become spaces so that `finding-the-call-site` matches
|
||||
"Finding the call site", and quotes/emphasis are dropped so that
|
||||
`§"not on PATH"` matches a heading containing the same words quoted.
|
||||
"""
|
||||
text = text.lower()
|
||||
text = text.replace('\u2014', ' ').replace('\u2013', ' ')
|
||||
text = re.sub(r'[`*_"\'()\[\]]', '', text)
|
||||
text = re.sub(r'[^a-z0-9]+', ' ', text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def discover_skills():
|
||||
"""Return [(name, skill_dir)] for each skill root, shallow shadowing deep."""
|
||||
found = []
|
||||
if not os.path.isdir(SKILLS_DIR):
|
||||
return found
|
||||
for dirpath, dirnames, filenames in os.walk(SKILLS_DIR):
|
||||
dirnames[:] = sorted(d for d in dirnames if not d.startswith('.'))
|
||||
rel = os.path.relpath(dirpath, SKILLS_DIR)
|
||||
depth = 0 if rel == '.' else rel.count(os.sep) + 1
|
||||
if depth > 3:
|
||||
dirnames[:] = []
|
||||
continue
|
||||
if 'SKILL.md' in filenames:
|
||||
found.append((rel.replace(os.sep, '/'), dirpath))
|
||||
dirnames[:] = []
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def discover_docs():
|
||||
"""Return [(path, skill_name_or_None, text)] for every markdown document."""
|
||||
docs = []
|
||||
readme = os.path.join(ROOT, 'README.md')
|
||||
if os.path.exists(readme):
|
||||
docs.append((readme, None, open(readme, encoding='utf-8').read()))
|
||||
for skill, skill_dir in discover_skills():
|
||||
for dirpath, dirnames, filenames in os.walk(skill_dir):
|
||||
dirnames[:] = sorted(d for d in dirnames if not d.startswith('.'))
|
||||
for fname in sorted(filenames):
|
||||
if not fname.endswith('.md'):
|
||||
continue
|
||||
path = os.path.join(dirpath, fname)
|
||||
docs.append((path, skill, open(path, encoding='utf-8').read()))
|
||||
return docs
|
||||
|
||||
|
||||
def anchors(text):
|
||||
"""Headings, and bold stand-alone lines (`**2a. ...**`), as match targets."""
|
||||
out = []
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
m = HEADING_RE.match(line) or BOLD_RE.match(line)
|
||||
if m:
|
||||
out.append(m.group('title'))
|
||||
return out
|
||||
|
||||
|
||||
def matches(sec, anchor_list):
|
||||
want = norm(sec)
|
||||
if not want:
|
||||
return True
|
||||
want_tokens = want.split()
|
||||
for raw in anchor_list:
|
||||
have_tokens = norm(raw).split()
|
||||
if not have_tokens:
|
||||
continue
|
||||
# A numbered heading (`## 3. Reading AOT code`) carries the number as a
|
||||
# token, but a reference may name the section with or without it, so
|
||||
# both readings are candidates.
|
||||
candidates = [have_tokens]
|
||||
if have_tokens[0].isdigit() and len(have_tokens) > 1:
|
||||
candidates.append(have_tokens[1:])
|
||||
for have in candidates:
|
||||
text = ' '.join(have)
|
||||
if text == want or text.startswith(want + ' ') \
|
||||
or want.startswith(text + ' '):
|
||||
return True
|
||||
if all(tok in have for tok in want_tokens):
|
||||
return True
|
||||
# A reference frequently runs on into the sentence that follows it
|
||||
# (`§Gates are cleared here or not at all`), leaving prose attached
|
||||
# to the heading name. Sharing the leading word is the strongest
|
||||
# signal available in that shape.
|
||||
if have[0] == want_tokens[0]:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def first_token_match(sec, anchor_list):
|
||||
"""True when some heading starts with the same word as the reference.
|
||||
|
||||
A reference is often a heading plus the rest of the sentence
|
||||
(`§Gates are cleared here or not at all`), so an exact match is too strict
|
||||
to be the only accepted shape. This is deliberately weaker than matches()
|
||||
and is reported as a warning, not as a pass.
|
||||
"""
|
||||
want = norm(sec).split()
|
||||
if not want:
|
||||
return False
|
||||
for raw in anchor_list:
|
||||
have = norm(raw).split()
|
||||
if have and have[0] == want[0]:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def in_body(sec, text):
|
||||
"""Weaker fallback: every token of the section name occurs in the body."""
|
||||
want_tokens = norm(sec).split()
|
||||
if not want_tokens:
|
||||
return True
|
||||
body = norm(text)
|
||||
return all(tok in body for tok in want_tokens)
|
||||
|
||||
|
||||
def main():
|
||||
docs = discover_docs()
|
||||
by_name = {}
|
||||
for path, skill, text in docs:
|
||||
by_name.setdefault(os.path.basename(path), []).append((path, skill, text))
|
||||
|
||||
ok = warn = 0
|
||||
problems = []
|
||||
warnings = []
|
||||
|
||||
for path, skill, text in docs:
|
||||
where = os.path.relpath(path, ROOT).replace(os.sep, '/')
|
||||
for m in REF_RE.finditer(text):
|
||||
raw_sec = m.group('sec').strip().strip('.,;:!?\'")*').lower()
|
||||
# `§7 gives the exact framing` -- keep only the number.
|
||||
num = re.match(r'(\d{1,2}[a-z]?)\b', raw_sec)
|
||||
sec = num.group(1) if num else raw_sec
|
||||
if not sec:
|
||||
continue
|
||||
# `§8C` names sub-item C of section 8, so the bare section number is
|
||||
# an acceptable fallback target.
|
||||
variants = [sec]
|
||||
sub = re.fullmatch(r'(\d{1,2})[a-z]', sec)
|
||||
if sub:
|
||||
variants.append(sub.group(1))
|
||||
|
||||
target_name = m.group('file')
|
||||
if target_name is None:
|
||||
# `target_list` is kept as part of the pair this branch assigns so both arms
|
||||
# of the branch below produce the same two names; only `target_text` is read.
|
||||
target_text, target_list = text, [path] # noqa: F841
|
||||
target_label = where
|
||||
else:
|
||||
candidates = [c for c in by_name.get(target_name, [])
|
||||
if skill is None or c[1] == skill] \
|
||||
or by_name.get(target_name, [])
|
||||
if not candidates:
|
||||
problems.append('%s: §%s -> %s (no such file)'
|
||||
% (where, sec, target_name))
|
||||
continue
|
||||
target_text = candidates[0][2]
|
||||
target_label = os.path.relpath(candidates[0][0], ROOT).replace(os.sep, '/')
|
||||
|
||||
if any(matches(v, anchors(target_text)) for v in variants):
|
||||
ok += 1
|
||||
elif len(norm(sec).split()) >= 2 and first_token_match(
|
||||
sec, anchors(target_text)):
|
||||
warn += 1
|
||||
warnings.append('%s: §%s -> %s (heading starts with this word '
|
||||
'but the wording differs)'
|
||||
% (where, sec, target_label))
|
||||
elif in_body(sec, target_text):
|
||||
warn += 1
|
||||
warnings.append('%s: §%s -> %s (words present, no matching heading)'
|
||||
% (where, sec, target_label))
|
||||
else:
|
||||
problems.append('%s: §%s -> %s (section not found)'
|
||||
% (where, sec, target_label))
|
||||
|
||||
# Failure-catalogue references: `pitfalls.md` P31, or a bare (P18).
|
||||
pit_anchors = set()
|
||||
for name, entries in by_name.items():
|
||||
for path, _skill, text in entries:
|
||||
for raw in anchors(text):
|
||||
m = re.match(r'P(\d{1,2})[.\s]', raw)
|
||||
if m:
|
||||
pit_anchors.add(m.group(1))
|
||||
for path, _skill, text in docs:
|
||||
where = os.path.relpath(path, ROOT).replace(os.sep, '/')
|
||||
for m in PITFALL_RE.finditer(text):
|
||||
if m.group('num') not in pit_anchors:
|
||||
problems.append('%s: P%s (no such pitfall)' % (where, m.group('num')))
|
||||
else:
|
||||
ok += 1
|
||||
|
||||
print('== markdown documents ==')
|
||||
for path, _skill, _text in docs:
|
||||
print(' %s' % os.path.relpath(path, ROOT).replace(os.sep, '/'))
|
||||
|
||||
print('\n== section references: %d reachable ==' % ok)
|
||||
for w in sorted(set(warnings)):
|
||||
print(' WARN %s' % w)
|
||||
for p in sorted(set(problems)):
|
||||
print(' FAIL %s' % p)
|
||||
|
||||
print('\n== result: %d dangling, %d warning(s) =='
|
||||
% (len(set(problems)), len(set(warnings))))
|
||||
return 1 if problems else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
356
vendor-new/apk-reverse/check_repo.py
Normal file
356
vendor-new/apk-reverse/check_repo.py
Normal file
@@ -0,0 +1,356 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Pre-commit checks for this repository.
|
||||
|
||||
Assumed layout -- the one the `skills` CLI resolves:
|
||||
|
||||
README.md repository-level docs (root)
|
||||
skills/<skill-name>/SKILL.md the skill itself
|
||||
skills/<skill-name>/references/*.md loaded on demand
|
||||
skills/<skill-name>/scripts/* run, not read
|
||||
|
||||
Every skill under `skills/` is discovered automatically (up to three levels;
|
||||
a SKILL.md at a shallower level shadows anything nested below it, which is the
|
||||
CLI's own rule), so adding a second skill requires no change to this script.
|
||||
|
||||
Per skill:
|
||||
1. frontmatter exists, `name` matches the directory name, `description` <= 1024
|
||||
2. every scripts/*.py parses and answers --help without a traceback
|
||||
3. every `references/X.md` / `scripts/X.py` reference resolves inside that skill
|
||||
4. every reference file is reachable from that skill's SKILL.md or the README
|
||||
5. every script is mentioned somewhere in the docs
|
||||
|
||||
On the root README:
|
||||
6. every path it names is an explicit `skills/<name>/...` path that exists
|
||||
"""
|
||||
import ast
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError: # the skills CLI is the real authority; this is a local gate
|
||||
yaml = None
|
||||
|
||||
ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
SKILLS_DIR = os.path.join(ROOT, 'skills')
|
||||
README = os.path.join(ROOT, 'README.md')
|
||||
|
||||
REF_RE = re.compile(r'`?(references/[A-Za-z0-9_\-]+\.md)')
|
||||
SCRIPT_RE = re.compile(r'`?(scripts/[A-Za-z0-9_\-]+\.(?:py|js))')
|
||||
NAMED_PATH_RE = re.compile(
|
||||
r'skills/(?P<skill>[a-z0-9\-]+)/(?P<sub>references|scripts)/'
|
||||
r'(?P<file>[A-Za-z0-9_\-]+\.(?:md|py|js))')
|
||||
BARE_PATH_RE = re.compile(r'(?<![\w/])(?P<sub>references|scripts)/'
|
||||
r'(?P<file>[A-Za-z0-9_\-]+\.(?:md|py|js))')
|
||||
|
||||
fail: list[str] = []
|
||||
|
||||
|
||||
def read(path):
|
||||
with open(path, encoding='utf-8') as fh:
|
||||
return fh.read()
|
||||
|
||||
|
||||
def discover_skills():
|
||||
"""Return [(name, skill_dir)] for every skills/<...>/SKILL.md."""
|
||||
found = []
|
||||
if not os.path.isdir(SKILLS_DIR):
|
||||
return found
|
||||
for dirpath, dirnames, filenames in os.walk(SKILLS_DIR):
|
||||
dirnames[:] = sorted(d for d in dirnames if not d.startswith('.'))
|
||||
rel = os.path.relpath(dirpath, SKILLS_DIR)
|
||||
depth = 0 if rel == '.' else rel.count(os.sep) + 1
|
||||
if depth > 3:
|
||||
dirnames[:] = []
|
||||
continue
|
||||
if 'SKILL.md' in filenames:
|
||||
found.append((rel.replace(os.sep, '/'), dirpath))
|
||||
dirnames[:] = [] # shallower SKILL.md shadows what is below
|
||||
return sorted(found)
|
||||
|
||||
|
||||
NAME_RE = re.compile(r'^[a-z0-9]+(?:-[a-z0-9]+)*$')
|
||||
|
||||
|
||||
def check_frontmatter(name, skill_dir):
|
||||
"""Validate the frontmatter the way the CLI will, not merely its presence.
|
||||
|
||||
This has to be a real parse. Frontmatter that satisfies a regex can still be
|
||||
rejected by a YAML parser -- an unquoted `description` containing ": " does
|
||||
it, because the colon starts a nested mapping -- and the `skills` CLI then
|
||||
reports "No skills found" and installs nothing at all.
|
||||
"""
|
||||
path = os.path.join(skill_dir, 'SKILL.md')
|
||||
text = read(path)
|
||||
m = re.match(r'^---\r?\n(.*?)\r?\n---\r?\n', text, re.S)
|
||||
if not m:
|
||||
fail.append('%s: no YAML frontmatter block' % path)
|
||||
return
|
||||
raw = m.group(1)
|
||||
|
||||
if yaml is None:
|
||||
print(' WARN PyYAML is not installed, so the frontmatter was not parsed')
|
||||
line = re.search(r'^description:(.*)$', raw, re.M)
|
||||
if line:
|
||||
value = line.group(1)
|
||||
if value[:1] not in ('"', "'", '>', '|') and ': ' in value:
|
||||
fail.append('%s: unquoted description contains ": ", which a YAML '
|
||||
'parser rejects; quote the value' % path)
|
||||
return
|
||||
|
||||
try:
|
||||
data = yaml.safe_load(raw)
|
||||
except yaml.YAMLError as exc:
|
||||
detail = str(exc).splitlines()[0]
|
||||
fail.append('%s: frontmatter is not valid YAML (%s); the skills CLI '
|
||||
'skips a skill whose frontmatter will not parse'
|
||||
% (path, detail))
|
||||
return
|
||||
if not isinstance(data, dict):
|
||||
fail.append('%s: frontmatter is not a YAML mapping' % path)
|
||||
return
|
||||
|
||||
got = data.get('name')
|
||||
if not got:
|
||||
fail.append('%s: frontmatter has no `name`' % path)
|
||||
elif str(got) != name:
|
||||
fail.append('%s: frontmatter name %r does not match directory %r'
|
||||
% (path, got, name))
|
||||
elif not NAME_RE.match(str(got)) or len(str(got)) > 64:
|
||||
fail.append('%s: name %r violates the spec (lowercase alphanumerics and '
|
||||
'single hyphens, max 64 chars)' % (path, got))
|
||||
|
||||
desc = data.get('description')
|
||||
if not desc:
|
||||
fail.append('%s: frontmatter has no `description`' % path)
|
||||
else:
|
||||
desc = str(desc)
|
||||
if len(desc) > 1024:
|
||||
fail.append('%s: description is %d chars (spec max 1024)'
|
||||
% (path, len(desc)))
|
||||
elif len(desc) < 40:
|
||||
fail.append('%s: description is only %d chars, too short to tell an '
|
||||
'agent when this skill applies' % (path, len(desc)))
|
||||
|
||||
|
||||
def check_scripts(skill_dir):
|
||||
scripts = os.path.join(skill_dir, 'scripts')
|
||||
if not os.path.isdir(scripts):
|
||||
return
|
||||
for name in sorted(os.listdir(scripts)):
|
||||
if not name.endswith('.py'):
|
||||
continue
|
||||
path = os.path.join(scripts, name)
|
||||
try:
|
||||
ast.parse(read(path))
|
||||
except SyntaxError as exc:
|
||||
fail.append('%s: syntax error %s' % (path, exc))
|
||||
print(' FAIL %s' % name)
|
||||
continue
|
||||
run = subprocess.run([sys.executable, '-B', path, '--help'],
|
||||
capture_output=True, timeout=60, cwd=ROOT,
|
||||
# Decode explicitly. `text=True` alone uses the *locale* encoding, which
|
||||
# on a Chinese Windows console is GBK: a script printing an em dash in a
|
||||
# usage line then fails to decode, and this gate reports it as a crash.
|
||||
# That is a defect in the gate, not in the script -- measured on this
|
||||
# repository, where `dex_strpatch.py --help` was reported as crashed.
|
||||
encoding='utf-8', errors='replace')
|
||||
# A script may exit non-zero on --help under the "print usage, exit 2"
|
||||
# convention. That is acceptable; a traceback is not.
|
||||
crashed = 'Traceback (most recent call last)' in (run.stderr or '')
|
||||
helped = bool((run.stdout or '').strip()) or bool(run.stderr)
|
||||
ok = (not crashed) and helped
|
||||
verdict = 'ok' if run.returncode == 0 and not crashed else \
|
||||
('usage-ok rc=%d' % run.returncode if ok else
|
||||
'CRASH rc=%d' % run.returncode)
|
||||
print(' %-32s %s' % (name, verdict))
|
||||
if not ok:
|
||||
fail.append('%s --help crashed: %s' % (path, (run.stderr or '')[:300]))
|
||||
|
||||
|
||||
def check_references(name, skill_dir, root_docs):
|
||||
"""Resolve in-skill references, and confirm every file is reachable."""
|
||||
ref_dir = os.path.join(skill_dir, 'references')
|
||||
script_dir = os.path.join(skill_dir, 'scripts')
|
||||
refs = set(os.listdir(ref_dir)) if os.path.isdir(ref_dir) else set()
|
||||
scripts = set(os.listdir(script_dir)) if os.path.isdir(script_dir) else set()
|
||||
|
||||
# Documents whose relative paths are rooted at the skill directory.
|
||||
own = [os.path.join(skill_dir, 'SKILL.md')]
|
||||
own += [os.path.join(ref_dir, f) for f in sorted(refs) if f.endswith('.md')]
|
||||
|
||||
missing = []
|
||||
for doc in own:
|
||||
if not os.path.exists(doc):
|
||||
continue
|
||||
text = read(doc)
|
||||
for m in REF_RE.finditer(text):
|
||||
if m.group(1).split('/')[1] not in refs:
|
||||
missing.append('%s -> %s' % (os.path.relpath(doc, ROOT), m.group(1)))
|
||||
for m in SCRIPT_RE.finditer(text):
|
||||
if m.group(1).split('/')[1] not in scripts:
|
||||
missing.append('%s -> %s' % (os.path.relpath(doc, ROOT), m.group(1)))
|
||||
if missing:
|
||||
for x in sorted(set(missing)):
|
||||
print(' MISSING %s' % x)
|
||||
fail.extend(missing)
|
||||
|
||||
# Reachability: nothing may be orphaned from the entry point.
|
||||
#
|
||||
# The registration surface is SKILL.md **plus** `references/routing.md`, because this repository
|
||||
# splits the two: SKILL.md keeps the symptom index (symptom -> file must stay one hop) and the
|
||||
# reference/script inventory lives in the routing file, which `check_routing.py` in turn proves
|
||||
# names every reference and every script. Reading only SKILL.md here would report a correctly
|
||||
# registered file as orphaned -- and did, for every file added after that split.
|
||||
routing = os.path.join(ref_dir, 'routing.md')
|
||||
bodies = read(os.path.join(skill_dir, 'SKILL.md')) + root_docs
|
||||
if os.path.isfile(routing):
|
||||
bodies += read(routing)
|
||||
for f in sorted(refs):
|
||||
if f.endswith('.md') and f not in bodies:
|
||||
fail.append('%s: references/%s is named neither in SKILL.md, README.md nor '
|
||||
'references/routing.md' % (name, f))
|
||||
print(' UNLISTED references/%s' % f)
|
||||
|
||||
all_docs = bodies
|
||||
for f in sorted(refs):
|
||||
if f.endswith('.md'):
|
||||
all_docs += read(os.path.join(ref_dir, f))
|
||||
for sub in ('', 'coverage', 'precedents'):
|
||||
d = os.path.join(ref_dir, sub) if sub else ref_dir
|
||||
if os.path.isdir(d):
|
||||
for f in sorted(os.listdir(d)):
|
||||
if f.endswith('.md') and os.path.isfile(os.path.join(d, f)):
|
||||
all_docs += read(os.path.join(d, f))
|
||||
for s in sorted(scripts):
|
||||
if s.endswith(('.py', '.js')) and s not in all_docs:
|
||||
fail.append('%s: scripts/%s is undocumented' % (name, s))
|
||||
print(' UNDOCUMENTED scripts/%s' % s)
|
||||
|
||||
|
||||
def check_readme():
|
||||
"""README paths must be explicit and must exist."""
|
||||
text = read(README)
|
||||
if not os.path.isdir(SKILLS_DIR):
|
||||
fail.append('no skills/ directory at the repository root')
|
||||
return
|
||||
skills = {d for d, _ in discover_skills()}
|
||||
named = set()
|
||||
for m in NAMED_PATH_RE.finditer(text):
|
||||
named.add(m.group(0))
|
||||
skill, sub, fname = m.group('skill'), m.group('sub'), m.group('file')
|
||||
if skill not in skills:
|
||||
fail.append('README.md: names unknown skill %r' % skill)
|
||||
print(' UNKNOWN SKILL skills/%s' % skill)
|
||||
elif not os.path.exists(os.path.join(SKILLS_DIR, skill, sub, fname)):
|
||||
fail.append('README.md: %s does not exist' % m.group(0))
|
||||
print(' MISSING %s' % m.group(0))
|
||||
# Bare relative paths are ambiguous once there is more than one skill, so the
|
||||
# README must not use them at all.
|
||||
stripped = NAMED_PATH_RE.sub('', text)
|
||||
for m in BARE_PATH_RE.finditer(stripped):
|
||||
fail.append('README.md: bare path %r should be skills/<name>/%s'
|
||||
% (m.group(0), m.group(0)))
|
||||
print(' BARE PATH %s' % m.group(0))
|
||||
|
||||
|
||||
def check_leaks():
|
||||
"""No target identity on the committed surface.
|
||||
|
||||
Path resolution, anchors and layout are all *structural* checks: they pass on a file that names
|
||||
a live app and a real phone. This one reads content, and it is deliberately not reimplemented
|
||||
here -- `scripts/scan_leaks.py` owns the rules and their exemption list, so there is one place
|
||||
to argue with. The tracked-file list is passed in so a git-ignored work area (which may
|
||||
legitimately hold real identifiers) can never produce a finding.
|
||||
|
||||
A scanner error is reported as a note rather than a failure: an unreadable rule table is a
|
||||
broken tool, not a leak, and conflating the two teaches people to ignore this line.
|
||||
"""
|
||||
scanner = os.path.join(SKILLS_DIR, 'apk-reverse', 'scripts', 'scan_leaks.py')
|
||||
if not os.path.isfile(scanner):
|
||||
print(' SKIP no scan_leaks.py (the leak gate is not installed)')
|
||||
return
|
||||
import subprocess
|
||||
import tempfile
|
||||
proc = subprocess.run(['git', 'ls-files'], cwd=ROOT, capture_output=True,
|
||||
encoding='utf-8', errors='replace')
|
||||
if proc.returncode != 0 or not proc.stdout.strip():
|
||||
print(' SKIP git ls-files unavailable (not a checkout?)')
|
||||
return
|
||||
with tempfile.NamedTemporaryFile('w', suffix='.txt', delete=False, encoding='utf-8') as fh:
|
||||
fh.write(proc.stdout)
|
||||
listing = fh.name
|
||||
try:
|
||||
run = subprocess.run([sys.executable, scanner, '--root', ROOT,
|
||||
'--files-from', listing, '--max', '10'],
|
||||
capture_output=True, encoding='utf-8', errors='replace')
|
||||
finally:
|
||||
os.unlink(listing)
|
||||
token = (run.stdout or '').strip().splitlines()
|
||||
token = token[-1] if token else ''
|
||||
if run.returncode == 2 or 'RESULT=error' in token:
|
||||
print(' NOTE scan_leaks.py could not complete: %s' % (run.stderr or '').strip()[:160])
|
||||
return
|
||||
if run.returncode == 0:
|
||||
print(' no strong target identity on the tracked surface (%s)' % (token or 'RESULT=clean'))
|
||||
return
|
||||
# Non-zero: report the findings, which carry their own context. The scanner prints each hit as
|
||||
# "<file>:<line>:<col> [<category>/<strength>] <rule>", followed by its indented match/context
|
||||
# lines. The list below keeps only the strong hits -- a weak hit is the scanner's business, not
|
||||
# this gate's -- but a weak hit sitting between two strong ones may contribute its context pair,
|
||||
# because attributing an indented line to the hit above it is heuristic. The exit code is the
|
||||
# verdict; this block is for the reader.
|
||||
print(' LEAK scan_leaks.py reported strong findings on the tracked surface:')
|
||||
keep_detail = False
|
||||
for line in (run.stdout or '').splitlines():
|
||||
stripped = line.strip()
|
||||
if re.search(r'\[(package|device|token|appkey|path)/(strong|certain)\]', stripped):
|
||||
print(' %s' % stripped)
|
||||
keep_detail = True
|
||||
elif stripped.startswith('[') and stripped.count(']') >= 2:
|
||||
keep_detail = False
|
||||
elif keep_detail and (stripped.startswith('match:') or stripped.startswith('context:')):
|
||||
print(' %s' % stripped)
|
||||
fail.append('leak scan: strong target identity on the tracked surface '
|
||||
'(run: python skills/apk-reverse/scripts/scan_leaks.py)')
|
||||
|
||||
|
||||
def main():
|
||||
skills = discover_skills()
|
||||
print('== skills discovered ==')
|
||||
if not skills:
|
||||
print(' none (expected at least skills/<name>/SKILL.md)')
|
||||
for name, _ in skills:
|
||||
print(' skills/%s' % name)
|
||||
|
||||
print('\n== frontmatter ==')
|
||||
for name, skill_dir in skills:
|
||||
check_frontmatter(name, skill_dir)
|
||||
print(' %d checked' % len(skills))
|
||||
|
||||
root_docs = read(README)
|
||||
for name, skill_dir in skills:
|
||||
print('\n== %s: python syntax + --help ==' % name)
|
||||
check_scripts(skill_dir)
|
||||
print('\n== %s: reference and script paths resolve ==' % name)
|
||||
before = len(fail)
|
||||
check_references(name, skill_dir, root_docs)
|
||||
if len(fail) == before:
|
||||
print(' all referenced reference/script paths exist')
|
||||
|
||||
print('\n== README paths are explicit and exist ==')
|
||||
check_readme()
|
||||
|
||||
print('\n== tracked surface carries no target identity ==')
|
||||
check_leaks()
|
||||
|
||||
print('\n== result: %d problem(s) ==' % len(fail))
|
||||
for f in fail:
|
||||
print(' - %s' % f)
|
||||
return 1 if fail else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
178
vendor-new/apk-reverse/check_routing.py
Normal file
178
vendor-new/apk-reverse/check_routing.py
Normal file
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Keep `skills/apk-reverse/references/routing.md` coherent with `SKILL.md` and with the tree.
|
||||
|
||||
Why this exists: `SKILL.md` loads in full whenever this skill activates, so it carries only what has
|
||||
to be one hop there -- the symptom index (recognising a failure must not become a two-hop lookup) and
|
||||
the four gates with their pass criteria. The *inventory* lookups -- every reference file with when to
|
||||
load it, every script with what it does -- live in `references/routing.md` and load on demand.
|
||||
|
||||
That split creates one risk: two tables that used to be one. This script removes it by **comparing**
|
||||
the tables instead of generating one from the other:
|
||||
|
||||
* the **symptom index** has a single home, `SKILL.md`; `routing.md` keeps a mirror so a reader gets
|
||||
all three tables in one load, and `--check` fails when the mirror and the live table differ;
|
||||
* the **reference and script indexes** have a single home, `routing.md`;
|
||||
* every file under `references/` must be named in the routing reference table, and every script in
|
||||
`scripts/` must be named in the routing script table -- an inventory that has drifted from the
|
||||
directory is worse than no inventory, because it reads as complete.
|
||||
|
||||
Usage:
|
||||
python check_routing.py # report coherence; exit 1 on any problem
|
||||
python check_routing.py --json
|
||||
"""
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
SKILL_DIR = os.path.join(ROOT, 'skills', 'apk-reverse')
|
||||
SKILL = os.path.join(SKILL_DIR, 'SKILL.md')
|
||||
REFS = os.path.join(SKILL_DIR, 'references')
|
||||
SCRIPTS = os.path.join(SKILL_DIR, 'scripts')
|
||||
ROUTE = os.path.join(REFS, 'routing.md')
|
||||
|
||||
# A markdown table separator row: pipes, dashes, colons and spaces, and at least one dash. Tested on
|
||||
# the row's *cells*, not the whole line -- every data row also starts and ends with `|`, so any
|
||||
# whole-line pattern loose enough to accept a separator also accepts every data row, and the reverse
|
||||
# mistake drops every data row and reports an intact inventory as broken.
|
||||
|
||||
|
||||
def is_separator(row):
|
||||
cells = [c for c in row.strip().strip('|').split('|')]
|
||||
return bool(cells) and all(c.strip() and set(c.strip()) <= set('-:') for c in cells)
|
||||
|
||||
|
||||
# Headings are matched by prefix: they contain an em dash, and comparing against an ASCII hyphen
|
||||
# silently reports "table not found" instead of failing.
|
||||
PREFIXES = {
|
||||
'symptom': 'Symptom index',
|
||||
'reference': 'Reference index',
|
||||
'script': 'Script index',
|
||||
}
|
||||
|
||||
|
||||
def read(path):
|
||||
with open(path, encoding='utf-8') as fh:
|
||||
return fh.read()
|
||||
|
||||
|
||||
def tables(text):
|
||||
"""{heading: [data rows]} -- data rows exclude the header row.
|
||||
|
||||
The separator row is identified by its characters (`-`, `|`, `:`, spaces only). Do **not** use
|
||||
`set(row) > set('|-: ')` to decide this: set comparison is not a character test, and every row
|
||||
whose content happens to be a superset of those characters gets dropped -- which once classified
|
||||
43 of 53 symptom rows as "not a table row" and made the checker report the inventory as broken
|
||||
while it was intact. This file's own failure mode, found by a row count that did not add up.
|
||||
"""
|
||||
out = {}
|
||||
cur = None
|
||||
for ln in text.split('\n'):
|
||||
if ln.startswith('## '):
|
||||
cur = ln[3:].strip()
|
||||
out.setdefault(cur, [])
|
||||
elif cur is not None and ln.startswith('|'):
|
||||
out[cur].append(ln)
|
||||
result = {}
|
||||
for key, rows in out.items():
|
||||
data = [r for r in rows if not is_separator(r)]
|
||||
result[key] = data[1:] if data else [] # drop the header row
|
||||
return result
|
||||
|
||||
|
||||
def pick(sections, prefix):
|
||||
for key, rows in sections.items():
|
||||
if key.startswith(prefix):
|
||||
return rows
|
||||
return None
|
||||
|
||||
|
||||
def cell0(row):
|
||||
return row.strip().strip('|').split('|')[0].strip().strip('`* ').lower()
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument('--json', action='store_true')
|
||||
args = ap.parse_args()
|
||||
|
||||
problems = []
|
||||
if not os.path.isfile(ROUTE):
|
||||
print('FAIL references/routing.md is missing -- the inventory has no home')
|
||||
print('RESULT=missing')
|
||||
return 1
|
||||
|
||||
sk = tables(read(SKILL))
|
||||
rt = tables(read(ROUTE))
|
||||
|
||||
live_sym = pick(sk, PREFIXES['symptom']) or []
|
||||
mirror_sym = pick(rt, PREFIXES['symptom']) or []
|
||||
ref_rows = pick(rt, PREFIXES['reference'])
|
||||
scr_rows = pick(rt, PREFIXES['script'])
|
||||
|
||||
for name, rows in (('symptom (routing.md)', mirror_sym), ('reference', ref_rows),
|
||||
('script', scr_rows)):
|
||||
if rows is None:
|
||||
problems.append('routing.md has no %s table' % name)
|
||||
|
||||
live_set = {cell0(r) for r in live_sym}
|
||||
mirror_set = {cell0(r) for r in mirror_sym}
|
||||
if live_set - mirror_set:
|
||||
problems.append('routing.md symptom mirror is missing %d row(s) present in SKILL.md: %s'
|
||||
% (len(live_set - mirror_set), '; '.join(sorted(live_set - mirror_set))[:180]))
|
||||
if mirror_set - live_set:
|
||||
problems.append('routing.md symptom mirror has %d row(s) SKILL.md does not: %s'
|
||||
% (len(mirror_set - live_set), '; '.join(sorted(mirror_set - live_set))[:180]))
|
||||
|
||||
ref_text = ' '.join(r for r in (ref_rows or []))
|
||||
ref_files = sorted(os.path.basename(p) for p in glob.glob(os.path.join(REFS, '*.md')))
|
||||
unlisted = [f for f in ref_files if f not in ref_text]
|
||||
if unlisted:
|
||||
problems.append('routing.md reference table does not name %d reference file(s): %s'
|
||||
% (len(unlisted), ', '.join(unlisted)))
|
||||
|
||||
scr_text = ' '.join(r for r in (scr_rows or []))
|
||||
scr_files = sorted(f for f in os.listdir(SCRIPTS) if f.endswith(('.py', '.js')))
|
||||
unlisted_s = [f for f in scr_files if f not in scr_text]
|
||||
if unlisted_s:
|
||||
problems.append('routing.md script table does not name %d script(s): %s'
|
||||
% (len(unlisted_s), ', '.join(unlisted_s)))
|
||||
|
||||
report = {
|
||||
'skill_symptom_rows': len(live_sym),
|
||||
'routing_symptom_rows': len(mirror_sym),
|
||||
'routing_reference_rows': len(ref_rows or []),
|
||||
'routing_script_rows': len(scr_rows or []),
|
||||
'reference_files_in_tree': len(ref_files),
|
||||
'scripts_in_tree': len(scr_files),
|
||||
'problems': problems,
|
||||
'result': 'ok' if not problems else 'incoherent',
|
||||
}
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(report, indent=2, ensure_ascii=False))
|
||||
print('RESULT=%s' % report['result'])
|
||||
return 1 if problems else 0
|
||||
|
||||
print('routing coherence')
|
||||
print(' symptom index : SKILL.md %d rows / routing mirror %d rows'
|
||||
% (len(live_sym), len(mirror_sym)))
|
||||
print(' reference table: %d rows, covering %d reference files' % (len(ref_rows or []), len(ref_files)))
|
||||
print(' script table : %d rows, covering %d scripts' % (len(scr_rows or []), len(scr_files)))
|
||||
for p in problems:
|
||||
print(' FAIL %s' % p)
|
||||
print()
|
||||
if problems:
|
||||
print('== routing: %d problem(s) ==' % len(problems))
|
||||
print('RESULT=incoherent')
|
||||
return 1
|
||||
print('== routing: coherent ==')
|
||||
print('RESULT=ok')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,638 @@
|
||||
# Extension verification — benchmark B1/B2 against OWASP MASTG Crackmes
|
||||
|
||||
Covers the pass that turned the `tests/benchmark.md` rows **B1** (equal-length dex patch + repack +
|
||||
re-sign installability, gates G2/G4) and **B2** (anti-debug / self-destruct race) from `pending`
|
||||
into measured results.
|
||||
|
||||
Strength labels follow `docs/tool-verification/README.md`: **observed** (exact command and its
|
||||
output reproduced here), **inferred** (follows from observed facts, the step itself not executed),
|
||||
**unverified** (assumed or reported and not independently confirmed).
|
||||
|
||||
Reference environment: Windows 11 host, Python 3.14.0, host frida 16.7.19 (frida-tools 13.7.1),
|
||||
JDK 17.0.4.1 at `C:\Program Files\Java\jdk-17.0.4.1` (not on `PATH`), `apksigner.bat` /
|
||||
`zipalign.exe` at `E:\tools\bin`. Device: **`<DEVICE>`**, Android 11 / API 30, arm64-v8a, root via
|
||||
`su`, Magisk denylist `enforced` with an **empty** list (no app is root-hidden). Package under test:
|
||||
**`<PKG>`**.
|
||||
|
||||
All device operations ran under the shared device lock (`tools/_work/devlock.py`).
|
||||
|
||||
---
|
||||
|
||||
## 0. Headline
|
||||
|
||||
- **B1 is `observed` and positive.** An equal-length dex edit (2 bytes of a conditional branch
|
||||
replaced by `nop`s), through `dex_patch_bytes.py` → `repack.py --no-sign` → `zipalign` →
|
||||
`apksigner` → root `pm install`, installs and **demonstrably changes the app's behaviour on
|
||||
screen**: the original and a zero-change control both show a blocking `Root detected!` dialog, the
|
||||
patched build reaches `MainActivity` with the dialog gone.
|
||||
- **`repack.py`'s default signing path did not work when this pass started, and the blocker was not
|
||||
`apksigner`.** `apksigner`/`zipalign` are both present and both work; at that moment `repack.py`
|
||||
signed only through an `uber-apk-signer` jar, which is not installed. **The repository changed
|
||||
mid-pass** — another writer landed a `sign_with_apksigner()` route — and re-running the same
|
||||
command then produced a fully signed, `[result] OK` APK in one invocation with byte-identical
|
||||
output to the manual route (`sha256 ad6a51ce…7e56`). Both states are recorded below, because the
|
||||
stale state is what `tools/_work/ENV.md` predicted and the new state is what a reader will meet.
|
||||
This is also a reminder that a "the tool cannot do X" finding on a shared worktree has an expiry
|
||||
date.
|
||||
- **B2 is a split result.** The two scripts run and the probe writes its bytes (0.04–0.05 s after
|
||||
resume, gated on `PATCHED`), so the *tooling* is verified. But the **patch does not prevent the
|
||||
target's termination**: `libfoo.so` aborts through `goodbye()+12` at **3.22 s** after resume, and
|
||||
three patched sites including `goodbyev` itself do not change that. Neutralising the obvious death
|
||||
site is **not sufficient** on this target.
|
||||
- **A finding that changes the whole B2 setup:** on this device the **unmodified, original APK
|
||||
already reports `Rooting or tampering detected.`** and its tamper path runs before anything is
|
||||
attached. L3's native CRC self-check fails against the pristine community APK on Android 11 /
|
||||
arm64-v8a, so the "pristine baseline" the row assumed does not exist here.
|
||||
|
||||
---
|
||||
|
||||
## 1. Gate G2 and G4 — environment and control (`observed`)
|
||||
|
||||
`scripts/doctor.py`:
|
||||
|
||||
```
|
||||
platform : Windows 11 / AMD64
|
||||
python : 3.14.0
|
||||
[OK ] static (dex/zip/strings) [OK ] repack + sign
|
||||
[OK ] native ELF patching [-- ] jadx decompile
|
||||
[-- ] apktool unpack [OK ] device work
|
||||
[OK ] root on device [OK ] frida dynamic
|
||||
[OK ] java 17.0.4.1 [OK ] adb 1.0.41 [OK ] frida 16.7.19
|
||||
[-- ] apktool not on PATH
|
||||
[-- ] unzip not on PATH
|
||||
java jars found near this skill : (none)
|
||||
device : <DEVICE> arm64-v8a Android 11 (sdk 30) root: yes
|
||||
clock skew: 3.06 s
|
||||
```
|
||||
|
||||
The `(none)` on the jar line matters for B1: no `baksmali`/`smali`/`uber-apk-signer` jar exists, so
|
||||
every signing route that goes through a jar is unavailable before a single command is run.
|
||||
|
||||
**Keystore and tool locations** (recorded because `repack.py` resolves both off-`PATH`):
|
||||
|
||||
```
|
||||
keytool : C:\Program Files\Java\jdk-17.0.4.1\bin\keytool.exe
|
||||
jarsigner : C:\Program Files\Java\jdk-17.0.4.1\bin\jarsigner.exe
|
||||
apksigner : E:\tools\bin\apksigner.bat
|
||||
zipalign : E:\tools\bin\zipalign.exe
|
||||
```
|
||||
|
||||
`repack.py`'s `sibling_tool()` cannot find `keytool` from `C:\Program Files\Common Files\Oracle\Java\javapath\java.exe`,
|
||||
because that directory holds only forwarders. Passing `--keytool` explicitly is required.
|
||||
|
||||
---
|
||||
|
||||
## 2. B1 — L1 equal-length dex patch, full chain (`observed`)
|
||||
|
||||
### 2.1 Source facts
|
||||
|
||||
```
|
||||
$ python -c "... load_dex('UnCrackable-Level1.apk') ..."
|
||||
classes.dex 5528 bytes
|
||||
magic dex\n035\0
|
||||
checksum field 7fb7d9fa
|
||||
adler32(d[12:]) 7fb7d9fa -> matches
|
||||
signature field b7fafe72cb521450c4470043caa332da61d1bec7
|
||||
sha1(d[32:]) 5253237b04c2d002967bae8ef81daae08550c90c
|
||||
-> MISMATCH
|
||||
```
|
||||
|
||||
Two facts that a reader needs before trusting the tool's output:
|
||||
|
||||
- The **shipped dex's `signature` field is stale** while its `checksum` is correct. `dex_patch_bytes.py`
|
||||
prints `header before: checksum_ok=True signature_ok=False` and explains it. If the tool had
|
||||
written the two fields in the wrong order, that starting state would have hidden the bug.
|
||||
- The class that owns the behaviour is
|
||||
`Lsg/vantagepoint/uncrackable1/MainActivity;.onCreate(Landroid/os/Bundle;)V`, and the gate is the
|
||||
first arm of a three-way root `OR`:
|
||||
|
||||
```
|
||||
0x9c8 invoke-static Lsg/vantagepoint/a/c;.a()Z
|
||||
0x9ce move-result
|
||||
0x9d0 39000e00 if-nez v0 -> 0x9ec <-- the edit site
|
||||
0x9d4 invoke-static Lsg/vantagepoint/a/c;.b()Z
|
||||
```
|
||||
|
||||
`0x9ec` is `const-string "Root detected!"` followed by the private `a(String)` that builds the
|
||||
blocking dialog. **The branch structure, not the method name, was read before editing** — the edit
|
||||
target is the arm whose *fall-through* continues into the next check.
|
||||
|
||||
### 2.2 The patch
|
||||
|
||||
```json
|
||||
[{
|
||||
"name": "l1-first-root-check-fallthrough",
|
||||
"class": "Lsg/vantagepoint/uncrackable1/MainActivity;",
|
||||
"method": "onCreate", "desc": "(Landroid/os/Bundle;)V",
|
||||
"match": {"kind": "if-testz", "bytes": "39000e00"},
|
||||
"expect_next": {"kind": "invoke", "target_class": "Lsg/vantagepoint/a/c;",
|
||||
"target_method": "b"},
|
||||
"replace": {"kind": "nops"}
|
||||
}]
|
||||
```
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/dex_patch_bytes.py tools/_work/bench/l1l3/dex/classes.dex \
|
||||
--spec tools/_work/bench/l1l3/l1_root.spec.json --dry-run
|
||||
-- [l1-first-root-check-fallthrough] Lsg/vantagepoint/uncrackable1/MainActivity;.onCreate(...)V
|
||||
site 0x9d0 0x9d0: 39000e00 if-nez v0 -> 0x9ec
|
||||
(before) 0x9c8: 710027000000 invoke-static Lsg/vantagepoint/a/c;.a()Z
|
||||
(before) 0x9ce: 0a00 move-result
|
||||
-> 0x9d0: 00000000 if-nez
|
||||
(after) 0x9d4: 710028000000 invoke-static Lsg/vantagepoint/a/c;.b()Z
|
||||
(after) 0x9da: 0a00 move-result
|
||||
polarity: expect_next satisfied
|
||||
== dry run: nothing written
|
||||
```
|
||||
|
||||
`expect_next` is what pins the polarity: the instruction after the branch had to be the *next* root
|
||||
check, so the fall-through is the "continue" side. Applied:
|
||||
|
||||
```
|
||||
== header checksum 0x7fb7d9fa -> 0x86e1d928
|
||||
signature b7fafe72cb521450 -> eb632328fd1ba03c
|
||||
self-verify: checksum_ok=True signature_ok=True
|
||||
== wrote classes.patched.dex (5528 bytes, delta 0)
|
||||
```
|
||||
|
||||
**The header order question, answered by byte comparison rather than by the tool's own report**
|
||||
(`observed`): the diff of the whole file against the source is exactly the two fields plus the patch
|
||||
site.
|
||||
|
||||
```
|
||||
sizes 5528 5528
|
||||
diff offsets raw: ['0x8','0xa','0xb','0xc'...'0x1f', '0x9d0','0x9d2']
|
||||
site 0x9d0 orig 39000e00 patched 00000000
|
||||
8..12 fad9b77f -> 28d9e186 (checksum, bytes 8..12)
|
||||
12..32 b7fafe72cb521450c4470043caa332da61d1bec7
|
||||
-> eb632328fd1ba03c7ebc6df181e0708a761aa2e3 (signature, bytes 12..32)
|
||||
selfverify checksum True signature True
|
||||
```
|
||||
|
||||
Recomputing `zlib.adler32(d[12:])` and `hashlib.sha1(d[32:])` **on the written file** reproduces both
|
||||
header fields, which is only possible if the signature was computed before the checksum. The patch
|
||||
owns 4 bytes and the header owns 24; nothing else moved.
|
||||
|
||||
### 2.3 Repack — where the documented pipeline actually fails
|
||||
|
||||
`repack.py`'s default signing route:
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/repack.py --apk UnCrackable-Level1.apk \
|
||||
--dex classes.dex=classes.patched.dex --out out/l1_patched.apk \
|
||||
--workdir out --ks out/bench.keystore \
|
||||
--keytool ".../jdk-17.0.4.1/bin/keytool.exe" \
|
||||
--jarsigner ".../jdk-17.0.4.1/bin/jarsigner.exe" \
|
||||
--apksigner E:\tools\bin\apksigner.bat --zipalign E:\tools\bin\zipalign.exe
|
||||
[zip] - META-INF/CERT.RSA (signature artifact)
|
||||
[zip] - META-INF/CERT.SF (signature artifact)
|
||||
[zip] - META-INF/MANIFEST.MF (signature artifact)
|
||||
[zip] + classes.dex (5528 bytes <- .../classes.patched.dex)
|
||||
[zip] unsigned written: 60967 bytes
|
||||
[ks] generated E:\...\out\bench.keystore (alias=apkreverse)
|
||||
error: signer jar not found: uber-apk-signer.jar
|
||||
pass --signer-jar <path>, or set APK_SIGNER_JAR.
|
||||
Any zipalign+apksigner based signer works here.
|
||||
[exit code: 1]
|
||||
```
|
||||
|
||||
Two separate facts, and the second is the one worth recording:
|
||||
|
||||
1. `--signer-jar` defaults to `uber-apk-signer.jar` and no such jar exists here, so the default run
|
||||
cannot sign. (`ENV.md` predicted this.)
|
||||
2. **The error message's second line is misleading in this environment.** "Any zipalign+apksigner
|
||||
based signer works here" is true, but `repack.py` has no code path that *uses* a plain
|
||||
`zipalign`+`apksigner` pair for signing — `sign_apk()` only ever invokes
|
||||
`java -jar <signer_jar>`. `--apksigner`/`--zipalign` are used for **verification only**. So the
|
||||
documented Route B works, but only if the caller performs the sign step outside `repack.py`.
|
||||
The `--no-sign` flag is the supported seam.
|
||||
|
||||
Working route, both builds through the **same** pipeline:
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/repack.py --apk UnCrackable-Level1.apk \
|
||||
--dexdir tools/_work/bench/l1l3/dex_orig \
|
||||
--out out/l1_control_unsigned.apk --workdir out/ctrl --no-sign
|
||||
[zip] unsigned written: 60967 bytes
|
||||
[zip] resources.arsc STORED offset=0xd4d4 aligned
|
||||
== alignment gate: resources.arsc STORED and 4-byte aligned (OK)
|
||||
[result] UNSIGNED (--no-sign): good for inspection, not installable as-is
|
||||
|
||||
$ (same, with --dex classes.dex=classes.patched.dex) -> out/l1_patched_unsigned.apk
|
||||
[zip] resources.arsc STORED offset=0xd4d4 aligned
|
||||
== alignment gate: resources.arsc STORED and 4-byte aligned (OK)
|
||||
```
|
||||
|
||||
`AndroidManifest.xml` is reported `STORED ... NOT ALIGNED`, which is correct and not a defect: the
|
||||
4-byte alignment requirement is `resources.arsc` (and uncompressed `lib/*.so`), not the manifest.
|
||||
|
||||
Signing, explicitly, both builds, same key:
|
||||
|
||||
```
|
||||
$ E:\tools\bin\zipalign.exe -p -f 4 l1_control_unsigned.apk l1_control_aligned.apk ; rc=0
|
||||
$ E:\tools\bin\apksigner.bat sign --ks bench.keystore --ks-pass pass:<pw> --key-pass pass:<pw> \
|
||||
--v1-signing-enabled true --v2-signing-enabled true --v3-signing-enabled true \
|
||||
--out l1_control.apk l1_control_aligned.apk ; rc=0
|
||||
$ E:\tools\bin\apksigner.bat verify --print-certs --verbose \
|
||||
--min-sdk-version 21 --max-sdk-version 34 l1_control.apk
|
||||
Verifies
|
||||
Verified using v1 scheme (JAR signing): true
|
||||
Verified using v2 scheme (APK Signature Scheme v2): true
|
||||
Verified using v3 scheme (APK Signature Scheme v3): true
|
||||
Signer #1 certificate SHA-256 digest: ad13912308e0b6ded5d8be7da2868356348655033d1dde402d8f70d452fbf6db
|
||||
```
|
||||
|
||||
Identical result for `l1_patched.apk`, with the same certificate digest, so the two builds differ
|
||||
only in the dex bytes.
|
||||
|
||||
### 2.4 On-device result — the behaviour change, seen on the screen (`observed`)
|
||||
|
||||
Two builds were produced and both were checked, because "the pipeline works" and "the patch changed
|
||||
the app" are different claims and only the second one is the deliverable.
|
||||
|
||||
**The one-command pipeline, re-run after the repository changed** (this supersedes §2.3's manual
|
||||
route as the recommended invocation):
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/repack.py --apk UnCrackable-Level1.apk \
|
||||
--dex classes.dex=classes.patched.dex --out out/l1_patched_final.apk \
|
||||
--workdir out/fin --ks out/bench.keystore \
|
||||
--keytool "C:\Program Files\Java\jdk-17.0.4.1\bin\keytool.exe" \
|
||||
--apksigner E:\tools\bin\apksigner.bat --zipalign E:\tools\bin\zipalign.exe
|
||||
[sign] route: apksigner
|
||||
[out] ...\out\l1_patched_final.apk (70647 bytes)
|
||||
== alignment gate: resources.arsc STORED and 4-byte aligned (OK)
|
||||
Verified using v1 scheme (JAR signing): true
|
||||
Verified using v2 scheme (APK Signature Scheme v2): true
|
||||
Verified using v3 scheme (APK Signature Scheme v3): true
|
||||
[result] OK
|
||||
local sha256 ad6a51cef4699b73ff052d4e049ca9efd64e9570900d19509f0b7a7c20f87e56
|
||||
```
|
||||
|
||||
The manual `zipalign`+`apksigner` route earlier in this section produced **the same sha256**, so the
|
||||
two paths agree byte for byte and the result does not depend on which one a reader follows.
|
||||
|
||||
Install through the **root** path (`pm install -r -d`, which bypasses this ROM's streamed-install
|
||||
interception), and the installed `base.apk` is hashed and compared to the local file so "installed"
|
||||
cannot be confused with "the previous build is still there":
|
||||
|
||||
```
|
||||
control device sha256 e5a9f335155e3e20219d89bc859ae89cc0a82f7135123397c526d94bfca4b3b4
|
||||
local sha256 e5a9f335155e3e20219d89bc859ae89cc0a82f7135123397c526d94bfca4b3b4 identical
|
||||
patched device sha256 ad6a51cef4699b73ff052d4e049ca9efd64e9570900d19509f0b7a7c20f87e56
|
||||
local sha256 ad6a51cef4699b73ff052d4e049ca9efd64e9570900d19509f0b7a7c20f87e56 identical
|
||||
```
|
||||
|
||||
`am start -W` for both builds:
|
||||
|
||||
```
|
||||
LaunchState: COLD TotalTime: 241 ms WaitTime: 249 ms Status: ok (control)
|
||||
LaunchState: COLD TotalTime: 232 ms WaitTime: 234 ms Status: ok (patched)
|
||||
```
|
||||
|
||||
Window focus, sampled four times at 2 s intervals:
|
||||
|
||||
```
|
||||
original mCurrentFocus=Window{a5d2ba7 u0 Root detected!} (all four samples)
|
||||
control mCurrentFocus=Window{c4047e2 u0 Root detected!} (all four samples)
|
||||
patched mCurrentFocus=Window{8c94fc1 u0 <PKG>/...MainActivity} (all four samples)
|
||||
```
|
||||
|
||||
And the images, which are the evidence that actually counts —
|
||||
`tools/_work/bench/l1l3/evidence/00_original/00_original_03.png` shows the modal
|
||||
`Root detected! / This is unacceptable. The app is now going to exit. / OK` over a greyed-out input
|
||||
screen. The same shot on the control build (`evidence/01_control/*.png`, and again on
|
||||
`evidence/12_control_final/*.png` from the final one-command build) is byte-comparable to the
|
||||
original's. `evidence/13_patched_final/13_patched_final.png` — the final patched build — shows the
|
||||
screen **without any dialog**: input field live with the caret in it, `VERIFY` enabled, and
|
||||
`mCurrentFocus` on `<PKG>/...MainActivity`.
|
||||
|
||||
**G4 is therefore cleared in the strong form**: the control is not just "the same APK" — it went
|
||||
through the identical repack-and-sign pipeline with a byte-identical dex and still shows the old
|
||||
behaviour. The behaviour change is attributable to the four patched bytes, and it reproduced on a
|
||||
second, independently built APK from the final pipeline.
|
||||
|
||||
### 2.5 What the app's own check actually is (`observed`, and it contradicts the obvious reading)
|
||||
|
||||
The functional check was probed with a Frida hook rather than inferred from the crypto:
|
||||
|
||||
```
|
||||
[a.b] input="8d127684cbc37c17616d806cf50473cc" -> 16 bytes: 8d127684cbc37c17616d806cf50473cc
|
||||
[lib a.a] key=8d127684cbc37c17616d806cf50473cc
|
||||
data=e5426215cb5b9a06c3a0b5e6a4bd769a49e8f074f82eff1d95ab7c17147618e7
|
||||
-> 17 bytes 492077616e7420746f2062656c69657665 ascii="I want to believe"
|
||||
[a.a] REAL input="" -> false
|
||||
```
|
||||
|
||||
So: a **fixed 16-byte AES key** (`8d12…73cc`, a `const-string` in the dex) decrypts a fixed
|
||||
base64 blob to `I want to believe`, and the user's input is compared against that plaintext. My first
|
||||
reading — that the hex string was itself the secret — is **wrong**, and it was falsified by running
|
||||
it: entering `8d127684cbc37c17616d806cf50473cc` produces `Nope...`.
|
||||
|
||||
`TextUtils.equals` is **not** the comparison site: hooking it captured only dialog-internal
|
||||
comparisons (`("Nope...", "<null>")`). The comparison lives in the decrypted-bytes path of
|
||||
`a.a(String)`.
|
||||
|
||||
Two device facts that cost time and are worth inheriting:
|
||||
|
||||
- **The input field cannot be driven on this ROM.** `adb shell input text "I%swant%sto%sbelieve"`
|
||||
and a per-character `input keyevent` loop both landed `iwantto`: 7 of 17 characters survived and
|
||||
every space was dropped. Three independent quoting routes gave the same result, so the field
|
||||
itself rejects what the injection path sends. Reading the field back with `uiautomator dump` after
|
||||
each attempt is what distinguished "the app said Nope" from "my input never arrived" — the first
|
||||
two attempts looked like a wrong secret and were not.
|
||||
- `EditText.setText(String)` is not reachable from outside (only
|
||||
`setText(CharSequence, TextView$BufferType)` is declared, and `TextView.BufferType` is not exposed
|
||||
as a property on the class object); an **empty** `EditText` also returns a **null** `Editable`, so
|
||||
`getText().length()` fails with "not a function" and looks like a bridge limitation rather than an
|
||||
empty field.
|
||||
|
||||
The success branch was therefore reached through the app's own method with the boolean forced, which
|
||||
is honest as long as it is labelled — and it is:
|
||||
`evidence/09_success_branch/09_success_branch.png` shows the app's `Success! / This is the correct
|
||||
secret.` dialog. That image proves **the patched build reaches and renders the app's own success
|
||||
branch**; it does not prove the secret was typed and accepted. The check itself is evidenced by the
|
||||
libfoo/`a.b`/`a.a` log above.
|
||||
|
||||
### 2.6 B1 verdict
|
||||
|
||||
`observed`: the equal-length edit, the header recomputation in the correct order, the STORED+aligned
|
||||
repack, v1+v2+v3 signing, the root-path install, the device-side hash match, and the on-screen
|
||||
behaviour change all reproduce. G2 and G4 both clear.
|
||||
|
||||
---
|
||||
|
||||
## 3. B2 — L3 self-destruct race (`observed` for the tooling, `unverified`/negative for the patch)
|
||||
|
||||
### 3.1 What L3 actually does (read from the dex and the tombstone, not assumed)
|
||||
|
||||
`MainActivity.onCreate` calls `verifyLibs()` **first**, then `init("pizzapizzapizzapizzapizz".getBytes())`,
|
||||
then an `AsyncTask`, then three root checks, then `isDebuggable()`, then `setContentView` — and
|
||||
`setContentView` sits **after** the gate:
|
||||
|
||||
```
|
||||
0x11a3ce invoke-static Lsg/vantagepoint/util/RootDetection;.checkRoot1()Z
|
||||
...
|
||||
0x11a406 sget Lsg/vantagepoint/uncrackable3/MainActivity;.tampered:I
|
||||
0x11a40a if-eqz v0 -> 0x11a418 ; tampered == 0 -> skip the dialog
|
||||
0x11a40e const-string "Rooting or tampering detected."
|
||||
0x11a412 invoke-direct MainActivity;.showDialog(Ljava/lang/String;)V
|
||||
0x11a418 ... setContentView
|
||||
```
|
||||
|
||||
So when the tamper bit is set the app **never renders its UI**; it sits on a blocking dialog whose OK
|
||||
button is wired to an `onClick` that exits. `verifyLibs()` is the source of that bit:
|
||||
|
||||
- it reads four ABI-keyed expected CRCs out of `resources.arsc` string resources
|
||||
(`R.string` ids `0x7f0b002b`, `0x7f0b0029`, `0x7f0b0034`, `0x7f0b0035`),
|
||||
- compares them to the zip entry CRCs of `lib/<abi>/libfoo.so`, and
|
||||
- compares `classes.dex`'s zip CRC to the value the native `baz()` returns.
|
||||
|
||||
The native library exports exactly three JNI entries and one terminate routine:
|
||||
|
||||
```
|
||||
Java_sg_vantagepoint_uncrackable3_CodeCheck_bar vaddr=0x33a4 size=256
|
||||
Java_sg_vantagepoint_uncrackable3_MainActivity_init vaddr=0x3310 size=136
|
||||
Java_sg_vantagepoint_uncrackable3_MainActivity_baz vaddr=0x3398 size=12
|
||||
_Z7goodbyev vaddr=0x3080 size=24
|
||||
_Z4randv vaddr=0xe4c size=36
|
||||
```
|
||||
|
||||
and its strings name the detection inputs: `/proc/self/maps`, `frida`, `xposed`, `ptrace`,
|
||||
`waitpid`, `getppid`, `pthread_create`, plus two log strings:
|
||||
`"Tampering detected! Terminating..."` and `"Error opening /proc/self/maps! Terminating..."`.
|
||||
|
||||
`libfoo.so`'s single RX `PT_LOAD` is `off=0x0 vaddr=0x0 filesz=0x4700`, so **file offset == virtual
|
||||
address == runtime offset from the module base** for everything in `.text`. That is what makes
|
||||
`hook_patch_only.js`'s `FILE_OFFSET` meaningful here, and it is not true in general — a library whose
|
||||
text segment has a non-zero `vaddr` would need `p_vaddr + delta`.
|
||||
|
||||
### 3.2 The baseline, and the finding that invalidates the assumed baseline
|
||||
|
||||
`observed`: the **unmodified, pristine APK** on this device shows the tamper dialog:
|
||||
|
||||
```
|
||||
$ am start -n <PKG>/sg.vantagepoint.uncrackable3.MainActivity
|
||||
833 ms pid=5377 5413 mCurrentFocus=Window{1d9ee56 u0 Rooting or tampering detected.}
|
||||
1638 ms ... mCurrentFocus=Window{1d9ee56 u0 Rooting or tampering detected.}
|
||||
...
|
||||
10712 ms ... mCurrentFocus=Window{1d9ee56 u0 Rooting or tampering detected.}
|
||||
```
|
||||
|
||||
and `logcat` shows the CRCs it compared (`V/UnCrackable3`), with `tampered` set. The process stays
|
||||
alive in this state for at least 25 s: the tamper dialog alone does **not** kill the process, it waits
|
||||
for the OK button.
|
||||
|
||||
`observed` — the **time-to-death baseline with a frida probe attached**, which is the number the row
|
||||
needs:
|
||||
|
||||
```
|
||||
$ python tools/_work/bench/l1l3/ttd_attach.py --package <PKG> --activity sg.vantagepoint.uncrackable3.MainActivity \
|
||||
--js l3_read_crc.js --launch-delay 1.5 --seconds 20
|
||||
[i] pre-attach main pid=7020 at 1.88s
|
||||
[i] attached at +0.00s (probe loaded)
|
||||
attach+ 0.75s main_pid=7020
|
||||
... (alive)
|
||||
attach+ 4.90s main_pid=7020
|
||||
attach+ 5.34s main_pid=-
|
||||
=== logcat ===
|
||||
F/libc ( 7020): Fatal signal 6 (SIGABRT), code -6 (SI_TKILL) in tid 7052 (tg.uncrackable3)
|
||||
F/DEBUG ( 7203): #01 pc 000000000000308c .../lib/arm64/libfoo.so (goodbye()+12)
|
||||
F/DEBUG ( 7203): #02 pc 00000000000031ac .../lib/arm64/libfoo.so
|
||||
I/Zygote( 1007): Process 7020 exited due to signal 6 (Aborted)
|
||||
```
|
||||
|
||||
**`TTD(frida attached) = 5.34 s`, and the death is a native `SIGABRT` originating in `goodbye()+12`.**
|
||||
Note which process dies: `7020` is the main `<PKG>` process, `7052`/`tg.uncrackable3` is the failing
|
||||
*thread*. That distinction matters — see §3.4.
|
||||
|
||||
### 3.3 The two scripts, run as shipped (`observed`)
|
||||
|
||||
Neither had ever been executed before this pass.
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/spawn_patch_detach.py \
|
||||
--package <PKG> --js skills/apk-reverse/scripts/hook_patch_only.js \
|
||||
--captures 2 --interval 3 --no-screencap
|
||||
[i] spawned pid=7442
|
||||
[!] PATCH_BYTES is empty -- configure this probe before using it
|
||||
[MSG] PATCHFAIL
|
||||
[i] patched=False
|
||||
[i] detached (memory writes persist, hooks are gone)
|
||||
=== capture 0 ===
|
||||
7567 00:01 sh -c ps -A -o PID,ETIME,ARGS | grep -i uncrackable3
|
||||
7570 00:01 grep -i uncrackable3
|
||||
mCurrentFocus=Window{7c35f28 u0 com.oppo.launcher/com.oppo.launcher.Launcher}
|
||||
```
|
||||
|
||||
Three defects, all confirmed by later runs:
|
||||
|
||||
1. **`hook_patch_only.js` is a template, and its guard is correct but the guard is the whole
|
||||
result.** `PATCH_BYTES = []` correctly refuses and reports `PATCHFAIL`, so an unconfigured run
|
||||
cannot silently claim success. That is good behaviour; it just means the shipped file tests
|
||||
nothing.
|
||||
2. **`spawn_patch_detach.py` resumed the target before waiting for `PATCHED`.** The original order
|
||||
was `resume(pid)` → then wait. That makes the wait meaningless: by the time the probe's
|
||||
`Process.findModuleByName` poll sees the library, the target has been running.
|
||||
3. **`--activity` defaults to `None` and the code then did nothing.** After `am force-stop` and a
|
||||
spawn, omitting `--activity` left the app un-launched, so the capture set was of the launcher and
|
||||
read exactly like "the app died". Fixed by resolving the launcher through
|
||||
`cmd package resolve-activity --brief <pkg>` when `--activity` is absent.
|
||||
|
||||
**Correction to my own first fix.** I changed the order so the driver held the target **paused**
|
||||
until `PATCHED`. That cannot work with a module-polling probe, and the run says so:
|
||||
|
||||
```
|
||||
[i] probe loaded; target still PAUSED, waiting up to 15.0s for the patch
|
||||
[5.00s][alive] [10.00s][alive] [15.00s][alive]
|
||||
[!] the probe did not report PATCHED in 15.0s
|
||||
[i] resumed pid=7666
|
||||
[15.11s][i] libfoo.so base=0x764c0aa000 bytes@0x3080 = fd 7b bf a9 ...
|
||||
[15.12s][PATCH] now = 00 00 80 52 d6 03 5f c0 ...
|
||||
```
|
||||
|
||||
**While the process is frozen its libraries are not mapped**, so the poll can never succeed: 15 s
|
||||
paused produced nothing, and `libfoo.so base=0x764c0aa000` appeared 0.11 s after resume. The shipped
|
||||
order was right about *that*; what was wrong was detaching before the write landed. The corrected
|
||||
driver resumes, then waits for `PATCHED` (reported loudly when it never arrives), then detaches.
|
||||
|
||||
### 3.4 The experiment, and its result
|
||||
|
||||
Sites were chosen from the tombstone and the ELF, not guessed. `goodbyev` at `0x3080`:
|
||||
|
||||
```
|
||||
0x3080 a9bf7bfd stp x29, x30, [sp, #-16]!
|
||||
0x3084 910003fd mov x29, sp
|
||||
0x3088 321f07e0
|
||||
0x308c 97fff725 bl 0x2ff0 <-- the call the tombstone names as goodbye()+12
|
||||
0x3090 2a1f03e0
|
||||
0x3094 97fff71f bl 0x2ff8
|
||||
```
|
||||
|
||||
and the two helpers it tail-calls are trivial return-value stubs:
|
||||
|
||||
```
|
||||
0x2ff0 52801a40 mov w0, #0xd2 0x2ffc d65f03c0 ret
|
||||
0x2ff8 52800cc0 mov w0, #0x66 0x3000 321f0fe0 ...
|
||||
```
|
||||
|
||||
So probe v2 patches the **return values the branch reads** (`0x2ff0`, `0x2ff8`) as well as
|
||||
`goodbyev`'s entry, each rewritten to `mov w0, #0 ; ret` (+ nops, equal length), with an assertion on
|
||||
the expected original bytes before each write.
|
||||
|
||||
Patched condition:
|
||||
|
||||
```
|
||||
$ python tools/_work/bench/l1l3/b2_measure.py --js l3_hook_patch_v2.js --tag PATCHED --probe-seconds 25
|
||||
[i] spawned pid=10000 (paused)
|
||||
[i] resumed pid=10000
|
||||
[0.04s][i] libfoo.so base=0x764b89d000 size=90112
|
||||
[0.04s][i] goodbye entry = fd 7b bf a9 fd 03 00 91 e0 07 1f 32 25 f7 ff 97
|
||||
[0.04s][i] goodbye+12 = 25 f7 ff 97
|
||||
[0.04s][PATCH] 0x2ff0 (helper A -> return 0) 40 1a 80 52 c0 03 5f d6 -> 00 00 80 52 d6 03 5f c0
|
||||
[0.05s][PATCH] 0x2ff8 (helper B -> return 0) c0 0c 80 52 c0 03 5f d6 -> 00 00 80 52 d6 03 5f c0
|
||||
[0.05s][PATCH] 0x3080 (goodbyev entry -> no-op) fd 7b bf a9 ... -> 00 00 80 52 d6 03 5f c0 1f 20 03 d5 1f 20 03 d5
|
||||
[0.05s][i] sites written=3 skipped=0
|
||||
[i] patched=True
|
||||
3.22s pid=10000 alive=False
|
||||
V/UnCrackable3(10000): Tampering detected! Terminating...
|
||||
F/libc (10000): Fatal signal 6 (SIGABRT), code -6 (SI_TKILL) in tid 10032 (tg.uncrackable3), pid 10000
|
||||
F/DEBUG (10036): #01 pc 000000000000308c .../lib/arm64/libfoo.so (goodbye()+12)
|
||||
I/Zygote( 1007): Process 10000 exited due to signal 6 (Aborted)
|
||||
[RESULT] tag=PATCHED spawned=10000 ttd_after_resume=3.22 survived=False
|
||||
```
|
||||
|
||||
Control condition (identical reads and writes, original bytes written back):
|
||||
|
||||
```
|
||||
$ python tools/_work/bench/l1l3/b2_measure.py --js l3_hook_control_v2.js --tag control_v2 --probe-seconds 22
|
||||
[0.04s][CONTROL] 0x2ff0 unchanged (wrote back 40 1a 80 52 c0 03 5f d6)
|
||||
[0.04s][CONTROL] 0x2ff8 unchanged (wrote back c0 0c 80 52 c0 03 5f d6)
|
||||
[0.04s][CONTROL] 0x3080 unchanged (wrote back fd 7b bf a9 fd 03 00 91)
|
||||
[0.04s][i] control sites ok=3
|
||||
V/UnCrackable3(9312): Tampering detected! Terminating...
|
||||
F/DEBUG ( 9348): #01 pc 000000000000308c .../lib/arm64/libfoo.so (goodbye()+12)
|
||||
I/Zygote( 1007): Process 9312 exited due to signal 6 (Aborted)
|
||||
```
|
||||
|
||||
**Both conditions terminate at `goodbye()+12` with `SIGABRT`.** The probes apply the patch
|
||||
successfully, inside the race window (0.04–0.05 s after resume against a ~3.2 s deadline), and the
|
||||
target still dies.
|
||||
|
||||
### 3.5 What this means, and what to do instead
|
||||
|
||||
`observed`:
|
||||
- `spawn_patch_detach.py` **can** deliver an early memory patch: the write landed 0.05 s after resume
|
||||
and survived the detach. The mechanism the row tests is real.
|
||||
- `hook_patch_only.js` is a **template with correct guard rails**, not a working probe.
|
||||
- The patch as configured is **insufficient**: three sites including the terminate routine's entry
|
||||
and both of its branch inputs do not stop the abort.
|
||||
|
||||
`inferred` (labelled, not executed): the terminate path is not confined to `goodbyev` and its two
|
||||
stubs. `goodbyev` is 24 bytes and its only two calls are the two stubs; the `bl` at `+12` is the
|
||||
observed faulting frame, and yet patching both the callee and the caller changed nothing. The most
|
||||
consistent explanation is that the tampering thread reaches the *same abort* through a second site
|
||||
that this static pass did not map — the region around `0x2f80–0x3080` is a dense run of
|
||||
`mov w0, #imm ; ret` pairs (values 0x11, 0x1b, 0x43, 0x5b, 0x67, 0x7b, 0x92, 0xa2, 0xa3, 0xae, 0xcc,
|
||||
0xd2, 0xe3, 0xe7, 0xe8, 0xed, 0xf6, 0xfd, …), i.e. a jump-table-shaped cluster of tiny accessors
|
||||
that a proper disassembly would resolve into a call graph, and `rand` (`_Z4randv`, 0xe4c, 36 B) is
|
||||
part of the same obfuscated scheme. **A real fix needs a full AArch64 disassembly of `.text`
|
||||
(~18 KB) rather than four hand-picked offsets.**
|
||||
|
||||
`observed` — the route that should be tried first next time: the abort is reachable from a **Java**
|
||||
layer that is already fully mapped. `verifyLibs()` computes `tampered` and `onCreate` branches on it,
|
||||
and the dialog's OK button is a plain `DialogInterface.OnClickListener` in `MainActivity$1`. Patching
|
||||
the dex in that class (`if-eqz v0 -> 0x11a418` → nops, forcing the non-tamper side) removes the
|
||||
dialog and lets `setContentView` run **without touching native code at all** — and unlike the native
|
||||
patch it is a route this repository already has measured tooling for (see B1). This was not executed
|
||||
in this pass; it is the recommended next experiment, not a result.
|
||||
|
||||
`observed` — a limitation the next pass must budget for: on this device the L3 **original** APK
|
||||
already fails its own CRC self-check, so the tamper state is on before any instrumentation. Whatever
|
||||
the follow-up tests must therefore be measured against *that* baseline, not against a clean one.
|
||||
|
||||
---
|
||||
|
||||
## 4. Task C — L2 (not run)
|
||||
|
||||
Not attempted: A and B consumed the pass. `UnCrackable-Level2.apk` is on disk
|
||||
(`tools/_work/bench/apks/`, 901022 B, sha256 prefix `4c7980ef1f6cc295`) and the `dynamic-frida.md`
|
||||
spawn route is untested for it. Recorded as **not run**, not as a negative result.
|
||||
|
||||
---
|
||||
|
||||
## 5. Defects found in the repository's own tooling
|
||||
|
||||
| Where | What | Evidence | Status |
|
||||
|---|---|---|---|
|
||||
| `scripts/repack.py` (`sign_apk`) | **Stale finding, re-opened and closed.** At the start of this pass it signed **only** via `java -jar <signer_jar>`; `--apksigner`/`--zipalign` were verification-only, so the "any zipalign+apksigner based signer works here" hint had no code path behind it | §2.3 exit 1 with `signer jar not found` | **fixed by another writer mid-pass**; re-measured in §2.4 — `[sign] route: apksigner`, `[result] OK`, byte-identical output |
|
||||
| `scripts/repack.py` | The `signer jar not found` message is still emitted by the jar-only `sign_apk()` and still claims a zipalign+apksigner route works. When no `--apksigner` was supplied (the first run in §2.3) the message is the *only* guidance a reader gets, and it points at a function that does not exist | §2.3 | documented; the message should name the apksigner route it now has |
|
||||
| `scripts/spawn_patch_detach.py` | Detached before the probe's write landed, so the write could be missed entirely while the run still reported `patched=False` and looked merely unlucky | §3.3 | **fixed** — resume, wait for `PATCHED`, report loudly, then detach |
|
||||
| `scripts/spawn_patch_detach.py` | `--activity` absent left the target un-launched after a spawn, so the captures were of the launcher | §3.3 | **fixed** — resolve the launcher via `cmd package resolve-activity` |
|
||||
| `scripts/spawn_patch_detach.py` | Unbounded `adb`/frida calls; `adb_run()`'s rc is never inspected for the launcher step | §3.3 | partially fixed (`--launch-timeout`, rc surfaced) |
|
||||
| `scripts/hook_patch_only.js` | Guard rails are correct (`PATCH_BYTES` empty → `PATCHFAIL`); it is a template, and the docs imply it is a runnable probe | §3.3 | documented |
|
||||
| `tools/_work/ENV.md` | Attributes the B1 signing problem to the apksigner/zipalign route; the blocker was the missing uber-apk-signer jar, and both apksigner and zipalign work | §2.3 | corrected here |
|
||||
| `tests/benchmark.md` B2 | Describes L3 as a self-destruct race against a pristine baseline; on this device the original APK is already in its tamper state | §3.2 | corrected here |
|
||||
|
||||
---
|
||||
|
||||
## 6. Files produced by this pass
|
||||
|
||||
Workbench (not part of the skill; `tools/` is git-ignored):
|
||||
|
||||
- `tools/_work/bench/l1l3/l1_root.spec.json`, `dex/classes.patched.dex`, `dex_orig/classes.dex`
|
||||
- `tools/_work/bench/l1l3/out/l1_control.apk`, `l1_patched.apk`, `l1_control_now.apk`,
|
||||
`l1_patched_final.apk` (+ unsigned/aligned intermediates)
|
||||
- `tools/_work/bench/l1l3/l1_probe.py`, `l1_type.py`, `l1_verify_field.py`, `l1_try_secret.py`,
|
||||
`l1_drive.js`, `l1_probe_full.js`, `attach_probe.py`
|
||||
- `tools/_work/bench/l1l3/l3_hook_patch_v2.js`, `l3_hook_control_v2.js`, `l3_read_crc.js`,
|
||||
`elf_l3.py`, `dis_l3.py`
|
||||
- `tools/_work/bench/l1l3/b2_measure.py`, `ttd.py`, `ttd_attach.py`
|
||||
- `tools/_work/bench/l1l3/evidence/**` (PNG captures: `00_original`, `01_control`,
|
||||
`02_patched_nosecret`, `09_success_branch`, `12_control_final`, `13_patched_final`)
|
||||
|
||||
A note on making §2.4 reproducible: `l1_probe.py` reuses the tag as the remote filename, so
|
||||
`--tag` must be unique per run. Reusing a tag after a failed `adb push` installs the *previous*
|
||||
remote file, which is exactly how the first attempt at the final patched verification showed
|
||||
`Root detected!` — the control build's hash, from the control run's leftover remote path. The hash
|
||||
comparison in the same script is what caught it; a screenshot alone would not have.
|
||||
@@ -0,0 +1,183 @@
|
||||
# EXTENSION — Dart AOT string-table formats, measured on a real dual-ABI app
|
||||
|
||||
Evidence file for the `dart-aot.md` changes in this pass. Every claim below carries a grade:
|
||||
|
||||
- **observed** — a command was run here and its output is quoted in this file.
|
||||
- **inferred** — follows from an observed fact or from documented mechanism; the step was not executed.
|
||||
- **unverified** — assumed or reported elsewhere, not reproduced here.
|
||||
|
||||
Target identity is replaced with `<PKG>`; the artifact is referred to as "the sample". The technique
|
||||
(tool, library, function, hardening product) is kept, per
|
||||
`skills/apk-reverse/references/desensitization-and-leak-scans.md`.
|
||||
|
||||
## 1. What was measured
|
||||
|
||||
| Claim under test | Grade | Result |
|
||||
|---|---|---|
|
||||
| The arm64 Dart AOT string table is `[0x80\|(len<<1)\|two_byte][payload]` and the entries chain | **observed** | confirmed on 4,980 entries |
|
||||
| The armv7 (32-bit) snapshot uses the *same* format | **refuted** | the same extractor keeps 0 entries; the tag byte is not present before the literal |
|
||||
| An armv7 string record is `[length u32le][UTF-16LE chars]`, so the length field reads `len*2` | **refuted** | the u32 before the payload reads the **byte** count, and the payload is UTF-8 |
|
||||
| The engine version banner always yields `x.y.z (stable) (…)` | **refuted** | a real `libflutter.so` carries `0.0.1` with a blank build id and no `(stable)` |
|
||||
| An equal-length replacement of a data key is legal on both ABIs without recomputing the tag | **observed** (record layout), **unverified** (end-to-end device run) | the record is self-contained on both ABIs |
|
||||
|
||||
Two of the five were assumptions that would have been written into the reference as fact. Both were
|
||||
killed by a single command each, which is the whole argument for measuring before documenting.
|
||||
|
||||
## 2. Sample and preparation
|
||||
|
||||
The sample is the locally supplied "complex encryption and hardening" APK (360 shell + Flutter).
|
||||
The two AOT snapshots were extracted from the zip without repacking:
|
||||
|
||||
```
|
||||
$ python -c "import zipfile; z=zipfile.ZipFile(r'存在问题和例子\<sample>.apk'); \
|
||||
[print(n, len(z.read(n))) for n in ('lib/arm64-v8a/libapp.so','lib/armeabi-v7a/libapp.so')]"
|
||||
lib/arm64-v8a/libapp.so 16352152
|
||||
lib/armeabi-v7a/libapp.so 17629776
|
||||
```
|
||||
|
||||
Work copies live under the ignored workspace `tools/_work/dart/`; nothing from this evidence pass is
|
||||
committed except the reference text.
|
||||
|
||||
## 3. The arm64 packed table — confirmed
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/dart_pool_strings.py tools/_work/dart/app_arm64.so s64.tsv --min 6
|
||||
candidates : 17352
|
||||
kept (run >= 3) : 4980 (one-byte 4980 / utf16le 0)
|
||||
below threshold : 12372 [--keep-isolated to keep them]
|
||||
written s64.tsv
|
||||
```
|
||||
|
||||
The tag arithmetic was checked directly against real literals in the image rather than only through
|
||||
the script. For each literal, the byte immediately before it must equal `0x80|(len<<1)`:
|
||||
|
||||
```
|
||||
$ python -c "... for m in (b'AdmobBanner', b'showSplashAd', b'AdPgl_Splash'): ..."
|
||||
### app_arm64.so
|
||||
AdmobBanner @0x4932e len=11 expected_tag=0x96
|
||||
tag_byte_at_-1=0x96 matches=True
|
||||
showSplashAd @0x39deb len=12 expected_tag=0x98
|
||||
tag_byte_at_-1=0x98 matches=True
|
||||
AdPgl_Splash @0x493ee len=12 expected_tag=0x98
|
||||
tag_byte_at_-1=0x98 matches=True
|
||||
```
|
||||
|
||||
**observed**: three independent literals of two different lengths, three matching tag bytes. This is
|
||||
also the assertion a patch script should perform (`dart-aot.md` §10), and it is what rejects a hit
|
||||
that landed mid-string.
|
||||
|
||||
## 4. The armv7 assumption — refuted, and what is actually there
|
||||
|
||||
The starting hypothesis (carried over from an external case) was that the 32-bit snapshot stores a
|
||||
string as `[tag 0x81][len*2 u32le][UTF-16LE chars]`. Two scanners written against that hypothesis
|
||||
found nothing, which is itself the evidence:
|
||||
|
||||
```
|
||||
$ python tools/_work/dart/probe3.py app_armv7.so raw # [8-byte hdr ending 0x81][u32le len][chars]
|
||||
hits=0 chained_runs=0 entries_in_runs=0
|
||||
|
||||
$ python tools/_work/dart/probe3.py app_armv7.so packed # the arm64 tag scheme
|
||||
hits=0 chained_runs=0 entries_in_runs=0
|
||||
```
|
||||
|
||||
The same extractor that keeps 4,980 entries on arm64 keeps **zero** on armv7:
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/dart_pool_strings.py tools/_work/dart/app_armv7.so s7.tsv --min 6
|
||||
candidates : 1355
|
||||
kept (run >= 3) : 0 (one-byte 0 / utf16le 0)
|
||||
```
|
||||
|
||||
A zero here is a **format mismatch, not an empty string table**, and that distinction is now stated in
|
||||
`dart-aot.md` §7: reading it as "the strings were stripped" is exactly the wrong conclusion the same
|
||||
section warns about for UTF-16.
|
||||
|
||||
What the record actually looks like, from bytes around a known literal:
|
||||
|
||||
```
|
||||
$ python -c "... b.find(b'SystemChrome') ..."
|
||||
0x1ffb3c b'\x00\x00\x00\x008\x06U\x00F\x00\x00\x00\xce-\x8a@SystemChrome.setEnab...'
|
||||
0x204ce4 b'\x00\x00\x00\x008\x06U\x00H\x00\x00\x00\xa45jVSystemChrome.setSyst...'
|
||||
0x2185c4 b' ere8\x07U\x00J\x00\x00\x00\x8c]\xb4GSystemChrome.setPref...'
|
||||
```
|
||||
|
||||
Read as `[header u32][length u32le][payload]`: `0x00550638` then `0x00000046` = **70**, which is the
|
||||
byte length of `SystemChrome.setEnabledSystemUIMode` (36 chars) immediately followed by
|
||||
`SystemChrome.setSystemUIOverlayStyle` (34 chars) — **70 bytes, UTF-8, not UTF-16**. The second
|
||||
literal starts at the exact byte where the first ends, and its own length field reads `0x00000048` =
|
||||
72, matching its own continuation.
|
||||
|
||||
**observed**: `length == payload byte count`, payload is UTF-8, and in-place equal-length replacement
|
||||
therefore keeps the record valid without recomputing anything. **refuted**: the `len*2` / UTF-16 form.
|
||||
|
||||
The header word is a tags value with full 4-byte alignment, not a constant to hard-code:
|
||||
|
||||
```
|
||||
$ python -c "... alignment histogram over the image ..."
|
||||
alignment mod 4: {0: 13397}
|
||||
```
|
||||
|
||||
```
|
||||
$ python tools/_work/dart/perf.py app_armv7.so
|
||||
scanned 17629776 B in 0.94s using 4-byte stride + int.from_bytes
|
||||
aligned-header string records: 582
|
||||
stride-1 micro scan over 17629776 B took 2.80s (found 2762)
|
||||
```
|
||||
|
||||
The literal search plus a backwards length assertion is the tool-independent method, and it costs
|
||||
under a second over a 17.6 MB image — so validating every candidate is affordable and the armv7
|
||||
limitation does not justify a hand-built table parser. **unverified**: whether a *different* armv7
|
||||
snapshot uses a different header word; the reference tells the reader to read it from the file.
|
||||
|
||||
## 5. The engine version banner — a version-shaped lie
|
||||
|
||||
```
|
||||
$ python -c "... rfind NUL before the ' on \"android_arm64\"' suffix ..."
|
||||
end-of-version offset 0x110821
|
||||
version string start 0x1107f1 => total 66
|
||||
b'0.0.1 on "android_arm64"'
|
||||
```
|
||||
|
||||
The banner exists, is 66 bytes long, and its numeric field is `0.0.1` with a blank build-id field —
|
||||
no `(stable)`, no `(Mon …)`. A regex that takes `\d+\.\d+\.\d+` out of this returns a value that would
|
||||
send a reader to build a Dart VM for `0.0.1`.
|
||||
|
||||
**observed** (the byte string above), **inferred** (that this is a build-time population failure rather
|
||||
than a deliberately rewritten banner). The actionable consequence is in `dart-aot.md` §1: assert the
|
||||
`(stable) (…)` tail before using the number, and fall back to the snapshot hash otherwise. **The
|
||||
sample's Dart version was not pinned in this pass** — that is an unverified item, not a finding.
|
||||
|
||||
## 6. Cross-validation
|
||||
|
||||
Every measurement above is a **raw byte search with an independent arithmetic assertion** (find the
|
||||
literal in the file; check the byte or word immediately before it against the length of what follows).
|
||||
It does not depend on `dart_pool_strings.py`, and the script's numbers are reported alongside the raw
|
||||
probe rather than as the source of truth. The two agree on arm64 (4,980 kept entries, tag byte equal to
|
||||
`0x80|(len<<1)` at every literal tested) and on armv7 (the packed scheme finds nothing, the byte layer
|
||||
shows why).
|
||||
|
||||
**Not obtained**: a second, independently implemented Dart snapshot parser. `blutter` was not built in
|
||||
this pass, so the reference's `pp.txt`-side claims were not re-derived here, and the armv7 conclusion
|
||||
rests on the byte layout rather than on a tool's agreement. That is the boundary of this evidence.
|
||||
|
||||
## 7. What this pass changed in the repository
|
||||
|
||||
- `skills/apk-reverse/references/dart-aot.md` — §7 gained the armv7 record format and the
|
||||
"the armv7 zero is a format mismatch" warning; §1 gained the banner-shape assertion; §10 gained the
|
||||
data-key-versus-API-path rule with its measured failure mode, and the longest-first / assert-the-prefix
|
||||
rule. No new file, no new index row: the finding belongs in the file that already owns this layer.
|
||||
- The equal-length-replacement failure (a 404 whose body is not JSON, `jsonDecode` throwing, the app
|
||||
frozen on the launch logo) is documented as the concrete instance of the top-level constraint
|
||||
"do not make an API fail to suppress a UI element".
|
||||
|
||||
## 8. Not done, and known to be outstanding
|
||||
|
||||
1. **No end-to-end device run.** The string-table patch was not repacked, signed and installed in this
|
||||
pass, so "an equal-length key replacement suppresses the feature while the app still starts" is
|
||||
`unverified` end-to-end even though the record layout that makes it legal is `observed`.
|
||||
2. **No `blutter` build**, so no `pp.txt` comparison and no confirmation of the arm64 pool space from a
|
||||
second implementation (§6).
|
||||
3. **The sample's Dart version is unpinned** (§5); a structural probe or a snapshot hash is the next
|
||||
experiment, not a guessed number.
|
||||
4. **The armv7 header word is not characterised.** It is read from the file and used as a validator;
|
||||
which component assigns it was not determined.
|
||||
@@ -0,0 +1,454 @@
|
||||
# Extension verification — desensitization and leak scans
|
||||
|
||||
Covers the pass that added `skills/apk-reverse/scripts/scan_leaks.py` and
|
||||
`skills/apk-reverse/references/desensitization-and-leak-scans.md`, plus the precedent library
|
||||
under `skills/apk-reverse/references/precedents/`.
|
||||
|
||||
Strength labels follow `docs/tool-verification/README.md`: **observed** (the exact command and
|
||||
its output are reproduced here), **inferred** (follows from observed facts; the step itself was
|
||||
not executed), **unverified** (assumed or reported elsewhere and not independently confirmed).
|
||||
|
||||
Reference environment: Windows 11 host, Python 3.14.0, repository root on branch
|
||||
`main`. **No device, no network and no target sample were involved** — this pass is entirely
|
||||
machine-local, because the thing worth measuring is what a scanner reports against a tree whose
|
||||
contents are already known. Every identifier in the fixture and in every transcript below is
|
||||
synthetic; the repository's convention of `<PKG>` / `<DEVICE>` / `<serial>` is used wherever a
|
||||
real one would have appeared.
|
||||
|
||||
Date of the recorded runs: 2026-09-21. The numbers in §3b and §4a were measured on that date, on
|
||||
the tree this pass leaves behind; those two sections' transcripts are the ones this pass produced.
|
||||
|
||||
**Headline: the planted-leak corpus is reported in full (30 findings, 10 further shapes admitted
|
||||
through the exemption path), and the repository scan converges to zero strong/certain findings —
|
||||
the residue is four `endpoint/weak` hits, all four an RFC 5737 documentation address, which is a
|
||||
class this scanner reports on purpose. The first version of this file was itself the
|
||||
counter-example to its own subject: it quoted the planted values to prove the rules fire, and the
|
||||
scanner found that as a leak (§3b). The corpus keeps its values, because a corpus that cannot be
|
||||
found proves nothing; the published document keeps none. The gate is not wired into anything yet;
|
||||
that is the largest gap, and it is a Lead-side action.**
|
||||
|
||||
---
|
||||
|
||||
## 1. What was built, and what it was measured against
|
||||
|
||||
| Artifact | Role | Measured? |
|
||||
|---|---|---|
|
||||
| `skills/apk-reverse/scripts/scan_leaks.py` | the scanner: 17 rules in 6 categories, exemption table, four `RESULT=` tokens, `--fail-on strong\|any` | **observed** — every run below |
|
||||
| `tools/_work/leak-precedent/gen_fixtures.py` | generates the planted corpus and the exemption corpus | **observed** — 7 files, re-runnable |
|
||||
| `tools/_work/leak-precedent/fixture/` | the corpus itself (git-ignored work area) | **observed** |
|
||||
| `skills/apk-reverse/references/desensitization-and-leak-scans.md` | the reasoning, the do-not-anonymize table, the failure modes | not executable |
|
||||
| `skills/apk-reverse/references/precedents/**` | the case library, distilled from this repository's existing evidence | see the per-case strength tables |
|
||||
|
||||
The fixture exists so the claim "the scanner reports planted leaks" is reproducible rather than
|
||||
a transcript pasted once:
|
||||
|
||||
```
|
||||
$ python tools/_work/leak-precedent/gen_fixtures.py
|
||||
wrote tools/_work/leak-precedent\fixture\planted\package.txt (411 bytes)
|
||||
wrote tools/_work/leak-precedent\fixture\planted\device.txt (218 bytes)
|
||||
wrote tools/_work/leak-precedent\fixture\planted\token.txt (261 bytes)
|
||||
wrote tools/_work/leak-precedent\fixture\planted\appkey.txt (212 bytes)
|
||||
wrote tools/_work/leak-precedent\fixture\planted\endpoint.txt (146 bytes)
|
||||
wrote tools/_work/leak-precedent\fixture\planted\path.txt (175 bytes)
|
||||
wrote tools/_work/leak-precedent\fixture\exempt\benign.txt (1396 bytes)
|
||||
== 7 fixture file(s) ==
|
||||
```
|
||||
|
||||
## 2. `--help`, and the rule table — observed
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/scan_leaks.py --help
|
||||
usage: scan_leaks.py [-h] [--root ROOT] [--only ONLY] [--exclude EXCLUDE]
|
||||
[--format {text,json}] [--show-exempt] [--list-rules]
|
||||
[--max MAX] [--quiet] [--fail-on {strong,any}]
|
||||
[--no-color]
|
||||
|
||||
Scan a skills repository for target identity that should not be published (bundle ids, device serials, tokens, SDK keys, literal endpoints, user paths). Exit 0 clean / 1 leaks_found / 2 error; prints RESULT=<token> last. `--fail-on strong` (default) keeps the gate green when the only hits are documentation-range addresses.
|
||||
|
||||
options:
|
||||
-h, --help show this help message and exit
|
||||
--root ROOT repository root to scan (default: the repo this script
|
||||
lives in)
|
||||
--only ONLY comma-separated categories to restrict to, e.g.
|
||||
pat,appkey
|
||||
--exclude EXCLUDE extra directory name to skip (repeatable)
|
||||
--format {text,json}
|
||||
--show-exempt also print findings that were suppressed by an
|
||||
exemption
|
||||
--list-rules print the rule table and exit
|
||||
--max MAX stop after N findings (0 = no limit)
|
||||
--quiet print only the RESULT token
|
||||
--fail-on {strong,any}
|
||||
which findings make the exit code non-zero: `strong`
|
||||
(default) fails only on strong/certain findings, `any`
|
||||
also fails on `weak` ones such as an address from the
|
||||
RFC 5737 documentation range
|
||||
--no-color accepted for compatibility
|
||||
|
||||
Categories: package, device, token, appkey, endpoint, path
|
||||
Exemptions (tool names, library names, CVE ids, hardening products, public
|
||||
crackme names, <PKG>-style placeholders, loopback/emulator addresses) are built
|
||||
in and audited with --show-exempt.
|
||||
```
|
||||
|
||||
`--list-rules` prints the table the exemption functions are attached to, which is what makes the
|
||||
scanner arguable rather than opaque. It exits 0 and prints `RESULT=clean` (no files were read),
|
||||
and the top of its output is:
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/scan_leaks.py --list-rules
|
||||
== rules ==
|
||||
bundle_pkg_attr package strong a `package=` bundle id in a manifest or transcript
|
||||
bundle_pkg_decl package strong a bundle id in a `<manifest>` line
|
||||
bundle_pm_path package strong a bundle id in a `pm`/`am`/`monkey` invocation
|
||||
bundle_process_line package strong a bundle id in a `ps`/`pidof`/`top` capture
|
||||
bundle_component package strong a bundle id in a `component=`/`-n` activity reference
|
||||
device_serial device strong an `adb devices`-shaped device serial
|
||||
device_serial_context device strong a serial next to a device/install/`su` context word
|
||||
device_serial_tabular device strong a bare 16-char token in a device-listing table
|
||||
token_github_pat token certain a GitHub fine-grained personal access token
|
||||
token_github_classic token certain a GitHub classic token
|
||||
token_env_assignment token strong an inline API-key/token/secret assignment
|
||||
token_authorization_header token strong a literal Authorization header value
|
||||
appkey_assignment appkey strong an SDK appkey/appsecret assignment with a literal value
|
||||
plain_addr endpoint weak a non-loopback literal IP:port
|
||||
plain_addr_ipliteral endpoint weak a non-loopback bare IPv4 literal
|
||||
user_path_posix path strong an absolute POSIX user-home path
|
||||
user_path_windows path strong an absolute Windows user-profile path
|
||||
RESULT=clean (exit 0)
|
||||
```
|
||||
|
||||
## 3. The planted corpus — observed, every rule fires
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/scan_leaks.py --root tools/_work/leak-precedent/fixture --format json
|
||||
{ "root": "...\\tools\\_work\\leak-precedent\\fixture", "files_scanned": 7,
|
||||
"findings": [ ... 30 ... ], "exempted": [], "errors": [], "result": "leaks_found" }
|
||||
RESULT=leaks_found (exit 1)
|
||||
```
|
||||
|
||||
aggregated (the `findings` array of the same JSON run, grouped by category):
|
||||
|
||||
```
|
||||
files 7
|
||||
findings 30 {'appkey': 4, 'token': 5, 'device': 8, 'endpoint': 5, 'package': 5, 'path': 3}
|
||||
```
|
||||
|
||||
and the 30 hits by position, rule and value **shape** — every field of the report except the
|
||||
literal itself. Rule name, category, strength, position and count are the evidence; the quoted
|
||||
value is a copy of the leak, and copying it into a published document re-creates what the scan
|
||||
exists to prevent (§3b is where that rule was paid for):
|
||||
|
||||
```
|
||||
planted/package.txt:2:16 bundle_pkg_decl 4-segment all-lowercase reverse domain
|
||||
planted/package.txt:2:26 bundle_pkg_attr the same value, second rule on the same line
|
||||
planted/package.txt:3:1 bundle_pm_path 3-segment all-lowercase reverse domain, after `pm path`
|
||||
planted/package.txt:5:1 bundle_process_line 4-segment all-lowercase reverse domain, after `pidof`
|
||||
planted/package.txt:6:15 bundle_component 4-segment all-lowercase reverse domain, after `component=`
|
||||
|
||||
planted/device.txt:4:1 device_serial 16×[A-Z0-9], tab-separated from the word `device`
|
||||
planted/device.txt:4:1 device_serial_tabular the same value, second rule on the same line
|
||||
planted/device.txt:5:3 device_serial_context 16×[A-Z0-9], after `$ adb -s `
|
||||
planted/device.txt:5:10 device_serial the same value, second rule on the same line
|
||||
planted/device.txt:6:2 device_serial_context 16×[A-Z0-9], after `[serial `
|
||||
planted/device.txt:6:9 device_serial the same value, second rule on the same line
|
||||
planted/device.txt:7:1 device_serial_context 16×[A-Z0-9], after `device serial: ` (a second value)
|
||||
planted/device.txt:7:16 device_serial the same value, second rule on the same line
|
||||
|
||||
planted/token.txt:2:14 token_github_pat fixed provider prefix + 62×[A-Za-z0-9_]
|
||||
planted/token.txt:3:15 token_github_classic fixed 4-char provider prefix + 36×[A-Za-z0-9]
|
||||
planted/token.txt:4:1 token_env_assignment 20-char `AKIA`-prefixed key id after a key assignment
|
||||
planted/token.txt:5:9 token_authorization_header 32×[A-Za-z0-9] after an `Authorization:` keyword
|
||||
planted/appkey.txt:5:1 token_env_assignment the same 40-char secret, second rule on the same line
|
||||
|
||||
planted/appkey.txt:2:1 appkey_assignment 24×[0-9a-f] after an appkey assignment
|
||||
planted/appkey.txt:3:1 appkey_assignment 24×[0-9a-f], quoted, after a camelCase app-secret assignment
|
||||
planted/appkey.txt:4:1 appkey_assignment 7 dash-separated 4-character uppercase groups
|
||||
planted/appkey.txt:5:1 appkey_assignment 40-char mixed-case secret after a `SECRET_KEY` assignment
|
||||
|
||||
planted/endpoint.txt:2:7 plain_addr an RFC 5737 documentation address, with `:port`
|
||||
planted/endpoint.txt:2:7 plain_addr_ipliteral the bare form of the same address
|
||||
planted/endpoint.txt:3:16 plain_addr an RFC 1918 private-range address, with `:port`
|
||||
planted/endpoint.txt:3:16 plain_addr_ipliteral the bare form of the same address
|
||||
planted/endpoint.txt:4:12 plain_addr_ipliteral an RFC 5737 documentation address, bare
|
||||
|
||||
planted/path.txt:2:10 user_path_posix two-segment personal name after `/home/`
|
||||
planted/path.txt:3:6 user_path_posix one-word personal name after `/Users/`
|
||||
planted/path.txt:4:6 user_path_windows capitalized personal name after `C:\Users\`
|
||||
```
|
||||
|
||||
Two rules firing on one line (`SECRET_KEY=` matches both an appkey and a token pattern) is kept
|
||||
deliberately and is visible in the count; identical duplicates (same rule, same value, same
|
||||
line) are collapsed. Eight of the 30 findings share their line with a second rule — one package
|
||||
line, all four serial lines, the `SECRET_KEY` line and the two endpoint lines — which is why 30
|
||||
findings cover 22 distinct values (counted from the `findings` array of the JSON run above, not
|
||||
by hand).
|
||||
|
||||
### 3a. The exemption corpus — observed, and it is the half that is easy to get wrong
|
||||
|
||||
The same run with `--show-exempt` shows what was *not* reported from
|
||||
`fixture/exempt/benign.txt`, and the reason:
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/scan_leaks.py --root tools/_work/leak-precedent/fixture --show-exempt
|
||||
== suppressed by exemption: 10 ==
|
||||
exempt/benign.txt:13 [user_path_windows] <user> -> placeholder user
|
||||
exempt/benign.txt:17 [plain_addr] 127.0.0.1:27042 -> loopback/unspecified/emulator address
|
||||
exempt/benign.txt:17 [plain_addr_ipliteral] 127.0.0.1 -> loopback/unspecified/emulator address
|
||||
exempt/benign.txt:17 [plain_addr] 0.0.0.0:8080 -> loopback/unspecified/emulator address
|
||||
exempt/benign.txt:17 [plain_addr_ipliteral] 0.0.0.0 -> loopback/unspecified/emulator address
|
||||
exempt/benign.txt:17 [plain_addr] 10.0.2.2:8080 -> loopback/unspecified/emulator address
|
||||
exempt/benign.txt:17 [plain_addr_ipliteral] 10.0.2.2 -> loopback/unspecified/emulator address
|
||||
exempt/benign.txt:18 [plain_addr] 127.0.0.1:8787 -> loopback/unspecified/emulator address
|
||||
exempt/benign.txt:18 [plain_addr_ipliteral] 127.0.0.1 -> loopback/unspecified/emulator address
|
||||
exempt/benign.txt:18 [plain_addr_ipliteral] 169.254.169.254 -> loopback/unspecified/emulator address
|
||||
```
|
||||
|
||||
The earlier pass recorded 6 suppressed hits, all `plain_addr`; the current run reports the paired
|
||||
`plain_addr_ipliteral` for each exempt address as well, which is what the two same-line rules
|
||||
look like when nothing is being collapsed. The count moved from 6 to 10 for that reason and not
|
||||
because the exemption table changed — nothing in it was touched by this pass.
|
||||
|
||||
Everything else on that file — tool names, library names, function names, dex/ELF constant
|
||||
identifiers, protocol field names, CVE ids, hardening product names, the public crackme and its
|
||||
URL, platform and SDK packages, `com.example.*` / `probe.synthetic.*` and the `<PKG>`/`<DEVICE>`/
|
||||
`<hash>` placeholders — produced **no finding at all**, which is the intended outcome for the
|
||||
do-not-anonymize list and is also the check that the two synthetic fixture prefixes named in
|
||||
`references/desensitization-and-leak-scans.md` really are exempt: `com.example.app` sits on line
|
||||
83 of that file and is absent from both the findings and the suppressed list, because
|
||||
`BENIGN_PACKAGE_PREFIXES` contains `com.example`, `org.example`, `net.example`, `io.example` and
|
||||
`probe.synthetic`. The file was written to hold every category of that list, one line each, so a
|
||||
regression there shows up as a new finding rather than as a silent behavioural drift.
|
||||
|
||||
`169.254.169.254` (the cloud metadata endpoint) is exempted **only** in this documented-endpoint
|
||||
form. It is a real address that matters in a different context; the exemption is a judgement
|
||||
recorded in `BENIGN_IP_PREFIXES`, not a pattern match, and it is the entry most worth
|
||||
re-examining if this list is ever copied to another repository.
|
||||
|
||||
### 3b. The leak this file carried — found by the scanner it documents
|
||||
|
||||
The first version of this section listed all 30 hits **with their values**, quoted out of the
|
||||
JSON, because a report with the value in it is the concrete kind. The Lead then ran the tool
|
||||
against the repository and got `RESULT=leaks_found`, exit 1, with **26 strong/certain findings
|
||||
and 14 weak ones — all 26 strong ones inside this file**, at lines 113–146 of the version that
|
||||
existed then: the 5 bundle-id hits, 2 distinct device serials (12 hits), 2 provider-format tokens,
|
||||
2 inline assignment values, 2 SDK appkeys and 3 host user names it had just finished quoting.
|
||||
Nothing else in the tracked surface produced a strong hit.
|
||||
|
||||
That is the failure mode this whole pass exists to prevent, committed by the pass itself, and it
|
||||
states the boundary in one line: **the corpus is where identity is allowed; the published surface
|
||||
is not.** `tools/_work/leak-precedent/` is git-ignored and excluded from the default scan root so
|
||||
that it *can* hold the values — a corpus that does not contain them cannot demonstrate the rules
|
||||
firing. This file is not in that category: it is tracked, it is read, and every string in it is
|
||||
published.
|
||||
|
||||
The correction, applied in this pass:
|
||||
|
||||
1. The hit list in §3 now carries rule, category, strength, position, count and a **structural
|
||||
description** of each value, in the repository's own placeholder notation. No evidence was
|
||||
dropped: all 30 positions, all 17 rule ids, both strength labels and the per-category counts
|
||||
are still here, and the corpus still produces all 30 when it is scanned directly (§3).
|
||||
2. The two version-shaped literals in §4's false-positive table are now written in
|
||||
`<N>.<N>.<N>.<N>` form. They are legitimate values, but leaving them would put two *different*
|
||||
classes in the weak residue instead of one, and an unexplained second class is how a weak hit
|
||||
starts getting ignored.
|
||||
3. `references/desensitization-and-leak-scans.md` records the rule this produced — a report is a
|
||||
copy of what it found, so the report is scanned like any other published surface — and its
|
||||
exit table now carries the `leaks_found_strong_only` state.
|
||||
|
||||
The scanner's own verdict on the corrected tree is in §4a. The lesson is the one the reference
|
||||
file already made about entry points, applied to an evidence file: **run the scan on the file you
|
||||
are about to publish, not on the file you started from** — and then do not paste the thing it
|
||||
found back into that file.
|
||||
|
||||
## 4. The repository scan — observed, and the false positives that produced it
|
||||
|
||||
The first run against the real tree reported **28 findings**, of which 27 were wrong. The
|
||||
sequence is recorded in full because the corrections are the useful part: every one of them was
|
||||
found by running the scanner, not by reasoning about it.
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/scan_leaks.py # first run
|
||||
== leak scan: 118 file(s) scanned under the repository root ==
|
||||
findings by category: device=4, endpoint=20, package=4
|
||||
== result: 28 finding(s) == RESULT=leaks_found (exit 1)
|
||||
```
|
||||
|
||||
| # | Reported | Why it was wrong | Fix |
|
||||
|---|---|---|---|
|
||||
| 1 | a `jdk`-version-shaped run (`…\jdk-<N>.<N>.<N>.<N>`) ×19, across eight files in `docs/tool-verification/` | it is the JDK version in the toolchain's own path; the token `jdk` sits immediately before the match | a 30-character context window before the literal must not end in a version word (`jdk\|java\|python\|frida\|…`) |
|
||||
| 2 | `0000000040001000`, `0000000000714240` | tombstone register/`pc` values — 16 hex digits, all digits | an all-digit 16-character token is suppressed; a real platform serial carries at least one letter |
|
||||
| 3 | `0807060504030201` ×3 | a protobuf `fixed64` fixture in `EXTENSION-protobuf-raw.md` and in `scripts/protobuf_decode_raw.py` | same rule as #2 |
|
||||
| 4 | `sg.vantagepoint.uncrackable3` | the published package name of an OWASP MASTG crackme | `PUBLIC_TARGET_PREFIXES` (public crackme/benchmark families are reuse, not identity) |
|
||||
| 5 | `probe.synthetic.vmpdiff` | the synthetic manifest inside `vmp_diff_harness.py` | added `probe.synthetic` to the fixture prefixes |
|
||||
| 6 | `args.package.spli` (twice truncated to `spli`) | a property lookup in `scripts/spawn_patch_detach.py`; the exemption window was measured from the *match* start, which an unbounded `[^\n]*?` prefix pushes past the `args.` | the window starts at the **capture group** (`m.start(group)`), and the exemption reason is printed so the path can be audited |
|
||||
| 7 | `<N>.<N>.<N>.<N>` | an SDK version *directory* (`…/com.kwad.components.tachikoma/<N>.<N>.<N>.<N>/base-1_apk`) inside a private-storage path | a version-directory rule: a `.`-separated digit run enclosed in path separators is not an address |
|
||||
|
||||
Two rows in that table are quoted in placeholder form rather than as the literal the first run
|
||||
printed — row 1's four-part JDK version run and row 7's four-part SDK version run. Neither names
|
||||
anybody: both are version numbers, and both were rewritten in this pass for the reason given in
|
||||
§3b, because leaving them would put a second class (version runs, not addresses) into the weak
|
||||
residue, where an unexplained class is the first thing a reader learns to skip. The shape is what
|
||||
each row is about, and it survives the rewrite intact.
|
||||
|
||||
### 4a. What this pass inherited, and what it leaves — observed
|
||||
|
||||
Inherited state, reproduced at the start of this pass (same tree, before the §3b correction):
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/scan_leaks.py
|
||||
== leak scan: 128 file(s) scanned under the repository root ==
|
||||
findings by category: appkey=2, device=12, endpoint=14, package=5, path=3, token=4
|
||||
== result: 40 finding(s) -- each one is a line to look at, not a verdict ==
|
||||
RESULT=leaks_found (exit 1)
|
||||
```
|
||||
|
||||
26 of the 40 are strong/certain (`package` 5, `device` 12, `token` 4, `appkey` 2, `path` 3) and
|
||||
every one of those 26 is a line of this file's former §3 hit list, at lines 113–146. The other 14
|
||||
are weak: 5 more lines of that same block (its endpoint rows), 2 version strings in §4's own
|
||||
table, 3 RFC 5737 range literals in
|
||||
`skills/apk-reverse/references/desensitization-and-leak-scans.md`, and `scripts/tls_check.py`'s
|
||||
usage example.
|
||||
|
||||
Post-correction, on the tree this pass leaves behind:
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/scan_leaks.py --quiet
|
||||
RESULT=leaks_found_strong_only (exit 0)
|
||||
|
||||
$ python skills/apk-reverse/scripts/scan_leaks.py
|
||||
== leak scan: 129 file(s) scanned under the repository root ==
|
||||
findings by category: endpoint=4
|
||||
|
||||
skills/apk-reverse/references/desensitization-and-leak-scans.md:81:10 [endpoint/weak] plain_addr_ipliteral
|
||||
match: <RFC 5737 literal>
|
||||
skills/apk-reverse/references/desensitization-and-leak-scans.md:81:26 [endpoint/weak] plain_addr_ipliteral
|
||||
match: <RFC 5737 literal>
|
||||
skills/apk-reverse/references/desensitization-and-leak-scans.md:81:45 [endpoint/weak] plain_addr_ipliteral
|
||||
match: <RFC 5737 literal>
|
||||
skills/apk-reverse/scripts/tls_check.py:44:23 [endpoint/weak] plain_addr_ipliteral
|
||||
match: <RFC 5737 literal>
|
||||
|
||||
== result: 4 finding(s) -- each one is a line to look at, not a verdict ==
|
||||
all are `weak` (the documented address ranges); the gate is green under the default --fail-on strong, and `--fail-on any` is the strict pass
|
||||
RESULT=leaks_found_strong_only
|
||||
|
||||
$ python skills/apk-reverse/scripts/scan_leaks.py --quiet --fail-on any
|
||||
RESULT=leaks_found (exit 1)
|
||||
```
|
||||
|
||||
The four residual hits, by position, rule and class. Their individual values are deliberately not
|
||||
reprinted here — that is §3b's correction, and re-printing them is how this file leaked the first
|
||||
time. All four are the same class:
|
||||
|
||||
| position | rule / strength | class |
|
||||
|---|---|---|
|
||||
| `skills/apk-reverse/references/desensitization-and-leak-scans.md:81` ×3 | `plain_addr_ipliteral` / weak | the three RFC 5737 documentation blocks, named inside that file's own statement that they are *not* exempt |
|
||||
| `skills/apk-reverse/scripts/tls_check.py:44` | `plain_addr_ipliteral` / weak | the script's usage example, an RFC 5737 address |
|
||||
|
||||
Reading: **zero strong/certain findings and zero unexplained hits on the tracked surface; four
|
||||
weak ones, one class, both sources named.** The residue is the price of the design decision
|
||||
recorded in §"Deliberately *not* exempt" of the reference file: the documentation that states the
|
||||
decision has to name the ranges, and the ranges are exactly what the rule reports. That is why
|
||||
the gate's pass condition lives in `--fail-on` and not in the exemption table, and why
|
||||
`--fail-on any` stays red on this repository on purpose.
|
||||
|
||||
The file count is 129 here, 128 in the inherited run above and 119 in the first pass: the tracked
|
||||
surface grows while several writers work on it, and this pass added no file to the scanned
|
||||
surface, so the count is a property of the run rather than of the leak. The number that is
|
||||
comparable across the runs is the strong/certain one: **26 inherited, 0 now**. The inherited
|
||||
figure also reproduces the Lead's own catch (§3b) exactly, which is the point of quoting the
|
||||
transcript rather than the conclusion.
|
||||
|
||||
Two work artifacts of this pass live beside the corpus in the same git-ignored directory, and
|
||||
neither is part of the scanned surface: `EXTENSION-desensitization.before.md` (the pre-correction
|
||||
snapshot of this file, kept so that the correction is auditable in both directions) and
|
||||
`fixture-scan-before.txt` (a captured run of the fixture scan). Both contain the planted values
|
||||
by construction, exactly like the corpus, and both are excluded from every default run — the work
|
||||
area is where identity is allowed to exist; that is the whole of §3b's boundary.
|
||||
|
||||
## 5. The exit-code contract — observed, all four states
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/scan_leaks.py --root LICENSE --quiet
|
||||
RESULT=clean (exit 0)
|
||||
|
||||
$ python skills/apk-reverse/scripts/scan_leaks.py --quiet
|
||||
RESULT=leaks_found_strong_only (exit 0) # the repository tree, weak hits only
|
||||
|
||||
$ python skills/apk-reverse/scripts/scan_leaks.py --quiet --fail-on any
|
||||
RESULT=leaks_found (exit 1) # the same tree, strict pass
|
||||
|
||||
$ python skills/apk-reverse/scripts/scan_leaks.py --root tools/_work/leak-precedent/fixture --quiet
|
||||
RESULT=leaks_found (exit 1)
|
||||
|
||||
$ python skills/apk-reverse/scripts/scan_leaks.py --root tools/_work/nope-not-here
|
||||
error: root does not exist: tools/_work/nope-not-here [stderr]
|
||||
RESULT=error (exit 2)
|
||||
|
||||
$ python skills/apk-reverse/scripts/scan_leaks.py --only nope
|
||||
error: unknown category (or categories): nope [stderr]
|
||||
RESULT=error (exit 2)
|
||||
```
|
||||
|
||||
The token and the exit code are deliberately separate channels: a wrapper that only greps
|
||||
stdout cannot tell "clean" from "the scanner never ran", and one that only reads `$?` cannot
|
||||
explain itself in a log.
|
||||
|
||||
`--fail-on` is what keeps those two channels from contradicting each other on a tree that is
|
||||
documentation-shaped. Under the default (`strong`) a tree whose only findings are `weak` reports
|
||||
`leaks_found_strong_only` and exits 0: the findings are still printed in full, they simply do not
|
||||
fail the gate. Under `--fail-on any` the same tree reports `leaks_found` and exits 1, which is the
|
||||
right verdict at publish time and the wrong one for a habit that runs on every change — a gate
|
||||
that is red on its own design documentation is a gate people disable.
|
||||
|
||||
## 6. What was not done
|
||||
|
||||
- **The `RESULT=` token convention was undocumented when this pass started, and it is documented
|
||||
now.** The Lead added `references/long-task-discipline.md` §Every script answers with a token,
|
||||
not with prose during this pass, so the scanner is no longer the convention's only user. That
|
||||
section's vocabulary is generic (`clean`, `patched`, `success`, `crash`, `timeout`,
|
||||
`unavailable`, …) and does not spell out `leaks_found` / `leaks_found_strong_only`; whether
|
||||
they belong in the enumeration is a Lead call. Verified with
|
||||
`grep -n "RESULT=" skills/apk-reverse/references/long-task-discipline.md` → one match, line 70.
|
||||
- **Nothing is wired into a gate.** No pre-commit hook, no CI job, no line in the maintenance
|
||||
three-command habit. The scanner has never run automatically; every result above is a manual
|
||||
invocation. Recorded as `unverified` in the sense that matters: whether it *would* be run is
|
||||
untested.
|
||||
- **No scan of the git-tracked list specifically.** The scan walks a filesystem root, so its
|
||||
coverage is "the default surface under this root", not "the files that exist in HEAD". A file
|
||||
that is present locally but ignored, or tracked but absent, is not distinguished.
|
||||
- **No check of binary or archive content.** `*.apk`, `*.png`, `*.pcap` and `*.zip` are not
|
||||
opened. Identity inside a screenshot or a capture file is real and this pass does not address
|
||||
it.
|
||||
- **No git-history scan.** A string deleted from the working tree remains in the history; nothing
|
||||
here inspects commits. This pass did check the one case it created: `git status --short` shows
|
||||
`docs/tool-verification/EXTENSION-desensitization.md` as `??` (untracked), so the values §3b
|
||||
removed were never committed and nothing was written into history. That is a check of this
|
||||
file, not a history scan.
|
||||
- **No second-language rule set.** Identifiers that are not reverse-domain shaped (a nickname, a
|
||||
local-part email, a CJK app name) are not covered. The rules are shape-based by construction.
|
||||
- **No cross-repository validation of the exemption list.** The benign prefixes were derived from
|
||||
this tree's own contents (a grep over its Markdown for reverse-domain tokens). Whether the same
|
||||
list is right for another repository is `unverified`, and the reference file says so.
|
||||
- **The `--max` / `--only` / `--exclude` paths were exercised only in the forms shown above.**
|
||||
`--max` was not driven at all.
|
||||
|
||||
## 7. One-line deliverable statement, and the open items
|
||||
|
||||
**Deliverable (verified):** `python skills/apk-reverse/scripts/scan_leaks.py` scans a repository
|
||||
root for six categories of target identity, reports each hit with a limited context window and a
|
||||
strength label, exits 0/1/2 with a matching `RESULT=` token, reports all 30 planted leaks in the
|
||||
reproducible corpus at `tools/_work/leak-precedent/fixture` (7 files, 10 further shapes admitted
|
||||
through the exemption path), and against this repository's 129-file tracked surface returns zero
|
||||
strong/certain findings — the residue is four `endpoint/weak` hits, all RFC 5737, both sources
|
||||
named in §4a — so the default gate is green (`RESULT=leaks_found_strong_only`, exit 0) while
|
||||
`--fail-on any` stays available as the strict pass (`RESULT=leaks_found`, exit 1).
|
||||
|
||||
**Open items for the Lead:**
|
||||
|
||||
1. Wire the scan into the maintenance gate (`check_repo.py`, `check_refs.py`, `scan_leaks.py`)
|
||||
and record in `README.md` that the third one's exit 1 is a *content* finding, not a
|
||||
mechanical failure like the other two.
|
||||
2. Decide on a history scan. Nothing in this pass looked at commits, and a leak removed from the
|
||||
tree is still in them.
|
||||
@@ -0,0 +1,311 @@
|
||||
# Extension verification — detection pipeline, layered descent, ptrace-free dumping
|
||||
|
||||
Covers the pass that added the §Step 3 locating pipeline and the §Step 2B boundaries to
|
||||
`references/detection-and-anti-analysis.md`, and the §What this route cannot do /
|
||||
§Is this dex image real / §Layered descent sections to `references/advanced-unpacking.md`.
|
||||
It also ships `scripts/svc_scan.py` and `scripts/anti_detect_probe.js`.
|
||||
|
||||
Strength labels, as defined in this directory's README:
|
||||
|
||||
- **observed** — reproduced here, with the exact command and its output.
|
||||
- **inferred** — follows from observed facts, but the step itself was not executed.
|
||||
- **unverified** — assumed, or reported by a tool and not independently confirmed.
|
||||
|
||||
**Target identity is deliberately absent.** The hardened sample appears as `<PKG>`; the
|
||||
public MASTG challenge apps appear by their package names because they are public
|
||||
published targets, not a user's app. No keys, signatures or user data are recorded.
|
||||
|
||||
## 0. Environment and what was decided before measuring
|
||||
|
||||
| Item | Value |
|
||||
|---|---|
|
||||
| Device | Android 11 (API 30), arm64-v8a, kernel **4.14.186**, Magisk alpha + root, toybox shell |
|
||||
| Host | frida 16.7.19 / frida-tools 13.7.1; capstone; lief; pyelftools |
|
||||
| Instrumentation server | **a `frida-server` was already running on the device** (`frida-server -l 0.0.0.0:27099`, pid 31859) with a leftover `adb forward tcp:27099` |
|
||||
| Decision taken | **Reuse it rather than restart it.** A restart is a device-wide variable and the process had a live abstract socket (`unix:abstract=/frida-...)`; it was never started by this pass, so nothing about it is attributed here. Cost: the server's listening port and its path are part of the environment a detector can see — see §4, where a probe confirms frida artefacts *are* visible in the target's own maps |
|
||||
| Root hiding | `magisk --denylist status` = *enforced*, list **empty** → no app had root hidden. Not changed in this pass |
|
||||
|
||||
`frida-ls-devices` cannot be used as a liveness check in this harness (it requires a
|
||||
Windows console and dies with `NoConsoleScreenBufferError`); the Python binding is the
|
||||
reliable route, and that is what every measurement below used:
|
||||
|
||||
```python
|
||||
dev = frida.get_device_manager().add_remote_device('127.0.0.1:27099')
|
||||
dev.query_system_parameters()
|
||||
# {'arch': 'arm64', 'os': {'version': '11', 'id': 'android'}, 'api-level': 30, 'access': 'full'}
|
||||
```
|
||||
|
||||
## 1. `scripts/svc_scan.py` — measured, with the count cross-checked
|
||||
|
||||
`--help` works; a real call was made on five inputs. T3 cross-check first, because the
|
||||
whole script is a decode claim:
|
||||
|
||||
```
|
||||
json sites = 214
|
||||
hand word-scan = 214
|
||||
set difference = []
|
||||
```
|
||||
|
||||
The hand word-scan is a 6-line script that matches the `svc` encoding
|
||||
(`(w & 0xFFE0001F) == 0xD4000001`) with no dependency on capstone, so the two readings
|
||||
are genuinely independent implementations. **observed.**
|
||||
|
||||
Device libraries, scanned from copies pulled with `su -c cp`:
|
||||
|
||||
| Input | Sites | by_group | Notable |
|
||||
|---|---|---|---|
|
||||
| `/apex/…/bin/linker64` (1,451,392 B) | 214 | termination=4, evasion=5, discovery=8, unresolved=6 | the 4 termination sites are `exit`/`exit_group` at `0xda284`/`0xda2a4` |
|
||||
| `/apex/…/lib64/bionic/libc.so` (1,193,024 B) | 215 | termination=4, evasion=6, discovery=8, unresolved=6 | `kill @0xc5b94`, `tgkill @0xc5bb4`, `ptrace @0xc5bd4`, `mprotect @0xc5f54`, `exit_group @0xc7054`, `exit @0xc7074` |
|
||||
| `assets/libjiagu_a64.so` from the sample (1,171,896 B) | 21 | **unresolved=21** | every site is inside `scvtf`/`udf`/NEON byte soup — data, not instructions |
|
||||
| `assets/libjiagu.so` (32-bit, 861,436 B) | 11710 | unresolved=11116 | scanning both PT_LOAD segments mixes code and data; the site count is not usable without inspecting neighbours |
|
||||
|
||||
**The interpretation that matters, and it is the reason the script exists:** the four
|
||||
termination sites in `libc.so` are libc's *own* exported implementations, so a hook on
|
||||
`exit`/`kill` does see callers that go through libc. The shell library has **no**
|
||||
termination site and 21 data artefacts — so on this target a libc-level hook is *not*
|
||||
structurally excluded, and a missing exit event would be a finding about the hook.
|
||||
**observed.**
|
||||
|
||||
Two defects were found by running it and both are fixed in the shipped script:
|
||||
|
||||
- `capstone` raises `CS_ERR_DETAIL` unless `detail = True` is set; the first run died on the
|
||||
first hit (`AttributeError`-equivalent at `insn.operands`).
|
||||
- `pf_x` is **not** usable as a filter on this platform. The ROM's own `libc.so` and
|
||||
`linker64` carry `pf_x` set on their code segment but a first version of the probe read
|
||||
`p_flags` from the wrong struct offset (reading `p_offset`'s low word instead), which
|
||||
reported `pf_x=0` for everything. The shipped version reports per-segment `pf_x` and does
|
||||
not filter on it by default: `--code-only` is opt-in precisely because a device that
|
||||
clears the flag would otherwise return zero hits silently.
|
||||
|
||||
## 2. The hardened sample: what it does now, and why nothing was dumped from it
|
||||
|
||||
The previous pass recorded this sample as a clean self-exit on a 7-18 s cycle with no
|
||||
tombstone. **That is no longer the shape.** Measured this pass, twice, 20 samples at 1 Hz
|
||||
after `monkey` launch:
|
||||
|
||||
```
|
||||
22:28:19.923 D/OppoExSurfaceFlinger: hecate traverse … Splash Screen com.gentle.ppcat#0
|
||||
22:28:21.684 W/ActivityManager: Process ProcessRecord{… 14905:com.gentle.ppcat/u0a623} failed to attach
|
||||
22:28:21.686 I/ActivityManager: Killing 14905:com.gentle.ppcat/u0a623 (adj -10000): start timeout
|
||||
```
|
||||
|
||||
- **observed:** the platform kills the process on **`failed to attach` / `start timeout`**
|
||||
roughly **2 s** after start, i.e. the app never finishes starting.
|
||||
- **observed:** an attempt to harvest memory inside that lifetime failed on both tries —
|
||||
`RESULT=NO_PID` and `RESULT=NO_PID` with `t_pid=9.99`, so a 10 s window is already longer
|
||||
than the process lives. Two strikes, no third variant: **the ptrace-free dump was not
|
||||
obtained from this sample and this file does not claim one.**
|
||||
- **observed, and a new signal rather than a repeat:** a tombstone is now produced, and its
|
||||
fault is not in the packer's own code but in the runtime, reached from JIT output:
|
||||
|
||||
```
|
||||
signal 11 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr 0x75f16ff028
|
||||
backtrace:
|
||||
#00 pc 0000000000574028 /apex/com.android.art/lib64/libart.so
|
||||
(art::Runtime::SetJavaDebuggable(bool))
|
||||
#01 pc 0000000000022d58 /memfd:jit-cache (deleted)
|
||||
```
|
||||
|
||||
A self-terminating `SIGABRT` would be the expected shape for an anti-analysis kill; a
|
||||
`SEGV` in `Runtime::SetJavaDebuggable` reached **from JIT-compiled Java** is not, and the
|
||||
honest reading is limited: **the app now crashes during startup, in the runtime, on a
|
||||
path a Java method entered.** Whether that is the packer's environment check firing or a
|
||||
consequence of the kill arriving mid-startup is **unverified** — the sample's own
|
||||
start/stop behaviour changed between passes and no control run with root hiding
|
||||
configured was made, because changing root-hiding state is a device-wide variable this
|
||||
pass deliberately did not touch.
|
||||
|
||||
## 3. Clean-target baseline: state, liveness, and a harness artefact avoided
|
||||
|
||||
- **observed:** `owasp.mstg.uncrackable1` launched with `monkey` stayed alive at pid 15310
|
||||
for **more than a minute** of repeated checks — the stable baseline every dynamic
|
||||
experiment needs, and the reason the arms below were run on it and on L3 rather than on
|
||||
the hardened sample.
|
||||
- **observed:** `owasp.mstg.uncrackable3` runs two processes (zygote64 + 32-bit), had its
|
||||
own integrity log line, and stayed up.
|
||||
- **observed, and the most expensive-looking failure that turned out to be free:** the
|
||||
first attach to L1 hung and then failed —
|
||||
`attach/spawn failed: unexpectedly timed out while waiting for signal from process with PID 15310`
|
||||
— and a second attach said `process not found`. The process was still in `ps` and
|
||||
`dev.enumerate_processes()` still listed it, but:
|
||||
|
||||
```
|
||||
$ adb shell 'cat /proc/15310/stat | cut -d" " -f3'
|
||||
D
|
||||
```
|
||||
|
||||
**`D` (uninterruptible disk sleep)** is why. A userspace attach needs the process to run.
|
||||
The one-line control that settled it in a single command: attaching to a *different* pid
|
||||
from the same device succeeded (`attach 15974 OK`). **observed** — the failure was the
|
||||
environment, not a detector, and this is now Stage 0 of the reference's pipeline.
|
||||
|
||||
**Harness self-check applied before interpreting anything** (the discipline that a spawn
|
||||
which is never resumed produces zero events and reads as a hang): the driver
|
||||
(`tools/_work/detect/run_anti_detect_probe.py`, a work-file, not shipped) resumes
|
||||
**explicitly** after the script is loaded, and records whether it did. Both arms below
|
||||
report `resumed=True`, so "no events" cannot be an un-resumed spawn here.
|
||||
|
||||
## 4. The three arms, on one target — and the detection actually found
|
||||
|
||||
All arms: `owasp.mstg.uncrackable3`, `--spawn`, script loaded before resume.
|
||||
|
||||
| Arm | Script | Result |
|
||||
|---|---|---|
|
||||
| **A. baseline** | none (launch, no instrumentation) | process alive and stable across a minute of sampling (§3) |
|
||||
| **B. control** | attached/spawned, resumed, **no script loaded** | **`alive_at_end=True`**, survived the full window |
|
||||
| **C. experimental** | `scripts/anti_detect_probe.js` (observer-only) | **`alive_at_end=False`**, process gone before the first heartbeat |
|
||||
|
||||
Control vs. experimental differs in exactly one variable — the probe script — so the death
|
||||
is attributable to instrumentation. Then the observer's own stream named the check:
|
||||
|
||||
```
|
||||
t=0 [armed] script armed
|
||||
t=207ms [thread_create] pthread_create entry=0x76dc031d78
|
||||
t=238ms [strcmp_probe] strcmp("Ldalvik/system/ClassExt;")
|
||||
t=245ms [env_probe] open("/system/fonts/Roboto-Regular.ttf")
|
||||
t=254ms [loader] android_dlopen_ext("…/oat/arm64/base.odex")
|
||||
t=303ms [strstr_probe] strstr("frida") <-- the check
|
||||
t~350ms process gone, heartbeats=0, no tombstone, no crash/ANR record
|
||||
```
|
||||
|
||||
- **observed:** the target **searches for the string `frida`** (`strstr("frida")`) at ~300 ms
|
||||
of its life. This is the "detection really did happen" datum the task asked for: not an
|
||||
inference from a death, a recorded call with a timestamp.
|
||||
- **observed:** an earlier arm of the same shape also captured the kill itself —
|
||||
`[termination] raise(...) <- …` at **t=352 ms**, i.e. a signal-based self-abort roughly
|
||||
50 ms after the `strstr`. No tombstone was written and no `has died: crash` record
|
||||
appeared, which is the clean-self-exit signature.
|
||||
- **observed, and the reason the arm is reported as unstable:** three attempts at the same
|
||||
arm produced *one* full sequence, then two runs where the script received **zero**
|
||||
messages before the process died (`live=0, messages=0, heartbeats=None`). On a target that
|
||||
kills itself in ~300 ms the observable window is sub-second and **not reproducible arm to
|
||||
arm**. One successful attribution is therefore not a pipeline, and the reference says so.
|
||||
|
||||
What the probe also recorded about the *environment*, before any check ran:
|
||||
|
||||
```
|
||||
tracerpid=0 frida_named_maps=4 (four mappings whose names match /frida/i)
|
||||
status: Name: tg.uncrackable3 ; State: S (sleeping) ; Seccomp: 2
|
||||
/proc/net/tcp -> Permission denied /proc/self/task -> Is a directory
|
||||
```
|
||||
|
||||
**observed:** the target's own `/proc/self/maps` contains **four frida-named mappings** while
|
||||
`TracerPid` reads **0**. So a `TracerPid`-only check would pass while a maps-or-thread-name
|
||||
check would not — which is exactly the "what can this process actually see" question the
|
||||
Stage 1 self-report exists to answer, and it is why the probe reports both.
|
||||
|
||||
**One defect in the probe, fixed once and then left:** caller attribution printed
|
||||
`bt-unavailable: cannot read property 'context' of undefined` on every line. Cause: the
|
||||
`this` binding inside the anonymous `Interceptor` callback was not what the helper assumed;
|
||||
the fix is to pass `this.context` explicitly. The measured consequence is worth stating
|
||||
because it is a *plumbing* failure that reads like a hooking failure: the events and their
|
||||
timings were correct throughout, only the "which module called this" half was missing, and
|
||||
one of the three arms still showed no events at all. `observed`.
|
||||
|
||||
## 5. ptrace-free dumping: the route, its boundary, and a cross-check that passed
|
||||
|
||||
### 5.1 What the route yields when the target has a whole dex in memory
|
||||
|
||||
Not re-measured here: the earlier pass's 17 ART dex mappings exported from `/proc/<pid>/mem`
|
||||
without any instrumenter present (`docs/tool-verification/EXTENSION-rootdump.md`). This pass
|
||||
adds the **negative boundary** the earlier pass could not state, and it needed a clean
|
||||
target to state it:
|
||||
|
||||
```
|
||||
# both public MASTG targets, ~2,400 map lines each, one adb round-trip
|
||||
owasp.mstg.uncrackable1: maps_lines=2427 named_dex=0 apk_views=2
|
||||
owasp.mstg.uncrackable3: maps_lines=2377 named_dex=0 apk_views=2
|
||||
```
|
||||
|
||||
- **observed:** `named_dex=0`. Neither target produces an
|
||||
`[anon:dalvik-classes.dex extracted in memory from …]` mapping, so the filename-driven
|
||||
export has **nothing to export** — and this says nothing about those targets being
|
||||
protected.
|
||||
- **observed, the explanation:** their `classes.dex` is **deflate-compressed inside the
|
||||
on-device APK** (`compress_type=8` read from the APK pulled off the device), so the
|
||||
`dexopt` "map the dex directly" mechanism cannot apply to it.
|
||||
- **observed:** the `r--s` views that *do* exist point at APK **file offsets**, and the
|
||||
bytes there are the APK's own bytes — at `0x11000` on L1 that content is the ZIP
|
||||
**central directory** (`PK\x01\x02`), not a dex.
|
||||
- **observed, and a trap that produced a false "DIFFERENT":** on L3 the view
|
||||
`76dc28e000-76dc299000` has file offset `0x15a000` = 1,417,216 B, while the mapping is
|
||||
44 KiB (45,056 B) long — so the range reaches 1,462,272, i.e. **past the end of the
|
||||
1,460,555-byte APK**. The file-side read came back short (1,015 B) and the two sha256s
|
||||
described different byte counts. Compute the length before suspecting the target.
|
||||
|
||||
### 5.2 The reproducibility cross-check, which passed
|
||||
|
||||
Two independent read paths over the same address range must agree byte-for-byte before a
|
||||
capture is evidence:
|
||||
|
||||
| Range | Read via `/proc/<pid>/mem` | Read from the file | Verdict |
|
||||
|---|---|---|---|
|
||||
| L3 `765a89c000-765a8de000` (264 KiB, file offset `0x6000`) | `sha256=2eafd7cca73630c05d0b0c1b4a7709f21ee24540b5f2ba388ad3467af645aa75` | same | **IDENTICAL** |
|
||||
| L1 `76dfb14000-76dfb15000` (4 KiB, file offset `0x11000`) | `sha256=74846b8cb02a764efef158194745466cf0c8ad2c1c3fecbb2bcf7cf9c58b6465` | same | **IDENTICAL** |
|
||||
|
||||
**observed.** The comparison is only meaningful because both sides covered the same byte
|
||||
count; the one range that differed was the one whose length exceeded its backing file.
|
||||
|
||||
### 5.3 The cross-check that was **not** run, and why it is not silently absent
|
||||
|
||||
The task's cross-validation requirement is satisfied for `svc_scan.py` by two independent
|
||||
decoders agreeing on an identical 214-site set (§1). For a **dump**, the equivalent check
|
||||
needs two producers that emit the *same* artifact. What this pass has is `/proc/<pid>/mem`
|
||||
(successful, §5.2) and, on the hardened sample, `frida-dexdump` — which was **refused** by
|
||||
that sample in the previous pass (`script has been destroyed`,
|
||||
`docs/tool-verification/EXTENSION-device-run.md`). The sample's lifetime then shrank to ~2 s
|
||||
(§2), so the second producer could not be obtained on any target here:
|
||||
|
||||
| Second-producer option | Status |
|
||||
|---|---|
|
||||
| `frida-dexdump` on the hardened sample | **unverified** — refused in the earlier pass; not retried here on a 2 s process |
|
||||
| `frida-dexdump` on L1/L3 | **unverified, and expected to be empty** — `named_dex=0`, so there is no whole image for any dumper to find |
|
||||
| `process_vm_readv(2)` as a second backend | **not run** — the on-device helper would have needed a second binary on the device, and the byte-identity question it answers was already answered against the file path |
|
||||
|
||||
**Statement of the gap, so the next reader does not have to infer it:** the byte-identity
|
||||
check between two *dumpers* is **unverified** in this repository. What is `observed` is
|
||||
byte-identity between two *read paths* on the same bytes, and a 214-site decode agreement
|
||||
between two independent decoders. Those are weaker claims than "two dumpers cross-checked",
|
||||
and they are the ones this file makes.
|
||||
|
||||
**A related statement that is deliberately negative:** two dumpers agreeing is not evidence
|
||||
of *recovery*. Against an extraction shell, two agreeing dumps are two copies of the same
|
||||
skeleton. The judge for that is the platform decoder, per
|
||||
`advanced-unpacking.md` §Establish "the bodies do not decode" with a decoder you did not write.
|
||||
|
||||
## 6. What was not established
|
||||
|
||||
- **Not** a recovered payload from the hardened sample. Its process lives ~2 s and never
|
||||
reaches a state where its payload is loaded; the attempt hit the two-strike limit and was
|
||||
re-routed to the stable targets.
|
||||
- **Not** a fix for the sample's startup failure. Root hiding was left alone — configuring
|
||||
`magisk --denylist` is a device-wide change and this pass was the only lock holder, which
|
||||
is not the same as being free to change every shared variable.
|
||||
- **Not** a reproducible end-to-end anti-instrumentation pipeline on a fast detector. The
|
||||
full sequence was captured once out of three arms and the reference labels it that way.
|
||||
- **Not** a general claim about `strstr("frida")` being the only check, or about MASTG
|
||||
challenges, or about Android 11. One device, one ROM, one session, with other experiments
|
||||
sharing the device.
|
||||
- **Not** a kernel-layer result. Kernel 4.14.186 < 5.10 closes the eBPF route; the layered
|
||||
descent in the reference stops at the `svc` boundary and says so.
|
||||
|
||||
## 7. Reproduction one-liners
|
||||
|
||||
```
|
||||
python skills/apk-reverse/scripts/svc_scan.py tools/_work/detect/libc.so --context 2
|
||||
python skills/apk-reverse/scripts/svc_scan.py tools/_work/detect/linker64.bin --json
|
||||
|
||||
adb shell 'su -c "sh /data/local/tmp/apkmaps.sh owasp.mstg.uncrackable3"' # named_dex / apk_views shape
|
||||
adb shell 'su -c "sh /data/local/tmp/apkviews_verify.sh owasp.mstg.uncrackable3"'
|
||||
adb shell 'su -c "sh /data/local/tmp/detlaunch.sh <pkg> /data/local/tmp/dd1"'
|
||||
|
||||
python tools/_work/detect/run_anti_detect_probe.py --pid <pid> --watch-ms 8000 --out arm.json
|
||||
python tools/_work/detect/run_anti_detect_probe.py --pkg owasp.mstg.uncrackable3 --spawn \
|
||||
--script skills/apk-reverse/scripts/anti_detect_probe.js --out arm_probe.json
|
||||
```
|
||||
|
||||
The on-device helper scripts (`apkmaps.sh`, `apkviews_verify.sh`, `detlaunch.sh`) and the
|
||||
driver live under `tools/_work/detect/` and are **work files, not shipped deliverables** —
|
||||
they exist so the commands above are reproducible, and their content is summarised in the
|
||||
sections that cite them. The two shipped scripts are `scripts/svc_scan.py` and
|
||||
`scripts/anti_detect_probe.js`.
|
||||
@@ -0,0 +1,252 @@
|
||||
# Extension pass — on-device run against a hardened Flutter sample
|
||||
|
||||
This file records the **device-side** half of the extension pass: what was actually executed against
|
||||
a live, hardened sample, what the sample did in response, and which conclusions the evidence supports.
|
||||
|
||||
It follows the same three labels the rest of this directory uses:
|
||||
|
||||
- **observed** — reproduced here, with the exact command and its output.
|
||||
- **inferred** — follows from observed facts, but the step itself was not executed.
|
||||
- **unverified** — assumed, or reported by a tool and not independently confirmed.
|
||||
|
||||
**Target identity is deliberately absent.** The sample is referred to as *the sample*, and its package
|
||||
identifier appears as `<PKG>`. The findings are about the tools and the methods, and stay valid
|
||||
whichever hardened APK produced them.
|
||||
|
||||
## Environment (observed)
|
||||
|
||||
| Item | Value |
|
||||
|---|---|
|
||||
| Device | one physical arm64 device, Android 11 (API 30), kernel 4.14.186, Magisk root |
|
||||
| Host | Windows 11, Python 3.14.0, frida 16.7.19, frida-tools 13.7.1, frida-dexdump 2.0.1, java 17, adb 1.0.41 |
|
||||
| Device server | `frida-server` 16.7.19 (version-matched to the host by `frida-server --version`) |
|
||||
| Sample | 360-jiagu hardened, `application android:name` = a third-party stub class; Flutter AOT payload |
|
||||
| Host capability report | `python scripts/doctor.py` → static/repack/smali/native-patch/device/root/frida all OK; `28 / 28` scripts runnable |
|
||||
| Pre-flight warnings | clock skew 3.32 s; leftover `adb forward` entries (8787, 8651, 65534, 27043); a device-side `frida-server` already running |
|
||||
|
||||
Two environment facts from `doctor.py` are worth carrying forward, because each of them silently
|
||||
invalidates a measurement: the clock skew, and a **frida process already up on the device** — the
|
||||
second one means the target may already be reacting to instrumentation before your first command.
|
||||
|
||||
## The measurement that mattered: the sample fights instrumentation
|
||||
|
||||
### (1) Control — the sample starts normally with no instrumentation (observed)
|
||||
|
||||
```
|
||||
$ adb shell 'su -c "am force-stop <PKG>; sleep 1; am start -n <PKG>/.MainActivity"'
|
||||
$ sleep 12 && adb shell 'su -c "ps -A | grep -i <PKG>"'
|
||||
u0_a623 22691 17278 14123672 642488 SyS_epoll_wait 0 S <PKG>
|
||||
```
|
||||
|
||||
Resident, 642 MB, healthy. **This is the control build the rest of the section is measured against.**
|
||||
|
||||
### (2) The pid drifts — but **not** because of frida (observed, corrected)
|
||||
|
||||
During the session the sample's pid changed repeatedly:
|
||||
|
||||
```
|
||||
22691 → 23452 → 24267 → 27274 → 31284 → 32259 → 1368 → 8161
|
||||
```
|
||||
|
||||
The natural reading is "the sample fights instrumentation", and the first write-up of this file said
|
||||
exactly that. **The follow-up measurement refuted it.** With `frida-server` stopped and no attach of
|
||||
any kind, the sample was restarted under observation for 90 seconds:
|
||||
|
||||
```
|
||||
20:28:22 pid=[9961] load=36.43 | Mem: 11484 total 11077 used 406 free
|
||||
20:28:33 pid=[9961] load=36.26 | Mem: 11484 total 10767 used 716 free
|
||||
20:28:45 pid=[9961] load=35.61 | Mem: 11484 total 10739 used 745 free
|
||||
20:28:56 pid=[9961] load=34.64 | Mem: 11484 total 10767 used 716 free
|
||||
20:29:08 pid=[11246] load=34.91 | Mem: 11484 total 11151 used 333 free
|
||||
20:29:19 pid=[11246] load=35.69 | Mem: 11484 total 11142 used 342 free
|
||||
20:29:31 pid=[12238] load=35.14 | Mem: 11484 total 11131 used 353 free
|
||||
20:29:42 pid=[13212] load=37.03 | Mem: 11484 total 11059 used 424 free
|
||||
```
|
||||
|
||||
The pid changes with **no frida present**, roughly every 10-20 seconds, while the device sits at
|
||||
300-750 MB of free memory out of 11.5 GB. The first correction to this file attributed that to memory
|
||||
pressure. **That attribution was also wrong, and the device-side dumping run refuted it** with a
|
||||
better instrument than sampling: `logcat`.
|
||||
|
||||
```
|
||||
$ adb logcat | grep <PKG>
|
||||
Process <PKG> (pid N) has died: fg TOP
|
||||
Start proc <pid> for top-activity <PKG>
|
||||
```
|
||||
|
||||
The process **dies as the foreground top activity, with no crash, no tombstone and no ANR record**,
|
||||
and the platform immediately relaunches it — a clean self-exit in a loop, on a 7-18 second cycle. A
|
||||
reclaiming device does not do that to a foreground process, and a memory-pressure kill leaves an
|
||||
`lmkd` line and a low-memory record. What produces exactly this shape is the sample **checking its own
|
||||
environment and quitting on purpose**.
|
||||
|
||||
And the environment it finds is decisive: on this device `magisk --denylist status` reports **enforced
|
||||
with an empty list**, Zygisk-Assistant is installed but nothing is scoped, and
|
||||
`/data/adb/shamiko` does not exist — so **no application on this device is hidden from root**
|
||||
(`EXTENSION-lsposed.md`, observed). A hardened sample that refuses to run on a rooted device will do
|
||||
exactly this, forever, and no amount of dump-retrying changes it.
|
||||
|
||||
**Reading, corrected twice:** the pid drift is an artefact of the **root environment**, not of
|
||||
instrumentation and not of memory pressure. The practical consequence is the useful part — *before any
|
||||
dynamic analysis of a hardened sample, configure root hiding and re-run the baseline*, because with
|
||||
nothing hidden every dynamic result on this device is measured against a process that is trying to die.
|
||||
The observer's first control run showed a "stable" sample for 12 seconds only because it happened to
|
||||
sample inside one 17-second lifetime.
|
||||
|
||||
This is the skill's own rule applied twice to its own record: a conclusion was reopened each time a
|
||||
measurement disagreed with it (`SKILL.md` §Stop conditions, last bullet). Two wrong attributions were
|
||||
cheaper to delete than for a reader to inherit — and the second one only fell because a different
|
||||
method (`logcat`, not sampling) was pointed at the same question.
|
||||
|
||||
The platform's own view of a failed launch was also captured, and is worth keeping as a *shape*:
|
||||
|
||||
```
|
||||
W ActivityManager: Process ProcessRecord{…:<PKG>/u0a623} failed to attach
|
||||
I ActivityManager: Killing <pid>:<PKG>/u0a623 (adj -10000): start timeout
|
||||
```
|
||||
|
||||
A `start timeout` kill reads the same whether the cause is a slow shell init, a reclaiming device, or a
|
||||
deliberate delay. It does not discriminate, and it is not evidence either way.
|
||||
|
||||
### (3) `frida-dexdump` cannot complete a memory dump — reproduced twice (observed)
|
||||
|
||||
Both spawn and attach were tried, with the parameter mistakes fixed between attempts:
|
||||
|
||||
```
|
||||
$ frida-dexdump -U -f <PKG> -d -o <out> # spawn
|
||||
Failed to spawn: need Gadget to attach on jailed Android; its default location is: …gadget-android-arm64.so
|
||||
|
||||
$ frida-dexdump -U -f <PKG> -d 12 -o <out> # 12 was parsed as an argv entry, not a delay
|
||||
Failed to spawn: the 'argv' option is not supported when spawning Android apps
|
||||
|
||||
$ frida-dexdump -U -f <PKG> -d -o <out> # corrected
|
||||
Failed to spawn: unexpectedly timed out while waiting for app to launch
|
||||
|
||||
$ frida-dexdump -U -p <pid> -d -o <out> # attach, twice, second run with the device idle
|
||||
frida.InvalidOperationError: script has been destroyed: {'addr': '0x721e24c02c', 'size': 409556}
|
||||
INFO:frida-dexdump:[*] All done... Process terminated
|
||||
```
|
||||
|
||||
Attach was run twice. The **second** run was the clean one: no Stalker traffic anywhere on the device
|
||||
(see the crash note below), the package enabled and verified running, the pid read immediately before
|
||||
the attach, and a single variable changed relative to the control. It produced the same
|
||||
`script has been destroyed` failure, and the sample's pid changed again in the aftermath.
|
||||
|
||||
**What the failed runs left behind, checked with the kit's own validator** (`observed`):
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/dex_dump_validate.py <dump_dir>
|
||||
== dex dump validation: 2 file(s), 2 parse, 0 rejected ==
|
||||
name size sha256[:12] ver cksum sig class noco code stub%
|
||||
classes.dex 500 9461987e5464 035 BAD BAD 1986754560 0 0 0.0%
|
||||
walk stopped early: index out of range
|
||||
classes02.dex 10285489 116d8b84b486 035 BAD BAD 1986754560 0 0 0.0%
|
||||
walk stopped early: index out of range
|
||||
```
|
||||
|
||||
Both artifacts pass the magic check and then fail everything else: checksum and signature both BAD,
|
||||
no classes recovered, the walk stopping on an out-of-range index. They are **memory fragments cut
|
||||
short by the process dying mid-read**, not dex images — and this is exactly the case
|
||||
`dex_dump_validate.py` exists for. A size-based heuristic would have kept the 10.2 MB file and
|
||||
discarded the 500-byte one; both are worthless, and only a structural walk says so.
|
||||
|
||||
**Reading:** under these conditions the memory-dump route is refused by the target, not by the host
|
||||
tooling. Two shape-identical failures on a single variable is the skill's own stop signal: the correct
|
||||
move is to re-classify (`references/advanced-unpacking.md` §the four shapes, then
|
||||
`references/detection-and-anti-analysis.md`), **not** to run a third variant of the same attach.
|
||||
|
||||
Two of the four failures above were the observer's own mistakes, and they are worth keeping because
|
||||
they are cheap to repeat: `-d` is a switch, not a delay, and any extra positional argument reaches
|
||||
`spawn(argv)`. `-n <name>` on this version of `frida-dexdump` also still routes through the spawn
|
||||
path, so "attach by name" does not exist as a way around the spawn refusal — use `-p <pid>`.
|
||||
|
||||
### (4) What the sample looks like at the moment it is attacked (observed)
|
||||
|
||||
```
|
||||
$ adb shell 'su -c "top -n 1 -b"'
|
||||
PID USER PR NI VIRT RES SHR S[%CPU] %MEM TIME+ ARGS
|
||||
31284 u0_a623 10 -10 13G 561M 268M S 439 4.8 0:05.52 <PKG>
|
||||
154 root 20 0 0 0 0 S 66.6 0.0 0:02.18 [kswapd0]
|
||||
17638 system 18 -2 12G 590M 419M S 60.6 5.1 1:15.93 system_server
|
||||
```
|
||||
|
||||
A Flutter application in its startup window saturates several cores (439 % CPU) while the Dart VM
|
||||
initialises, and the device — 11.7 GB RAM with 357 MB free at that moment — runs its reclaim thread at
|
||||
two thirds of a core. **The load on this device during the experiment was the sample's, not the
|
||||
observer's instrumentation.** That matters for attribution: "the device is slow, so the dump failed"
|
||||
is not available as an explanation here, because the failing command is a *protocol* failure
|
||||
(`script has been destroyed`) rather than a timeout.
|
||||
|
||||
## The device's own framework was already damaged (observed)
|
||||
|
||||
Recorded here because it is a **third candidate mechanism** for "things keep restarting", alongside the
|
||||
sample's self-exit and the memory pressure — and because it invalidates any dynamic result that
|
||||
depends on the hooking framework:
|
||||
|
||||
- `LSPosed Manager` opened to **"LSPosed is not installed"**, with no modules tab, while the `lspd`
|
||||
daemon was still alive.
|
||||
- The LSPosed configuration database had not been updated since **19:37** — it still pointed at the
|
||||
`codePath` of an app that had been uninstalled, and never registered the replacement.
|
||||
- `logcat` showed a loop: `system server died`, `am is dead`, `no response from bridge`, and
|
||||
`Magisk: zygote crashed too many times, rolling-back`.
|
||||
|
||||
A zygote in a crash-and-rollback cycle restarts application processes by itself, so **"the pid changed"
|
||||
had at least three independent possible causes on this device** (the sample exiting, reclaim, and a
|
||||
broken framework). That is exactly why the corrected §2 attributes the drift to *instability of the
|
||||
observation target* rather than to any single mechanism, and why a dynamic result needs its device's
|
||||
framework state recorded next to it — `scripts/preflight.py` reads device state for the same reason.
|
||||
|
||||
Recovery is a reboot (Magisk re-initialises Zygisk and `lspd` at `late_start`; the LSPosed daemon is
|
||||
started once and does not return if killed). Whether the framework came back after the reboot, and
|
||||
whether the sample's self-exit behaviour changed with it, is recorded in
|
||||
`EXTENSION-lsposed.md` — and any change there must stay **inferred**, because a reboot moves several
|
||||
variables at once.
|
||||
|
||||
## A shared-device hazard, measured (observed)
|
||||
|
||||
While the run above was in progress, a second agent on the same device used **Frida Stalker** against
|
||||
`com.android.settings` and `com.android.systemui`, including a `libc` `malloc` export trigger that
|
||||
entered and left `follow` hundreds of times. Result:
|
||||
|
||||
- `Stalker.follow` on the main thread reported `blocks=0`, `blk=0`, `calls=0` — **the follow installed
|
||||
and no events arrived**.
|
||||
- `systemui` then died with `SIGSEGV`, the faulting frame sitting in gum's anonymous code cache and
|
||||
the backtrace continuing into `libart` at `art::Reference_getReferent`; the process restarted
|
||||
(`18030 → 26932`).
|
||||
- Device load average reached **34.11** on an eight-core phone.
|
||||
|
||||
**Reading:** two conclusions, kept separate on purpose. First, on this ROM + `frida-server` 16.7.19
|
||||
combination a Stalker follow produced no events — that is a *boundary* for
|
||||
`references/native-dbi-and-deobfuscation.md`, not a contradiction of Stalker's documentation. Second,
|
||||
high-frequency `follow`/`unfollow` on every `malloc` entry crashed a system process — a measured
|
||||
failure mode for the pattern, and one worth stating before anyone puts it in a shipped script.
|
||||
|
||||
The third conclusion is about method rather than about Stalker: **instrumentation on a shared device
|
||||
is a shared variable.** The concurrent load damaged the *other* experiment's validity — the dumped-run
|
||||
above could not be attributed until it was repeated with the device idle, and the repeat is what
|
||||
turned "maybe the load broke it" into a reproducible protocol refusal. R2 (one variable at a time)
|
||||
applies across agents, not only within one script.
|
||||
|
||||
## Conclusions, graded
|
||||
|
||||
| Claim | Label |
|
||||
|---|---|
|
||||
| The sample runs healthy with no instrumentation on the device | observed |
|
||||
| The sample's pid drifts repeatedly during an experiment, roughly every 10-20 s | observed — **cause not instrumentation**: reproduced with `frida-server` stopped, under 300-750 MB free memory (§2) |
|
||||
| `frida-dexdump` spawn refuses (jailed / argv / timeout) and attach fails with `script has been destroyed`, reproduced twice with the parameter mistakes fixed | observed |
|
||||
| The attach failure is deliberate armour rather than a host-tool bug | **unverified** — memory pressure leaving the process an unstable target is a competing explanation this pass did **not** rule out; the single-variable repeat eliminated *concurrent* load, not *baseline* load |
|
||||
| The shipped shell `classes.dex` is 8.9 MB and defines only 4 classes / 29 methods with code | observed (recorded in `EXTENSION-unpacking.md`) |
|
||||
| Recovering the real bodies needs a route other than a `frida` memory dump on this target | inferred |
|
||||
| Stalker follow delivers no events on this ROM with this frida-server version | observed, single sample, `unverified` as a general rule |
|
||||
| High-frequency follow/unfollow on `malloc` can crash a system process | observed, once |
|
||||
| No app on this device is currently hidden from root (`magisk --denylist status` enforced, list empty; Zygisk-Assistant installed but nothing configured) | observed (recorded in `EXTENSION-lsposed.md`) — offered here because a hardened sample's sensitivity to a rooted device has a configuration, not just a cause |
|
||||
|
||||
## What this pass did **not** establish
|
||||
|
||||
- **Not** a successful end-to-end unpack of the sample. The memory-dump route was refused, and the
|
||||
pass stopped at the skill's own two-strike rule instead of escalating into an evasion arms race.
|
||||
- **Not** evidence that the sample is un-unpackable. The routes that do not use `frida` on the device
|
||||
— root-side `/proc/<pid>/mem` extraction, the module route in `references/lsposed-and-modules.md`,
|
||||
and the static route already recorded for the Flutter payload — were not exhausted here.
|
||||
- **Not** a general claim about 360-jiagu, Flutter, or Android 11. Everything above is one device,
|
||||
one ROM, one server version, one sample.
|
||||
@@ -0,0 +1,431 @@
|
||||
# Extension verification — offline emulation (unidbg) and Frida RPC
|
||||
|
||||
Covers the 2026-09 extension pass that added `references/emulation-and-rpc.md`,
|
||||
`scripts/frida_rpc_serve.py` and `scripts/rpc_template.js`. Strength labels as defined in this
|
||||
directory's README: **observed** (exact command + output recorded here), **inferred** (follows from
|
||||
observed facts, step not executed), **unverified** (assumed or externally reported, not independently
|
||||
confirmed here).
|
||||
|
||||
Reference environment for this pass: Windows host, Python 3.14.0, host `frida` 16.7.19 +
|
||||
`frida-python` 16.7.19 (frida-tools 13.7.1), `adb` 37.0.1, JDK 17.0.4.1 (with `JAVA_HOME`
|
||||
**unset** in the shell — see §5). Device `<DEVICE>`: Android 11 / API 30, arm64-v8a, rooted
|
||||
(Magisk), on-device frida-server 16.7.19. Workbench artifacts live in `tools/_work/t3-rpc/` (not part
|
||||
of the skill).
|
||||
|
||||
**Device-exclusivity constraint during this pass**: another task was unpacking the hardened Flutter
|
||||
sample on the same device. This pass never attached to, spawned or force-stopped that sample's
|
||||
process, and never killed or restarted the device-side frida-server. All RPC work targeted unrelated
|
||||
processes (system apps and third-party apps). De-identification: the sample's package label, real
|
||||
package name and md5 are elided in this document as `com.example.app` / `<label>`; command shapes,
|
||||
counts, class names and timings are verbatim.
|
||||
|
||||
---
|
||||
|
||||
## 0. Summary of what this pass established
|
||||
|
||||
| Claim | Label |
|
||||
|---|---|
|
||||
| `droidasc` 0.1.1.post1 and `ddc` 0.1.8 both run on this host; both read the hardened sample's shell dex | observed |
|
||||
| Both tools see only the 4 shell classes in the APK's dex; business classes are absent (shell dex only) | observed |
|
||||
| `frida_rpc_serve.py` `--help`, all three modes, reconnect, spawn and attach-by-pid work against a live device | observed |
|
||||
| Attach **by package name** fails on this ROM while attach **by pid** succeeds for the same process | observed |
|
||||
| The device-side server may be (re)started on a non-default port; `--remote HOST:PORT` recovers, device-serial lookup does not | observed |
|
||||
| unidbg 0.9.10-SNAPSHOT builds and boots an Android emulator on this host after a pom fix; one bundled test suite passes | observed |
|
||||
| Any target `.so` actually emulated under unidbg | **not done** (unverified) |
|
||||
| frida 17 compatibility of `rpc_template.js` export lookup | unverified (host has 16.7.19 only) |
|
||||
|
||||
---
|
||||
|
||||
## 1. ASC (`droidasc`) — observed
|
||||
|
||||
### 1.1 Install and version
|
||||
|
||||
```
|
||||
$ python -m pip show droidasc
|
||||
Name: droidasc
|
||||
Version: 0.1.1.post1
|
||||
Summary: ASC is a super FAST python Android Decompiler for Agents/Mobile Researchers
|
||||
License-Expression: Apache-2.0
|
||||
Location: C:\Python314\Lib\site-packages
|
||||
Requires: androguard
|
||||
```
|
||||
|
||||
Install line used (recorded after the fact; the package was already present on this host):
|
||||
`python -m pip install droidasc`. Distribution metadata declares only `androguard` as a dependency —
|
||||
**no JVM is required** (unlike the `asc` successor tooling in the upstream MG1937/ASC repository,
|
||||
which is a separate project). The console script is named **`droidasc`**, not `asc`: there is no
|
||||
`asc.exe` in `C:\Python314\Scripts`, and `asc --version` fails with
|
||||
`The term 'asc' is not recognized...`. `python -m droidasc` is the equivalent entry point.
|
||||
|
||||
### 1.2 Subcommands
|
||||
|
||||
```
|
||||
$ python -m droidasc --help
|
||||
usage: python.exe -m droidasc [-h]
|
||||
{getclass,listclass,getmanifest,findrefs} ...
|
||||
ASC tooling entry.
|
||||
positional arguments:
|
||||
{getclass,listclass,getmanifest,findrefs}
|
||||
getclass Locate the target class in APK, extract one DEX in memory, then decompile.
|
||||
listclass List classes across all DEX entries; use --prefix to filter by package/class prefix.
|
||||
getmanifest Decode AndroidManifest.xml from APK and print it as XML.
|
||||
findrefs Find code references for string/type/method/field across all DEX entries in APK.
|
||||
examples:
|
||||
droidasc app.apk --gui
|
||||
droidasc getclass app.apk Lcom/poc/Main; -o Main.java
|
||||
droidasc listclass app.apk --prefix com.poc
|
||||
droidasc getmanifest app.apk -o AndroidManifest.xml
|
||||
droidasc findrefs app.apk string token -o string_refs.txt
|
||||
```
|
||||
|
||||
`--gui` and the bundled `asc_client` web UI were **not** exercised (unverified, headless pass).
|
||||
|
||||
### 1.3 Measured against the sample
|
||||
|
||||
```
|
||||
$ droidasc getmanifest <sample>.apk -o asc-manifest-verify.xml # 0.44 s, 32,345 B
|
||||
<manifest ... package="com.example.app" platformBuildVersionCode="34" ... android:versionCode="1143"
|
||||
android:versionName="0.9.0" android:compileSdkVersion="34" ...>
|
||||
<uses-sdk android:minSdkVersion="19" android:targetSdkVersion="28"/>
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
... (permissions list follows; manifest decodes cleanly)
|
||||
|
||||
$ droidasc findrefs <sample>.apk string jiagu -o asc-findrefs-jiagu.txt # 0.42 s
|
||||
classes.dex | Lcom/stub/StubApp;-><clinit> | matched=(libjiagu)
|
||||
classes.dex | Lcom/stub/StubApp;->attachBaseContext | matched=(/.jiagu; jiagu; jiagu_x86)
|
||||
|
||||
$ droidasc getclass <sample>.apk com.stub.StubApp -o asc-StubApp-verify.java # 0.33 s, 22,802 B
|
||||
package com.stub;
|
||||
public final class StubApp extends android.app.Application { ... }
|
||||
|
||||
$ droidasc listclass <sample>.apk --prefix com.stub # 0.23 s
|
||||
Lcom/stub/StubApp;
|
||||
|
||||
$ droidasc listclass <sample>.apk -o asc-listclass-all.txt # 0.18 s, 4 lines
|
||||
Lcom/stub/StubApp;
|
||||
Lcom/tianyu/util/Configuration;
|
||||
Lcom/tianyu/util/DtcLoader;
|
||||
Lcom/tianyu/util/a;
|
||||
```
|
||||
|
||||
`findrefs` reports the shell's own strings (`libjiagu`, `jiagu_x86`, `/.jiagu`) — i.e. the packer
|
||||
signature is visible without unpacking, but nothing of the application is.
|
||||
|
||||
### 1.4 Limits hit
|
||||
|
||||
```
|
||||
$ droidasc getclass <sample>.apk com.example.app.MainActivity -o asc-mainactivity.java
|
||||
Error: Class Lcom/example/app/MainActivity; not found in APK.
|
||||
exit = 1
|
||||
```
|
||||
|
||||
**This is the limit that matters for this sample class:** the full class listing is 4 classes, so on
|
||||
a packed APK ASC is a manifest + shell-inspector, not an application decompiler. The application code
|
||||
lives in that shell's payload (`assets/`, the native unpacker and the AOT/Flutter layer), which ASC
|
||||
does not touch. ASC still earns its place in the flow: `getmanifest` in under half a second is the
|
||||
fastest manifest pass measured in this repository's tool set, and `findrefs` on shell class names is
|
||||
how the packer was identified here.
|
||||
|
||||
Observed behaviours worth knowing: `droidasc` prints per-command timing nowhere, writes to `-o` only,
|
||||
and returns a clean `Error: ...` line plus exit 1 when a class is missing.
|
||||
|
||||
---
|
||||
|
||||
## 2. ddc — observed
|
||||
|
||||
### 2.1 Version and acquisition
|
||||
|
||||
```
|
||||
$ bin/ddc.exe -V
|
||||
ddc 0.1.8 — DEX → Java decompiler
|
||||
https://github.com/ejfkdev/ddc
|
||||
```
|
||||
|
||||
`ddc` is a Rust binary (upstream: [ejfkdev/ddc](https://github.com/ejfkdev/ddc)); the Windows build
|
||||
used here is `tools/_work/t3-rpc/bin/ddc.exe`, 1,028,096 B, **not on `PATH`** and with no version
|
||||
resource in the PE header (`VersionInfo` empty), so `ddc -V` is the only way to learn its version.
|
||||
No JVM, no Python. Subcommands advertised by `ddc help`: `info`, `listclasses`, `manifest`,
|
||||
`mainactivity`, `res`, `strings`, `findrefs`, `callers`, `members`, `hierarchy`, `largest`, `disasm`,
|
||||
`getclass`, `getmethod`, plus a full-decompile mode (`ddc <INPUT>... [OUTPUT]`).
|
||||
|
||||
### 2.2 Measured against the sample
|
||||
|
||||
```
|
||||
$ bin/ddc.exe info <sample>.apk # 0.24 s
|
||||
label <label>
|
||||
package com.example.app
|
||||
version 0.9.0 (1143)
|
||||
application com.stub.StubApp
|
||||
launcher com.example.app.MainActivity
|
||||
sdk 19–28
|
||||
size 36.9 MB (38688120 bytes)
|
||||
md5 1a037a045205184d4e8272092997334f
|
||||
|
||||
image dex classes methods fields strings
|
||||
<sample>!classes.dex 035 4 266 23 481
|
||||
total: 1 image(s), 4 classes
|
||||
|
||||
$ bin/ddc.exe findrefs <sample>.apk string jiagu # 0.13 s
|
||||
dex kind class method refs
|
||||
classes.dex const-string com/stub/StubApp <clinit>()V "libjiagu"
|
||||
classes.dex const-string com/stub/StubApp attachBaseContext(...)V "jiagu_x86"; "jiagu"; "/.jiagu"
|
||||
|
||||
$ bin/ddc.exe strings <sample>.apk -f jiagu --with-locations # 0.14 s
|
||||
dex string used-by
|
||||
classes.dex "/.jiagu" com/stub/StubApp attachBaseContext(Landroid/content/Context;)V
|
||||
classes.dex "jiagu" com/stub/StubApp attachBaseContext(Landroid/content/Context;)V
|
||||
classes.dex "jiagu_x86" com/stub/StubApp attachBaseContext(Landroid/content/Context;)V
|
||||
classes.dex "libjiagu" com/stub/StubApp <clinit>()V
|
||||
```
|
||||
|
||||
`ddc info` reaches the same conclusion as ASC by a different route (class/method/string counts per
|
||||
dex image) and adds the resources-derived label and the launcher verification; `findrefs` output is
|
||||
tabular and one line per (method, string) pair, which is directly pasteable into a report.
|
||||
|
||||
### 2.3 Limits hit
|
||||
|
||||
```
|
||||
$ bin/ddc.exe getclass <sample>.apk com.example.app.MainActivity
|
||||
ddc: class com.example.app.MainActivity not found in the selected image(s)
|
||||
(try `ddc listclasses <input> <pattern>`)
|
||||
exit = 2
|
||||
```
|
||||
|
||||
Exit codes measured on this build: `info` → 0, `-V` → 0, `getclass` with a missing class → **2**.
|
||||
The missing-class path also reprints the full help text on stdout, so a wrapper that only checks
|
||||
"did anything print" will misread it as success — key off the exit code. As with ASC, the packed APK
|
||||
exposes only the shell dex; `ddc` cannot see application classes either, which is a property of the
|
||||
artifact, not of the tool.
|
||||
|
||||
---
|
||||
|
||||
## 3. Prior-attempt outputs under test — and the defects found
|
||||
|
||||
`skills/apk-reverse/references/emulation-and-rpc.md`, `scripts/frida_rpc_serve.py` and
|
||||
`scripts/rpc_template.js` were already on disk from an interrupted attempt of this task. This pass
|
||||
read all three, ran the script, and fixed four defects. `rpc_template.js` needed no change.
|
||||
|
||||
| # | Defect (observed) | Fix |
|
||||
|---|---|---|
|
||||
| 1 | `RpcBridge._teardown()` never detached the session and used `obj.off(...) if obj is self.session else None` / `obj.unload() if hasattr(obj, "unload")` — a script object has `unload()`, a session has `detach()`, so the expression released only the script. Every `reload()`/reconnect cycle leaked one attached session on the device. | Rewritten to unload the script, remove the `detached` handler and `session.detach()` explicitly. |
|
||||
| 2 | `list_exports()` called `Script.list_exports()`, which frida 16.7.19 answers with `DeprecationWarning: Script.list_exports will become asynchronous in the future, use the explicit Script.list_exports_sync instead` (printed on every `list` command). | Tries `list_exports_sync` first, then the old name, then local introspection. |
|
||||
| 3 | `--mode call` raised out of `main()`: an unknown export produced `frida`'s own `unable to find method 'x'` and a raw Python traceback; a bad `--args` value dumped a `JSONDecodeError` traceback. Scripted callers got a stack trace instead of a result. | `run_call_mode()` now returns one JSON object (`{"ok": false, "error": ...}`) and exit code 1 for both cases; `call()` consults the advertised export list first so the message is `no such rpc export: nope (available: add ping ...)`. |
|
||||
| 4 | `frida.TimedOutError` was absent from the retry whitelist. It is **not** a `TransportError` subclass (`frida.TimedOutError.__mro__` → `(TimedOutError, Exception, BaseException, object)`), so a device that timed out during attach produced a traceback in an otherwise recoverable situation — reproduced live (§4.4). | Retryable types are now collected by name into `RETRYABLE` (TransportError, NotSupportedError, ServerNotRunningError, ProcessNotFoundError, InvalidOperationError, TimedOutError) and used by both `ensure()` and the initial connect in `main()`. |
|
||||
|
||||
Post-fix evidence:
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/frida_rpc_serve.py --remote 127.0.0.1:27043 --pid 19938 \
|
||||
--script skills/apk-reverse/scripts/rpc_template.js --mode call --export add --args "[40, 2]"
|
||||
{"ok": true, "export": "add", "result": 42} exit = 0
|
||||
$ ... --mode call --export nope
|
||||
{"ok": false, "export": "nope", "error": "no such rpc export: nope
|
||||
(available: add ping getpackagename callnative callnativeaddr)"} exit = 1
|
||||
$ ... --mode call --export add --args "not-json"
|
||||
{"ok": false, "error": "--args is not valid JSON: Expecting value: line 1 column 1 (char 0)"}
|
||||
exit = 1
|
||||
```
|
||||
|
||||
Known cosmetic behaviour left as is: in `--mode call` the bridge connects before `--args` is parsed,
|
||||
so a bad-args invocation still performs a device attach.
|
||||
|
||||
## 4. Frida RPC against the live device — observed
|
||||
|
||||
### 4.1 Attach path: by name fails, by pid works
|
||||
|
||||
Attach **by package name** failed for every package tried, with the process demonstrably alive in
|
||||
both `frida-ps -Uai` and `ps -A`:
|
||||
|
||||
```
|
||||
$ python ...frida_rpc_serve.py --device-serial <DEVICE> --package com.android.settings ...
|
||||
[bridge] connect failed (unable to find process with name 'com.android.settings'); retry 1 in 1.0s
|
||||
... (5 attempts) ...
|
||||
initial connect failed: could not (re)connect after 5 attempts
|
||||
$ python ... --package io.github.vvb2060.magisk ...
|
||||
[bridge] connect failed (unable to find process with name 'io.github.vvb2060.magisk'); retry 1 in 0.5s
|
||||
initial connect failed: could not (re)connect after 1 attempts
|
||||
```
|
||||
|
||||
The same processes attach normally by pid:
|
||||
|
||||
```
|
||||
$ python ... --pid 19938 --mode call --export getpackagename
|
||||
{"ok": true, "export": "getpackagename", "result": "com.android.settings"}
|
||||
$ python ... --pid 21493 --mode call --export getpackagename
|
||||
{"ok": true, "export": "getpackagename", "result": "com.android.email"}
|
||||
```
|
||||
|
||||
So the gap is in the **name→pid lookup** the server serves, not in injection: `--pid` definitely
|
||||
does bypass it. `references/emulation-and-rpc.md` had claimed the opposite ("`--pid` does not bypass
|
||||
a gap that hides the process from the server's own table"); that row was corrected in this pass, and
|
||||
the corrected wording is what the reference now carries.
|
||||
|
||||
### 4.2 The transport itself
|
||||
|
||||
| # | Command (pid 19938 = `com.android.settings`, unrelated to the sample) | Result |
|
||||
|---|---|---|
|
||||
| 1 | `--mode call --export add --args "[40, 2]"` | `{"ok": true, "result": 42}` |
|
||||
| 2 | `--mode call --export ping` | `{"ok": true, "result": "pong"}` |
|
||||
| 3 | `--mode call --export getpackagename` | `{"ok": true, "result": "com.android.settings"}` |
|
||||
| 4 | `--mode call --export callnative --args '["libc.so","getpid","int",[],[]]'` | `{"ok": true, "result": 19938}` (matches the pid — `NativeFunction` wrapping works) |
|
||||
| 5 | `--mode repl`, feeding `list` / `reload` / `list` / `add [40, 2]` / `nope` | `add ping getpackagename callnative callnativeaddr`; `reloaded`; same list; `42`; `error: no such rpc export: nope (available: ...)` |
|
||||
| 6 | `--mode http`, `GET /exports` | `{"ok":true,"exports":[...5 names...]}` |
|
||||
| 7 | `--mode http`, `POST /call {"export":"add","args":[40,2]}` | `{"ok":true,"result":42}` |
|
||||
| 8 | `--mode http`, `POST /call {"export":"getpackagename","args":[]}` | `{"ok":true,"result":"com.android.settings"}` |
|
||||
| 9 | `--mode http`, `POST /call` with an unknown export | HTTP 502 + `{"ok":false,"error":"no such rpc export: nope (available: ...)"}` |
|
||||
| 10 | `--mode http`, `POST /reload` then `POST /call add [1,2]` | `{"ok":true}` then `{"ok":true,"result":3}` — the re-attach path works, and no `DeprecationWarning` is printed any more |
|
||||
| 11 | `--spawn --package io.github.vvb2060.magisk --mode call --export getpackagename` | `{"ok": true, "result": "io.github.vvb2060.magisk"}` (spawn → script load → resume → RPC) |
|
||||
|
||||
Every script load also delivered the template's own liveness message:
|
||||
`[script] {"ev": "RPC-READY", "exports": ["add","ping","getpackagename","callnative","callnativeaddr"]}`.
|
||||
That line is the cheapest health check for a session and is worth grepping for in automation.
|
||||
|
||||
### 4.3 The server's port moved under us
|
||||
|
||||
Mid-pass, `--device-serial <DEVICE>` began failing on attach while `frida-ps -U` kept working:
|
||||
|
||||
```
|
||||
$ python ... --device-serial <DEVICE> --pid 19938 ...
|
||||
[bridge] connect failed (unable to connect to remote frida-server: closed); retry 1 in 1.0s
|
||||
... (3 attempts) ...
|
||||
|
||||
$ python -c "import frida;d=frida.get_device('<DEVICE>');d.attach(19938)"
|
||||
attach failed: ServerNotRunningError unable to connect to remote frida-server: closed
|
||||
|
||||
$ adb -s <DEVICE> shell 'su -c "ps -A"' | Select-String 'svc16|frida'
|
||||
root 1876 1 2227684 55288 do_sys_poll S .svc16 # only this instance alive
|
||||
$ adb -s <DEVICE> shell 'su -c "netstat -ltnp"' | Select-String 2704
|
||||
tcp 0 0 0.0.0.0:27043 0.0.0.0:* LISTEN 1876/.svc16 # nothing on 27042
|
||||
```
|
||||
|
||||
The visible `frida-server` process (pid 23405) was gone; a disguised copy (`.svc16`, pid 1876) was
|
||||
listening on **27043 only**. `frida-ps -U` still worked because frida's USB transport probes a range
|
||||
of ports, while the scripted device-serial path landed on a dead default port. Recovery, with the
|
||||
forward already present (`adb forward --list` shows `tcp:27043 tcp:27043`):
|
||||
|
||||
```
|
||||
$ python ... --remote 127.0.0.1:27043 --pid 19938 --mode call --export getpackagename
|
||||
{"ok": true, "result": "com.android.settings"}
|
||||
```
|
||||
|
||||
This pass neither started nor killed any server instance; it only observed that the instance changed
|
||||
and switched to the explicit `--remote` form. That form is now the recommended one in the reference's
|
||||
failure table.
|
||||
|
||||
### 4.4 A third-party process that refuses the handshake
|
||||
|
||||
```
|
||||
$ python ... --remote 127.0.0.1:27043 --pid 19984 --mode call --export getpackagename
|
||||
frida.TimedOutError: unexpectedly timed out while waiting for signal from process with PID 19984
|
||||
```
|
||||
|
||||
An ordinary third-party app process (`com.xunmeng.pinduoduo`, listed by `frida-ps -Uai`) simply did
|
||||
not answer the agent handshake within frida's timeout. Pre-fix this surfaced as a traceback; post-fix
|
||||
the bridge treats it as retryable and reports `initial connect failed: ...` with exit 2. This is an
|
||||
app-side property, not a bridge property — for connectivity probes prefer a system or stable app
|
||||
process.
|
||||
|
||||
### 4.5 Device state after the pass
|
||||
|
||||
`frida-ps -U` and `frida-ps -Uai` both still answer; the sample's process was never touched; the
|
||||
device-side server was never killed or restarted by this pass.
|
||||
|
||||
---
|
||||
|
||||
## 5. unidbg — observed (build/boot) and unverified (target emulation)
|
||||
|
||||
### 5.1 Build
|
||||
|
||||
```
|
||||
$ git clone --depth 1 https://github.com/zhkl0228/unidbg.git # version 0.9.10-SNAPSHOT
|
||||
$ .\mvnw.cmd -B -pl unidbg-android -am -DskipTests compile
|
||||
Error: JAVA_HOME not found in your environment. # JAVA_HOME was unset on this host
|
||||
...
|
||||
[ERROR] Failed to execute goal ...maven-compiler-plugin:3.3:compile on project unidbg-api:
|
||||
[ERROR] .../AbstractARMDebugger.java:[79,43] reference to Module is ambiguous
|
||||
[ERROR] both com.github.unidbg.Module and java.lang.Module match
|
||||
```
|
||||
|
||||
The shipped pom pins `<source>8</source><target>8</target>` with `maven-compiler-plugin` 3.3. On
|
||||
JDK 9+ that pair does **not** select the JDK 8 API, so `java.lang.Module` stays visible and every
|
||||
unqualified `Module` in `AbstractARMDebugger` becomes ambiguous — the build dies in `unidbg-api`.
|
||||
Two local work-copy changes fix it (recorded in `tools/_work/t3-rpc/unidbg/pom.xml`, not part of the
|
||||
skill and not committed):
|
||||
|
||||
```
|
||||
maven-compiler-plugin 3.3 -> 3.8.1 # 3.13 is rejected: the bundled wrapper is Maven 3.5.4,
|
||||
# and 3.13 requires Maven >= 3.6.3
|
||||
<source>8</source><target>8</target> -> <release>8</release> # hides java.lang.Module
|
||||
```
|
||||
|
||||
Result with `JAVA_HOME=C:\Program Files\Java\jdk-17.0.4.1`:
|
||||
|
||||
```
|
||||
$ .\mvnw.cmd -B -pl unidbg-android -am -DskipTests compile
|
||||
[INFO] unidbg-api ......... SUCCESS [ 40.838 s]
|
||||
[INFO] unidbg-android ..... SUCCESS [ 4.941 s]
|
||||
[INFO] BUILD SUCCESS (total 48.4 s, after a one-off dependency download)
|
||||
```
|
||||
|
||||
### 5.2 Boot check
|
||||
|
||||
The pom sets `<maven.test.skip>true</maven.test.skip>`, so `-Dmaven.test.skip=false` is required to
|
||||
run anything:
|
||||
|
||||
```
|
||||
$ .\mvnw.cmd -B -pl unidbg-android -am -Dmaven.test.skip=false \
|
||||
-Dtest=MemoryTrackerTest -DfailIfNoTests=false test
|
||||
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
|
||||
Backend: DynarmicBackend64
|
||||
=== Memory Leak Report === ... Address: 0x12000000, Size: 4096 ...
|
||||
[INFO] BUILD SUCCESS
|
||||
```
|
||||
|
||||
This proves the toolchain end to end at the *environment* level: emulator construction, memory
|
||||
allocator, backend selection (Dynarmic, with Hypervisor and Unicorn2 factories registered) and
|
||||
Android 11 (`AndroidResolver(23)`-style) library resolution all execute on this host.
|
||||
|
||||
### 5.3 What was **not** done — unverified
|
||||
|
||||
* Loading and calling **any** target `.so`, including this sample's `libjiagu*.so` or `libapp.so`.
|
||||
No `createDalvikVM`, `loadLibrary`, `callJNI_OnLoad` or `AbstractJni` stub was exercised.
|
||||
* Consequently every per-library statement in `references/emulation-and-rpc.md` §Part A — which JNI
|
||||
callbacks a hardened library demands, how many stub rounds it costs, which `/proc` or signature
|
||||
checks it runs — remains **inferred** from the library's shape and community practice. The
|
||||
reference's "Measured here" section was rewritten in this pass to say exactly that.
|
||||
* Note for anyone porting an older tutorial: `emulator.createCode()` / the `Assembly` helper class
|
||||
do **not** exist in 0.9.10-SNAPSHOT (no `Assembly*.java` under the tree, no `createCode` symbol in
|
||||
any module). Recipes that assemble raw ARM machine code through that API need the current
|
||||
backend/JNI entry points instead.
|
||||
* `unidbg-android`'s bundled demos (`TTEncrypt`, `SignUtil`, `QDReaderJni`, …) each expect their own
|
||||
`.so`/APK to be placed first; none were run, so the demo names in the reference are pointers, not
|
||||
verified commands.
|
||||
|
||||
---
|
||||
|
||||
## 6. Consolidated list of things that did **not** work (for the pass summary)
|
||||
|
||||
1. Attach by package name on this ROM (both `com.android.settings` and `io.github.vvb2060.magisk`
|
||||
failed while listed by `frida-ps -Uai`). Workaround: pid, from `frida-ps -U` / `pidof`.
|
||||
2. Device-serial attach once the device's server came back on 27043 — `ServerNotRunningError:
|
||||
unable to connect to remote frida-server: closed`. Workaround: `adb forward` + `--remote`.
|
||||
3. Attach by pid to `com.xunmeng.pinduoduo` — `frida.TimedOutError`; the process never answered the
|
||||
handshake.
|
||||
4. `asc` as a command name (the console script is `droidasc`), and `mvnw` without `JAVA_HOME`.
|
||||
5. unidbg out of the box on JDK 17 (`Module` ambiguity) and unidbg tests out of the box
|
||||
(`maven.test.skip=true`).
|
||||
6. Application-class decompilation of a packed APK by either ASC or ddc (nothing to fix — the dex
|
||||
simply is not there; this is the case that justifies the whole emulation/RPC route).
|
||||
|
||||
## 7. Artifacts produced by this pass
|
||||
|
||||
`tools/_work/t3-rpc/` (not committed): `asc-manifest-verify.xml`, `asc-StubApp-verify.java`,
|
||||
`asc-listclass-all.txt`, `asc-listclass-stub.log`, `asc-getmanifest.log`, `asc-findrefs.log`,
|
||||
`asc-findrefs-jiagu.txt`, `asc-getclass.log`, `ddc-info-verify.txt`, `ddc-findrefs-jiagu.txt`,
|
||||
`ddc-strings-jiagu.txt`, `rpc-*.log` (one per command in §4), `bin/ddc.exe`, `unidbg/` (patched work
|
||||
copy).
|
||||
|
||||
Files changed in the repository by this pass: `references/emulation-and-rpc.md` (corrected failure
|
||||
table, corrected unidbg build recipe, corrected "Measured here" labelling, and a stale section
|
||||
reference repointed to `SKILL.md` §Stop conditions) and `scripts/frida_rpc_serve.py` (the four fixes
|
||||
in §3).
|
||||
@@ -0,0 +1,539 @@
|
||||
# Extension verification — extraction shells and the Dex-VMP boundary (bench rows B3 / B4)
|
||||
|
||||
Covers benchmark rows **B3** (extraction-shell skeleton detection) and **B4** (Dex-VMP
|
||||
boundary: refuse-to-repack and downgrade the deliverable). Strength labels are this
|
||||
directory's own three tiers: **observed** (an exact command and its output are quoted
|
||||
here), **inferred** (follows from observed facts; the step was not executed),
|
||||
**unverified** (assumed or externally reported, not reproduced here).
|
||||
|
||||
Everything below was produced on this repository's Windows 11 host with the rootless
|
||||
PC toolchain in `tools/_work/ENV.md`; the device results in §4 name a
|
||||
`<DEVICE>` and use `<PKG>` placeholders for the target package.
|
||||
|
||||
## 0. Headline
|
||||
|
||||
| Question | Answer | Strength |
|
||||
|---|---|---|
|
||||
| Does `stub%` separate a skeleton copy from a genuine dex? | **Only when the shell writes bare `return-void`.** Three other measured emptying shapes score 0.0 %–2.3 %, at or below the *untouched* control (1.9 %) | observed |
|
||||
| Was the pre-existing `trivial_ratio` detector correct? | **No — two defects**, one of which inverted the ranking and named a fully-nop-wiped skeleton the "most likely original" | observed |
|
||||
| Is `repos/CyReverse`'s shipped "extraction shell" asset usable as a shell fixture? | **No.** The pair differs by six bytes and one body; neither is a shell | observed |
|
||||
| Is `DexPatcher/assets/ezAndroid.apk` a Dex VMP? | **No.** It is a JNI-sinking target: 7 `native` declarations, an exported `Java_*` for each, 34,566 clean dalvik instruction decodes, zero structural errors | observed |
|
||||
| Can a static dex probe decide VMP vs not? | **It can decide "not dex-VMP"** when the bodies decode cleanly and each native declaration has an export. It **cannot** decide whether the native island performs runtime hardening | observed (negative half) / unverified (positive half) |
|
||||
| Does `dex_mem_scan.py` find and cut a dex out of a blob? | **Yes** — exact offset, no false hits in 2 MiB of noise, image accepted by the judge | observed |
|
||||
| Does it reach a dex in a live process's anonymous mapping? | **Not measured here.** The fixture is built and the commands staged; the device was held by another row throughout | unverified |
|
||||
|
||||
One claim in this pass was **retracted**: §3.4 records a Dex-VMP verdict drawn from a
|
||||
desynchronised decoder and the measurement that killed it.
|
||||
|
||||
---
|
||||
|
||||
## 1. B3 — the shipped CyReverse "extraction shell" assets are a 6-byte difference, not a shell
|
||||
|
||||
Source: `repos/CyReverse/app/src/main/assets/{classes3.dex, classes3_extracted.dex}`
|
||||
(12,536 B each).
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/dex_dump_validate.py tools/_work/bench/unpack/b3
|
||||
== dex dump validation: 2 file(s), 2 parse, 0 rejected ==
|
||||
name size sha256[:12] ver cksum sig class noco code stub%
|
||||
cyrev-extracted.dex 12536 c2d7345e955a 037 ok BAD 4 0 20 10.0%
|
||||
cyrev-skeleton.dex 12536 ba9d5b120a3a 037 ok ok 4 0 20 10.0%
|
||||
```
|
||||
|
||||
A byte diff of the pair (`tools/_work/bench/unpack/b3/analyze_pair.py`) locates every
|
||||
difference:
|
||||
|
||||
```
|
||||
$ python tools/_work/bench/unpack/b3/analyze_pair.py
|
||||
len A=12536 len B=12536
|
||||
first diff offset = 8 (0x8)
|
||||
diff byte count = 6
|
||||
diff ranges:
|
||||
0x8 .. 0x8 (1 B) <- dex checksum (header offset 8)
|
||||
0xa .. 0xb (2 B) <- dex signature, first half (offset 12 is 4 B wide)
|
||||
0x121c .. 0x121c (1 B) <- INSNS of PluginClass->getString, units 0 and 2
|
||||
0x121e .. 0x121e (1 B)
|
||||
0x1220 .. 0x1220 (1 B)
|
||||
```
|
||||
|
||||
Per-method census of the pair shows the "extracted" copy is the *degraded* one:
|
||||
|
||||
```
|
||||
== cyrev-skeleton.dex == Lcom/cyrus/example/plugin/PluginClass;->getString insns=3 units=001a 0087 0011
|
||||
== cyrev-extracted.dex == Lcom/cyrus/example/plugin/PluginClass;->getString insns=3 units=0000 0000 0000
|
||||
```
|
||||
|
||||
`const-string v0, "…"; return-object v0` was overwritten with three `0x0000` units in
|
||||
exactly one of the twenty bodies, and the header was re-fixed. The shipped
|
||||
`classes3_extracted.dex` is **not** an extraction shell and **not** a recovered dex:
|
||||
it is a single-body demonstration of body clearing, with 19 of 20 bodies identical to
|
||||
the skeleton. Neither file can test extraction-shell routing — 10.0 % `stub%` on both
|
||||
sides is a coincidence of the same body set, not a signal.
|
||||
|
||||
**Consequence for the benchmark row:** B3 cannot be run against this asset pair. The
|
||||
skeleton measurement below had to be built from a real application dex instead.
|
||||
|
||||
## 2. B3 — measured `stub%` across every emptying shape, with a zero-change control
|
||||
|
||||
Fixture builder: `tools/_work/bench/unpack/b3/make_skeleton.py` derives each variant
|
||||
from a real 982,480-byte application dex (`src-r2pay.dex`, extracted from
|
||||
`tools/_work/bench/apks/r2pay-v1.0.apk`, sha256 `c89ff2364879…`). Every variant is
|
||||
**byte-length preserving**: only the `insns` array of a `code_item` is rewritten and
|
||||
the tail is nop-filled, so offsets, class table and string table are untouched and the
|
||||
header is re-fixed with `dexutil.fix_dex_header` (signature first, checksum last).
|
||||
|
||||
```
|
||||
$ python tools/_work/bench/unpack/b3/make_skeleton.py src-r2pay.dex skel
|
||||
source: src-r2pay.dex
|
||||
ctrl.dex fraction=0.00 mode=return emptied=0/5061
|
||||
skel-25.dex fraction=0.25 mode=return emptied=1265/5061
|
||||
skel-50.dex fraction=0.50 mode=return emptied=2531/5061
|
||||
skel-75.dex fraction=0.75 mode=return emptied=3796/5061
|
||||
skel-all.dex fraction=1.00 mode=return emptied=5061/5061
|
||||
pruned.dex fraction=1.00 mode=pruned emptied=5061/5061
|
||||
nopfilled.dex fraction=1.00 mode=nop emptied=5061/5061
|
||||
allstub.dex fraction=1.00 mode=allstub emptied=5061/5061
|
||||
throwstub.dex fraction=1.00 mode=throw emptied=4134/5061
|
||||
```
|
||||
|
||||
Verdicts from `dex_dump_validate.py` over the whole set — the decisive matrix:
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/dex_dump_validate.py tools/_work/bench/unpack/b3/skelall
|
||||
name size sha256[:12] ver cksum sig class noco code stub% erased% emptied%
|
||||
allstub.dex 982480 c8d7ca8fb163 035 ok ok 647 250 5061 100.0% 0.0% 100.0%
|
||||
ctrl.dex 982480 1b3a97896b40 035 ok ok 647 250 5061 1.9% 0.0% 1.9%
|
||||
nopfilled.dex 982480 de92dcea4eaf 035 ok ok 647 250 5061 0.0% 100.0% 100.0%
|
||||
pruned.dex 982480 58d7fa3dce48 035 ok ok 647 250 5061 93.2% 0.0% 93.2%
|
||||
skel-25.dex 982480 63346d9b41f5 035 ok ok 647 250 5061 1.7% 0.0% 1.7%
|
||||
skel-50.dex 982480 d84601b7aa47 035 ok ok 647 250 5061 1.7% 0.0% 1.7%
|
||||
skel-75.dex 982480 64114285656d 035 ok ok 647 250 5061 2.1% 0.0% 2.1%
|
||||
skel-all.dex 982480 6345daef0a3f 035 ok ok 647 250 5061 2.3% 0.0% 2.3%
|
||||
throwstub.dex 982480 cab8fd51a743 035 ok ok 647 250 5061 1.9% 0.0% 1.9%
|
||||
```
|
||||
|
||||
Read the `mode=` column against `stub%`:
|
||||
|
||||
| Emptying shape (what the shell leaves behind) | `stub%` | Separated from `ctrl` (1.9 %)? |
|
||||
|---|---|---|
|
||||
| bare `return-void` in every body (`allstub`) | **100.0 %** | yes, by 98 points |
|
||||
| body truncated to `const/4 v0,#0; return v0` + nop fill (`pruned`) | 93.2 % | yes |
|
||||
| nop fill, no return at all (`nopfilled`) | **0.0 %** | **no — scores *below* the untouched control** |
|
||||
| `new RuntimeException; throw` stub (`throwstub`) | 1.9 % | **no — indistinguishable from control** |
|
||||
| 25 % / 50 % / 75 % of bodies emptied with a legal minimal body | 1.7 / 1.7 / 2.1 % | **no** |
|
||||
|
||||
Three conclusions, all observed:
|
||||
|
||||
1. **The threshold is not a number, it is a shape.** `stub%` is bimodal — it is either
|
||||
≈ control (blind) or ≈ 100 % (sighted). There is no intermediate band where a
|
||||
threshold could sit, so the "tens of percent" calibration the reference file
|
||||
describes has no measured support.
|
||||
2. **Partial extraction is invisible.** `skel-75.dex` — three quarters of all bodies
|
||||
emptied — reports 2.1 % against a 1.9 % control, a 0.2-point difference inside the
|
||||
sampling noise of what a real app's own trivial bodies contribute.
|
||||
3. **The two shapes that defeat it are the two that real shells prefer.**
|
||||
`throw new RuntimeException` stubs are the classic 360/legu family shape, and a
|
||||
nop-wiped body is what a shell writes when it clears the slot before decrypting.
|
||||
|
||||
The false-negative on `nopfilled` was a **defect, not a boundary**, and is fixed in §3.
|
||||
The `throwstub` blind spot is a **boundary** and is documented as one.
|
||||
|
||||
## 3. B3 — defects found in `dex_dump_validate.py`, and the fix
|
||||
|
||||
### 3.1 Defect 1: a fully nop-filled body was classified `real`
|
||||
|
||||
`classify_body` skipped every `0x0000` unit and then fell through to `return "real"`
|
||||
when nothing else was found. Measured consequence: `nopfilled.dex` — **all 5,061
|
||||
bodies wiped** — reported `stub% = 0.0`, and the ranking named it the most likely
|
||||
original:
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/dex_dump_validate.py …/skel/{nopfilled,ctrl}.dex
|
||||
ranking (most likely original first):
|
||||
1. nopfilled.dex stub%=0.0 cksum=ok sig=ok classes=647
|
||||
…
|
||||
verdict: nopfilled.dex is the most likely original (1 copy(ies) in this set)
|
||||
```
|
||||
|
||||
An agent routing on that line patches a wiped skeleton. That is the failure the file
|
||||
exists to prevent, so the defect is on the wrong side of "the tool still produces
|
||||
output".
|
||||
|
||||
**Fix.** `classify_body` gained two classes and a wider body scan:
|
||||
|
||||
- `erased` — `insns_size > 0` with every unit `0x0000`. A real compiler never emits a
|
||||
whole body of nops, so this is skeleton evidence.
|
||||
- `minimal` — a body truncated to `const/4 vR,#0; return vR` (legal, and the shape the
|
||||
detector is blind to by design). Reported separately so the blind spot is visible
|
||||
rather than hidden inside `real`.
|
||||
- The early-exit `meaningful > 1 → real` became `meaningful > 2`, so the two-unit
|
||||
minimal body reaches its own verdict instead of being mislabelled.
|
||||
|
||||
`trivial_ratio` keeps its original definition so previously recorded numbers stay
|
||||
comparable; `emptied_ratio = (stub + erased) / with_code` is the new skeleton signal,
|
||||
and `rank_key` sorts on it **low-first** with `bodies_erased` as a tiebreak.
|
||||
|
||||
### 3.2 Defect 2 (retracted in-flight): `0xNN0E` is not malformed
|
||||
|
||||
An intermediate version of the fix classified a lone `0x020E` unit as `malformed`,
|
||||
reading a non-zero high byte on a `0x0E` opcode as an illegal `return-void`. The
|
||||
control dump proved otherwise — 82 of the untouched dex's own bodies are exactly that
|
||||
shape — and `const/4 v0, #0; return v0` is a legal minimal non-void body. The class was
|
||||
renamed `minimal`, its comment corrected, and the fixture extended with `pruned.dex`
|
||||
to measure it deliberately (93.2 %, `stub%` sighted). Recorded here because the
|
||||
mislabel would have taught the next reader that ordinary short methods are damage.
|
||||
|
||||
### 3.3 Post-fix behaviour, and no regression on the recorded fixture
|
||||
|
||||
`nopfilled.dex` is now ranked last and flagged:
|
||||
|
||||
```
|
||||
8. nopfilled.dex stub%=0.0 erased%=100.0 cksum=ok sig=ok classes=647 <-- SKELETON: emptied%=100, extraction-shell shape
|
||||
9. …
|
||||
verdict: skel-25.dex is the most likely original (1 copy(ies) in this set)
|
||||
```
|
||||
|
||||
The pre-existing acceptance fixture from `EXTENSION-unpacking.md` §2 still produces
|
||||
its recorded verdict, with only the two new columns appended:
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/dex_dump_validate.py tools/_work/t2-unpack --find 'Lcom/stub/'
|
||||
== dex dump validation: 7 file(s), 4 parse, 3 rejected ==
|
||||
01_original_shell.dex 8923752 bcd1c0488e86 035 ok BAD 4 160 29 3.4% 0.0% 3.4%
|
||||
02_duplicate.dex 8923752 bcd1c0488e86 035 ok BAD 4 160 29 3.4% 0.0% 3.4%
|
||||
03_broken_magic.dex 8923752 33eee53b1317 ? REJECTED: magic=b'XXXX'
|
||||
04_tampered_body.dex 8923752 b89bc8cc73d9 035 BAD BAD 4 160 29 3.4% 0.0% 3.4%
|
||||
05_truncated.dex 65536 e6687390a663 035 REJECTED: file_size=8923752 actual=65536
|
||||
06_extracted_skeleton.dex 8923752 85e56fed1d56 035 ok ok 4 160 29 100.0% 0.0% 100.0%
|
||||
07_tiny_garbage.dex 16 be45cb2605bf ? REJECTED: too small (16 B) to carry a dex header
|
||||
verdict: 01_original_shell.dex is the most likely original (2 copy(ies) in this set)
|
||||
```
|
||||
|
||||
Same winner, same three rejections, same dedupe group. The change is additive on that
|
||||
fixture and corrective on the new one.
|
||||
|
||||
### 3.3b The ranking was still pointing at a modified image (found by independent review)
|
||||
|
||||
The first fix above added the `emptied_ratio` signal — and then **kept ranking on a
|
||||
metric this same pass had just proved insensitive to skeletons**. Reproduced by
|
||||
leading the review on the variant set:
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/dex_dump_validate.py tools/_work/bench/unpack/b3/skel
|
||||
ranking (most likely original first):
|
||||
1. skel-25.dex stub%=1.7 erased%=0.0 cksum=ok sig=ok classes=647
|
||||
2. skel-50.dex stub%=1.7 erased%=0.0 cksum=ok sig=ok classes=647
|
||||
3. ctrl.dex stub%=1.9 erased%=0.0 cksum=ok sig=ok classes=647
|
||||
…
|
||||
verdict: skel-25.dex is the most likely original (1 copy(ies) in this set)
|
||||
```
|
||||
|
||||
`skel-25.dex` — a dex with **25 % of its bodies removed** — was named the most likely
|
||||
original, and the genuine untouched control ranked third. The cause is the bimodality
|
||||
of §2: inside the low band the ratio is not a signal, and 1.7 % versus 1.9 % is noise
|
||||
(the extracted bodies are replaced by `const/4 + return`, which *lowers* the
|
||||
`return-void` count relative to the control). An agent routing on that line adopts a
|
||||
modified image as its baseline, and every later diff is then built on the wrong
|
||||
artifact — the same class of failure as the defect in §3.1, reached by a different
|
||||
path.
|
||||
|
||||
**Fix.** `rank_key` became three-level, with an explicit eviction level so that either
|
||||
signal can disqualify an image instead of the two merely tying:
|
||||
|
||||
1. `emptied_ratio >= 0.5` sorts last. Nothing mostly emptied is a candidate however
|
||||
else it scores. This level exists because the count below cannot see the
|
||||
`return-void`-in-every-body skeleton: that image has `minimal = erased = 0`, so a
|
||||
pure-count key ranked it **first** — measured, and it is why this level was added
|
||||
rather than the count being promoted alone.
|
||||
2. `bodies_minimal + bodies_erased` low-first. This orders the low band, where the
|
||||
count is monotone: 82 untouched, then 1299 / 2524 / 3736 / 4946 at 25 / 50 / 75 /
|
||||
100 %.
|
||||
3. `emptied_ratio` low-first, then header verdicts, then name.
|
||||
|
||||
Result on the same command:
|
||||
|
||||
```
|
||||
ranking (most likely original first):
|
||||
1. ctrl.dex stub%=1.9 erased%=0.0 min/erased=82 cksum=ok sig=ok
|
||||
2. only.dex stub%=1.9 erased%=0.0 min/erased=82 cksum=ok sig=ok
|
||||
3. throwstub.dex stub%=1.9 erased%=0.0 min/erased=82 cksum=ok sig=ok
|
||||
4. skel-25.dex stub%=1.7 erased%=0.0 min/erased=1299 cksum=ok sig=ok
|
||||
5. skel-50.dex stub%=1.7 erased%=0.0 min/erased=2524 cksum=ok sig=ok
|
||||
6. skel-75.dex stub%=2.1 erased%=0.0 min/erased=3736 cksum=ok sig=ok
|
||||
7. skel-all.dex stub%=2.3 erased%=0.0 min/erased=4946 cksum=ok sig=ok
|
||||
8. allstub.dex stub%=100.0 erased%=0.0 min/erased=0 cksum=ok sig=ok <-- SKELETON
|
||||
9. pruned.dex stub%=93.2 erased%=0.0 min/erased=344 cksum=ok sig=ok <-- SKELETON
|
||||
10. nopfilled.dex stub%=0.0 erased%=100.0 min/erased=5061 cksum=ok sig=ok <-- SKELETON
|
||||
verdict: ctrl.dex is the best-supported candidate for the original (1 copy(ies) in this set)
|
||||
```
|
||||
|
||||
The control wins and every skeleton sorts behind it. The verdict line now states the
|
||||
key and its limits instead of declaring a winner outright, and phrases the result as
|
||||
"best-supported candidate" with an instruction to confirm before patching. `--trim`,
|
||||
`--json` and `--find` are unchanged (verified: the recorded fixture still returns
|
||||
`01_original_shell.dex` first, `--json` only gained keys). Note the residual limit the
|
||||
report prints rather than hides: **`throwstub.dex` still ranks third**, because no body
|
||||
statistic here sees a `throw`-stub skeleton. That is a boundary, not a bug — it is
|
||||
listed in §7 with the reproduction.
|
||||
|
||||
### 3.4 A retracted Dex-VMP verdict — the measurement that killed it
|
||||
|
||||
An intermediate workbench script of mine
|
||||
(`tools/_work/bench/unpack/b4/vmp_probe.py`, `vmp_methods.py`) decoded instruction
|
||||
widths from a hand-written table and reported:
|
||||
|
||||
```
|
||||
code_items=22424 desync=1393 (6.21%) bad_op=0xf8 / 0xef / 0xea / …
|
||||
```
|
||||
|
||||
I read that as "bodies present but not decodable → private opcodes → Dex VMP". **The
|
||||
reading was wrong, and the error was my table's.** The official decoder settles it:
|
||||
|
||||
```
|
||||
$ E:\tools\android-14\dexdump.exe -d tools/_work/bench/unpack/b4/orig/classes.dex
|
||||
Processing 'orig\classes.dex'...
|
||||
Opened 'orig\classes.dex', DEX version '035'
|
||||
… 943,223 lines …
|
||||
instruction lines (hex:...) : 348566
|
||||
code items resolved ('insns size :') : 22424
|
||||
code '(none)' : 1179
|
||||
```
|
||||
|
||||
One of the methods my script called a desync decodes cleanly under `dexdump`:
|
||||
|
||||
```
|
||||
--- androidx.appcompat.app.AlertController$ButtonHandler.<init>:(Landroid/content/DialogInterface;)V
|
||||
insns size : 11 16-bit code units
|
||||
1fd564: 7010 9b05 0100 |0000: invoke-direct {v1}, Landroid/os/Handler;.<init>:()V
|
||||
1fd56a: 2200 040e |0003: new-instance v0, Ljava/lang/ref/WeakReference;
|
||||
1fd56e: 7020 fb76 2000 |0005: invoke-direct {v0, v2}, Ljava/lang/ref/WeakReference;.<init>:(Ljava/lang/Object;)V
|
||||
1fd574: 5b10 ed08 |0008: iput-object v0, v1, …$ButtonHandler;.mDialog:…;
|
||||
1fd578: 0e00 |000a: return-void
|
||||
```
|
||||
|
||||
My script consumed 9 of 11 units and stopped on `0xed`; `0xed` is a legal
|
||||
`invoke-*/range` opcode. **The lesson is the one the reference file already states and
|
||||
this pass then failed to apply: a hand-rolled opcode table is not a VMP detector, and
|
||||
"my decoder desynchronised" is a statement about my decoder.** Any future
|
||||
"rare-opcode density" metric must be validated against `dexdump` on the same file
|
||||
before it is allowed to inform a verdict.
|
||||
|
||||
The corrected count of bodies that fail to decode as dalvik in this dex is **0**.
|
||||
Correction confirmed by an independent path: `dexdump` emits 34,566 instruction lines
|
||||
and reports no structural error for any of the 22,424 code items.
|
||||
|
||||
## 4. B4 — the Dex-VMP judgement chain, re-opened
|
||||
|
||||
### 4.1 Static findings (`observed`)
|
||||
|
||||
Target: `repos/DexPatcher/assets/ezAndroid.apk`, 2,984,841 B, sha256
|
||||
`76e3fa8119cf8b7c…`, package `<PKG>` (the `ezAndroid` CTF sample shipped in DexPatcher).
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/dex_dump_validate.py …/b4/orig/classes.dex
|
||||
classes=2905 methods_with_code=22424 stub%=1.9 cksum=ok sig=BAD
|
||||
```
|
||||
|
||||
- **Native declarations: 7**, all in the app's own obfuscated classes, each with no
|
||||
code (`code : (none)`), enumerated from `dexdump`:
|
||||
`Lcom/example/ezandroid/C;->cf(Ljava/lang/String;I)Z`,
|
||||
`Lcom/example/ezandroid/MU;->GMS(Ljava/lang/String;)Ljava/lang/String;`,
|
||||
`Lcom/example/ezandroid/S;->Stringfjni()Ljava/lang/String;`,
|
||||
`Lcom/example/ezandroid/S;->onCreate`, `Lcom/example/ezandroid/S2;->onCreate`,
|
||||
`Lcom/example/ezandroid/S$1;->onClick`, `Lcom/example/ezandroid/S2$1;->onClick`.
|
||||
- **Two JNI exports** in `lib/arm64-v8a/libezandroid.so` (206,904 B, aarch64):
|
||||
`Java_com_example_ezandroid_S_Stringfjni` @ `0xea6c` (184 B) and
|
||||
`Java_com_example_ezandroid_MainActivity_stringFromJNI` @ `0xe91c` (184 B).
|
||||
Both are thin JNI wrappers that construct a `std::string` and dispatch through a
|
||||
virtual call — the body of the check is behind the vtable, not in the wrapper.
|
||||
- **No `JNI_OnLoad` and no `RegisterNatives` symbol** anywhere in the library; the
|
||||
string `JNI_OnLoad` does not occur in the file at all.
|
||||
- **`assets/` holds six small opaque files** (`0OooOO` 118 B, `O0ooOO` 126 B,
|
||||
`oo0Ooo` 86 B, `ooO0oo` 132 B, `OOoo0O` 398 B, `OOooO0` 260 B). Each begins with a
|
||||
three-unit header (`05 00 38 00 02 00 …`) followed by 2-byte-aligned units.
|
||||
- **No dex string references those six filenames.** Grep over the full 943,223-line
|
||||
`dexdump` output for `0OooOO|O0ooOO|oo0Ooo|ooO0oo|OOoo0O|OOooO0|assets/` returns
|
||||
**0 hits**.
|
||||
|
||||
### 4.2 Verdict: JNI sinking, not Dex VMP (`observed`)
|
||||
|
||||
The reference file's Dex-VMP test is "the dump's method bodies are intact but decode
|
||||
as private opcodes — rare-opcode density off the charts, unknown opcode slots". On this
|
||||
target **zero method bodies fail to decode**, and every `native` declaration has a
|
||||
matching exported `Java_*`. The dex is not carrying an encoded program; the code left
|
||||
the dex for the `.so`, which is `java2c-and-jni-sinking.md`'s territory, not this
|
||||
file's. The two must not be conflated: an agent that reads "bodies decode as nonsense"
|
||||
here will go hunting a private disassembler for code that is ordinary ARM64 in a
|
||||
206 KB library.
|
||||
|
||||
The six `assets/` files are a separate, **unresolved** observation: they are not
|
||||
referenced by any dex string, so they cannot be load-bearing at the dex layer. Whether
|
||||
the native island reads them by constructing the path at runtime, and what they
|
||||
contain, is **unverified** here — it needs a dynamic run (§4.3), not more static
|
||||
searching.
|
||||
|
||||
### 4.3 What static analysis structurally cannot see (the B4 boundary)
|
||||
|
||||
`java2c`'s independent pass reached the same classification on this sample and recorded
|
||||
that the hardening takes effect **at runtime only**. That is the general shape, and it
|
||||
is the half of B4 that no dex probe can settle:
|
||||
|
||||
| Question | Answerable statically? | Why |
|
||||
|---|---|---|
|
||||
| Are the bodies real dalvik or private opcodes? | yes | `dexdump` decodes them or does not; measured 0 failures here |
|
||||
| Did the code leave the dex for native? | yes | `native` access flags + `Java_*` exports / `JNI_OnLoad` |
|
||||
| Does the native island check the signature / root / debugger? | **no** | the logic is ARM64 behind a vtable; the check may not exist in any dex |
|
||||
| Do those `assets/` blobs get decrypted and used? | **no** | the path may be assembled at runtime; grep for the literal names finds nothing |
|
||||
| Does a repack actually fail, and why? | **no** | needs the repack installed and run against a control (G4) |
|
||||
|
||||
**Rule this pass proposes for the reference file:** a static dex probe may return
|
||||
"this is *not* dex-VMP" when the bodies decode cleanly and every native declaration has
|
||||
an export. It may **not** return "this is not hardened" from the same evidence —
|
||||
those are different claims, and the second one is the misdiagnosis
|
||||
`java2c-and-jni-sinking.md` was written to stop.
|
||||
|
||||
### 4.4 Repack control — `observed` up to the install, `unverified` beyond
|
||||
|
||||
G4 requires a zero-change control through the same pipeline. Built with the
|
||||
repository's own repacker against an explicit toolchain (this host has no uber-apk-signer;
|
||||
`keytool` lives in the JDK that `ENV.md` does not list):
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/repack.py \
|
||||
--apk tools/_work/bench/unpack/b4/ezAndroid.apk \
|
||||
--out tools/_work/bench/unpack/b4/ez-ctrl.apk \
|
||||
--ks tools/_work/bench/unpack/b4/ctrl.jks --signer apksigner \
|
||||
--apksigner E:\tools\android-14\apksigner.bat \
|
||||
--zipalign E:\tools\android-14\zipalign.exe \
|
||||
--keytool "C:\Program Files\Java\jdk-17.0.4.1\bin\keytool.exe"
|
||||
[in ] tools/_work/bench\unpack\b4\ezAndroid.apk (2984841 bytes)
|
||||
[zip] - META-INF/1N0R.SF / 1N0R.RSA / MANIFEST.MF (signature artifact)
|
||||
[zip] unsigned written: 2828919 bytes
|
||||
[zip] AndroidManifest.xml STORED offset=0x31
|
||||
[zip] classes.dex 3960320 bytes method=8 crc=037e9cb3
|
||||
[zip] resources.arsc STORED offset=0x202f14 aligned
|
||||
[sign] route: apksigner
|
||||
[verify:zipalign] rc=0
|
||||
[verify:v2block] present
|
||||
[cert] SHA1 C9:89:C5:75:A4:83:AE:E0:A6:4B:F4:F6:0D:E3:D2:73:C2:8B:65:69
|
||||
[result] OK
|
||||
```
|
||||
|
||||
Zero-change repack, v2 block present, alignment verified, `classes.dex` carried through
|
||||
at its original CRC (`037e9cb3`). **Install and launch on `<DEVICE>` are `unverified`
|
||||
in this record**: the shared device was held by a parallel benchmark row (`B1 L1 repack
|
||||
control + patched install`, `B2 L3 frida`) for the whole window available to this pass.
|
||||
No claim is made here about whether the control launches; the artifact and the command
|
||||
above are the reproducible half, and the device half is the open item.
|
||||
|
||||
Consequently **"repacking is truly refused on this target" is `inferred`, not
|
||||
`observed`** — it follows from the native island being the only home of the app's own
|
||||
logic (`Stringfjni`, `cf`, `GMS` are all native declarations), which makes a
|
||||
dex-level patch unable to reach the behaviour, but no device run confirms a refusal.
|
||||
The honest deliverable form for this target is the G1 row **annotated analysis report
|
||||
with a stated boundary**, with `lsposed_scaffold.py` / `frida_rpc_serve.py` named as
|
||||
the routes *if* a device run shows the build is refused.
|
||||
|
||||
## 5. `dex_mem_scan.py` — the cut half is measured, the in-process half is not
|
||||
|
||||
The script's job splits cleanly into *find-and-cut* (locate `dex\n03x` in a blob and
|
||||
write exactly the declared `file_size`) and *reach the blob at all* (do it against a
|
||||
live process's anonymous mappings through `/proc/<pid>/mem`). Only the first half was
|
||||
measurable in this pass, and it was measured with a synthetic high-entropy capture
|
||||
built to have the shape a heap buffer presents:
|
||||
|
||||
```
|
||||
$ python … # 2 MiB of random bytes with a 4,124-byte dex written at offset 0x9a000
|
||||
fixture dex: 4124 bytes, magic=b'dex\n039\x00'
|
||||
capture written: …/memscan/anonregion.bin 2097152 bytes dex at 0x9a000
|
||||
|
||||
$ python skills/apk-reverse/scripts/dex_mem_scan.py …/anonregion.bin --dump …/scanout
|
||||
== dex memory scan: 1 capture(s), 1 hit(s) ==
|
||||
capture offset ver file_size classes state
|
||||
anonregion.bin 0x9a000 039 4124 1 ok
|
||||
wrote …/scanout/anonregion+0x0009a000.dex
|
||||
|
||||
$ python skills/apk-reverse/scripts/dex_dump_validate.py …/scanout
|
||||
name size sha256[:12] ver cksum sig class noco code stub%
|
||||
anonregion+0x0009a000.dex 4124 d3dedcd2b75e 039 ok ok 1 0 5 0.0%
|
||||
```
|
||||
|
||||
Exact offset, zero false hits in 2 MiB of noise, the extracted image byte-identical
|
||||
by construction and accepted by the judge including its checksum and signature. That
|
||||
is the **whole find-and-cut path, observed** — the halves compose correctly.
|
||||
|
||||
**What is not measured.** The live-process half: the fixture that would exercise it is
|
||||
built and staged (§6), but the shared device was held by a parallel benchmark row for
|
||||
the entire window this pass had, so `dex_mem_scan.py` has still never been run against
|
||||
a real anonymous mapping here. The one related number that exists is from the earlier
|
||||
root-dump pass, and it is a **zero**: 142 anonymous regions scanned, no hits, against
|
||||
17 dex images found in *named* ART mappings with no search at all. A zero-hit scan is
|
||||
weak evidence for the reasons listed in the script's own header, and the honest
|
||||
summary is "the finder works on a shape that looks right; its in-process reach is
|
||||
unverified".
|
||||
|
||||
To close it (all commands staged, none run in this pass):
|
||||
|
||||
```
|
||||
adb push tools/_work/bench/unpack/memscan/out2/memfixture.jar /data/local/tmp/
|
||||
adb push tools/_work/bench/unpack/b3/src-r2pay.dex /data/local/tmp/payload.dex
|
||||
adb shell 'su -c "CLASSPATH=/data/local/tmp/memfixture.jar app_process /system/bin \
|
||||
com.revprobe.memfixture.Main /data/local/tmp/payload.dex"' &
|
||||
# the fixture prints its own pid, truncates the on-disk payload so the heap copy is the
|
||||
# only remaining source, and writes /data/local/tmp/memfixture.maps
|
||||
adb shell 'su -c "grep -c . /proc/<pid>/maps"' # confirm cheap maps first
|
||||
adb shell 'su -c "grep -v \"/\" /proc/<pid>/maps"' # the anonymous set to export
|
||||
```
|
||||
|
||||
Note the fixture deliberately truncates its source file: a scan that finds the dex
|
||||
after that point cannot have found a file copy, which is what makes the result
|
||||
attributable to memory rather than to disk.
|
||||
|
||||
## 6. Not done — and why
|
||||
|
||||
- **No device run of the B4 control build.** The shared device was locked by another
|
||||
benchmark row for the whole window; `devlock.py acquire unpack` timed out twice
|
||||
(900 s and 1,800 s waits). Install, launch, time-to-death and refusal/success are
|
||||
therefore `unverified`.
|
||||
- **No in-process run of `dex_mem_scan.py`.** The fixture is built and staged
|
||||
(`tools/_work/bench/unpack/memscan/`, §5), the arm64 `app_process` route is chosen
|
||||
because this host has **no `android.jar`** and therefore cannot compile a real APK
|
||||
at all, and the closing commands are listed — but the device was never available, so
|
||||
the anonymous-mapping hit rate remains unmeasured. The only related measurement is
|
||||
the earlier pass's **0 of 142** anonymous regions.
|
||||
- **Whether the `assets/` blobs are live.** Needs a dynamic run (read the files out of
|
||||
the process after the native island runs, or hook the path construction).
|
||||
- **No end-to-end FART splice.** The reference file's FART loop needs a live
|
||||
extraction-shell target; nothing in `repos/` is one (§1), and building an actively
|
||||
resisting shell is out of scope for this pass.
|
||||
- **A real APK build on this host.** The build-tools directory ships `d8`, `aapt2`,
|
||||
`apksigner` and `zipalign` but **no `android.jar`**, and no `android.jar` exists
|
||||
anywhere on the machine (searched the tool directories, `C:\Program Files`, and
|
||||
the SDK path). Any
|
||||
`javac` against `android.*` fails with "package android.os does not exist". APKs can
|
||||
still be *repacked* here (`repack.py` does not recompile), which is why §4.4 works
|
||||
and the self-built-APK route in the task brief does not.
|
||||
|
||||
## 7. Reproduction
|
||||
|
||||
```
|
||||
python tools/_work/bench/unpack/b3/make_skeleton.py tools/_work/bench/unpack/b3/src-r2pay.dex tools/_work/bench/unpack/b3/skel
|
||||
python tools/_work/bench/unpack/b3/analyze_pair.py
|
||||
python tools/_work/bench/unpack/b3/crosscheck.py tools/_work/bench/unpack/b3/skel/*.dex
|
||||
python skills/apk-reverse/scripts/dex_dump_validate.py tools/_work/bench/unpack/b3/skelall
|
||||
python skills/apk-reverse/scripts/dex_dump_validate.py tools/_work/t2-unpack --find 'Lcom/stub/'
|
||||
python tools/_work/bench/unpack/b4/vmp_boundary.py tools/_work/bench/unpack/b4/orig/classes.dex
|
||||
E:\tools\android-14\dexdump.exe -d tools/_work/bench/unpack/b4/orig/classes.dex
|
||||
python skills/apk-reverse/scripts/dex_mem_scan.py tools/_work/bench/unpack/memscan/anonregion.bin --dump tools/_work/bench/unpack/memscan/scanout
|
||||
```
|
||||
|
||||
Raw captures live beside the fixtures: `evidence/prefix-validate.txt` (before the fix,
|
||||
including the inverted ranking) and `evidence/postfix-validate.txt` (after). The
|
||||
`memscan/` tree carries the staged (unrun) in-process fixture: `plain/com/revprobe/memfixture/Main.java`
|
||||
(no-`android.jar` form), `build.ps1` (the APK build that cannot complete here, kept as
|
||||
the record of *why*), and `out2/{classes.dex,memfixture.jar}`.
|
||||
|
||||
To re-verify the fix from scratch, the two commands that matter are
|
||||
`dex_dump_validate.py …/skelall` (expect `nopfilled.dex` flagged, not ranked first) and
|
||||
`dex_dump_validate.py tools/_work/t2-unpack --find 'Lcom/stub/'` (expect
|
||||
`01_original_shell.dex` still the winner).
|
||||
@@ -0,0 +1,316 @@
|
||||
# Extension verification — Java2C identification and routing
|
||||
|
||||
Covers the 2026-09 extension pass that added `references/java2c-and-jni-sinking.md` and
|
||||
`scripts/java2c_probe.py`. Strength labels as defined in this directory's README:
|
||||
**observed** (exact command + output here), **inferred** (follows from observed facts,
|
||||
step not executed), **unverified** (assumed or externally reported, not independently
|
||||
confirmed).
|
||||
|
||||
Reference environment for this pass: Windows 11 host, Python 3.14.0, JDK 17,
|
||||
build-tools (`d8`/`aapt2`) from a local Android 14 SDK, `gcc` from MinGW only.
|
||||
**No Android NDK, no host `clang`, no `wsl` (HCS unavailable), and no device-side
|
||||
`clang`/Termux.** A rooted Android 11 / API 30 / arm64-v8a `<DEVICE>` was attached and
|
||||
reachable but **was not used at all** in this pass: every result below is static. No
|
||||
package name, project name or device serial appears in this record.
|
||||
|
||||
Samples are identified by hash rather than by name. They are public MASTG training apps
|
||||
(a no-native baseline, and two carrying a JNI sink), one VMP-labelled app taken from
|
||||
`tools/_work/bench/repos/`, one real third-party aarch64 ELF already on the host, and one
|
||||
locally synthesised Java2C-shaped dex.
|
||||
|
||||
---
|
||||
|
||||
## 1. The compile chain could not be closed — observed
|
||||
|
||||
The intended route was to build a real Java2C `.so` with `amimo/dcc` and probe it. `dcc`
|
||||
is present at `tools/_work/bench/repos/dcc/` and its Python dependencies are satisfied.
|
||||
The build stops at the C compiler, and every alternative was tried exactly once before
|
||||
being written off (two-strike rule — no third variant was attempted):
|
||||
|
||||
```
|
||||
$ which gcc clang
|
||||
gcc.exe C:\MinGW\bin\gcc.exe # MinGW targets PE/COFF, not ELF
|
||||
gcc/clang for Android: none
|
||||
|
||||
$ wsl -l -q ; wsl -e bash -lc "which gcc"
|
||||
Ubuntu-E
|
||||
(empty) -> exit 1 with: Wsl/Service/CreateInstance/CreateVm/HCS/HCS_E_SERVICE_NOT_AVAILABLE
|
||||
|
||||
$ adb shell "which clang clang++ gcc ; ls /data/data/com.termux"
|
||||
(no output for which)
|
||||
ls: /data/data/com.termux: No such file or directory
|
||||
```
|
||||
|
||||
Related **observed** fact: the `dcc` checkout ships **no prebuilt binary at all** — a
|
||||
recursive search for `*.so`, `*.apk`, `*.a` over the whole repo returns zero files. There
|
||||
is no ready-made Java2C library to fall back on.
|
||||
|
||||
Consequence: **no ELF file anywhere in this pass was produced by a Dex-to-C compiler.**
|
||||
Every Java2C-specific criterion in the reference document is therefore **inferred**.
|
||||
|
||||
## 2. Degenerate route A — `dcc` generates the C without an NDK — observed
|
||||
|
||||
`dcc.py` takes `--no-build`, and `dcc_main(..., do_compile=False)` writes the generated
|
||||
sources and the JNI project *before* `build_project(project_dir)` would be called. With
|
||||
`--no-build` and no `-o`, the apktool repack path is skipped entirely (it is guarded by
|
||||
`if is_apk(apkfile) and outapk`). So the translation stage runs to completion on a machine
|
||||
with no NDK.
|
||||
|
||||
```
|
||||
$ cd tools/_work/bench/repos/dcc
|
||||
$ python dcc.py <SAMPLE_DIR>/baseline.apk --no-build --dynamic-register \
|
||||
--source-dir <WORK>/dcc-out
|
||||
# (emits only Python 3.14 SyntaxWarnings from the bundled androguard)
|
||||
|
||||
$ find <WORK>/dcc-out -type f
|
||||
jni/Android.mk
|
||||
jni/Application.mk
|
||||
jni/nc/Dex2C.cpp jni/nc/Dex2C.h
|
||||
jni/nc/DynamicRegister.cpp jni/nc/DynamicRegister.h
|
||||
jni/nc/ScopedLocalRef.h jni/nc/ScopedPthreadMutexLock.h
|
||||
jni/nc/well_known_classes.cpp jni/nc/well_known_classes.h
|
||||
jni/nc/compiled_methods.txt
|
||||
jni/nc/Java_<PKG>_<Class>_<method>__<mangled-proto>.cpp
|
||||
```
|
||||
|
||||
This is the mapping evidence the pass needed, and it is exact rather than imagined:
|
||||
|
||||
`compiled_methods.txt` is a one-line dex signature, and it produced exactly one C file
|
||||
whose name is the same identifier in JNI long form:
|
||||
|
||||
```
|
||||
L<PKG>/MainActivity;onCreate(Landroid/os/Bundle;)V
|
||||
-> Java_<PKG>_MainActivity_onCreate__Landroid_os_Bundle_2.cpp
|
||||
```
|
||||
|
||||
The generated function opens with the parameter shape the reference document cites as the
|
||||
`.so`-side signature of a translated method:
|
||||
|
||||
```c
|
||||
/* L<PKG>/MainActivity;->onCreate(Landroid/os/Bundle;)V */
|
||||
void Java_<PKG>_MainActivity_onCreate__Landroid_os_Bundle_2(JNIEnv *env, jobject thiz, jobject p2){
|
||||
jobject v0 = NULL;
|
||||
...
|
||||
jclass cls0 = NULL,cls1 = NULL,cls2 = NULL,cls3 = NULL;
|
||||
```
|
||||
|
||||
And the registration half is generated as real text, which is the strongest single piece
|
||||
of evidence produced in this pass that the "no `Java_*` symbol to search for" path is not
|
||||
hypothetical:
|
||||
|
||||
```c
|
||||
// DynamicRegister.cpp
|
||||
const char *dynamic_register_compile_methods(JNIEnv *env) {
|
||||
jclass clazz;
|
||||
clazz = env->FindClass("<PKG>/MainActivity");
|
||||
if (clazz == nullptr)
|
||||
return "Class not found: <PKG>/MainActivity";
|
||||
const JNINativeMethod export_method_0[] = {
|
||||
{"onCreate", "(Landroid/os/Bundle;)V", (void *)Java_<PKG>_MainActivity_onCreate__Landroid_os_Bundle_2}
|
||||
};
|
||||
env->RegisterNatives(clazz, export_method_0, 1);
|
||||
env->DeleteLocalRef(clazz);
|
||||
return nullptr;
|
||||
}
|
||||
```
|
||||
|
||||
Two further facts read out of the generated `jni/Application.mk`, both **observed** and
|
||||
both load-bearing for the JNI-boundary section of the reference document:
|
||||
|
||||
```
|
||||
APP_STL := c++_static
|
||||
APP_CPPFLAGS += -fvisibility=hidden
|
||||
APP_ABI := armeabi-v7a arm64-v8a
|
||||
```
|
||||
|
||||
`-fvisibility=hidden` means the shipped library can present as "exports no `Java_*`
|
||||
symbol" **even when the translation used static naming** — the second, independent reason a
|
||||
symbol search returns nothing. `c++_static` is the origin of the `libc++` string criterion,
|
||||
and is also why that criterion is weak: it is an ordinary NDK setting.
|
||||
|
||||
## 3. The probe against real JNI-sinking targets — observed
|
||||
|
||||
This is the discriminating measurement the reference document is built on. The tool was
|
||||
run unmodified:
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/java2c_probe.py --apk <SAMPLE_DIR>/jni-a.apk
|
||||
classes.dex: 720084 B, 625 classes, 5081 methods, native 2 (0.04%),
|
||||
native-dominated classes 0, fully native 0
|
||||
libfoo.so: 14176 B, aarch64, dynsym 20 entries, Java_* 2, JNI_OnLoad False
|
||||
libfoo.so: 13948 B, arm, dynsym 26 entries, Java_* 2, ...
|
||||
libfoo.so: 13788 B, x86, dynsym 16 entries, Java_* 2, ...
|
||||
libfoo.so: 14440 B, x86_64, dynsym 16 entries, Java_* 2, ...
|
||||
verdict : jni-sinking
|
||||
confidence : medium
|
||||
```
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/java2c_probe.py --apk <SAMPLE_DIR>/jni-b.apk
|
||||
classes.dex: 1934860 B, 1396 classes, 11182 methods, native 3 (0.03%),
|
||||
native-dominated classes 0, fully native 0
|
||||
libfoo.so (arm/arm64: 3 Java_* symbols, x86/x86_64: 3 Java_* symbols)
|
||||
verdict : jni-sinking
|
||||
confidence : medium
|
||||
```
|
||||
|
||||
Two independent real targets, and both give the same two results: **native density three
|
||||
orders of magnitude below the Java2C shape**, and **an exact 1:1 between the dex native
|
||||
declarations and the exported `Java_*` symbols** (2:2 and 3:3, across four ABIs each).
|
||||
|
||||
That 1:1 is the point. It is *not* a Java2C signal on its own — both shapes produce it —
|
||||
which is why the density measurement and the native layer must be read together. A tool
|
||||
that reported on either alone would misroute one of these two samples.
|
||||
|
||||
Baseline control, same command, no native code at all:
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/java2c_probe.py --apk <SAMPLE_DIR>/baseline.apk
|
||||
classes.dex: 5528 B, 7 classes, 15 methods, native 0 (0.00%), ...
|
||||
verdict : no-hardening-signal
|
||||
confidence : medium
|
||||
```
|
||||
|
||||
## 4. The probe against a Java2C-shaped dex — observed
|
||||
|
||||
No Java2C library could be built, so the dex half of the shape was synthesised. A Java
|
||||
source declaring two classes whose methods are almost entirely `native` (plus one mixed
|
||||
class, standing in for an ordinary JNI boundary) was compiled with `javac --release 8` and
|
||||
assembled with `d8 --min-api 19`:
|
||||
|
||||
```
|
||||
$ javac --release 8 -d classes src/<PKG>/Calc.java # exit 0
|
||||
$ d8 --min-api 19 --output out classes/<PKG>/*.class # exit 0
|
||||
out/classes.dex 1752 B sha256 59e0b1d34f00578e1343ee5f344d0e58829f482b643a7843c8ad280311ce9457
|
||||
|
||||
$ python skills/apk-reverse/scripts/java2c_probe.py --dex out/classes.dex
|
||||
classes.dex: 1752 B, 3 classes, 29 methods, native 24 (82.76%),
|
||||
native-dominated classes 2, fully native 1
|
||||
verdict : java2c-suspect-needs-so
|
||||
confidence : low
|
||||
```
|
||||
|
||||
The low confidence is the correct answer, not a shortcoming: the tool refuses to claim
|
||||
Java2C from the dex alone, and says so in its route block. That refusal is the guard
|
||||
against the misdiagnosis this pass exists to prevent.
|
||||
|
||||
Combining that dex with a real library that registers dynamically makes the `java2c` branch
|
||||
reachable, which is what proves the branch is not dead code — the input is composite and is
|
||||
labelled as such:
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/java2c_probe.py --dex out/classes.dex \
|
||||
--so <WORK>/libandroid.so
|
||||
verdict : java2c
|
||||
confidence : medium
|
||||
evidence: (strong) native layer: dynamic registration in libandroid.so
|
||||
(JNI_OnLoad exported, zero Java_* symbols)
|
||||
```
|
||||
|
||||
## 5. The JNI boundary — why `RegisterNatives` cannot be found — observed
|
||||
|
||||
The reference document claims a symbol search fails silently and that looking for a
|
||||
`RegisterNatives` symbol is structurally unable to work. Both are measured here.
|
||||
|
||||
A real 273,120 B aarch64 system library pulled from one of the samples exports a JNI entry
|
||||
point and no `Java_*` symbols, and the probe flags it as dynamic registration — the
|
||||
affirmative check that does work:
|
||||
|
||||
```
|
||||
libandroid.so: 273120 B, aarch64, dynsym 504 entries, Java_* 0,
|
||||
JNI_OnLoad True, RegisterNatives-import False
|
||||
(strong) native layer: dynamic registration in libandroid.so
|
||||
(JNI_OnLoad exported, zero Java_* symbols)
|
||||
```
|
||||
|
||||
Independent confirmation that the absence is real and not a parser gap — the same check run
|
||||
outside the script:
|
||||
|
||||
```
|
||||
$ python -c "d=open('gadget.so','rb').read(); [print(k, d.count(k.encode())) for k in
|
||||
['JNI_OnLoad','RegisterNatives','Java_','Dex2C','libc++_shared.so','frida']]"
|
||||
JNI_OnLoad 0
|
||||
RegisterNatives 0
|
||||
Java_ 0
|
||||
Dex2C 0
|
||||
libc++_shared.so 0
|
||||
frida 311
|
||||
```
|
||||
|
||||
A 25.8 MB real third-party aarch64 ELF with 311 occurrences of its own name contains zero
|
||||
`JNI_OnLoad` and zero `Java_`. The probe reported the same (0 `Java_*`, `JNI_OnLoad False`),
|
||||
so the two readings agree and the probe is not reporting a parse failure as an absence.
|
||||
|
||||
**Why the `RegisterNatives` search cannot fire** `[observed]`: the library above registers
|
||||
dynamically and contains **zero literal `RegisterNatives` symbols**. The cause is a
|
||||
documented property of the NDK C++ headers `[inferred]`: `jni.h` declares `RegisterNatives`
|
||||
as an inline member of `JNIEnv` that dispatches through the function table, so a call site
|
||||
emits no external symbol. The observed half is the missing symbol; the explanation is
|
||||
inferred from the mechanism.
|
||||
|
||||
## 6. Coverage of the probe's code paths — observed
|
||||
|
||||
| Path | Exercised by | Result |
|
||||
|---|---|---|
|
||||
| dex header + class_defs + class_data (ULEB128) | 5 real dex images + 1 synthetic | Parsed; header size match reported |
|
||||
| `direct_methods` / `virtual_methods` decoding | the synthetic fixture (has both) | Correct after the defect in §7 |
|
||||
| native density, native-dominated classes | all six | 0.03–0.04 % real vs 82.76 % synthetic |
|
||||
| no-`code_item` and trivial-body ratio | all six | 0 on the fixture, 4–5 % on the real apps |
|
||||
| ELF header, arch, section table | 10 real ELFs (4 ABI variants of two `libfoo.so`, 2 app libs, 1 gadget) | aarch64/arm/x86/x86_64 all identified |
|
||||
| `.dynsym` parse, `Java_*` extraction | real `libfoo.so` ×8 | 2 and 3 symbols, matching the dex exactly |
|
||||
| `JNI_OnLoad` export detection | real system library | True |
|
||||
| dynamic-registration verdict | same library | Fired |
|
||||
| string-signature scan | 25.8 MB ELF, all samples | Weak hits only (`dcc` ×6 on the gadget); no false strong hit |
|
||||
| `--json` | synthetic fixture | Valid JSON, metrics block parses |
|
||||
| `--dir`, `--apk`, `--dex`, `--so` | all four modes | OK |
|
||||
| `--help` | direct | OK |
|
||||
|
||||
The string scan produced exactly one class of hit across every sample: the weak `dcc`
|
||||
substring (6 occurrences inside a 25.8 MB binary, i.e. coincidental). **No strong marker was
|
||||
ever produced by any real sample**, which is why the Java2C verdict rests on structure
|
||||
rather than on strings.
|
||||
|
||||
## 7. Not exercised — and why the Java2C verdict stays inferred
|
||||
|
||||
- **A real Java2C `.so`.** Never produced; the compile chain is dead (§1). Everything about
|
||||
how a *compiled* Java2C library presents — whether the generated functions keep their
|
||||
names under `-fvisibility=hidden`, whether `Dex2C` runtime strings survive into `.rodata`
|
||||
— is **inferred** from the generated sources and the build flags, not observed.
|
||||
- **The runtime claim that no dex bytecode exists in memory.** This is the mechanism the
|
||||
whole document turns on and it is **inferred from the translation model**. No live Java2C
|
||||
app was run. It is falsifiable and cheap to test on a live sample; that test is named in
|
||||
the reference document.
|
||||
- **VMP identification by static dex metrics.** One VMP-labelled sample (sha256
|
||||
`76e3fa8119cf8b7c4b4768be557d71863433a8ae4d2ad40901eae6bb829349bc`, a 2,984,841 B APK
|
||||
from the local bench repos, identified by hash only)
|
||||
was measured and reported **`jni-sinking`**: its static dex has 23,603 methods of which 7
|
||||
are native, with a 4 % stub ratio, and the archive contains no additional encrypted
|
||||
payload. **This is a recorded boundary, not a success**: whatever hardening that sample
|
||||
carries is applied at runtime, and a static dex probe is structurally unable to see it.
|
||||
Do not read this row as "the sample is unhardened".
|
||||
- **Anything on a device.** The `<DEVICE>` was not used; there is no dynamic evidence in
|
||||
this record.
|
||||
|
||||
## 8. Two defects this pass found in its own tool — observed
|
||||
|
||||
Both were found by running the tool against the synthetic fixture and checking the numbers
|
||||
by hand, and both are fixed in the shipped script.
|
||||
|
||||
1. **`direct_methods` and `virtual_methods` were decoded as one continuous run.** They are
|
||||
two independent encoded lists; each one's first `method_idx_diff` is relative to 0.
|
||||
Walking them as one run mis-attributed every virtual method to the following class. The
|
||||
fixture exposed it as `Codec: 5 methods` where the source declares 4. Fix: decode them as
|
||||
two passes. After the fix the per-class counts match the source exactly (Calc 20/21
|
||||
native, Codec 3/3, Helper 1/2).
|
||||
|
||||
2. **The "whole class is native" criterion was unreachable.** `javac` — and, per the
|
||||
generated output, a translation pass — always leaves `<init>`/`<clinit>` behind, so no
|
||||
class is ever literally all-native unless a constructor is counted as native. The
|
||||
fixture showed `fully native 0` while two of its classes were plainly native-shaped. Fix:
|
||||
constructors are excluded and the test is now ">= 70 % of >= 3 named methods native",
|
||||
reported alongside the literal count. After the fix: `native-dominated classes 2,
|
||||
fully native 1`.
|
||||
|
||||
Both defects produced *plausible* output — a wrong count and a zero that reads as "nothing
|
||||
found" — which is the failure shape this whole repository is organised against. Recording
|
||||
them here because a reader relying on either number would have drawn a wrong conclusion.
|
||||
@@ -0,0 +1,121 @@
|
||||
# Extension verification — kernel/environment hardening + on-device tooling
|
||||
|
||||
Covers the 2026-09 extension pass that added `references/kernel-and-environment-hardening.md`,
|
||||
`references/on-device-tooling.md` and `scripts/mt_mcp_probe.py`. Strength labels as defined in
|
||||
this directory's README: **observed** (exact command + output here), **inferred** (follows from
|
||||
observed facts, step not executed), **unverified** (assumed or externally reported, not
|
||||
independently confirmed).
|
||||
|
||||
Reference environment for this pass: rooted physical device, Android 11 (API 30), arm64-v8a,
|
||||
kernel **4.14.186**, Magisk alpha + Zygisk, LSPosed v1.9.2 activated, Zygisk-Assistant v2.1.4
|
||||
and MoveCertificate v1.6.2 installed (pending reboot merge), MT Manager 2.26.9 installed.
|
||||
|
||||
---
|
||||
|
||||
## 1. MT APK MCP probe — measured
|
||||
|
||||
### 1.1 `scripts/mt_mcp_probe.py` runs and reports correctly
|
||||
|
||||
`--help` works (argparse, three options). Observed:
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/mt_mcp_probe.py --help
|
||||
usage: mt_mcp_probe.py [-h] [--url URL] [--timeout TIMEOUT] [--json]
|
||||
```
|
||||
|
||||
Full **observed** output of the probe in the service-down state, with the port forward in place:
|
||||
|
||||
```
|
||||
$ adb forward --list
|
||||
<serial> tcp:8787 tcp:8787
|
||||
<serial> tcp:8651 tcp:8651
|
||||
<serial> tcp:65534 tcp:65534
|
||||
|
||||
$ python skills/apk-reverse/scripts/mt_mcp_probe.py
|
||||
[waiting] MCP server is not answering at http://127.0.0.1:8787/mcp
|
||||
The MT APK MCP must be started by hand in the MT UI (adb cannot start it):
|
||||
1. On the phone: MT Manager -> side drawer -> Tools -> APK MCP -> Start
|
||||
...
|
||||
exit code 2
|
||||
```
|
||||
|
||||
The failure was localised to the service (not the transport) on the device side — **observed**:
|
||||
|
||||
```
|
||||
$ adb -s <serial> shell "su -c 'netstat -tln | grep 8787 || echo NOT_LISTENING'"
|
||||
NOT_LISTENING
|
||||
```
|
||||
|
||||
So at pass time: port forward alive, device reachable, MT's MCP service not started in the MT UI
|
||||
(it requires a manual start that adb cannot perform). The script's designed degraded-mode
|
||||
behaviour (waiting instructions + exit 2) is therefore **verified by observation**; the online
|
||||
path (initialize handshake → grouped `mt_apk_*` inventory) is **unverified on this device** — it
|
||||
was not exercised because the service was never started during the pass. The JSON-RPC shape
|
||||
(initialize with `protocolVersion 2024-11-05`, `notifications/initialized`, `tools/list`,
|
||||
`Accept: application/json, text/event-stream`) follows the MCP Streamable HTTP specification and
|
||||
MT's documented transport; treat the exact response parsing as inferred until a live run.
|
||||
|
||||
### 1.2 What was deliberately not done
|
||||
|
||||
No attempt was made to start MT's MCP from adb (documented as impossible), and no scripted
|
||||
tap-driver was built to press the UI button — that is a human step. The probe script doubling as
|
||||
a "wait until up" check (exit 2 → retry after manual start) is the supported loop.
|
||||
|
||||
## 2. Phone-module environment — observed earlier, cited here
|
||||
|
||||
These facts were measured in the prior configuration pass and archived in
|
||||
`tools/_phone-modules/README-手机模块环境.md` and `README-MCP与网络环境.md`; this pass cites them
|
||||
rather than re-measuring. They count as **observed** (exact commands and outputs exist in those
|
||||
archives):
|
||||
|
||||
- LSPosed v1.9.2 (7024) activation: log lines `ZygiskCompanion: welcome to LSPosed!`, version
|
||||
banner, `lspd` daemon running as system, manager UI reporting activated.
|
||||
- Zygisk requires one reboot after enable before `/data/adb/zygisk` exists; modules install to
|
||||
`/data/adb/modules_update/` and merge into `/data/adb/modules/` only on reboot.
|
||||
- Shamiko v1.2.5 kept as backup, **not** installed — documented conflict with Zygisk-Assistant
|
||||
(both do root hiding); Zygisk-Assistant chosen because it does not depend on DenyList
|
||||
enforcement.
|
||||
- mcp-termux / stackplz eBPF route closed on this device: requires kernel 5.10+, device has
|
||||
4.14.186; its bundled kmodules ship only 5.10/5.15/6.1/6.6/6.12 variants.
|
||||
- MT Manager 2.26.9 installed; MCP port confirmed as 8787 in MT settings; forwards 8787/8651/
|
||||
65534 established.
|
||||
|
||||
## 3. Kernel-level content — unverified / inferred, with sources
|
||||
|
||||
No kernel-level mechanism in `kernel-and-environment-hardening.md` was executed, on this device
|
||||
or any other. Specifically **unverified here**: eBPF kprobe/uprobe tracing, seccomp-BPF
|
||||
installation into a target, KPM/LKM hooking, self-ptrace anti-anti-debug, and every hiding claim
|
||||
of Shamiko / Zygisk-Assistant against a real detector (the module archives also record that
|
||||
hiding was never validated against an actual detection scenario).
|
||||
|
||||
The **inferred** claims that carry external sources (fetched 2026-09, URLs in the reference
|
||||
file):
|
||||
|
||||
| Claim | Source |
|
||||
|---|---|
|
||||
| KernelSU: kernel-based su, official support GKI 2.0 = kernel 5.10+ (Android 12-era), backport to 4.14 requires self-built kernel; module system conflicts with Magisk magic mount; su-only coexistence possible (kernel vs ramdisk patching) | tiann/KernelSU README + FAQ (github.com) |
|
||||
| APatch: KernelPatch patches stock boot.img kernel (no kernel source needed — the difference from KernelSU); SuperKey-gated SuperCall syscall; KPM kernel-space modules with inline/syscall-table hooks; SELinux hooked not rewritten; Shamiko officially unsupported | apatch.dev FAQ |
|
||||
| Shamiko: reads Magisk denylist but requires DenyList enforcement OFF; whitelist mode via `/data/adb/shamiko/whitelist` with documented performance cost | Shamiko README (LSPosed distribution) |
|
||||
| Zygisk-Assistant: Magisk use = target on denylist + Enforce OFF; KernelSU/APatch use = ZygiskNext + "Umount modules/Exclude modifications" | snake-4/Zygisk-Assistant README |
|
||||
| GKI 2.0 starts at android12-5.10; eBPF extension docs target GKI kernels | source.android.com (kernel architecture section) |
|
||||
|
||||
The version gate consequence (eBPF row closed on 4.14) is **observed** for this device (uname)
|
||||
and **inferred** as a general gate from the GKI sources above.
|
||||
|
||||
## 4. Not run / open items
|
||||
|
||||
- Live MCP handshake: blocked on a human UI action (start APK MCP in MT). The probe script is
|
||||
ready; re-run and record the real `initialize` + `tools/list` output when it happens.
|
||||
- Zygisk-Assistant + MoveCertificate merge: pending the device reboot recorded in the module
|
||||
archive; their post-merge behaviour is untested.
|
||||
- No LSPosed hook module was built or run against a real target in either pass — the framework
|
||||
is activated (observed), the module workflow remains inferred.
|
||||
|
||||
## 5. Reproduction one-liners
|
||||
|
||||
```
|
||||
python skills/apk-reverse/scripts/mt_mcp_probe.py # probe + inventory or waiting help
|
||||
adb forward --list # confirm 8787 forward exists
|
||||
adb -s <serial> shell "su -c 'netstat -tln | grep 8787'" # device-side listen check
|
||||
adb -s <serial> shell uname -r # the 4.14 kernel gate, observed
|
||||
```
|
||||
@@ -0,0 +1,236 @@
|
||||
# Extension verification — kernel-level weapons (templates, gates, and a closed door)
|
||||
|
||||
Covers the extension pass that added `scripts/kernelsu_syscall_mask.py` and the `§4a` section of
|
||||
`references/kernel-and-environment-hardening.md`. Strength labels as defined in this directory's
|
||||
README: **observed** (exact command + output here), **inferred** (follows from observed facts,
|
||||
step not executed), **unverified** (assumed or externally reported, not independently confirmed).
|
||||
|
||||
Reference environment: Windows 11 host, Python 3.14.0; `<DEVICE>` = rooted Android 11 (API 30)
|
||||
arm64-v8a, Magisk alpha, no APatch/KernelPatch.
|
||||
|
||||
**Headline: the generator, the gate table and the userspace module skeleton are measured. No
|
||||
kernel-side artifact was compiled or loaded anywhere, and every route is closed on the reference
|
||||
device. That is the deliverable — a template plus a measured statement of the door.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Kernel gate, measured on the device — observed
|
||||
|
||||
```
|
||||
$ adb shell 'uname -r; cat /proc/version'
|
||||
4.14.186+
|
||||
Linux version 4.14.186+ (nobody@android-build) (Android (6443078 based on r383902) clang version
|
||||
11.0.1 (https://android.googlesource.com/toolchain/llvm-project b397f81060ce6d701042b782172ed13bee898b79),
|
||||
LLD 11.0.1 (/buildbot/tmp/tmp6_m7QH ...)) #1 SMP PREEMPT Wed Mar 30 23:32:42 CST 2022
|
||||
```
|
||||
|
||||
4.14.186 is below the 5.10 GKI gate for the eBPF route, and the device carries no
|
||||
KernelPatch-patched image. Read-only query; no device lock was needed and no state was changed.
|
||||
|
||||
## 2. Host toolchain probe — observed
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/kernelsu_syscall_mask.py gates
|
||||
== kernel-side routes and their gates ==
|
||||
|
||||
eBPF tracepoint/kprobe GKI kernel 5.10+ (Android 12+)
|
||||
why: Android's BPF/tracing support as bpftrace-class tooling and
|
||||
stackplz expect it is a GKI-era feature; BTF is required for
|
||||
CO-RE style probes.
|
||||
check: uname -r -> expect 5.10.x or newer; ls /sys/kernel/btf/vmlinux
|
||||
|
||||
KernelPatch / APatch KPM KernelPatch-patched boot image + kpm toolchain
|
||||
why: KPMs load through kpimg injected into the kernel image's
|
||||
payload segment; building one needs a bare-metal ARM64
|
||||
compiler (aarch64-none-elf-gcc), not the NDK.
|
||||
check: ls /data/adb/ | grep -i apatch ; which aarch64-none-elf-gcc
|
||||
|
||||
Out-of-tree LKM Kernel source matching the device's exact version + vermagic
|
||||
why: A module built against a different kernel revision is refused
|
||||
at load time on a mismatched vermagic, and the device's kernel
|
||||
source is frequently not published at all.
|
||||
check: uname -r ; look for a matching kernel source tree for the device
|
||||
|
||||
seccomp-BPF (for contrast) any modern kernel, but it is not this route
|
||||
why: seccomp can make a syscall FAIL (SECCOMP_RET_ERRNO/TRAP); it
|
||||
cannot rewrite the CONTENT a successful read returns. It closes
|
||||
doors, it does not paint them -- which is the whole requirement
|
||||
for a spoofed /proc read.
|
||||
check: n/a -- this is a capability ceiling, not a version check
|
||||
|
||||
== host probe (what can be checked from here) ==
|
||||
|
||||
bare-metal ARM64 compiler: NOT FOUND
|
||||
the KPM route cannot be built from this host as configured
|
||||
ndk-build: NOT FOUND
|
||||
make: NOT FOUND
|
||||
|
||||
A kernel source tree, a patched boot image and the device's own kernel headers cannot be
|
||||
checked from the host; they are device facts.
|
||||
```
|
||||
|
||||
The three `NOT FOUND` lines are the measured reason the KPM route cannot be built from here, and
|
||||
they are also consistent with `ENV.md`'s record that the machine has no Android NDK. The LKM route
|
||||
additionally needs kernel source that does not exist for this device.
|
||||
|
||||
## 3. `generate` — observed
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/kernelsu_syscall_mask.py generate \
|
||||
--out tools/_work/t5-vmp/sysmask --package '<PKG>'
|
||||
generated module scaffold in tools/_work/t5-vmp/sysmask
|
||||
module.prop 165 B
|
||||
customize.sh 166 B
|
||||
post-fs-data.sh 830 B
|
||||
service.sh 467 B
|
||||
uninstall.sh 298 B
|
||||
config/syscall_mask.json 1079 B
|
||||
kpm/syscall-mask.c 8096 B
|
||||
kpm/Makefile 880 B
|
||||
lkm/syscall-mask.c 2434 B
|
||||
ebpf/syscall_mask.bpf.c 4338 B
|
||||
README.md 3752 B
|
||||
|
||||
STATUS: the userspace module is installable; every kernel-side file is an UNVERIFIED template.
|
||||
No kernel code was compiled or loaded.
|
||||
```
|
||||
|
||||
`module.prop`, as generated (the package identity is the `<PKG>` placeholder by design — this file
|
||||
carries no real package name):
|
||||
|
||||
```
|
||||
id=syscall-mask
|
||||
name=Syscall Mask
|
||||
version=0.1.0
|
||||
versionCode=1
|
||||
author=apk-reverse
|
||||
description=config-driven syscall return masking (openat/read/stat on /proc/self/*)
|
||||
```
|
||||
|
||||
The configuration table rendered into the KPM template from `config/syscall_mask.json`, so the
|
||||
rule set is data rather than hand-edited C:
|
||||
|
||||
```
|
||||
static const struct mask_rule rules[] = {
|
||||
{ "/proc/self/maps", 0, MASK_DENY, -2, 0 },
|
||||
{ "/proc/self/maps", 0, MASK_FAKE_SIZE, -2, 0 },
|
||||
{ "/proc/self/status", "TracerPid", MASK_FILTER_LINE, -2, 0 },
|
||||
{ "/proc/self/status", "TracerPid", MASK_FILTER_LINE, -2, 0 },
|
||||
};
|
||||
```
|
||||
|
||||
And the KPM lifecycle surface, which is the part an interface drift would break first:
|
||||
|
||||
```
|
||||
int kfunc_def(strncpy_from_user)(char *dst, const char __user *src, long count);
|
||||
/* fp_hook_syscalln(__NR_openat, before_openat, 0, 0); */
|
||||
/* fp_hook_syscalln(__NR_newfstatat, before_newfstatat, 0, 0); */
|
||||
/* fp_hook_syscalln(__NR_read, before_read, 0, 0); */
|
||||
KPM_INIT(mask_init);
|
||||
KPM_EXIT(mask_exit);
|
||||
```
|
||||
|
||||
Those names (`KPM_NAME` / `KPM_INIT` / `KPM_EXIT`, `kfunc_def`, `fp_hook_syscalln`,
|
||||
`hook_fargs4_t`, `syscall_argn`, `args->skip_origin`, `args->ret`) are taken from a public KPM
|
||||
development write-up rather than invented — blackr0ck, *APatch KPM 开发:绕过 DirtySepolicy /
|
||||
Duck Detector 的 SELinux Root 检测*, [bbs.kanxue.com/thread-291665.htm](https://bbs.kanxue.com/thread-291665.htm),
|
||||
fetched 2026-02. **They are still `unverified` here**: nobody compiled against a `kpmodule.h` in
|
||||
this pass, and the generated template says so in its own header for exactly that reason.
|
||||
|
||||
The registration calls are left **commented out** in the template. A generated file that arms a
|
||||
kernel hook by default is a file that arms a kernel hook the first time somebody runs `make` in a
|
||||
hurry.
|
||||
|
||||
## 4. `verify` — observed
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/kernelsu_syscall_mask.py verify --dir tools/_work/t5-vmp/sysmask
|
||||
== verifying tools/_work/t5-vmp/sysmask ==
|
||||
module.prop: author=apk-reverse, description=config-driven syscall return masking
|
||||
(openat/read/stat on /proc/self/*), id=syscall-mask, name=Syscall Mask,
|
||||
version=0.1.0, versionCode=1
|
||||
config: 4 rule(s), actions=['deny', 'fake_size', 'filter_line']
|
||||
kpm target present: Makefile, syscall-mask.c
|
||||
status: UNVERIFIED TEMPLATE -- not compiled, not loaded
|
||||
lkm target present: syscall-mask.c
|
||||
status: UNVERIFIED TEMPLATE -- not compiled, not loaded
|
||||
ebpf target present: syscall_mask.bpf.c
|
||||
status: UNVERIFIED TEMPLATE -- not compiled, not loaded
|
||||
kpm/syscall-mask.c: KPM_* lifecycle macros present, kfunc_def not extern
|
||||
|
||||
== 0 problem(s): structure is consistent ==
|
||||
(this says nothing about whether the kernel side compiles or loads)
|
||||
```
|
||||
|
||||
`verify` is deliberately read-only and deliberately narrow: it checks `module.prop` fields, the rule
|
||||
JSON, and the one static trap in the KPM template that has a documented consequence (an `extern`
|
||||
`kfunc_def` becomes `*UND*` and the loader refuses the module). It states on its last line what it
|
||||
does **not** establish, because "0 problems" on a template is the easiest sentence in this file to
|
||||
over-read.
|
||||
|
||||
### 4a. A false positive this check produced, and its fix — observed
|
||||
|
||||
The first run reported:
|
||||
|
||||
```
|
||||
== 1 problem(s) ==
|
||||
- syscall-mask.c declares kfunc_def with `extern`, which the loader rejects as an unknown symbol
|
||||
verify rc=1
|
||||
```
|
||||
|
||||
The template does not declare it with `extern` — its comment *explains* why `extern` is forbidden,
|
||||
and the check's own regex ran from that prose across to the real declaration's semicolon. A linter
|
||||
that reads comments as code produces reports nobody can act on. The fix strips `/* */` and `//`
|
||||
comments before any token check (`_strip_c_comments`). Both the false positive and the fix are
|
||||
recorded here rather than quietly repaired, because the failure mode — a check that fires on its
|
||||
own documentation — is one a reader will meet again.
|
||||
|
||||
## 5. The userspace module's ceiling — observed by construction, and worth stating plainly
|
||||
|
||||
`post-fs-data.sh` and `service.sh` in the generated skeleton do not load anything. That is not an
|
||||
omission: a KernelSU/Magisk/APatch-userspace module runs as root in the normal world and cannot
|
||||
change what a syscall returns. The generated `README.md` says this in its first paragraph, and
|
||||
`post-fs-data.sh` carries the reasoning in a comment:
|
||||
|
||||
```
|
||||
# The kernel side (if you built and loaded one) owns the syscall table; this
|
||||
# script's whole job is to make the configuration visible to it and to leave a
|
||||
# record that the module actually ran. Do not put a syscall hook here: this is
|
||||
# userspace, and nothing here can change what openat/read/stat return.
|
||||
```
|
||||
|
||||
The uninstall hook likewise refuses to unload a kernel module, because a silent unload during
|
||||
package removal is how a device ends up in a boot loop.
|
||||
|
||||
## 6. eBPF route — gate table and the capability ceiling
|
||||
|
||||
| Route | Gate | Check on the device | Status here |
|
||||
|---|---|---|---|
|
||||
| eBPF tracepoint/kprobe | GKI 5.10+ with tracing/BTF | `uname -r`; `ls /sys/kernel/btf/vmlinux` | **closed** — device is 4.14.186 |
|
||||
| seccomp-BPF | any modern kernel | n/a | not this route: it can only fail a syscall, not rewrite a successful read |
|
||||
| KernelPatch / APatch KPM | patched boot image + `aarch64-none-elf-gcc` | `ls /data/adb/ \| grep -i apatch`; `which aarch64-none-elf-gcc` | **closed** — neither present; patch-the-boot-image risk declined |
|
||||
| Out-of-tree LKM | kernel source matching vermagic | `uname -r` + a matching source tree | **closed** — no source for this device |
|
||||
|
||||
The probe template (`ebpf/syscall_mask.bpf.c`) is a `sys_enter_openat` tracepoint observer with a
|
||||
uid filter map and a ringbuf event, and it documents two dead ends in the file itself:
|
||||
`bpf_override_return()` only applies to `ALLOW_ERROR_INJECTION` functions, which raw syscall
|
||||
entries are not, and a tracepoint cannot rewrite a result at all. **A tracepoint observes; it does
|
||||
not spoof.** The honest eBPF shape for this job is therefore observation — which is still worth
|
||||
having, because `§5` of the reference file requires you to establish whether the target uses a raw
|
||||
`svc` or a libc call *before* designing any spoof.
|
||||
|
||||
## 7. What was not done
|
||||
|
||||
- **No kernel-side code was compiled.** Not the KPM, not the LKM, not the eBPF program. No
|
||||
`kpmodule.h`, no kernel headers, no `bpftool`, no bare-metal ARM64 compiler on this host.
|
||||
- **Nothing was loaded, flashed or activated on `<DEVICE>`.** No boot-image patch, no module load,
|
||||
no kernel modification. The task forbids it for good reason: a kernel module that faults takes
|
||||
the device down before it can report why.
|
||||
- **The KPM API surface is `unverified`.** The macro and helper names follow a public write-up; the
|
||||
argument arity of `fp_hook_syscalln` in particular is marked `TODO(verify-on-device)` in the
|
||||
generated source rather than guessed, because a wrong arity in a hook registration is a
|
||||
load-time failure at best.
|
||||
- **The struct-stat size offset is a TODO, not a value.** The `newfstatat` handler deliberately does
|
||||
not carry a magic offset: it is ABI-specific and copying one from a different architecture is how
|
||||
a spoof corrupts an unrelated field.
|
||||
- **`verify` establishes structure, not capability.** Its own output says so.
|
||||
@@ -0,0 +1,567 @@
|
||||
# Extension verification — LSPosed module route
|
||||
|
||||
Covers the 2026-09 extension pass that added `skills/apk-reverse/references/lsposed-and-modules.md`
|
||||
and `skills/apk-reverse/scripts/lsposed_scaffold.py`. Strength labels as defined in this directory's
|
||||
README: **observed** (exact command + output here), **inferred** (follows from observed facts, step
|
||||
not executed), **unverified** (assumed or externally reported, not independently confirmed).
|
||||
|
||||
Reference environment for this pass: rooted physical device, Android 11 (API 30), arm64-v8a,
|
||||
Magisk alpha + Zygisk, LSPosed v1.9.2 (7024) activated, Zygisk-Assistant v2.1.4 installed, MT Manager
|
||||
2.26.9 installed, one hardened Flutter sample installed (referred to below as *the sample*; no target
|
||||
identity is recorded here). Host: Windows, JDK 17.0.4.1, Android build-tools 34.0.0, API 28
|
||||
`android.jar`, Xposed `api-82` stub jar.
|
||||
|
||||
**Identifier convention:** every occurrence of the sample's package identifier in the transcripts
|
||||
below is normalized to `<PKG>` (`<PKG>.MainActivity`, `<PKG>.BaseApplication`), and its sub-package
|
||||
prefixes are shortened the same way. The transcripts are otherwise verbatim, including the class
|
||||
names a hook reported — those are what make the evidence checkable.
|
||||
|
||||
This pass closes an item left open by the previous extension record, which noted that "no LSPosed hook
|
||||
module was built or run against a real target" (`EXTENSION-kernel-ondevice.md` §4). The build half is
|
||||
now **observed**; the injection half is **partially observed** and the gap is stated in §6.
|
||||
|
||||
---
|
||||
|
||||
## 1. Scaffold script — observed
|
||||
|
||||
### 1.1 `--help` and generation
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/lsposed_scaffold.py --help
|
||||
usage: lsposed_scaffold.py [-h] --package PACKAGE --name NAME
|
||||
[--hook-target TARGETS] --out OUT
|
||||
[--class-name CLASS_NAME] [--tag TAG]
|
||||
[--description DESCRIPTION] [--min-sdk MIN_SDK]
|
||||
[--target-sdk TARGET_SDK] [--hook-class HOOK_CLASS]
|
||||
[--hook-method HOOK_METHOD]
|
||||
[--hook-params HOOK_PARAMS] [--force]
|
||||
```
|
||||
|
||||
Generation of a throwaway module, **observed**:
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/lsposed_scaffold.py \
|
||||
--package com.example.probe --name "Example probe" \
|
||||
--hook-target com.example.target \
|
||||
--hook-class com.example.target.Helper --hook-method check \
|
||||
--hook-params "java.lang.String" --out <work>/scaffold-gen
|
||||
[+] <work>/scaffold-gen/AndroidManifest.xml
|
||||
[+] <work>/scaffold-gen/assets/xposed_init
|
||||
[+] <work>/scaffold-gen/src/com/example/probe/MainHook.java
|
||||
[+] <work>/scaffold-gen/README.md
|
||||
```
|
||||
|
||||
`assets/xposed_init` contains exactly one line, `com.example.probe.MainHook` (**observed**). Re-running
|
||||
without `--force` leaves existing files alone and says so, so the script is safe to re-run.
|
||||
|
||||
### 1.2 The generated source compiles as written
|
||||
|
||||
The point of the scaffold is that its output builds, so this was checked rather than assumed —
|
||||
**observed**, exit code 0, four class files (entry class plus three anonymous hook classes):
|
||||
|
||||
```
|
||||
$ javac -encoding UTF-8 -source 8 -target 8 -nowarn -cp "<android.jar>;<api-82.jar>" \
|
||||
-d <work>/build/classes <work>/scaffold-gen/src/com/example/probe/MainHook.java
|
||||
$ echo $?
|
||||
0
|
||||
MainHook.class MainHook$1.class MainHook$2.class MainHook$3.class
|
||||
```
|
||||
|
||||
## 2. Gradle-free build chain — observed end to end
|
||||
|
||||
Built with a parameterised driver that reproduces the chain documented in the reference. Times are
|
||||
wall-clock on the host above; the module is one class.
|
||||
|
||||
| Step | Command shape | Time |
|
||||
|---|---|---|
|
||||
| compile | `javac -encoding UTF-8 -source 8 -target 8 -nowarn -cp <android.jar>;<api-82.jar> -d build/classes src/**/*.java` | 850 ms |
|
||||
| jar | `jar cf build/classes.jar -C build/classes .` | 259 ms |
|
||||
| dex | `d8.bat --min-api 24 --lib <android.jar> --output build build/classes.jar` | 1143 ms |
|
||||
| link | `aapt2.exe link -o build/base.apk -I <android.jar> --manifest AndroidManifest.xml --min-sdk-version 24 --target-sdk-version 28 -A assets` | 126 ms |
|
||||
| add dex | `(cd build && aapt.exe add unsigned.apk classes.dex)` | 97 ms |
|
||||
| align | `zipalign.exe -f -p 4 build/unsigned.apk build/aligned.apk` | 76 ms |
|
||||
| sign | `apksigner.bat sign --ks build/test.keystore --ks-pass pass:android --key-pass pass:android --v2-signing-enabled true --out build/module.apk build/aligned.apk` | 1794 ms |
|
||||
| verify | `apksigner.bat verify --min-sdk-version 24 --print-certs build/module.apk` | 725 ms |
|
||||
| | **total** | **5.27 s** |
|
||||
|
||||
Resulting artifact, **observed**: `module.apk` 12,745 bytes, `classes.dex` 5,024 bytes,
|
||||
`apksigner verify` reporting v2 signature valid with signer `CN=module, O=local`. APK entry list
|
||||
(**observed**, read back with zipfile):
|
||||
|
||||
```
|
||||
AndroidManifest.xml 1680
|
||||
resources.arsc 40
|
||||
assets/xposed_init 27
|
||||
classes.dex 5024
|
||||
META-INF/MANIFEST.MF 375
|
||||
```
|
||||
|
||||
A second, independent build of a hand-written one-class module (different package, different source)
|
||||
produced the same shape and also verified, so the chain is not specific to the scaffold's output.
|
||||
|
||||
### 2.1 Three build-chain traps, all measured rather than assumed
|
||||
|
||||
These are the failures that cost time, quoted from the actual runs:
|
||||
|
||||
1. **`d8` rejects a directory as program input.** Pointing it at the compiled `classes/` directory
|
||||
fails with:
|
||||
```
|
||||
Error in program input '<work>/build/classes':
|
||||
com.android.tools.r8.internal.Sb: Unsupported source file type
|
||||
Compilation failed
|
||||
```
|
||||
Jarring the classes first (`jar cf classes.jar -C classes .`) and passing the jar succeeds. This is
|
||||
the opposite of what several command-line guides imply.
|
||||
2. **`aapt add` fails with `Unable to add 'classes.dex' to 'unsigned.apk': file not found`** when the
|
||||
dex is not in the current working directory. It resolves its argument relative to the cwd and stores
|
||||
the same name, so the dex must be copied out of `dex/` to sit beside the apk.
|
||||
3. **`jar` and `keytool` are not on `PATH` even where `javac` is.** On this host `javac` resolved to
|
||||
Oracle's `javapath` shim while `keytool` was absent entirely; both tools had to be called from the
|
||||
JDK's `bin` directory explicitly. A build script that assumes they are on `PATH` breaks on a
|
||||
working machine.
|
||||
|
||||
Additionally the reference notes that `-bootclasspath` no longer exists on JDK 9+, so the Android API
|
||||
comes from `-cp` alongside the Xposed stub jar — **observed** (that is the form used above, and it
|
||||
compiled and dexed cleanly).
|
||||
|
||||
## 3. Module state on the device — observed
|
||||
|
||||
A previously built probe module was already installed on the device from an earlier pass. Its state
|
||||
was read, not modified, except where noted:
|
||||
|
||||
```
|
||||
$ adb -s <serial> shell "pm path com.t1.lsphook"
|
||||
package:/data/app/~~<hash>==/com.t1.lsphook--<hash>==/base.apk
|
||||
|
||||
$ adb -s <serial> shell "dumpsys package com.t1.lsphook | grep -E 'User 0|enabled'"
|
||||
User 0: ceDataInode=35322 installed=true ... stopped=true notLaunched=true enabled=0 ofs=0 ...
|
||||
```
|
||||
|
||||
`enabled=0` here is the **package manager's** component state, not the LSPosed module switch. Enabling
|
||||
the package was the one state change this pass made on the device:
|
||||
|
||||
```
|
||||
$ adb -s <serial> shell "pm enable com.t1.lsphook"
|
||||
Package com.t1.lsphook new state: enabled
|
||||
$ adb -s <serial> shell "dumpsys package com.t1.lsphook | grep 'User 0'"
|
||||
User 0: ... enabled=1 ...
|
||||
```
|
||||
|
||||
LSPosed itself, **observed**:
|
||||
|
||||
```
|
||||
$ adb -s <serial> shell "su -c 'cat /data/adb/modules/zygisk_lsposed/module.prop'"
|
||||
id=zygisk_lsposed
|
||||
name=Zygisk - LSPosed
|
||||
version=v1.9.2 (7024)
|
||||
$ adb -s <serial> shell "su -c 'ps -A -o PID,NAME | grep lspd'"
|
||||
744 lspd
|
||||
$ adb -s <serial> shell "dumpsys package org.lsposed.manager | grep versionName"
|
||||
versionName=1.9.2
|
||||
```
|
||||
|
||||
### 3.1 The installed module could never have run — observed
|
||||
|
||||
Before reusing that module for an injection attempt, its internals were checked. `assets/xposed_init`
|
||||
declared one entry class while the dex in the same APK contained a different one:
|
||||
|
||||
```
|
||||
$ python -c "... zipfile.ZipFile('installed-module.apk').read('assets/xposed_init') ..."
|
||||
com.t1.lsphook.MainHook
|
||||
|
||||
$ dexdump -f installed.dex | grep 'Class descriptor'
|
||||
Class descriptor : 'Lcom/example/t1probe/ProbeHook$1;'
|
||||
Class descriptor : 'Lcom/example/t1probe/ProbeHook$2;'
|
||||
Class descriptor : 'Lcom/example/t1probe/ProbeHook$3;'
|
||||
Class descriptor : 'Lcom/example/t1probe/ProbeHook;'
|
||||
```
|
||||
|
||||
The entry class named in `xposed_init` does not exist in that dex (`MainHook` appears nowhere in the
|
||||
string table; `ProbeHook` does). LSPosed resolves the class named by `xposed_init`, so this module
|
||||
could only fail at load — and it would fail **silently** from the outside, which is precisely the
|
||||
failure mode the reference lists. Recorded because "the package is installed, the `xposedmodule`
|
||||
meta-data is present and `pm path` returns a path" is still not evidence that the module can run:
|
||||
the entry class has to exist.
|
||||
|
||||
Two consequences measured in the same check:
|
||||
|
||||
- A second APK produced by that earlier pass **did** contain the matching class, but was signed with a
|
||||
different key — signer DN `O=local` against the installed build's `O=tools`, SHA-256
|
||||
`892c18be…` against `9c86f386…`. Replacing the installed module therefore requires an uninstall
|
||||
first; `adb install -r` would have failed on a signature mismatch.
|
||||
- A module rebuilt from the same source with this pass's chain (`build_module.ps1`, the chain in §2)
|
||||
was verified to contain the entry class, the `T1HOOK` log tag, the `injected: ` literal and the
|
||||
target package string in its dex, and is the artifact intended for the injection attempt in §6.
|
||||
Building it took 5.07 s and produced 12,745 bytes.
|
||||
|
||||
The **linked** manifest was checked as well, because LSPosed reads the compiled manifest rather than
|
||||
the source file. `aapt2 dump xmltree <apk> --file AndroidManifest.xml` on the rebuilt APK shows all
|
||||
three keys intact (`xposedmodule=true`, `xposeddescription="Minimal lifecycle hook probe for route
|
||||
verification"`, `xposedminversion=82`). The broken APK's manifest was **equally intact** — which is the
|
||||
point of recording this pair: a correct manifest, an installed package and a valid signature still do
|
||||
not mean the module can run. Entry-class existence is a separate check, and it is the one that failed
|
||||
here.
|
||||
|
||||
### 3.2 Replacing it, and an install failure that belongs to the ROM — observed
|
||||
|
||||
The broken module was removed and the rebuilt one installed. The install path is worth recording
|
||||
because it failed for a reason that has nothing to do with LSPosed:
|
||||
|
||||
```
|
||||
$ adb -s <serial> uninstall com.t1.lsphook
|
||||
Success
|
||||
$ adb -s <serial> install module.apk
|
||||
[timed out after 240000 ms] # never landed; pm path returned nothing afterwards
|
||||
$ adb -s <serial> push module.apk /data/local/tmp/<work>/module.apk
|
||||
$ adb -s <serial> shell "pm install -r /data/local/tmp/<work>/module.apk"
|
||||
Failure [-99]
|
||||
$ adb -s <serial> shell "su -c 'pm install -r /data/local/tmp/<work>/module.apk'"
|
||||
Success
|
||||
```
|
||||
|
||||
Three findings from that sequence:
|
||||
|
||||
- `Failure [-99]` from a shell-identity `pm install`, with logcat showing the ROM's own security
|
||||
centre (`com.coloros.safecenter`) taking the foreground from `com.android.packageinstaller` at the
|
||||
same moment. The vendor layer is intercepting the install; the framework is not rejecting the APK.
|
||||
- The same APK installs without complaint as **root**. That is the working path on this ROM, and the
|
||||
one used here.
|
||||
- The streaming `adb install` did not merely fail — it **hung for four minutes** and left no package
|
||||
behind. Retrying as root is cheaper than waiting that out.
|
||||
|
||||
Installed state afterwards, **observed**:
|
||||
|
||||
```
|
||||
$ adb -s <serial> shell "pm path com.t1.lsphook"
|
||||
package:/data/app/~~c7aQQNK-GiP3BKd1g1iEvw==/com.t1.lsphook-<hash>/base.apk
|
||||
$ adb -s <serial> shell "pm enable com.t1.lsphook"
|
||||
Package com.t1.lsphook new state: enabled
|
||||
$ adb -s <serial> shell "dumpsys package com.t1.lsphook | grep 'User 0'"
|
||||
User 0: ... installed=true ... enabled=1 ...
|
||||
```
|
||||
|
||||
So the **package-side** half of the deployment was completed in this pass: correct entry class,
|
||||
correct meta-data, installed, PM-enabled. The scope half did not happen, for the reason in §6.
|
||||
|
||||
## 4. Where LSPosed keeps module and scope state — observed
|
||||
|
||||
The configuration database was copied off the device (three WAL files) and parsed read-only:
|
||||
|
||||
```
|
||||
$ adb -s <serial> shell "su -c 'cp -f /data/adb/lspd/config/modules_config.db* /data/local/tmp/<work>/'"
|
||||
$ adb -s <serial> pull /data/local/tmp/<work> ...
|
||||
```
|
||||
|
||||
Schema and contents, **observed** (SQLite):
|
||||
|
||||
```
|
||||
CREATE TABLE modules (mid integer PRIMARY KEY AUTOINCREMENT, module_pkg_name text NOT NULL UNIQUE,
|
||||
apk_path text NOT NULL, enabled BOOLEAN DEFAULT 0 CHECK (enabled IN (0,1)))
|
||||
CREATE TABLE scope (mid integer, app_pkg_name text NOT NULL, user_id integer NOT NULL,
|
||||
PRIMARY KEY (mid, app_pkg_name, user_id), ...)
|
||||
CREATE TABLE configs (module_pkg_name text NOT NULL, user_id integer NOT NULL, `group` text NOT NULL,
|
||||
`key` text NOT NULL, data blob NOT NULL, ...)
|
||||
|
||||
== table modules ==
|
||||
(1, 'lspd', '/data/adb/modules/zygisk_lsposed/manager.apk', 0)
|
||||
(2, 'com.t1.lsphook', '/data/app/~~<hash>==/com.t1.lsphook--<hash>==/base.apk', 0)
|
||||
== table scope ==
|
||||
(no rows)
|
||||
== table configs ==
|
||||
('lspd', 0, 'config', 'misc_path', <blob>)
|
||||
```
|
||||
|
||||
Three facts worth keeping from this, because each maps to a documented failure mode:
|
||||
|
||||
- `enabled` in the LSPosed database is a **separate** bit from the package manager's `enabled` state.
|
||||
Both were 0 for the probe module when read, and only the package-manager one was changed here.
|
||||
- The `scope` table was **empty**: an installed module that is not scoped is injected nowhere, which is
|
||||
why "installed" is not evidence of injection.
|
||||
- `sqlite_sequence.modules` was 3 while only ids 1 and 2 exist — a module had been registered and
|
||||
removed in an earlier pass. The ids are not a count of what is loaded.
|
||||
|
||||
## 5. lspd lifecycle — observed from its own startup scripts
|
||||
|
||||
Read from the installed module directory, **observed**:
|
||||
|
||||
```
|
||||
$ adb -s <serial> shell "su -c 'cat /data/adb/modules/zygisk_lsposed/service.sh'"
|
||||
...
|
||||
unshare -m sh -c "$MODDIR/daemon --from-service $@&"
|
||||
|
||||
$ adb -s <serial> shell "su -c 'cat /data/adb/modules/zygisk_lsposed/daemon'"
|
||||
...
|
||||
exec /system/bin/app_process $java_options /system/bin --nice-name=lspd org.lsposed.lspd.Main "$@"
|
||||
```
|
||||
|
||||
`service.sh` is Magisk's late-start service script, which runs once per boot. **Inferred** (not
|
||||
executed, deliberately): an `lspd` killed from a root shell is not restarted by anything reachable from
|
||||
that shell, and the cheap-looking "just restart the daemon to pick up my database edit" move would take
|
||||
the framework's scope decisions away from every subsequently forked process. This pass therefore did
|
||||
**not** kill `lspd`, and the reference says so explicitly.
|
||||
|
||||
Related **inferred** consequence, stated in the reference: hand-editing `modules_config.db` is not a
|
||||
supported way to change scope, because the daemon serves decisions from its live copy and the file is
|
||||
its persisted state. The manager path updates both.
|
||||
|
||||
## 6. Injection: the framework had to be repaired first, then it worked — observed
|
||||
|
||||
With the module installed and PM-enabled, the next step was LSPosed Manager: enable the module there
|
||||
and add the sample to its scope. That is where the pass stopped — and the reason is a property of the
|
||||
device's LSPosed state at the time, not of the module.
|
||||
|
||||
Manager opened normally but reported the framework as **not installed**. Observed both on screen and in
|
||||
the dumped node tree:
|
||||
|
||||
```
|
||||
node: "未安装" / "LSPosed 未安装" (overview status card)
|
||||
node: "API 版本" -> "未安装"
|
||||
node: "Xposed API 调用保护" -> "未安装"
|
||||
node: "Dex 优化器包装" -> "未安装"
|
||||
bottom navigation: 仓库 / 概览 / 设置 # there is no module-list tab in this state
|
||||
```
|
||||
|
||||
The daemon was still alive (`ps -A` → `744 lspd`), so this is not "LSPosed is absent". Two further
|
||||
observations pin down what it actually is:
|
||||
|
||||
- **`lspd` stopped updating its own configuration.** The database still listed the **uninstalled**
|
||||
module under its old, no-longer-existing path, and never gained an entry for the freshly installed
|
||||
one. Its size and WAL set were unchanged across the whole pass (156,376 bytes in three files, the
|
||||
same as before the uninstall):
|
||||
```
|
||||
== table modules ==
|
||||
(1, 'lspd', '/data/adb/modules/zygisk_lsposed/manager.apk', 0)
|
||||
(2, 'com.t1.lsphook', '/data/app/~~wiGaHYIZn5j3Am2JrJvA5g==/com.t1.lsphook-<hash>/base.apk', 0)
|
||||
^ that codePath no longer exists: the package was uninstalled this pass
|
||||
== table scope ==
|
||||
(still empty)
|
||||
```
|
||||
A daemon that had re-scanned packages would have dropped id 2 and registered the new install path.
|
||||
- **Its log records the framework bridge dying and never recovering.** The verbose capture contains
|
||||
`system server died`, `am is dead`, then `no response from bridge, retry in 1s` on a loop, and
|
||||
`Magisk: zygote crashed too many times, rolling-back`.
|
||||
|
||||
`[inferred]` The consistent explanation is that the half of the framework that lives in
|
||||
`system_server`/`zygote` was lost (the same capture shows Magisk rolling a zygote restart back),
|
||||
leaving `lspd` as a live process with nothing to talk to. Manager asks the framework for its state,
|
||||
gets nothing, and falls back to its "not installed" onboarding UI — which has no module list, so
|
||||
**scope cannot be configured through the UI at all** in that state.
|
||||
|
||||
Recorded consequences, deliberately not worked around:
|
||||
|
||||
- Scope was **not** configured, so no module was injected into any process, so no `T1HOOK` line could
|
||||
appear. The injection claim therefore remains **unverified on this device**, stated plainly rather
|
||||
than replaced by a weaker proxy (such as "the module is installed" or "`lspd` is running", both of
|
||||
which were true the whole time and neither of which means anything about injection).
|
||||
- The pass did not try to "fix" it by killing `lspd`, hand-editing the database, or restarting
|
||||
`zygote`: the first two are explained in the reference's §Deploy and the third would have destroyed
|
||||
concurrent instrumentation work another agent was running on the same device.
|
||||
- The recovery action is a **device reboot**, which re-initialises Zygisk and `lspd`. It was not taken
|
||||
because the device was shared and under heavy load. It is the concrete next step, and it is cheap:
|
||||
after a reboot, open Manager, expect the module list to be present, enable the module, tick the
|
||||
sample in its scope, then watch `logcat -T <ts> -s T1HOOK` while the sample restarts itself. Per the
|
||||
reference, the scope change applies to the target's **next** process start, so a self-restarting
|
||||
target is the ideal case — no spawn, no force-stop, no attach needed.
|
||||
- Manager was returned to the background afterwards (`mCurrentFocus` back to the launcher). The sample
|
||||
was never spawned, force-stopped or attached to during this pass.
|
||||
|
||||
### 6.1 The repair — observed
|
||||
|
||||
A reboot was approved and taken. It is the whole fix, and it is cheap:
|
||||
|
||||
```
|
||||
$ adb -s <serial> reboot
|
||||
[ 0 s] boot_completed='' ... polled with getprop, no fixed sleep
|
||||
[ 31 s] boot_completed=1
|
||||
--- post-boot ---
|
||||
load average 17.40 4.37 1.46 (was 29.58 31.87 33.61 before)
|
||||
746 lspd (new pid; was 744)
|
||||
MoveCertificate zygisk-assistant zygisk_lsposed (all three modules present)
|
||||
```
|
||||
|
||||
Manager then reported the framework normally, and the **module tab appeared** — the recovery
|
||||
criterion. Observed on the overview page: `已激活 / 1.9.2 (7024) - Zygisk`, `API 版本 100`,
|
||||
`Xposed API 调用保护 已启用`, `Dex 优化器包装 支持`; bottom navigation went from three tabs to five
|
||||
(仓库 / 模块 / 概览 / 日志 / 设置). The module package itself survived the reboot
|
||||
(`pm path` intact, `enabled=1`), so no reinstall was needed — the ROM install workaround in §3.2 only
|
||||
matters on a fresh install.
|
||||
|
||||
### 6.2 Configuring scope through the UI — observed
|
||||
|
||||
Done strictly by reading coordinates out of a `uiautomator dump`, never by tapping a guess:
|
||||
|
||||
| Step | What was read from the dump | Coordinate tapped | Result |
|
||||
|---|---|---|---|
|
||||
| module tab | `[216,2160][432,2293]` | (324, 2260) | module list opened |
|
||||
| module row | `T1 LSPosed probe`, row `[48,611][1008,793]` | (528, 702) | detail page |
|
||||
| enable switch | switch node `[804,651][972,795]` | (888, 723) | `checked=true` |
|
||||
| scope row | `<PKG>`, row `[252,1855][591,1912]` | (528, 1878) | `checked=true` |
|
||||
|
||||
The scope list had to be scrolled five screens to reach the target (the list is alphabetical by
|
||||
display name, not by package). The database then confirmed the change, and — for the first time in
|
||||
this whole exercise — **`lspd` wrote to it**:
|
||||
|
||||
```
|
||||
== table modules ==
|
||||
(2, 'com.t1.lsphook', '/data/app/~~c7aQQNK-GiP3BKd1g1iEvw==/com.t1.lsphook-<hash>/base.apk', 1)
|
||||
enabled ^ = 1
|
||||
== table scope ==
|
||||
(2, '<PKG>', 0)
|
||||
```
|
||||
|
||||
The database file grew from 156,376 to 201,696 bytes across the pass. Both facts together are the
|
||||
proof that the framework, not just its daemon process, came back.
|
||||
|
||||
### 6.3 The injection line, and where it actually appears — observed
|
||||
|
||||
The target was launched once with `am start`, and the module fired on **every** subsequent process
|
||||
start. Nine consecutive target starts produced nine full injection blocks:
|
||||
|
||||
```
|
||||
Loading legacy module com.t1.lsphook from /data/app/~~c7aQQNK-GiP3BKd1g1iEvw==/.../base.apk
|
||||
Loading class com.t1.lsphook.MainHook
|
||||
T1HOOK injected: <PKG> / <PKG> cl=dalvik.system.PathClassLoader@c226f2f
|
||||
T1HOOK loaders at-handleLoadPackage:
|
||||
0: dalvik.system.PathClassLoader c226f2f
|
||||
1: java.lang.BootClassLoader 8454a72
|
||||
T1HOOK hook installed: Application.attach
|
||||
T1HOOK hook installed: Activity.onCreate
|
||||
T1HOOK hook installed: Application.onCreate
|
||||
T1HOOK Application.attach #1 this=<PKG>.BaseApplication cl=dalvik.system.PathClassLoader@c226f2f
|
||||
T1HOOK Application.attach #2 this=com.stub.StubApp cl=dalvik.system.PathClassLoader@c226f2f
|
||||
T1HOOK Application.onCreate this=com.stub.StubApp
|
||||
T1HOOK Application.onCreate this=<PKG>.BaseApplication
|
||||
T1HOOK Activity.onCreate -> <PKG>.MainActivity
|
||||
T1HOOK injected: com.google.android.webview / <PKG> cl=dalvik.system.PathClassLoader@141f448
|
||||
T1HOOK Activity.onCreate -> com.qq.e.ads.PortraitADActivity
|
||||
```
|
||||
|
||||
**The verification criterion had to be corrected, and this is the most transferable finding of the
|
||||
pass.** The reference (and this record's earlier draft, and the scaffold's generated README) said to
|
||||
watch `logcat -s <TAG>`. On this device that is **empty** — as is `logcat -s LSPosed-Bridge`, and as
|
||||
is a full-buffer `grep -i T1HOOK`. The module's output exists only in
|
||||
|
||||
```
|
||||
/data/adb/lspd/log/modules_<timestamp>.log
|
||||
```
|
||||
|
||||
which is the same file that earlier appeared to contain nothing but `Logd maybe crashed (err=Socket
|
||||
operation on non-socket), retrying in 1s...`. The two facts are related: the ROM's `logd` route is
|
||||
broken, so LSPosed's logcat channel delivers nothing while its file channel works normally. Anyone
|
||||
debugging a module on such a device with `logcat` alone would conclude "the module never ran" while
|
||||
looking at nine successful injections. Read **both**: `logcat -s <TAG>` where the platform is healthy,
|
||||
`/data/adb/lspd/log/modules_*.log` always. The evidence file for this pass is 201 lines, of which 126
|
||||
are `T1HOOK`.
|
||||
|
||||
Three further observations from the same log, none of which needed any extra instrumentation:
|
||||
|
||||
- **The packer's double Application is visible in the hook order.** `Application.attach` fires twice —
|
||||
first for the app's real `BaseApplication`, then for `com.stub.StubApp` — and `onCreate` then runs
|
||||
for the stub *before* the real Application. Hooks installed at `handleLoadPackage` time survive
|
||||
this swap, and by `Activity.onCreate` the classes reported are the **unpacked** ones
|
||||
(`<PKG>.MainActivity`), not the stub's. That is the concrete value of this route on a
|
||||
packed target: the Java layer is reachable without touching the APK.
|
||||
- **Scope is per app, not per process.** The module also injected into the app's WebView process
|
||||
(`com.google.android.webview / <PKG>`) and reported the ad SDK's activity
|
||||
(`com.qq.e.ads.PortraitADActivity`) — worth knowing before scoping a module widely.
|
||||
- **The target's self-restart cycle is unaffected by injection.** Nine pids in ~3 minutes, one every
|
||||
22–23 s (8111 → 9392 → 10557 → 11453 → 12345 → 13236 → 14155 → 15061 → 15934), each run reaching
|
||||
`MainActivity.onCreate`, the WebView process and an ad activity before ending. The module only
|
||||
observes, so this says nothing about cause; and because a reboot changes many variables at once, any
|
||||
comparison with the pre-reboot cycle (a different observer measured 10–20 s) is **inferred at best
|
||||
and not a controlled result**.
|
||||
|
||||
## 7. Log surfaces — observed, and one trap
|
||||
|
||||
```
|
||||
$ adb -s <serial> shell "su -c 'ls -la /data/adb/lspd/log/'"
|
||||
kmsg.log 993690
|
||||
modules_2026-09-21T18:44:31.523.log 401
|
||||
props.txt 46937
|
||||
verbose_2026-09-21T18:44:31.518.log 178482
|
||||
```
|
||||
|
||||
The `modules_*.log` file is **not** module output. Its entire content, **observed**:
|
||||
|
||||
```
|
||||
----part 1 start----
|
||||
Logd maybe crashed (err=Socket operation on non-socket), retrying in 1s...
|
||||
(repeated)
|
||||
```
|
||||
|
||||
`verbose_*.log` is a full logcat snapshot that includes unrelated system events (it contained, for
|
||||
example, Magisk zygote-restart lines and OEM process deaths from an earlier incident). Useful for
|
||||
correlating a run with system state; useless as a module log. The injection proof must come from
|
||||
`logcat -s <TAG>`, which is what the reference and the scaffold's README recommend.
|
||||
|
||||
## 8. Root-hiding layer — observed
|
||||
|
||||
```
|
||||
$ adb -s <serial> shell "su -c 'cat /data/adb/modules/zygisk-assistant/module.prop'"
|
||||
id=zygisk-assistant
|
||||
name=Zygisk Assistant
|
||||
version=v2.1.4 (1013f8a-release)
|
||||
description=A Zygisk module to hide root.
|
||||
|
||||
$ adb -s <serial> shell "su -c 'magisk --denylist status'"
|
||||
Denylist is enforced
|
||||
$ adb -s <serial> shell "su -c 'magisk --denylist ls'"
|
||||
(no output)
|
||||
$ adb -s <serial> shell "su -c 'ls /data/adb/shamiko'"
|
||||
ls: /data/adb/shamiko: No such file or directory
|
||||
```
|
||||
|
||||
So at pass time the DenyList was **enforced but empty**: no process was being hidden from anything,
|
||||
while LSPosed and `lspd` were running normally, and Shamiko was not installed. Worth recording because
|
||||
"the target knows it is on a rooted device" is frequently an environment fact of exactly this shape,
|
||||
and it is cheaper to check than to escalate (`references/detection-and-anti-analysis.md`).
|
||||
|
||||
The Shamiko/Zygisk-Assistant comparison in the reference is **inferred** from their own documentation
|
||||
plus the on-device module metadata above; the hiding behaviour itself was not exercised against any
|
||||
detector in this pass.
|
||||
|
||||
## 9. Not run / open items
|
||||
|
||||
- **Injection is not verified, and the blocker is now identified rather than unknown.** §6 records the
|
||||
state that stopped it: Manager reporting the framework as not installed, `lspd` alive but no longer
|
||||
updating its own configuration, and its log showing the framework bridge die and never recover. The
|
||||
action known to clear that state is a **device reboot**; after it the remaining sequence is short
|
||||
(enable the module in Manager, tick the sample in its scope, read `logcat -T <ts> -s T1HOOK` on the
|
||||
target's next self-restart). Until someone does that, every statement in the reference about a module
|
||||
*actually being injected* rests on framework documentation rather than on this bench.
|
||||
- Editing `modules_config.db` directly was **not** attempted, on purpose: §5 explains why it would have
|
||||
been an unreliable experiment, and the unsupported-ness of that path is recorded as inferred rather
|
||||
than tested by breaking a working setup.
|
||||
- Killing/restarting `lspd` was **not** attempted, for the same reason.
|
||||
- The module's effect on a hardened, detection-aware target was not measured, because injection was not
|
||||
reached. Consequently nothing here validates or contradicts the ClassLoader-count detection reported
|
||||
externally (see the reference's *What detects you anyway*); that source is cited, not reproduced.
|
||||
- The vendor install interception (§3.2) was worked around by installing as root; whether it can be
|
||||
disabled from the ROM's own settings was **not** investigated, and whether `Failure [-99]`
|
||||
reproduces on an idle device or on other vendor builds is **unverified**.
|
||||
- `scaffold.py` was exercised on Windows only; the POSIX path in the generated README's command block
|
||||
is **unverified** (the commands are standard, but not run here).
|
||||
|
||||
## 10. Reproduction one-liners
|
||||
|
||||
```
|
||||
python skills/apk-reverse/scripts/lsposed_scaffold.py --help # argparse surface
|
||||
python skills/apk-reverse/scripts/lsposed_scaffold.py --package <pkg> --name "<name>" \
|
||||
--hook-target <target.pkg> --out <dir> # writes 4 files
|
||||
# build (any ABI-neutral host with JDK + build-tools + android.jar + api-82.jar)
|
||||
javac -source 8 -target 8 -cp "<android.jar>:<api-82.jar>" -d build/classes src/**/*.java
|
||||
jar cf build/classes.jar -C build/classes . # d8 needs a jar, not a dir
|
||||
d8 --min-api 24 --lib <android.jar> --output build build/classes.jar
|
||||
aapt2 link -o build/base.apk -I <android.jar> --manifest AndroidManifest.xml \
|
||||
--min-sdk-version 24 --target-sdk-version 28 -A assets
|
||||
(cd build && aapt add unsigned.apk classes.dex) # dex must be in the cwd
|
||||
zipalign -f -p 4 build/unsigned.apk build/aligned.apk
|
||||
apksigner sign --ks build/test.keystore --ks-pass pass:android --key-pass pass:android \
|
||||
--v2-signing-enabled true --out build/module.apk build/aligned.apk
|
||||
apksigner verify --print-certs build/module.apk
|
||||
|
||||
adb -s <serial> shell "pm path <module.pkg>" # installed?
|
||||
adb -s <serial> shell "dumpsys package <module.pkg> | grep 'User 0'" # pm enabled state
|
||||
adb -s <serial> shell "su -c 'ps -A -o PID,NAME | grep lspd'" # daemon alive
|
||||
adb -s <serial> shell "su -c 'magisk --denylist status'" # hiding layer state
|
||||
```
|
||||
@@ -0,0 +1,270 @@
|
||||
# Extension verification — native DBI/deobfuscation and protocol reverse engineering
|
||||
|
||||
Covers the 2026-09 extension pass that added `references/native-dbi-and-deobfuscation.md` and
|
||||
`references/protocol-reverse.md`. Strength labels as defined in this directory's README:
|
||||
**observed** (exact command + output here), **inferred** (follows from observed facts, step not
|
||||
executed), **unverified** (assumed or externally reported, not independently confirmed).
|
||||
|
||||
Reference environment for this pass: Windows 11 host, Python 3.14.0, host frida 16.7.19
|
||||
(frida-tools 13.7.1); rooted physical device, Android 11 (API 30), arm64-v8a, 8-core mid-range SoC,
|
||||
with an on-device frida-server 16.7.19 that was already running when the pass started and was never
|
||||
killed or restarted (pid 23405 at pass time). All Stalker work ran against system processes; the
|
||||
hardened Flutter sample under analysis by a different task on the same device was never attached to,
|
||||
spawned, or stopped by this pass.
|
||||
|
||||
**The headline result is a negative one, and it is the reason this record matters: on this
|
||||
combination, Stalker follow installs successfully and produces no events.** The record separates
|
||||
that finding from a crash observed later in the same session; they are different observations with
|
||||
different causes and must not be merged.
|
||||
|
||||
---
|
||||
|
||||
## 1. Scripts under test — observed
|
||||
|
||||
`skills/apk-reverse/scripts/stalker_trace.js` (456 lines) and
|
||||
`skills/apk-reverse/scripts/stalker_report.py` (236 lines) were already on disk from an earlier
|
||||
attempt of this task; this pass read them, changed neither, and exercised both.
|
||||
|
||||
```
|
||||
$ node --check skills/apk-reverse/scripts/stalker_trace.js
|
||||
syntax check exit = 0
|
||||
|
||||
$ python skills/apk-reverse/scripts/stalker_report.py --help
|
||||
usage: stalker_report.py [-h] [--top TOP] [--skeleton SKELETON]
|
||||
[--edges EDGES] [--seq SEQ] [--json PATH] [--quiet]
|
||||
log
|
||||
Summarize a stalker_trace.js log into a block histogram, a first-visit CFG
|
||||
skeleton, and call-edge tables.
|
||||
```
|
||||
|
||||
Two workbench drivers were written for this pass and live in `tools/_work/t4-dbi/` (not part of the
|
||||
skill): `drive_usb.py`, which attaches over USB and calls
|
||||
`rpc.exports.config(...)` / `start(tid)` explicitly, and `pb_selftest.py` (§7).
|
||||
|
||||
Why a second driver was needed at all: `scripts/run_probe.py` injects the probe faithfully (its
|
||||
`--via usb` path attached and logged `READY`/`TRIG`/`DONE` correctly), but it never calls the
|
||||
script's rpc exports, so `stalker_trace.js` runs with its built-in `autoStart` and follows whichever
|
||||
thread `Process.getMainThreadId()` reports. That thread is not the Android main thread on this
|
||||
device (§3), and there is no way to override it from that driver.
|
||||
|
||||
## 2. Report parser validated against a hand-written fixture — observed
|
||||
|
||||
`tools/_work/t4-dbi/synthetic.log` is a **hand-written** fixture (16 lines, 1,008 B) in the script's
|
||||
documented output grammar, including lines wrapped the way `run_probe.py` wraps them. It is not a
|
||||
device run and proves nothing about Stalker; it proves the parser reads the grammar.
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/stalker_report.py tools/_work/t4-dbi/synthetic.log \
|
||||
--top 4 --skeleton 6 --edges 4 --seq 8
|
||||
module libdemo.so base=0x7abc000000 size=1048576 (target)
|
||||
unique blocks first-seen (BB) : 3
|
||||
block executions (BLK) : 7
|
||||
call edges (CALL) : 2
|
||||
DONE reason=timeout truncated=0 (device-side counts: bb=3 blk=7 call=2)
|
||||
|
||||
== execution histogram: top 4 (dispatcher candidates) ==
|
||||
rank count pct block firstSeen
|
||||
1 4 57.1% libdemo.so+0x9f6c0 #1
|
||||
2 2 28.6% libdemo.so+0x9f710 #2
|
||||
3 1 14.3% libdemo.so+0x9f800 #5
|
||||
|
||||
== call edges (top 4 of 2) ==
|
||||
1 0x7ab3001020 -> libdemo.so+0x9f6c0
|
||||
1 libdemo.so+0x9f800 -> libdemo.so+0x9f710
|
||||
|
||||
== collapsed execution sequence (first 6 runs of 6) ==
|
||||
libdemo.so+0x9f6c0 -> libdemo.so+0x9f710 -> libdemo.so+0x9f6c0 -> libdemo.so+0x9f800 ->
|
||||
libdemo.so+0x9f6c0*2 -> libdemo.so+0x9f710
|
||||
```
|
||||
|
||||
The four reductions the reference promises — histogram, first-visit skeleton, call edges, collapsed
|
||||
sequence — all behave as documented, including the `*N` repeat collapse that makes a
|
||||
dispatcher/real-block rhythm legible. `--quiet` and `--json` paths were exercised too (§3, §4).
|
||||
|
||||
## 3. Device attempt A — follow a chosen thread, zero events (observed)
|
||||
|
||||
Two system processes, each attached for 8 s with `followMs=4000`, target module `libc.so`,
|
||||
`autoStart` disabled by the driver, and the followed tid passed explicitly.
|
||||
|
||||
```
|
||||
$ python tools/_work/t4-dbi/drive_usb.py --js skills/apk-reverse/scripts/stalker_trace.js \
|
||||
--pid <settings pid> --seconds 8 --log tools/_work/t4-dbi/run2_settings_main.log
|
||||
READY stalker_trace loaded; target=libc.so trigger={"kind":"main"} followMs=4000 maxBlocks=100000 moduleLoaded=true
|
||||
TRIG following tid=26261 (main-thread follow (pid=19938)) module=libc.so base=0x721be88000
|
||||
TRACE MOD libc.so base=0x721be88000 size=999424 path=/apex/com.android.runtime/lib64/bionic/libc.so
|
||||
DONE reason=reconfig blocks=0 blk=0 calls=0 truncated=0
|
||||
TRIG main trigger armed (autoStart=false); call start()
|
||||
[rpc] start(19938) -> {"following": true, "followingTid": 19938}
|
||||
DONE reason=timeout blocks=0 blk=0 calls=0 truncated=0
|
||||
```
|
||||
|
||||
Facts worth keeping separate from the interpretation:
|
||||
|
||||
- `Process.getMainThreadId()` returned **26261** for a process whose pid is **19938**. On Android the
|
||||
main thread's tid equals the pid, so the script's default thread selection followed a different
|
||||
thread. This is observed on this device/version and is the reason the reference tells the reader to
|
||||
pass `start(tid)` explicitly.
|
||||
- Re-running with `start(19938)` — the real main thread — produced **the same zero**:
|
||||
`DONE reason=timeout blocks=0 blk=0 calls=0`.
|
||||
- A third run against a system UI process (`run3_systemui_libc.log`, same `followMs=4000`)
|
||||
reproduced it identically. `stalker_report.py --quiet` on all three logs prints the same summary line
|
||||
(`unique blocks first-seen (BB): 0`, `block executions (BLK): 0`, `call edges (CALL): 0`) and the
|
||||
script's own "no target-module blocks at all" diagnosis.
|
||||
|
||||
**Interpretation: unverified as to cause.** The pipeline was healthy up to `TRIG following`, the
|
||||
module was loaded and in `maps`, and the Interceptor trigger demonstrably fired (§4) — so the
|
||||
narrowest supportable statement is "follow installs, events do not arrive in `onReceive`". Whether
|
||||
that is a frida-server build property, a ROM restriction, or something about the followed threads was
|
||||
not established and is not claimed. No trace was successfully recorded on this device during this
|
||||
pass.
|
||||
|
||||
## 4. Device attempt B — hot-function export trigger, zero events (observed)
|
||||
|
||||
The `export` trigger kind was exercised with `malloc` in `libc.so` as the trigger target, so that
|
||||
each call would start a follow and each return would end it. The trigger fired repeatedly for the
|
||||
whole window.
|
||||
|
||||
```
|
||||
$ python tools/_work/t4-dbi/drive_usb.py --js skills/apk-reverse/scripts/stalker_trace.js \
|
||||
--pid <systemui pid> --seconds 6 --log tools/_work/t4-dbi/run4_systemui_malloc.log \
|
||||
--cfg '{"targetModule":"libc.so","trigger":{"kind":"export","module":"libc.so","export":"malloc"},"followMs":1000,"maxBlocks":20000}'
|
||||
|
||||
$ lines = 609 follows = 201 done_lines = 201 nonzero_blk = 0
|
||||
# per-firing shape:
|
||||
TRIG following tid=18915 (trigger 0x721becbbf0) module=libc.so base=0x721be88000
|
||||
DONE reason=trigger-leave blocks=0 blk=0 calls=0 truncated=0
|
||||
```
|
||||
|
||||
This is the strongest evidence for the §3 statement: **201 fully-formed follow cycles, each with a
|
||||
clean `DONE`, and not one reported block in any of them.** A trigger that never fires would show
|
||||
`TRIG-FAIL` or a single cycle; a filter that dropped everything would still be consistent with this,
|
||||
which is why §3's claim is deliberately limited to "events do not arrive".
|
||||
|
||||
## 5. Device attempt C — a system UI process died during the hot-trigger run (observed)
|
||||
|
||||
Same run as §4, minutes later, on the same pid. The process died and was restarted by the system.
|
||||
|
||||
```
|
||||
$ adb shell pidof com.android.systemui
|
||||
18030 # before
|
||||
26932 # after
|
||||
|
||||
$ adb shell "su -c 'logcat -b crash -d -t 80'"
|
||||
09-21 20:22:25.327 ... F DEBUG : pc 00000070cf8ae464 pst 0000000040001000
|
||||
09-21 20:22:25.719 ... F DEBUG : backtrace:
|
||||
09-21 20:22:25.719 ... F DEBUG : #00 pc 000000000001f464 <anonymous:70cf88f000>
|
||||
09-21 20:22:25.719 ... F DEBUG : #01 pc 00000000004d0b64 /apex/com.android.art/lib64/libart.so
|
||||
(art::Reference_getReferent(_JNIEnv*, _jobject*)+44)
|
||||
09-21 20:22:25.719 ... F DEBUG : #03 pc 000000000008220c boot.oat (art_jni_trampoline+124)
|
||||
09-21 20:22:25.719 ... F DEBUG : #08 pc 0000000000714240 boot-framework.oat (android.os.Looper.loop+2176)
|
||||
```
|
||||
|
||||
Reading of the record, stated as an attribution hypothesis rather than a proven mechanism: frame
|
||||
`#00` sits in an **anonymous executable region** (`<anonymous:70cf88f000>`), which is the shape of a
|
||||
DBI's translated-code cache rather than of a file-backed library, and the return path leads into
|
||||
`libart`. The condition that distinguishes this run from §3/§4 is **hundreds of follow/unfollow
|
||||
cycles on a hot function**, so the attributed cause is that cycle — a follow/unfollow race against
|
||||
ART's own execution state — not "Stalker is unusable". The engine-level question (would a single
|
||||
long follow of the same thread also crash it?) was not tested and remains **unverified**.
|
||||
|
||||
The consequence written into the reference is therefore operational, not diagnostic: *do not use a
|
||||
hot function as a follow trigger, and do not build a per-call follow/unfollow cycle.* Note also that
|
||||
this crash is **not** the explanation for the zero-event results in §3/§4: those runs produced their
|
||||
zeros before any death, on two other processes, and one of them never touched an export trigger.
|
||||
|
||||
## 6. Cross-task contamination on a shared device — observed (reported by the Lead)
|
||||
|
||||
While §3–§5 were running, the device's `load average` was **34.11** (`adb shell uptime`, 1:38 after
|
||||
boot) on an 8-core mid-range phone, and a dynamic job belonging to **another task on the same
|
||||
device** — a dex-dumping attach against the hardened Flutter sample — was destroyed mid-flight with
|
||||
`script has been destroyed` and a terminating process. Both facts are the Lead's observation of its
|
||||
own job, quoted here because they are part of this pass's outcome; no claim is made that the sample
|
||||
was affected for any reason other than device load, and the sample's own pid changes observed
|
||||
independently during that window are **not** attributed to this pass.
|
||||
|
||||
The rule this produced, added to the reference's failure modes: on a shared device, a heavy DBI run
|
||||
is not an isolated experiment. One job pushing the device to load 30+ makes every *other* dynamic
|
||||
result on that device unattributable, which is `references/long-task-discipline.md` §single-variable
|
||||
discipline applied to hardware rather than to a patch. Practically: announce the window, keep
|
||||
`followMs` in the low seconds, cap `maxBlocks`, attach rather than spawn, and treat a
|
||||
`script has been destroyed` in a *different* job as a signal that your own load is the variable.
|
||||
|
||||
## 7. Protobuf wire format self-test — observed
|
||||
|
||||
`tools/_work/t4-dbi/pb_selftest.py` (workbench) encodes a message by hand and decodes it by walking
|
||||
the wire format, with no `protoc`, no schema, and no third-party protobuf runtime. It exists to give
|
||||
`references/protocol-reverse.md` §1 an output behind its claims.
|
||||
|
||||
```
|
||||
$ python tools/_work/t4-dbi/pb_selftest.py
|
||||
== canonical varint check ==
|
||||
varint(0 ) = 00 ok
|
||||
varint(150 ) = 9601 ok
|
||||
varint(300 ) = ac02 ok
|
||||
varint(86942 ) = 9ea705 ok
|
||||
|
||||
== fixture bytes ==
|
||||
nested payload (5 B): 0801120178
|
||||
packed payload (6 B): 038e029ea705
|
||||
message (29 B): 089601120774657374696e671a0508011201782206038e029ea7052800
|
||||
|
||||
== schema-free decode (the shape protoc --decode_raw produces) ==
|
||||
field 1 varint 150
|
||||
field 2 len-delimited(7) str 'testing'
|
||||
field 3 len-delimited(5) nested message:
|
||||
field 1 varint 1
|
||||
field 2 len-delimited(1) str 'x'
|
||||
field 4 len-delimited(6) hex '038e029ea705'
|
||||
field 5 varint 0
|
||||
|
||||
== what the decode cannot tell you ==
|
||||
explicit 0 at field 5 : 2800
|
||||
field 5 never written : b''
|
||||
-> proto3 cannot distinguish these two on the wire; a decoded "0" is not evidence the sender set the field.
|
||||
-> field numbers in the walk are local to their message; only a schema says which message field 3 belongs to.
|
||||
|
||||
== re-encode round trip ==
|
||||
rebuild from the walked rows equals original: True
|
||||
```
|
||||
|
||||
Two of the reference's claims are now measured rather than repeated: the packed repeated field
|
||||
(`3, 270, 86942`) is reported by a schema-free decode as opaque hex because element boundaries need a
|
||||
schema, and an explicitly written proto3 zero is byte-identical to an absent field. The round-trip
|
||||
check is also load-bearing as a testing habit: the first version of this script printed
|
||||
`equals original: False`, and the defect was the decoder's own string/bytes ambiguity — the same
|
||||
false-confidence trap the reference warns about for third-party decoders.
|
||||
|
||||
## 8. Not run, and therefore unverified
|
||||
|
||||
Everything below is stated in the references as inferred or unverified, and was **not** exercised in
|
||||
this pass:
|
||||
|
||||
- Any successful Stalker trace. No histogram, no dispatcher identification, and no CFG skeleton was
|
||||
produced from real device execution; §2's fixture is hand-written.
|
||||
- Whether Stalker works on this device under a different frida-server build, a different ROM, or with
|
||||
a single long follow instead of per-call cycles.
|
||||
- `events.exec` and `events.ret` (the per-instruction and return-edge paths) — never enabled.
|
||||
- QBDI / frida-qbdi, angr, Triton, unidbg, Unicorn: not installed, not run. §4/§5 of
|
||||
`native-dbi-and-deobfuscation.md` are method descriptions.
|
||||
- Trace-driven deflattening end to end, including the state-variable identification and the LIEF
|
||||
rewrite. The community write-up cited there was read, not reproduced; its performance numbers are
|
||||
the author's.
|
||||
- `protoc`, `protobuf-inspector`, `blackboxprotobuf`, `pbtk`/`protodump`, `grpcurl`: not installed,
|
||||
not run. The schema-recovery routes in `protocol-reverse.md` §2 are inferred from generated-code
|
||||
markers, not demonstrated on a real APK.
|
||||
- gRPC and QUIC/HTTP3 capture: no HTTP/2 or HTTP/3 client was exercised; §3/§4 of
|
||||
`protocol-reverse.md` are unverified.
|
||||
- `universal-flutter-ssl-pinning` / PyGhidra discovery, and the Frida/Renef output it generates: not
|
||||
reproduced. The README's own testing table (Google Flutter arm64-v8a, Shorebird builds, Ghidra
|
||||
12.x, Frida 16.x/17.x) is the only evidence behind that section.
|
||||
- The `MoveCertificate` module's interaction with a Flutter client: not tested. The reference states
|
||||
the boundary conditionally (store-reading clients only), not as a measured result.
|
||||
|
||||
## 9. What this pass licenses
|
||||
|
||||
It licenses the following statements and nothing wider: the trace/report toolchain is syntactically
|
||||
sound and its parser is verified against its documented grammar; an Android 11 arm64 device with
|
||||
frida-server 16.7.19 accepted Stalker follows and delivered no events, so *a zero-block trace is not
|
||||
by itself evidence about the target*; a per-call follow on a hot export killed a system process; and
|
||||
the protobuf wire-format rules the new reference teaches are measured on a fixture, including the two
|
||||
traps (packed fields, explicit zeros) that a decode cannot resolve without a schema.
|
||||
@@ -0,0 +1,383 @@
|
||||
# Extension verification — schema-free protobuf decoding
|
||||
|
||||
Covers the pass that added `skills/apk-reverse/scripts/protobuf_decode_raw.py` and the measured half
|
||||
of `references/protocol-reverse.md` §1 ("The decoder that ships here"). Strength labels as defined in
|
||||
this directory's README: **observed** (exact command + output recorded here), **inferred** (follows
|
||||
from observed facts, step not executed), **unverified** (assumed or externally reported, not
|
||||
independently confirmed here).
|
||||
|
||||
Reference environment for this pass: Windows host, Python 3.14.0, `protobuf` **6.33.6**. No device
|
||||
was used (see §7) — every result below is host-side, offline, and reproducible with the standard
|
||||
library alone. Workbench artifacts live in `tools/_work/t5-proto/` (not part of the skill, and
|
||||
`tools/` is gitignored).
|
||||
|
||||
The delivered script imports **no** third-party module. `google.protobuf` is used here for one
|
||||
purpose only: to *generate* reference bytes from a real schema, so the hand-written decoder is
|
||||
checked against the reference implementation instead of against itself.
|
||||
|
||||
---
|
||||
|
||||
## 0. Summary of what this pass established
|
||||
|
||||
| Claim | Label |
|
||||
|---|---|
|
||||
| `protobuf_decode_raw.py` `--help`, `--selftest`, hex/file/stdin inputs, `--json`, `--max-depth`, `--reencode` all run on this host | observed |
|
||||
| 26/26 built-in known-answer checks pass (field decode **and** byte-exact round trip) | observed |
|
||||
| The decoder agrees field-by-field with a message serialized by `google.protobuf` 6.33.6, and its re-encode is byte-identical to `SerializeToString()` | observed |
|
||||
| A real AndroidX DataStore file written by `scripts/datastore_inject.py` decodes to the correct structure and round-trips byte-identically | observed |
|
||||
| Editing one value in the JSON tree and re-encoding produced a file that `datastore_inject.py` read back as the edited value | observed |
|
||||
| proto3 zero semantics: an implicit (no-presence) field set to 0 writes **nothing**; a presence-carrying field set to 0 writes its tag plus a zero byte | observed |
|
||||
| A packed field and a nested message are **both** legal readings of the same length-delimited bytes (measured on real encoded bytes, not argued) | observed |
|
||||
| `--split varint-length` is protobuf's `writeDelimitedTo` framing; handed a gRPC frame it silently produced 5 frames instead of failing | observed |
|
||||
| Decoding a **device-side** protobuf file (real app data read over adb) | **not done** (device lock held by another agent; §7) |
|
||||
| Any protobuf payload recovered from the benchmark APKs | **not done** — the one APK scanned carries no `.pb`/`.proto` resource (§7) |
|
||||
| `blackboxprotobuf` / `protobuf-inspector` behaviour | unverified (not installed on this host; the reference still says so) |
|
||||
|
||||
---
|
||||
|
||||
## 1. Input forms, measured
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/protobuf_decode_raw.py --help
|
||||
usage: protobuf_decode_raw.py [-h] [--hex STR] [--json] [--max-depth N]
|
||||
[--no-expand] [--split {none,varint-length}]
|
||||
[--reencode JSON] [--out PATH] [--check PATH]
|
||||
[--selftest]
|
||||
[input]
|
||||
... exit = 0
|
||||
```
|
||||
|
||||
Hex text, a binary file and stdin are all accepted, and the two stdin shapes are told apart without
|
||||
a flag:
|
||||
|
||||
```
|
||||
$ '0a220a176578616d706c655f6578706972795f65706f63685f6d7312072080b08fe6b277' \
|
||||
| python skills/apk-reverse/scripts/protobuf_decode_raw.py -
|
||||
input: stdin (hex text), 36 byte(s)
|
||||
frame 0 bytes 0..36 (36 B) status=end_of_buffer
|
||||
f1 len-delimited(34) view=nested_message
|
||||
...
|
||||
|
||||
$ cmd /c "python skills\apk-reverse\scripts\protobuf_decode_raw.py - < tools\_work\t5-proto\ds.preferences_pb"
|
||||
input: stdin (raw bytes), 81 byte(s)
|
||||
frame 0 bytes 0..81 (81 B) status=end_of_buffer
|
||||
```
|
||||
|
||||
Separators and escapes, in one command (two frames, `0x` prefixes, spaces, a `\xNN` escape):
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/protobuf_decode_raw.py --hex "0x08 0x96 0x01 | \x12\x07testing"
|
||||
frame 0 bytes 0..3 (3 B) status=end_of_buffer
|
||||
f1 varint 150
|
||||
frame 1 bytes 3..12 (9 B) status=end_of_buffer
|
||||
f2 len-delimited(7) view=utf8_string
|
||||
...
|
||||
string: 'testing'
|
||||
```
|
||||
|
||||
`08,96,\n01` (commas and a newline) and the single token `0x089601` both decode to the same field.
|
||||
**This command is the one that found the script's first real defect — see §6.**
|
||||
|
||||
---
|
||||
|
||||
## 2. Known-answer fixtures: `--selftest`
|
||||
|
||||
Twelve fixtures are built byte by byte inside the script (no external file, no schema), covering a
|
||||
canonical varint, a multi-byte varint, `uint64` maximum, two-level nesting, a packed field, fixed64
|
||||
and fixed32, an explicit zero, a deprecated group, a non-canonical varint, a 10-byte overflow, two
|
||||
top-level messages in one stream, and varint-length framing. Each is decoded with an expected value
|
||||
**and** passed through the round trip.
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/protobuf_decode_raw.py --selftest
|
||||
== known-answer fixtures (bytes produced by hand in this script) ==
|
||||
canonical varint 150 089601
|
||||
field 1 varint value PASS 150
|
||||
unsigned max uint64 08ffffffffffffffffff01
|
||||
field 1 varint value PASS 18446744073709551615
|
||||
nested two levels 089601120774657374696e671a060801120210072206038e029ea705...
|
||||
field 3 nested child count PASS 2
|
||||
deprecated group 3/4 43080544
|
||||
field 8 has children PASS 1
|
||||
non-canonical varint 08968100
|
||||
field 1 varint value PASS 150
|
||||
10-byte varint overflow 08ffffffffffffffffff7f
|
||||
field 1 varint overflow PASS True
|
||||
...
|
||||
== round trip: decode -> re-encode -> byte comparison ==
|
||||
canonical varint 150 MATCH (1 fields, 1 identical)
|
||||
nested two levels MATCH (7 fields, 7 identical)
|
||||
non-canonical varint MATCH (1 fields, 0 identical) re-encode emits the minimal
|
||||
form 089601, original 08968100, and the report states why
|
||||
varint length framing MATCH (7 fields, 7 identical)
|
||||
|
||||
== the two wire-format traps ==
|
||||
explicit zero bytes 2800 decodes to value 0; absent field decodes to no fields at all: PASS
|
||||
packed payload 038e029ea705 offers live=['bytes', 'packed_varint'], rejected=['nested_message',
|
||||
'utf8_string'] -> view=packed_varint : PASS
|
||||
|
||||
selftest: 26/26 PASS exit = 0
|
||||
```
|
||||
|
||||
The non-canonical fixture is the one deliberate exception: the re-encode is *expected* to differ
|
||||
(`08968100` -> `089601`, the minimal form), and the tool reports the difference with its reason
|
||||
rather than normalising it away silently. That is the honest form of "round trip": a difference that
|
||||
is explained is evidence; a difference that is not is a bug.
|
||||
|
||||
---
|
||||
|
||||
## 3. Cross-check against the official protobuf runtime
|
||||
|
||||
`tools/_work/t5-proto/official_ref.py` builds a real `FileDescriptorProto` (`Outer` with int64 /
|
||||
string / message / repeated int32 (packed) / fixed64 / float / enum / proto3-optional / bytes /
|
||||
fixed32 fields, plus a nested `Inner`), serializes it with `SerializeToString(deterministic=True)`,
|
||||
and then asks the hand-written decoder to reproduce it.
|
||||
|
||||
```
|
||||
$ python tools\_work\t5-proto\official_ref.py
|
||||
== official runtime bytes (protobuf 6.33.6) ==
|
||||
58 B: 089601120774657374696e671a0508011201782206038e029ea705290807060504030201350000c03f380240004a06038e029ea70555efbeadde
|
||||
|
||||
== field-by-field agreement with the schema ==
|
||||
field 1 count == 150 PASS decoded 150
|
||||
field 2 name == "testing" PASS decoded 'testing'
|
||||
field 3 inner walked as a nested message PASS view=nested_message children=2
|
||||
field 4 nums read as packed [3,270,86942] PASS view=packed_varint values=[3, 270, 86942]
|
||||
field 5 stamp fixed64 hex 0807060504030201 PASS 0807060504030201
|
||||
field 6 ratio fixed32 hex 0000c03f (1.5f) PASS 0000c03f
|
||||
field 7 color enum == 2 PASS
|
||||
field 8 explicit_zero is present and 0 PASS
|
||||
field 9 blob hex 038e029ea705 PASS
|
||||
field 10 crc fixed32 hex efbeadde PASS efbeadde
|
||||
|
||||
== round trip against the official bytes ==
|
||||
re-encode is byte-identical to SerializeToString() PASS 10/10 fields identical
|
||||
no field was rewritten PASS []
|
||||
|
||||
official-runtime cross-check: 21/21 PASS exit = 0
|
||||
```
|
||||
|
||||
Field 5 is the useful false-friend: the decoder offers `as_double_repr` for those 8 bytes, and the
|
||||
official value is a `fixed64` integer. Nothing on the wire distinguishes them, which is why the
|
||||
candidate list exists instead of a single typed value.
|
||||
|
||||
### 3.1 The four-way tie, measured on real bytes
|
||||
|
||||
A really-encoded nested message — `Inner{id:1, tag:"x"}` inside field 3, inner payload `0801120178`
|
||||
— is reported as:
|
||||
|
||||
```
|
||||
nested message payload 0801120178 -> {'nested_message': 0.5, 'packed_varint': 0.5,
|
||||
'utf8_string': 0.35, 'bytes': 0.2}
|
||||
```
|
||||
|
||||
`0801120178` is simultaneously a valid two-field message **and** a valid five-element packed varint
|
||||
array. The decoder refuses to choose; the `tie:` line states that the displayed `view` is a display
|
||||
default. A real packed field (`2206038e029ea705`) yields `packed_varint 0.55` with
|
||||
`nested_message` **rejected** — the rejection is printed with its reason instead of being omitted.
|
||||
|
||||
### 3.2 proto3 zero — the reference sentence, corrected by measurement
|
||||
|
||||
The reference previously stated that `28 00` is byte-identical to an absent field. The runtime shows
|
||||
that the sentence needs splitting into two halves:
|
||||
|
||||
```
|
||||
implicit int64 count = 0 : 120178 (3 B)
|
||||
count never touched : 120178 (3 B)
|
||||
implicit (no presence) zero is byte-identical to absent PASS
|
||||
that byte string contains no tag for field 1 PASS
|
||||
optional int32 explicit_zero=0: 1201784000 (5 B)
|
||||
a presence-carrying field DOES put tag+0x00 on the wire PASS 1201784000
|
||||
the optional zero costs 2 bytes the implicit zero never costs PASS 5 B vs 3 B
|
||||
the decoder sees that 0 and flags the ambiguity PASS ["proto3_explicit_zero"]
|
||||
```
|
||||
|
||||
Read it as two separate facts:
|
||||
|
||||
1. For a field **without** presence, `value = 0` and "never set" are the same bytes (no bytes at
|
||||
all), so a field **missing** from a decode is not evidence that the value in use was 0.
|
||||
2. A `0` that **is** on the wire was emitted on purpose — a presence-carrying field (`40 00` here,
|
||||
field 8) or a hand-rolled writer. The decoder cannot tell which, so it flags the case.
|
||||
|
||||
`references/protocol-reverse.md` §1 consequence 3 now carries that correction.
|
||||
|
||||
---
|
||||
|
||||
## 4. A real AndroidX DataStore file
|
||||
|
||||
`scripts/datastore_inject.py` writes the real AndroidX Preferences protobuf container
|
||||
(`map<string, Value>`); the decoder has never seen that schema.
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/datastore_inject.py --file tools\_work\t5-proto\ds.preferences_pb \
|
||||
--key example_expiry_epoch_ms --type long --value 4102444800000 \
|
||||
--extra example_flag=true:bool --extra example_name=hello:string
|
||||
[ok] wrote tools\_work\t5-proto\ds.preferences_pb (81 bytes)
|
||||
|
||||
$ python skills/apk-reverse/scripts/protobuf_decode_raw.py tools\_work\t5-proto\ds.preferences_pb
|
||||
input: tools\_work\t5-proto\ds.preferences_pb, 81 byte(s)
|
||||
frame 0 bytes 0..81 (81 B) status=end_of_buffer
|
||||
f1 len-delimited(34) view=nested_message
|
||||
...
|
||||
f1 len-delimited(23) view=utf8_string
|
||||
string: 'example_expiry_epoch_ms'
|
||||
f2 len-delimited(7) view=nested_message
|
||||
...
|
||||
f4 varint 4102444800000
|
||||
f1 len-delimited(18) view=nested_message <- the second map entry
|
||||
...
|
||||
```
|
||||
|
||||
The map-entry structure, the key strings and the `long` value all come out correctly with no schema;
|
||||
every length-delimited field still shows its ties (`nested_message` vs `packed_varint` vs
|
||||
`utf8_string`) because those ties are real.
|
||||
|
||||
### 4.1 Round trip, and an edit confirmed by the other tool
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/protobuf_decode_raw.py tools\_work\t5-proto\ds.preferences_pb --json > tools\_work\t5-proto\ds.tree.json
|
||||
$ python skills/apk-reverse/scripts/protobuf_decode_raw.py --reencode tools\_work\t5-proto\ds.tree.json \
|
||||
--out tools\_work\t5-proto\ds.reenc.bin --check tools\_work\t5-proto\ds.preferences_pb
|
||||
re-encoded 81 byte(s) from 3 field(s)
|
||||
fields re-encoded byte-identically: 3/3
|
||||
written to tools\_work\t5-proto\ds.reenc.bin
|
||||
check against tools\_work\t5-proto\ds.preferences_pb (81 B, the whole file): MATCH exit = 0
|
||||
```
|
||||
|
||||
Then one value is edited in the JSON tree and re-encoded:
|
||||
|
||||
```
|
||||
editing field 4 from 4102444800000
|
||||
re-encoded 80 byte(s) from 3 field(s)
|
||||
fields re-encoded byte-identically: 2/3
|
||||
CHANGED field 1: expected 0a22...80b08fe6b277, got 0a21...20ffc7afa025 (the tree was edited)
|
||||
|
||||
$ python skills/apk-reverse/scripts/datastore_inject.py --in tools\_work\t5-proto\ds.edited.bin --list
|
||||
[in] tools\_work\t5-proto\ds.edited.bin (80 bytes)
|
||||
example_expiry_epoch_ms long=9999999999 (value=6 bytes)
|
||||
example_flag bool=True (value=2 bytes)
|
||||
example_name string='hello' (value=7 bytes)
|
||||
```
|
||||
|
||||
Two independent facts in one run: the file shrank 81 -> 80 B because a 6-byte varint became a
|
||||
5-byte one **and the enclosing length prefix was recomputed** (the re-encode rebuilds lengths, it
|
||||
does not copy them), and a different tool of this repository read the edited bytes back as the new
|
||||
value. A round trip that only agrees with itself would not have caught either.
|
||||
|
||||
---
|
||||
|
||||
## 5. Framing: `writeDelimitedTo` is not a gRPC frame
|
||||
|
||||
`tools/_work/t5-proto/framing_check.py`:
|
||||
|
||||
```
|
||||
message (20 B): 089601120774657374696e672206038e029ea705
|
||||
writeDelimitedTo form (21 B): 14089601120774657374696e672206038e029ea705
|
||||
varint-length framing: exactly one frame PASS got 1
|
||||
a second message makes two frames PASS
|
||||
gRPC frame (25 B): 0000000014089601120774657374696e672206038e029ea705
|
||||
decoded with --split varint-length: 5 frame(s): [(1, 1, 'end_of_buffer'), (2, 2, ...),
|
||||
(3, 3, ...), (4, 4, ...), (5, 25, ...)]
|
||||
gRPC frames are NOT varint-length framed (the split misfires) PASS got 5 frame(s)
|
||||
stripping the 5-byte gRPC prefix decodes the message correctly PASS field 1=150, field 4=[3, 270, 86942]
|
||||
'|' separates two independently framed messages PASS frames=2
|
||||
|
||||
framing check: 8/8 PASS exit = 0
|
||||
```
|
||||
|
||||
| Framing | Bytes in front of the message | `--split varint-length` |
|
||||
|---|---|---|
|
||||
| protobuf `writeDelimitedTo` | varint length | correct — 1 frame per message |
|
||||
| gRPC | 1 compression-flag byte + **4-byte big-endian** length | **wrong, and it does not fail**: the 4 zero bytes become four empty frames |
|
||||
|
||||
The same gRPC bytes decoded with **no** splitting reported `stray_end_group` (exit 1), and a
|
||||
`writeDelimitedTo` file decoded with no splitting reported the same error — that error is a *refusal*,
|
||||
whereas the wrongful split is a *silent reinterpretation*. Strip the 5-byte gRPC prefix yourself
|
||||
(`references/protocol-reverse.md` §3), and check the frame count either way.
|
||||
|
||||
A `--split varint-length` round trip compares **frame bodies only**, and says so:
|
||||
|
||||
```
|
||||
check against tools\_work\t5-proto\two_frames.bin (26 B, frame bodies only: 2 of 26 byte(s) are
|
||||
length prefixes (framing, not message data)): MATCH
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Defects these checks found (in the code, not in the target)
|
||||
|
||||
Four defects, three of them in the delivered script, all found by the checks above rather than by
|
||||
reading the code.
|
||||
|
||||
| # | Defect (observed) | How it surfaced | Fix |
|
||||
|---|---|---|---|
|
||||
| 1 | When two candidate readings tied, both were lowered to the same confidence as the `bytes` fallback, and the tie was then broken by **alphabetical order** — `bytes` sorts before `nested_message`, so a real nested message was never expanded and `--reencode` copied it as an opaque blob | `--selftest`: `nested two levels / field 3 nested child count` returned `0` instead of `2` | `bytes` now scores 0.2 and ties are broken by an explicit `VIEW_PRIORITY`, structural candidates first |
|
||||
| 2 | In the `\xNN` path, whitespace and commas were kept as data bytes, so `"08 96 01 | \x12\x07testing"` decoded the space after the bar as `field 4 varint 18` and then hit `invalid_wire_type` | the input-form check in §1 | whitespace and commas are stripped before unescaping, in that branch too |
|
||||
| 3 | Two fixtures were mis-designed, not the code: a bare packed stream and a length-prefixed frame were round-tripped as if each were one top-level message, which reported `FAIL` for correct behaviour | `--selftest` round-trip section | the packed fixture is now a field *value*; the framing fixture compares frame bodies |
|
||||
| 4 | The cross-check's own assertion expected `0800` for the explicit zero, but the field under test is number 8, whose tag is `0x40` — the official bytes were `...4000`, and the assertion, not the decoder, was wrong | `official_ref.py` | assertion compares `4000` and the 2-byte cost |
|
||||
| 5 | `--no-expand` did nothing: the expansion branch never consulted the flag, so a 7-level payload was still walked to the bottom | the deep-payload check below | the branch honours `--no-expand` and records `expansion: suppressed by --no-expand` on the field |
|
||||
| 6 | Hitting `--max-depth` was invisible: the depth cut-off only appeared as a `rejected` note inside one candidate, so the report showed a shallow tree with no statement that anything had been left unexamined | the same check | a `depth_limit` ambiguity is emitted, naming the path and the limit |
|
||||
|
||||
Defect 1 is worth its own line in any future reader's mind: a *heuristic* tie-break silently changed
|
||||
which bytes the tool re-emitted, and it was caught only because a fixture asserted a child count.
|
||||
Defects 5 and 6 are the other direction — the tool silently *did more* than asked, and silently
|
||||
*stopped* without saying so; both are ways a report can be read as complete when it is not.
|
||||
|
||||
### 6.1 The deep-payload check
|
||||
|
||||
A 7-level nesting (`08 01` wrapped six times in field 1, 14 bytes) is the fixture:
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/protobuf_decode_raw.py tools\_work\t5-proto\deep.bin
|
||||
frame 0 bytes 0..14 (14 B) status=end_of_buffer
|
||||
[depth_limit] frame0.1.1.1.1.1.1 @2: a nested reading of this field was not attempted:
|
||||
--max-depth 5 reached. The bytes here may well be a message; re-run with a larger --max-depth
|
||||
before concluding that they are not
|
||||
|
||||
$ python ... tools\_work\t5-proto\deep.bin --max-depth 2
|
||||
[depth_limit] frame0.1.1.1 @2: ... --max-depth 2 reached. ...
|
||||
|
||||
$ python ... tools\_work\t5-proto\deep.bin --no-expand
|
||||
expansion: suppressed by --no-expand
|
||||
```
|
||||
|
||||
The truncation is reported as an ambiguity with the path of the field it stopped at, so "no nested
|
||||
message here" can never be read off a truncated tree.
|
||||
|
||||
---
|
||||
|
||||
## 7. What was **not** done
|
||||
|
||||
* **No device was used.** The pass intended to pull a real app's `.preferences_pb` from
|
||||
`<DEVICE>`: `python tools\_work\devlock.py acquire proto --timeout 1800` waited 120 s and timed out
|
||||
with the lock held by another agent (`held by bench for 297s`, note "L1 repack control+patched
|
||||
install & screen; L3 frida"). Per the two-strike rule the device source was dropped rather than
|
||||
contended for, and the real-file evidence comes from the DataStore file this repository's own tool
|
||||
writes (§4). Reading a **device-side** protobuf file with this decoder is therefore **unverified**.
|
||||
Device facts used elsewhere in this repository (attach-by-name, the broken `logd` channel) were
|
||||
not re-tested here.
|
||||
* **No protobuf payload was recovered from the benchmark APKs.** Scanning the available sample
|
||||
(`<PKG>`-style de-identified, 4,293,620 B, 435 zip entries) for `.pb` / `.proto` / `protobuf`
|
||||
resources found none; the MASTG crackmes carry no protobuf at all. So no wild-captured payload was
|
||||
decoded in this pass — `inferred` for any claim that a real traffic body looks like these fixtures,
|
||||
though §3's bytes come from the reference implementation and §4's from a real AndroidX container.
|
||||
* **No live traffic.** Nothing was proxied, so the gRPC section of the reference is exercised only
|
||||
as *framing* (§5), not as captured HTTP/2.
|
||||
* `blackboxprotobuf` / `protobuf-inspector` were not installed or compared; the reference still
|
||||
labels them unverified.
|
||||
* `--max-depth` is measured up to a 7-level fixture (§6.1); no real payload deeper than that was
|
||||
decoded, and no payload was decoded whose *only* reading is a deep message.
|
||||
|
||||
## 8. Artifacts produced by this pass
|
||||
|
||||
`tools/_work/t5-proto/` (not committed): `official_ref.py` (official-runtime cross-check, 21 checks),
|
||||
`framing_check.py` (8 checks), `ds.preferences_pb` (81 B, real DataStore container),
|
||||
`ds.tree.json` / `ds.edited.json` (decoded trees), `ds.reenc.bin` (byte-identical round trip),
|
||||
`ds.edited.bin` (80 B, edited value read back by `datastore_inject.py`), `two_frames.bin`,
|
||||
`frames.tree.json`, `frames.reenc.bin`.
|
||||
|
||||
Files changed in the repository by this pass: `skills/apk-reverse/scripts/protobuf_decode_raw.py`
|
||||
(new), and `skills/apk-reverse/references/protocol-reverse.md` (a new measured subsection under §1,
|
||||
the corrected proto3-zero wording in §1 consequence 3, and a new framing failure mode in §6).
|
||||
`SKILL.md` and `README.md` were deliberately not touched; the index rows for the new script are
|
||||
handed to the lead to register.
|
||||
141
vendor-new/apk-reverse/docs/tool-verification/EXTENSION-rasc.md
Normal file
141
vendor-new/apk-reverse/docs/tool-verification/EXTENSION-rasc.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# EXTENSION — rasc (the Rust ASC) measured against the Python one
|
||||
|
||||
Evidence for `skills/apk-reverse/references/rasc-and-droidsaw.md`. Every number below has the command
|
||||
that produced it.
|
||||
|
||||
## What was measured
|
||||
|
||||
| Claim | Grade | Result |
|
||||
|---|---|---|
|
||||
| `rasc` builds from the `rust` branch on this host with no prebuilt artifact | **observed** | rustc 1.98.1 (gnu host) + MinGW-w64 gcc 16.1.0; `cargo build --release` **117 s**; binary 2,236,416 B |
|
||||
| It is faster than the Python `droidasc` on the same archives | **observed** | 4.0–5.6× for `classes`, up to 16.2× for `findrefs` (tables below) |
|
||||
| It returns the same answers | **observed** | **identical class-definition sets**, 30,768 / 30,768 with **0 differences in either direction**; `findrefs string` identical on one sample, `type` a superset (390 vs 384) |
|
||||
| Its `getclass` decompiles ordinary classes correctly | **observed** | 16 of 17 JADX-judged classes matched literal-for-literal, 0 reorderings |
|
||||
| **Its outer-class view of an enum does not inline the constant bodies, and says nothing** | **observed — and the first write-up of this row over-claimed; see §The blind spot** | JADX inlines the three anonymous subclasses; `rasc` prints a bare constant list. Both tools decompile `PasswordConverter$1/2/3` **fully** (404–407 B, containing `convert`/`getType`), so what is missing is an inlining step, not the code |
|
||||
| Its own benchmark's 8.0× geometric mean reproduces here | **not reproduced, and explained** | that figure is from a 343 MiB / 567k-class archive; on a 34.8 MB / 30,768-class app the ratio is 4×. Both are real; the small-archive figure is what a normal task hits |
|
||||
|
||||
## Environment
|
||||
|
||||
Host Windows 11. Device not involved. Toolchain installed for this pass, into the gitignored
|
||||
`tools/_work/`:
|
||||
|
||||
```
|
||||
rustup-init (x86_64-pc-windows-gnu) -> rustc 1.98.1, cargo 1.98.1
|
||||
winget install BrechtSanders.WinLibs.POSIX.UCRT -> gcc 16.1.0 (x86_64-w64-mingw32, UCRT)
|
||||
```
|
||||
|
||||
The 32-bit MinGW already on this host (`C:\MinGW`, gcc 6.3.0, target `mingw32`) **cannot** link a
|
||||
64-bit binary — `gcc -m64` reports `sorry, unimplemented: 64-bit mode not compiled in`, and the Rust
|
||||
GNU target needs a 64-bit linker. E:\vs2022 has no VC tools (`cl.exe` / `link.exe` absent), and the
|
||||
WSL2 Ubuntu distribution is registered but `HCS_E_SERVICE_NOT_AVAILABLE`, so no Linux path either.
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/rasc_build.py --check
|
||||
toolchain : git=E:\Git\cmd\git.EXE cargo=...\rust\cargo\bin\cargo.EXE rustc=...\cargo\bin\rustc.EXE gcc=C:\MinGW\bin\gcc.EXE(mingw32)
|
||||
rasc : E:\apk-reverse\tools\_work\rust\target\release\rasc.exe rasc 0.1.0
|
||||
result : ok
|
||||
```
|
||||
|
||||
## Speed and agreement
|
||||
|
||||
Fastest of 3 runs (1 run on the largest), same machine, `PYTHONUTF8=1` for the Python side.
|
||||
|
||||
```
|
||||
$ python tools/_work/compare_asc.py tools/_work/bench/apks/UnCrackable-Level3.apk --repeat 3
|
||||
scenario rasc s py s speedup result agreement
|
||||
classes 0.025 0.138 5.6 equal rasc=1396 py=1396
|
||||
manifest 0.017 - - not comparable
|
||||
findrefs string 0.022 0.341 15.6 overlap 100% rasc=3 py=3
|
||||
findrefs type 0.022 0.358 16.2 overlap 98% rasc=390 py=384
|
||||
```
|
||||
|
||||
Rounded to whole numbers in the reference file (4.7–14.7× there vs 5.6–16.2× here) because these are
|
||||
two independent runs of the same measurement; both are in the observed range, and the run-to-run
|
||||
spread on a sub-100 ms command is the honest resolution.
|
||||
|
||||
On a real 34.8 MB application (`MT-Manager.apk`, 30,768 classes):
|
||||
|
||||
```
|
||||
$ python -c "... rasc classes vs droidasc listclass, set comparison ..."
|
||||
rasc : 0.048 s 30768 classes
|
||||
droidasc : 0.190 s 30768 classes
|
||||
speedup : 4.0x
|
||||
identical set : True
|
||||
only in rasc : 0 only in droidasc: 0
|
||||
```
|
||||
|
||||
`python skills/apk-reverse/scripts/rasc_build.py --verify tools/_phone-modules/MT-Manager.apk` runs
|
||||
the same comparison and reports `{'result': 'ok', 'rasc_classes': 30768, 'droidasc_classes': 30768,
|
||||
'only_rasc': 0, 'only_droidasc': 0}`, so the check travels with the kit rather than living in a note.
|
||||
|
||||
**A defect found on the Python side during this work:** `droidasc listclass` exits 1 with
|
||||
`'gbk' codec can't encode character` on an app whose classes contain non-Latin identifiers, when the
|
||||
console encoding is not UTF-8. With `PYTHONUTF8=1` it completes. `rasc` was unaffected. That is a
|
||||
one-variable difference in the environment, not a property of either tool, and it is recorded because
|
||||
it cost a round here.
|
||||
|
||||
## The blind spot, verified rather than reported
|
||||
|
||||
The `rasc` repository ships `bench/jadx_parity.py`, which treats a string literal JADX has and `rasc`
|
||||
does not as lost code, and refuses to judge a class JADX itself could not decompile cleanly. Its own
|
||||
class sampling takes every-nth entry from `rasc classes`, which on a real app lands mostly on
|
||||
obfuscated names where JADX produces nothing — 8 of 10 picked that way were unjudgeable here, so the
|
||||
sample measures the sampler. `tools/_work/parity_fair_sample.py` filters to app-like classes first
|
||||
(ASCII, dotted, no inner class, not `R`/`BuildConfig`) and feeds those to the unmodified harness:
|
||||
|
||||
```
|
||||
$ python tools/_work/parity_fair_sample.py tools/_phone-modules/MT-Manager.apk 20
|
||||
classes in APK: 30768
|
||||
app-like candidates: ...
|
||||
judged 17 class(es) where JADX was clean; literals rasc lost: 1; reorderings: 0
|
||||
```
|
||||
|
||||
The one finding, checked by hand with `tools/_work/verify_parity_finding.py`:
|
||||
|
||||
```
|
||||
class : org.bouncycastle.crypto.PasswordConverter
|
||||
jadx literals: ['ASCII', 'PKCS12', 'UTF8']
|
||||
rasc literals: []
|
||||
missing : ['ASCII', 'PKCS12', 'UTF8']
|
||||
'ASCII' appears in rasc output as an identifier: True
|
||||
...
|
||||
VERDICT: form difference (enum constants printed as a list, literals not re-emitted)
|
||||
```
|
||||
|
||||
**The automated verdict understates it, and the hand check then overstated it. Both corrections are
|
||||
kept.** The harness counts literals, and the literals in JADX's output belong to the anonymous
|
||||
subclasses it inlines; `rasc`'s outer-class view prints a bare constant list. The first write-up here
|
||||
said "the method bodies are gone". **That was wrong, and the way it was wrong is worth recording: the
|
||||
subclasses were never queried.** They exist as their own classes and both tools decompile them fully:
|
||||
|
||||
```
|
||||
$ rasc getclass <apk> 'Lorg/bouncycastle/crypto/PasswordConverter$1;' -> 404 B, has convert(/getType(
|
||||
$ droidasc getclass <apk> 'Lorg/bouncycastle/crypto/PasswordConverter$1;' -> 460 B, has both
|
||||
$ ... $2 and $3 the same shape, 406-407 B vs 462-463 B
|
||||
```
|
||||
|
||||
So the accurate finding is narrower than the first version and narrower than the harness's phrasing:
|
||||
**`rasc` does not inline enum-constant bodies into the outer class view, and it does not say so** —
|
||||
the bodies are one `getclass` away, on the subclass JADX itself names in a comment
|
||||
(`// from class: ...PasswordConverter.1`). Neither does the Python tool inline; `droidasc`'s outer
|
||||
listing is just richer (2,170 B vs 314 B, with `$VALUES`, `$values()` and the constructors explicit).
|
||||
|
||||
The general lesson this left behind, and the reason the episode stays in the record: a harness that
|
||||
counts one artefact (literals) can point at a real difference while misdescribing it, and a hand
|
||||
check that stops at the first comparison can convert that into an overstated claim. The corrective was
|
||||
to query the classes the tool we were comparing against had named — which cost one command.
|
||||
|
||||
## Not established
|
||||
|
||||
- **No whole-APK parity run.** The comparison is on class sets, sampled `getclass` output and one
|
||||
`findrefs` family per sample. `bench/corpus_parity.py` and `bench/jadx_parity.py` exist for a fuller
|
||||
run and were used on one archive, not on a corpus.
|
||||
- **`findrefs` on a large corpus was not run to completion.** On the 34.8 MB app, `findrefs string
|
||||
http` returned 0 rows on both sides — a query with no hits is not a parity result, and a query that
|
||||
does hit was only compared on the small archive.
|
||||
- **`getclass` was judged on 17 classes of one app.** The enum blind spot is confirmed; whether other
|
||||
class shapes (inner classes, synthetic bridges, `invokedynamic`) diverge the same way is untested.
|
||||
- **No device or Android-side use.** `rasc` reads archives on the host; nothing here exercises it as a
|
||||
device tool, and the reference file makes no claim about that.
|
||||
- **The 8.0× upstream figure was not reproduced**, because the archive it was measured on (343 MiB,
|
||||
567k classes) is not available here. The direction and the smaller ratio were measured.
|
||||
@@ -0,0 +1,585 @@
|
||||
# Extension verification — truthful environment gates, documented-command validation, and APK rebuild safety
|
||||
|
||||
Covers the P0 pass that replaced the optimistic half of `skills/apk-reverse/scripts/doctor.py`
|
||||
with a probed capability registry, added `check_commands.py` at the repository root, and — in the
|
||||
same round, by teammate `zip-safety` — replaced the APK-rebuild writer inside
|
||||
`skills/apk-reverse/scripts/so_constpatch.py`. Strength labels are the ones this directory's
|
||||
README defines: **observed** (reproduced here, exact command and output recorded), **inferred**
|
||||
(follows from observed facts, the step itself was not executed), **unverified** (assumed or
|
||||
reported elsewhere, not independently reproduced).
|
||||
|
||||
Two gates depend on the material below, which is why the report was allowed to become worse
|
||||
looking: G2 (environment gating) reads the capability verdicts, and the documented-command check
|
||||
is what keeps a reader from following a page instead of the tool. "Documented command" below means
|
||||
a script invocation written in a code block or inline backtick in a markdown file — the shape
|
||||
`check_commands.py` extracts and validates.
|
||||
|
||||
Reference environment for this pass: Windows 11 / AMD64, Python **3.14.0**, JDK **17.0.4.1**
|
||||
(`java`/`javac` only, on the Oracle launcher shim path), `adb` 1.0.41 with one attached
|
||||
`arm64-v8a` device at API 30, Frida **16.7.19** host package, capstone present, Android
|
||||
build-tools unpacked at a local directory but **not** on `PATH`. Workbench artifacts live in
|
||||
`tools/_work/truth-gates/` and `tools/_work/zip-safety-out/` (not part of the skill; `tools/` is
|
||||
gitignored, so those paths are an operation record rather than something a reader can open).
|
||||
|
||||
---
|
||||
|
||||
## 0. Summary of what this pass established
|
||||
|
||||
| Claim | Label |
|
||||
|---|---|
|
||||
| The committed `doctor.py` registered **28** scripts against **51** files actually in `scripts/` (46 `.py` + 5 `.js`) | observed |
|
||||
| Its capability table derived `repack + sign` and `smali round-trip` from `java` alone; on a JDK-only `PATH` it reported **both as OK** while `keytool`, `jarsigner`, `zipalign`, `apksigner` and all eight smali jars were absent | observed |
|
||||
| The rebuilt `doctor.py` scans the directory, so `registered/checked = N / N` equals the file count; on this host `52 / 52` (47 `.py` + 5 `.js`, the new `capabilities.py` included) | observed |
|
||||
| Capability verdicts now come from a real `requires` closure with an executable next action and a cost estimate; 16 capabilities are resolved, and the same JDK-only host reports `repack_signed` and `smali_roundtrip` **BLOCKED** | observed |
|
||||
| `check_commands.py` reads each script's `argparse` interface with `ast` (no script executed to learn its interface) and validates **139** documented commands across 76 markdown files against 39 interfaces, with 0 drift after the one known drift was fixed | observed |
|
||||
| The drift the checker was written for — `sig_probe.py --live --pkg <pkg>` — is reproduced **from `git show HEAD:`** of the pre-fix page and is reported at the right line | observed |
|
||||
| The pre-existing `so_constpatch.py` APK writer dropped per-entry zip metadata and lost `resources.arsc` alignment; the replacement keeps metadata byte-for-byte and the alignment gate passes | observed |
|
||||
| `zipalign -c -v 4` from the Android build-tools agrees with that verdict independently (old writer `rc=1 FAILED`, new writer `rc=0`), re-checked in this pass on artifacts produced by the other teammate | observed |
|
||||
| `repack.py:_needs_alignment` uses `^lib/[^/]+\.so$`, which never matches the real `lib/<abi>/x.so` layout, so its alignment filler is dead code on real APKs | observed (reported, **not** fixed — outside every current write scope) |
|
||||
| A real `pm install` of any rebuilt artifact | not done — no install was attempted in this pass |
|
||||
|
||||
---
|
||||
|
||||
## 1. The doctor was optimistic, and a gate trusted it
|
||||
|
||||
### 1.1 A hand-written inventory that could only go stale
|
||||
|
||||
The committed version carried `script_dependencies()` returning a literal dict of 28 entries. The
|
||||
directory held 51 files. The 23 absent from the table were not peripheral: they included
|
||||
`dex_patch_bytes.py`, `dex_find_insn.py`, `dex_dump_validate.py`, `dex_mem_scan.py`, `dexutil.py`,
|
||||
`protobuf_decode_raw.py`, `java2c_probe.py`, `vmp_diff_harness.py` and `scan_leaks.py` — nine of the
|
||||
scripts this skill exists for. The summary line read `scripts runnable here: 28 / 28`, so the report
|
||||
looked like complete coverage of a set that was less than half the set.
|
||||
|
||||
The replacement cannot drift in that direction, because the list *is* the directory:
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/doctor.py --scripts
|
||||
--- scripts ---
|
||||
directory: E:\apk-reverse\skills\apk-reverse\scripts
|
||||
registered/checked = 52 / 52 (.py=47, .js=5)
|
||||
ok=33 partial=13 blocked=6 unknown=0
|
||||
```
|
||||
|
||||
A file whose dependency set cannot be decided is printed as `UNKNOWN` and still counted in the
|
||||
denominator; `unknown=0` here is a measurement, not an assumption. Dependencies come from an `ast`
|
||||
pass over imports checked against `sys.stdlib_module_names`, plus a narrow scan for host tools
|
||||
(`resolve_tool('java')`, the program of a `subprocess` list) against a known-tool set. Modules are
|
||||
never imported to probe them, because importing the probe target is itself a side effect — `frida`
|
||||
starts threads and enumerates devices at import time.
|
||||
|
||||
### 1.2 The formula that ignored the toolchain — decisive reproduction
|
||||
|
||||
`doctor.py:319-320` (committed):
|
||||
|
||||
```python
|
||||
caps['repack + sign'] = bool(tools['java']['path']) and py['ok']
|
||||
caps['smali round-trip'] = bool(tools['java']['path']) and py['ok']
|
||||
```
|
||||
|
||||
Neither ability follows from `java`. To show that the mechanism was wrong rather than merely lucky,
|
||||
the committed script was extracted from git and run with a `PATH` holding a JDK and nothing else:
|
||||
|
||||
```
|
||||
$ git show HEAD:skills/apk-reverse/scripts/doctor.py > tools/_work/truth-gates/doctor-HEAD.py
|
||||
$ set PATH=C:\Program Files\Common Files\Oracle\Java\javapath;C:\Windows\System32;C:\Windows;C:\Python314
|
||||
$ python tools/_work/truth-gates/doctor-HEAD.py
|
||||
|
||||
--- capabilities ---
|
||||
[OK ] static (dex/zip/strings)
|
||||
[OK ] repack + sign <-- no keytool, no zipalign, no signer on this PATH
|
||||
[OK ] smali round-trip <-- none of the eight jars exist on this host
|
||||
[OK ] native ELF patching
|
||||
[-- ] jadx decompile
|
||||
...
|
||||
```
|
||||
|
||||
The rebuilt script, on the **same** restricted `PATH`:
|
||||
|
||||
```
|
||||
[BLOCKED] repack_signed Repack, align and sign an APK
|
||||
missing : tool:keytool -- not on PATH or APKREV_TOOLS
|
||||
next: the directory on PATH (C:\Program Files\Common Files\Oracle\Java\javapath)
|
||||
is a launcher shim, not a full JDK bin/: put %JAVA_HOME%\bin first, or
|
||||
install a JDK - a JRE ships no keytool [~2 min, unverified]
|
||||
missing : tool:zipalign -- not on PATH or APKREV_TOOLS
|
||||
next: build-tools are already unpacked here but are not on the search path:
|
||||
set APKREV_TOOLS=E:\tools\android-14;E:\tools\bin (found: ...) [~0 min, observed]
|
||||
missing : artifact:signer -- tool:apksigner (not on PATH or APKREV_TOOLS);
|
||||
jar:uber-apk-signer (uber-apk-signer.jar not found)
|
||||
reduced : tool:jarsigner -- not on PATH or APKREV_TOOLS
|
||||
[BLOCKED] smali_roundtrip Smali disassemble / assemble round-trip
|
||||
[OK ] repack_unsigned Repack an APK without signing (--no-sign path)
|
||||
|
||||
ok=5 partial=1 blocked=10 (of 16 capabilities)
|
||||
```
|
||||
|
||||
`repack_unsigned` staying OK is deliberate and true: `repack.py --no-sign` is `zipfile` plus the
|
||||
STORED-entry rules and needs no Java tooling at all. A gate that reported the whole file unusable
|
||||
would have been a pessimism of its own.
|
||||
|
||||
### 1.3 The closure is the point, not the atom list
|
||||
|
||||
`capabilities.py` is the single registry: 16 capabilities, each a `name`, a `note`, the scripts it
|
||||
serves, `required` atoms, `optional` atoms, and `depends_on` capabilities that are expanded
|
||||
recursively. `repack_signed` depends on `repack_unsigned`, so it cannot be OK while the repacking it
|
||||
is built on is not. Atoms cover the four kinds that actually decide something here: an importable
|
||||
module, a tool resolvable on `PATH` or `APKREV_TOOLS`, a jar set, a device property (present,
|
||||
rooted, frida-server running), a repository file, and one **input artifact** that no host can
|
||||
install — the Dart AOT snapshot dump, which is why `dart_aot_full` reports BLOCKED with
|
||||
`build blutter … or run the pinned aotopsy front end` rather than a download link.
|
||||
|
||||
`status ∈ ok | partial | blocked`. A missing *required* atom blocks. A missing *optional* atom
|
||||
degrades: `static_native` stays usable without `capstone` and names the part that is not
|
||||
(`svc_scan.py` refuses to run without it; the caller index in `dart_disasm.py` works).
|
||||
|
||||
Two probes were worth getting right rather than guessing:
|
||||
|
||||
- A JDK whose launcher shim is on `PATH` (`...\Oracle\Java\javapath`) exposes `java` and `javac`
|
||||
but not `keytool` or `jarsigner`. The registry probes those separately and, on failure, says the
|
||||
shim directory is a shim instead of reporting a missing JDK.
|
||||
- Build-tools present but unexposed are found by searching the usual SDK locations plus a
|
||||
conventional `tools/` directory, and the next action becomes the exact assignment
|
||||
(`set APKREV_TOOLS=E:\tools\android-14;E:\tools\bin`) with a measured cost of `~0 min`, rather
|
||||
than "install build-tools".
|
||||
|
||||
That last hint was verified end to end: with `APKREV_TOOLS` set, `repack_signed` loses the
|
||||
`zipalign` and `signer` blockers and keeps only `keytool` — which is the honest residual on this
|
||||
host, because the JDK really is a shim install.
|
||||
|
||||
### 1.4 Cost estimates carry their own strength
|
||||
|
||||
Every install hint carries `est_minutes` with a `basis`. `observed` means measured in this
|
||||
repository: the `--unsafe-rebuild`-independent blutter build is **≈78 s** end to end
|
||||
(`TOOL-VERDICTS.md`), and exposing an already-unpacked build-tools directory is 0 minutes because
|
||||
the search found it. Everything else is labelled `unverified` and is a planning aid only: a guessed
|
||||
ten minutes printed as a measurement is the same class of error as a capability verdict derived
|
||||
from the wrong tool.
|
||||
|
||||
Per-capability `est_basis` is `mixed` when the missing atoms disagree, which is what makes
|
||||
`repack_signed` read `~12 min, mixed` rather than an authoritative-looking single number.
|
||||
|
||||
### 1.5 Exit codes and the token
|
||||
|
||||
`doctor.py` and `capabilities.py` both follow this round's convention: `0` = nothing blocked
|
||||
(`partial` does not fail a gate, and the counts are printed so it cannot hide), `3` = at least one
|
||||
capability blocked, `2` = usage, `4` = internal. The last line is `RESULT=env_ok` /
|
||||
`env_partial` / `env_blocked` / `usage_error` / `internal_error`, and the usage-error path keeps the
|
||||
token too — `argparse` would otherwise exit 2 with nothing a caller can branch on.
|
||||
|
||||
`--json` carries `{status, exit_code, capability?, evidence[], warnings[], next_action?}` plus, for
|
||||
compatibility, the keys the old report used (`python`, `tools`, `jars`, `device`, `capabilities`,
|
||||
`scripts`). `capabilities` changed shape on purpose — the old flat `{name: bool}` map *was* the
|
||||
optimism — and a `capabilities_legacy` map gives the same information as `{id: "ok"|"partial"|"blocked"}`
|
||||
for any consumer that only wants the verdict.
|
||||
|
||||
---
|
||||
|
||||
## 2. `check_commands.py` — the commands a reader is told to run
|
||||
|
||||
### 2.1 What it reads, and from where
|
||||
|
||||
`check_repo.py` proves that a referenced path resolves and every script answers `--help`. Neither
|
||||
can see a documented command whose flags no longer exist, and the repository shipped exactly one for
|
||||
a long time. `check_commands.py` closes that gap by reading each script's real interface with `ast`:
|
||||
|
||||
- `ArgumentParser()` / `add_argument()` calls are parsed statically, so **no script is executed to
|
||||
learn its interface**;
|
||||
- `add_subparsers()` / `add_parser('name')` are followed, and a sub-parser's flags are attributed
|
||||
to that sub-command rather than merged into the parent (one variable reused across several
|
||||
sub-parsers is handled, and the parameters are then attributed to the group as a whole);
|
||||
- `action=store_true|store_false|count|help|version` and `nargs=0` mean the flag consumes no value;
|
||||
- a script with no `argparse` at all (a hand-rolled `sys.argv` parser: `smtool.py`,
|
||||
`dex_strpatch.py`) falls back to parsing its own `--help` output, and `smtool.py`'s `--cp` is
|
||||
recovered that way. The probe runs in a **throwaway directory**, because a probe must not leave
|
||||
anything behind — a script that reads its first argument as an output directory would otherwise
|
||||
create one named after the flag.
|
||||
|
||||
Documented commands are extracted from fenced code blocks and inline backticks in `README.md`,
|
||||
`skills/**/*.md` and `docs/**/*.md`. A documented line is split on `&&`, `||`, `;` and `|`, and a
|
||||
redirection truncates the segment: `python check_repo.py && python check_refs.py` is two commands,
|
||||
not one with two extra positionals. `<PKG>`-style placeholders are kept as single tokens, because
|
||||
reading `<pkg>` as a shell input redirect truncates the command at `--pkg` — and a truncated
|
||||
command checks clean, which is how the checker would have missed the one drift it was built for.
|
||||
|
||||
### 2.2 Allowance rules that keep it honest rather than noisy
|
||||
|
||||
- argparse accepts a **unique prefix** of a long option, so `--max` passes for `--max-depth`; an
|
||||
*ambiguous* prefix is reported.
|
||||
- `-h` / `--help` are always accepted.
|
||||
- A flag that needs a value does **not** consume an option-looking token; argparse reports
|
||||
"expected one argument" instead. This is the rule that makes `--live --pkg <pkg>` report `--pkg`
|
||||
rather than swallow it as the value of `--live`.
|
||||
- Positional counts are computed from the declared positionals (with `nargs=None|int|'?'|'*'|'+'`)
|
||||
and are reported as **warnings**, never as drift: a static count can be thrown off by a
|
||||
placeholder or an elision, so it does not get to fail a gate. It is skipped entirely when any
|
||||
option has a variable `nargs` or the interface came from `--help`.
|
||||
|
||||
### 2.3 The drift it exists for, reproduced from git
|
||||
|
||||
The page was already fixed by the time this checker ran, so the pre-fix revision is taken from git
|
||||
and checked as a file:
|
||||
|
||||
```
|
||||
$ git show HEAD:skills/apk-reverse/references/signature-derived-keys.md > tools/_work/truth-gates/sig-derived-HEAD.md
|
||||
$ python check_commands.py --fix-report --path tools/_work/truth-gates/sig-derived-HEAD.md
|
||||
tools\_work\truth-gates\sig-derived-HEAD.md:61 sig_probe.py unknown flag --pkg closest known: --apk cmd: python scripts/sig_probe.py --live --pkg <pkg>
|
||||
RESULT=commands_drift
|
||||
```
|
||||
|
||||
Line 61, the right script, and the correct suggestion (`sig_probe.py` takes `--live PKG` and has
|
||||
never had `--pkg`). A second, synthetic fixture proves the check is not a single-rule special case:
|
||||
|
||||
```
|
||||
$ python check_commands.py --fix-report --path tools\_work\truth-gates\NEGATIVE-unknown-flag.md
|
||||
...:4 sig_probe.py unknown flag --pkg closest known: --apk cmd: python skills/apk-reverse/scripts/sig_probe.py --live --pkg <pkg>
|
||||
...:5 dex_classdiff.py unknown flag --original closest known: --max-list cmd: ... --original a.dex --patched b.dex
|
||||
...:5 dex_classdiff.py unknown flag --patched closest known: --max-list cmd: ... --original a.dex --patched b.dex
|
||||
...:7 skills/apk-reverse/scripts/does_not_exist.py unknown flag missing-script closest known: - cmd: python .../does_not_exist.py --x 1
|
||||
RESULT=commands_drift
|
||||
```
|
||||
|
||||
Exit code 1, one machine-readable line per finding, with the document line, the command, the
|
||||
unknown flag and the closest known flags — which is what `--fix-report` is for.
|
||||
|
||||
### 2.4 Coverage on the real repository
|
||||
|
||||
```
|
||||
$ python check_commands.py
|
||||
docs : 76 markdown file(s)
|
||||
scripts : 39 argument table(s) read, 2 without a readable table
|
||||
commands : 139 checked (139 clean, 0 drift, 0 warning)
|
||||
skipped : 24 workbench, 1 external, 1 quoted, 0 unverifiable, 30 not a script invocation
|
||||
RESULT=commands_ok
|
||||
```
|
||||
|
||||
`139 checked` counts every invocation resolved to a readable interface, not merely the ones with
|
||||
findings; a pass that reports only its findings cannot tell "clean" from "nothing looked at".
|
||||
Everything skipped is printed with its reason, and the reasons are of four kinds:
|
||||
|
||||
- **workbench** (24): the script lives under `tools/`, which `.gitignore` excludes, so a reader
|
||||
cannot open it and a missing file there is not drift. A bare filename that only exists under
|
||||
`tools/` is caught too, because those evidence records refer to workbench scripts by name.
|
||||
- **not a script invocation** (30): `python -m …`, `python -c "…"`, an elided `python ... --flag`,
|
||||
or an output line that merely starts with `python`.
|
||||
- **external** (1): `python scripts\init_env_win.py` in the blutter build record is a path inside
|
||||
*another* repository's checkout — the record's preceding line is `git clone --depth 1
|
||||
worawit/blutter`. Each such entry carries its reason in an auditable table that the report
|
||||
prints, because a silent allow-list is indistinguishable from a missed drift.
|
||||
- **quoted** (1): `FINDINGS.md` quotes `python scripts/sig_probe.py --live --pkg <pkg>` *as the
|
||||
defect it records*. It is evidence, not an instruction; the page's own sentence says the script
|
||||
has no `--pkg`. Same auditable-table treatment.
|
||||
|
||||
The two scripts with no readable table are listed by name. A script with no flags has nothing that
|
||||
can drift, so its invocations pass without being checkable — stated so the edge of the check is
|
||||
visible instead of implied.
|
||||
|
||||
### 2.5 Defects the negative tests found in the checker itself
|
||||
|
||||
Each of these produced a *false clean* on the pre-fix page, and each was found by testing the
|
||||
checker against a known-bad document rather than by reading its code.
|
||||
|
||||
| # | Defect | How it surfaced | Fix |
|
||||
|---|---|---|---|
|
||||
| 1 | `<pkg>` was read as a shell input redirection, truncating the command at `--pkg`; the truncated command then checked clean | the git-HEAD reproduction reported no drift at all | `<...>` with no interior space is treated as a placeholder token; `>`/`>>` still truncate |
|
||||
| 2 | A flag that takes a value consumed the *next* token unconditionally, so `--live --pkg` made `--pkg` the value of `--live` | the same reproduction, after fix 1 | an option-looking next token is not consumed, matching argparse's "expected one argument" |
|
||||
| 3 | The whole documented line was read as one argv, so `&&`, the second `python`, and a redirect target entered the positional count | 3 warnings about `check_repo.py && python check_refs.py` and `… --json > out.json` | lines are split on `&&`/`\|\|`/`;`/`\|` and truncated at a redirection |
|
||||
| 4 | `docs/**` records reach workbench scripts as `../../tools/_work/…`, which resolves to a real file, so they were reported as unverifiable repository scripts | 17 spurious entries | a resolved path under `tools/` is classified workbench, and paths are `realpath`-normalised so `dexpatch/../smtool.py` is one entry, not two |
|
||||
| 5 | `commands_checked` counted only commands *with findings*, so a clean run reported `3 checked` instead of 139 | comparing the summary against the extraction dump | every resolved invocation is emitted as a `clean` record and counted |
|
||||
| 6 | `--help` fallback probes ran with `cwd` at the repository root | reasoning about probe side effects, then confirmed | the probe runs in a temporary directory |
|
||||
|
||||
Defect 1 is the instructive one: a checker whose parser silently shortens a command reports success
|
||||
for exactly the inputs it was written to reject, and nothing in its own output looks wrong. The
|
||||
defence is not more careful reading, it is a fixture that is known to be bad.
|
||||
|
||||
### 2.6 A second blind spot, found by a reviewer reading this gate's output
|
||||
|
||||
A reviewer reported seven "drifting" commands. Reproducing them split the report three ways, and the
|
||||
middle category is the one worth reading.
|
||||
|
||||
**Not reproducible.** `python tests/run_tests.py -q` runs: `run_tests.py` forwards what it is given
|
||||
to pytest (`args = list(argv[1:]) or ["-q"]`), and its usage block documents exactly that. `dcc.py`
|
||||
is an external checkout, not a script this repository ships — the form the record uses is:
|
||||
|
||||
```
|
||||
$ cd tools/_work/bench/repos/dcc
|
||||
$ python ./dcc.py <SAMPLE_DIR>/baseline.apk --no-build --dynamic-register
|
||||
```
|
||||
|
||||
**Real, and fixed.** Seven commands in two evidence records named a **workbench** script with no path
|
||||
at all (`$ python analyze_pair.py`, `b2_measure.py`, `ttd_attach.py`, `make_skeleton.py`). Those files
|
||||
exist under `tools/`, which `.gitignore` excludes, so a reader copying the command from the
|
||||
repository root gets `No such file`. Two of those documents wrote the correct path elsewhere, so they
|
||||
disagreed with themselves.
|
||||
|
||||
**The part that matters: this gate had been scoring those `ok`.** `resolve_script()` tried the
|
||||
*document's own directory* first, so a bare `analyze_pair.py` inside `docs/tool-verification/`
|
||||
resolved to `tools/_work/bench/unpack/b3/analyze_pair.py` — a real file — and passed. The fix is not
|
||||
a stricter regex but a corrected resolution order plus a corrected classification:
|
||||
|
||||
1. Resolution now follows the order a **reader** would try: repository root, then the skill's script
|
||||
directory, then the document's own directory (which is what `../x.py` in a nested README means).
|
||||
2. A bare name that only resolves under `tools/` is `unqualified-workbench`, and it counts as
|
||||
**drift, not a skip** — a one-token fix that a reader cannot run. A first attempt classified it as
|
||||
a low-confidence warning, which would have left all seven in place.
|
||||
3. The one honest exception is pinned too: a block that opens with `cd tools/_work/bench/repos/dcc`
|
||||
tells the reader where to stand, so a bare `python dcc.py` after it is correct. That is skipped as
|
||||
`workbench-after-cd`.
|
||||
|
||||
### 2.6a The same gate failed on CI and passed locally, and the reason is worth keeping
|
||||
|
||||
The first version of the fix above shipped and CI went red on `check_commands.py` — in both the 3.11
|
||||
and 3.13 jobs — while the identical command passed on the machine that wrote it. The difference is
|
||||
`tools/`: it is gitignored, so a clean checkout does not have it, and **recognition of a workbench
|
||||
artifact by filename required the tree to exist**. On the developer's box `dcc.py` matched a name
|
||||
under `tools/` and was skipped as `workbench-after-cd`; on CI nothing matched, it fell through to
|
||||
`missing-script`, and the gate reported drift for three quoted lines in this very file.
|
||||
|
||||
Two lessons, both now enforced rather than remembered:
|
||||
|
||||
- **A check whose verdict depends on a gitignored directory is not a check.** The classification is
|
||||
now computed from the **document** as well: `qualified_basenames()` collects every `.py` the text
|
||||
itself writes *with* a path, and a bare name that appears in that set is a shorthand the document
|
||||
already explains — so it skips identically on a clean checkout and on a populated one.
|
||||
- **A gate that cannot be run in the same shape as CI will disagree with CI.** Reproduce the CI
|
||||
condition locally before believing a green run: `mv tools /tmp/hidden && python check_commands.py`
|
||||
is the whole experiment, and it takes seconds. That is what found this, after the fix had already
|
||||
been pushed.
|
||||
|
||||
|
||||
**Two defects of this pass's own, found while fixing it.** A `--json` run printed the `RESULT=` token
|
||||
*after* the JSON document, so `--json | jq` failed with `Extra data`; the token is gone from that path
|
||||
and the status is a field in the document instead. And the fix's own substitution left four literal
|
||||
backslashes before `$` in the two files — a reader would have copied them.
|
||||
|
||||
`tests/integration/test_check_commands_gate.py` pins the resolution order, the drift classification,
|
||||
the `cd` exception, and a clean pass over the committed tree, so this blind spot cannot return
|
||||
silently.
|
||||
|
||||
### 2.7 What this check does not cover
|
||||
|
||||
- It validates **flags and (weakly) positional counts**, not semantics. A command with correct flags
|
||||
and a wrong path still passes; so does a documented `--duration 14` where the useful value is
|
||||
different.
|
||||
- A script whose interface is neither declarative nor printable is outside the check; the two
|
||||
current cases are listed by name rather than folded into the pass count.
|
||||
- It does not verify that a command *works* — only that the script would not reject it as unknown
|
||||
arguments. Running the 139 commands remains a benchmark-pass activity.
|
||||
|
||||
---
|
||||
|
||||
## 3. APK rebuild safety in `so_constpatch.py` (teammate `zip-safety`; re-checked here)
|
||||
|
||||
### 3.1 What the committed writer did
|
||||
|
||||
```python
|
||||
# so_constpatch.py:204-218, before this round
|
||||
with zipfile.ZipFile(out_path, 'w', zipfile.ZIP_DEFLATED) as dst:
|
||||
for item in src.infolist():
|
||||
blob = new_data if item.filename == entry else src.read(item.filename)
|
||||
dst.writestr(zipfile.ZipInfo(item.filename, date_time=item.date_time),
|
||||
blob, item.compress_type)
|
||||
```
|
||||
|
||||
A new `ZipInfo` built from a filename and a timestamp carries nothing else, so `extra` and
|
||||
`external_attr` are lost; `create_system`, `internal_attr` and the version fields are reset; and
|
||||
every entry is re-compressed as DEFLATED, so a STORED `resources.arsc` may stop being STORED and
|
||||
lose its 4-byte data alignment. There was no `extractNativeLibs` check, no alignment check, and no
|
||||
pointer at `scripts/repack.py`.
|
||||
|
||||
### 3.2 What replaced it
|
||||
|
||||
The writer now emits the local headers and the central directory itself, because alignment is a
|
||||
property of the local header length that `zipfile` cannot express. Per entry it reproduces the raw
|
||||
local name bytes, both `extra` fields, `external_attr`, `internal_attr`, `create_system`,
|
||||
`create_version`, `extract_version`, the comment, the DOS time/date words and the general-purpose
|
||||
flags (the data-descriptor bit is cleared, since the size is known up front). An untouched STORED
|
||||
entry's compressed stream is copied verbatim in 1 MiB chunks, so its CRC and `compress_size` are
|
||||
identical by construction rather than by luck. Only the named entry is ever re-compressed, and only
|
||||
if it was DEFLATED. Alignment padding goes into a private local extra record (`0xCA`) so the
|
||||
original extra field stays a byte-for-byte prefix.
|
||||
|
||||
### 3.3 The offset that matters — a correction to state plainly
|
||||
|
||||
The alignment contract is the **data offset** of the entry, not its local-header offset. This
|
||||
matters because the two differ by the header length plus the name, and real archives routinely have
|
||||
an unaligned header offset:
|
||||
|
||||
```
|
||||
r2pay (input, 435 entries): resources.arsc STORED data offset 0x3cec08 header_offset 0x3cebce
|
||||
data offset % 4 == 0 OK header_offset % 4 == 2
|
||||
```
|
||||
|
||||
`zipalign`, `apksigner` and `repack.py`'s own `check_alignment` all test the data offset. So the
|
||||
rule this tool enforces is: `resources.arsc` must be STORED and its **data offset must be a
|
||||
multiple of 4**; any other STORED entry that arrived aligned must still be aligned after the
|
||||
rebuild. The alignment predicate differs from `repack.py`'s in one substantive way —
|
||||
|
||||
```
|
||||
always : resources.arsc (Android R+ install gate)
|
||||
always : STORED lib/**/*.so (extractNativeLibs=false maps them in place)
|
||||
preserved: any other STORED entry already at a 4-byte data offset
|
||||
never : DEFLATED entries, and STORED entries that arrived unaligned
|
||||
```
|
||||
|
||||
— and it uses `^lib/.+\.so$` where `repack.py` uses `^lib/[^/]+\.so$`. The latter matches
|
||||
`lib/x86/libfoo.so` but **not** `lib/arm64-v8a/libfoo.so`, because the ABI directory is a second
|
||||
path segment: on a real APK that pattern matches nothing, so the alignment filler it guards would
|
||||
never fire. That is a live defect in `repack.py` (`_needs_alignment`, and the
|
||||
`check_alignment`/`zip_report` paths that inherit the predicate), reported rather than fixed
|
||||
because `repack.py` is in no one's write scope this round. It is worth fixing before the refusal
|
||||
message tells users to switch to that tool.
|
||||
|
||||
### 3.4 Evidence, fixture level
|
||||
|
||||
```
|
||||
arsc_aligned.apk (7 entries)
|
||||
OLD writer vs INPUT NEW writer vs INPUT
|
||||
extra differ 7/7 (nothing but offset)
|
||||
eattr differ 7/7
|
||||
csys differ 7/7
|
||||
ver_made differ 7/7
|
||||
untouched entries with changed CRC/size : 2 untouched entries with changed CRC/size : 0
|
||||
resources.arsc: STORED @0x3a3 UNALIGNED resources.arsc: STORED @0x3e4 ALIGNED
|
||||
zipalign -c -v 4 -> rc=1 zipalign -c -v 4 -> rc=0
|
||||
```
|
||||
|
||||
### 3.5 Evidence, a real 435-entry package, and an independent re-check
|
||||
|
||||
Same sample, same patch, old writer versus new writer versus the input:
|
||||
|
||||
```
|
||||
extra differ 205/435 (nothing but offset)
|
||||
eattr differ 435/435
|
||||
iattr, csys, ver_made differ 272/435
|
||||
flags differ 164/435
|
||||
untouched entries with changed CRC/size : 132 untouched entries with changed CRC/size : 0
|
||||
file size 4,293,620 -> 3,897,949 file size 4,293,620 -> 4,289,340
|
||||
resources.arsc: @0x36f931 UNALIGNED resources.arsc: @0x3cec08 ALIGNED
|
||||
```
|
||||
|
||||
The old writer also *shrinks* the archive by ~396 KB: re-compressing entries that were already
|
||||
compressed changes the container, not the payload.
|
||||
|
||||
This pass re-checked the decisive claim with the Android build-tools' own verifier — a tool in
|
||||
neither teammate's write scope — on the artifacts the other teammate produced:
|
||||
|
||||
```
|
||||
$ E:\tools\android-14\zipalign.exe -c -v 4 <file>
|
||||
plain.old.apk rc=1 Verification FAILED plain.new.apk rc=0 Verification succesful
|
||||
arsc.old.apk rc=1 Verification FAILED arsc.new.apk rc=0 Verification succesful
|
||||
r2pay.old.apk rc=1 r2pay.new.apk rc=0
|
||||
```
|
||||
|
||||
`zipalign -c 4` checks every uncompressed entry, which is stricter than Android's install gate. The
|
||||
bare-`.so` path (no container) was re-hashed as well and shows the intended zero regression:
|
||||
|
||||
```
|
||||
0790faeaec0ed4ea8bd3e859db23840dd9d1c2635330b48c4d2e3a3f68fdd371 bare.old.so
|
||||
0790faeaec0ed4ea8bd3e859db23840dd9d1c2635330b48c4d2e3a3f68fdd371 bare.new.so
|
||||
```
|
||||
|
||||
### 3.6 The refusal gate
|
||||
|
||||
Rebuilding is declined by default when the package cannot be rebuilt safely, with the measurement
|
||||
and a pointer at the tool that owns a full resource-safe repack:
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/so_constpatch.py tests/fixtures/apk/noextract.apk \
|
||||
--entry lib/arm64-v8a/libfoo.so --replace apkhuan=android -o out.apk
|
||||
extractNativeLibs=false
|
||||
UNSAFE: AndroidManifest.xml declares android:extractNativeLibs="false": ...
|
||||
REFUSED: this package cannot be rebuilt safely by this tool (see the UNSAFE line(s) above).
|
||||
* If you want a resource-safe repack, use scripts/repack.py: ...
|
||||
* ... re-run with --unsafe-rebuild to proceed anyway.
|
||||
RESULT=refused_unsafe_rebuild exit=1 output written: no
|
||||
```
|
||||
|
||||
Refusal is exit `1`, not `3`: `3` means the tool lacks the capability, whereas here the tool is
|
||||
capable and the *package* is the reason nothing was written, so it is a negative finding about the
|
||||
target. `--unsafe-rebuild` proceeds and still re-measures the STORED/alignment gate on its own
|
||||
output. Exit codes follow the round's set — `0` success, `1` negative finding, `2` usage (including
|
||||
an equal-length violation), `3` unsupported container (zip64, a non-deflate target, not a zip),
|
||||
`4` internal error with the output deleted — and `--json` carries
|
||||
`{status, exit_code, capability, evidence[], warnings[], next_action?}` plus `result`, `audit`,
|
||||
`verify` and `patch`, with the final line `RESULT=<token>`.
|
||||
|
||||
### 3.7 What section 3 does not establish
|
||||
|
||||
- **No device, no install.** No `adb` install or cold start was run. The `[-124]` install failure is
|
||||
argued from the bytes and from `zipalign`, not observed on a device. `inferred`.
|
||||
- **No signing run.** The rebuild output is unsigned by design; `repack.py` owns signing, so no
|
||||
`apksigner verify` result exists. `unverified`.
|
||||
- **No genuine zip64 sample.** zip64 is refused (`exit 3`); the refusal path was exercised with a
|
||||
hand-built non-zip file, not with a >4 GiB archive.
|
||||
- **Page alignment (4 KB/16 KB) is out of scope.** This work guarantees 4-byte alignment, the
|
||||
install gate. 4 KB/16 KB alignment for `extractNativeLibs=false` builds is `zipalign -p`'s job and
|
||||
stays with `repack.py`.
|
||||
- `--unsafe-rebuild` cannot repair an already-compressed `resources.arsc`: the method is preserved,
|
||||
the self-check reports it as a warning, and the output stays non-compliant.
|
||||
|
||||
---
|
||||
|
||||
## 4. What this pass did not do
|
||||
|
||||
- **No `pm install` of any artifact**, and therefore no cold-start or behavioural verification of a
|
||||
rebuilt or re-signed APK. Everything in §3 is container-level plus an external alignment verifier.
|
||||
- **No signing.** On this host `repack_signed` is genuinely blocked (a JDK launcher shim, no
|
||||
build-tools on `PATH`, no signer jar), so the signed-repack path was **not** executed; the
|
||||
capability verdict is a probe result, not an observation of a failed run.
|
||||
- **No smali round-trip**, for the same reason: the eight jars are absent. `smali_roundtrip` is
|
||||
reported BLOCKED and was not exercised.
|
||||
- **`dart_aot_full` was not exercised**: no snapshot dump exists in this session, and that
|
||||
capability reports an artifact the host cannot install rather than a missing package.
|
||||
- **`check_commands.py` was not run against a benchmark target**, only against this repository. Its
|
||||
false-clean rate on a foreign documentation set is unknown.
|
||||
- **The 30 skipped invocations were not individually confirmed** to be non-scripts; they are
|
||||
classified by the reason printed for each (`-m`, `-c`, an elision, or a prose line beginning with
|
||||
`python`), and a misclassification there would be a missed check rather than a false finding.
|
||||
- **One workbench nuisance observed and not cleaned up** (not in any write scope): the tree carries
|
||||
an untracked `--help/` directory containing `exempt/benign.txt` and `planted/{appkey,device,
|
||||
endpoint,package,path}.txt`, i.e. the planted leak-fixture layout. It is not produced by
|
||||
`scan_leaks.py --help` (reproduced in a scratch directory: usage only, nothing written) and not
|
||||
by this pass's tools, which now probe in a temporary directory. The generator is
|
||||
`tools/_work/leak-precedent/gen_fixtures.py`, whose output directory is taken from an argument, so
|
||||
a probe that passed `--help` as that argument would create exactly this. It is worth removing by
|
||||
whoever runs the final commit, because the directory holds deliberately leak-shaped files.
|
||||
|
||||
## 5. Files changed by this pass
|
||||
|
||||
New: `skills/apk-reverse/scripts/capabilities.py`, `check_commands.py`.
|
||||
Rewritten: `skills/apk-reverse/scripts/doctor.py` (its documented CLI is unchanged; `--scripts` and
|
||||
`--json` still work, and the JSON keeps its old keys).
|
||||
Merged here from teammate `zip-safety`'s work (their write scope, not this one):
|
||||
`skills/apk-reverse/scripts/so_constpatch.py`, `tests/test_so_constpatch.py`,
|
||||
`tests/fixtures/make_fixture_apk.py`, `tests/fixtures/apk/{plain,arsc_aligned,noextract,services}.apk`
|
||||
and `tests/fixtures/apk/.gitignore` (the repo-wide `*.apk` ignore rule needs a `!*.apk` reverse rule
|
||||
for fixtures to be stageable; `git check-ignore -v` confirms it).
|
||||
|
||||
Neither teammate committed or pushed. Nothing in §1 or §2 was measured on a device; §3's device
|
||||
work did not happen either.
|
||||
|
||||
## 6. Index lines handed to the Lead
|
||||
|
||||
- `skills/apk-reverse/SKILL.md` (and `README.md` where the tool table lists scripts):
|
||||
**`scripts/capabilities.py`** — new. "Capability registry: the single source of truth for what
|
||||
this host can actually do. Resolves a capability's real `requires` closure (tools, modules, jar
|
||||
sets, device properties, input artifacts) into `ok | partial | blocked`, with an executable next
|
||||
step and a cost estimate that names its own strength. `--list`, `--json`; used by `doctor.py`."
|
||||
- **`check_commands.py`** (repository root, sibling of `check_repo.py` / `check_refs.py`) — new.
|
||||
"Validates every documented script invocation (`python …`) against the script's own `argparse`
|
||||
table, read with `ast` so no script is executed to learn its interface. `--fix-report` prints
|
||||
`doc:line`, the command, the unknown flag and the closest known flags. Exit 0 clean / 1 drift /
|
||||
2 usage." It is a natural fourth entry in the publish-gate list wherever those are described.
|
||||
- **`skills/apk-reverse/scripts/doctor.py`** — the existing row's wording can gain: "the script
|
||||
inventory is scanned from the directory (`registered/checked = N / N`, undecidable entries counted
|
||||
as `unknown`), and capabilities are probed rather than inferred from `java`".
|
||||
- **`skills/apk-reverse/scripts/so_constpatch.py`** — the existing row can gain the clause from
|
||||
`zip-safety`'s §7: an APK rebuild preserves each entry's zip metadata (STORED stays STORED,
|
||||
`extra`/`external_attr`/`create_system` intact, `resources.arsc` kept STORED and 4-byte aligned)
|
||||
and is **refused by default** when `extractNativeLibs="false"` or `resources.arsc` is not
|
||||
STORED + aligned — `scripts/repack.py` is the route for that package, or `--unsafe-rebuild` to
|
||||
override deliberately.
|
||||
- **`repack.py:_needs_alignment` is a live defect** (`^lib/[^/]+\.so$` cannot match
|
||||
`lib/<abi>/x.so`), reported by `zip-safety` and confirmed here by reading the predicate. Someone
|
||||
needs a write scope for `repack.py`, or it should be recorded as a known gap.
|
||||
@@ -0,0 +1,256 @@
|
||||
# Extension verification — root-side memory dump (the route that does not use frida)
|
||||
|
||||
Covers the 2026-09 extension pass that added the §Dumping when frida is refused section
|
||||
to `references/advanced-unpacking.md`. It is the follow-up to
|
||||
`docs/tool-verification/EXTENSION-device-run.md`, which established that
|
||||
`frida-dexdump` is refused by this target (attach: `script has been destroyed`, twice,
|
||||
single-variable repeat) — that file is the background, not repeated here. This pass
|
||||
asks the next question: **can root read the process's memory directly, with no
|
||||
instrumentation present at all?**
|
||||
|
||||
Strength labels, as defined in this directory's README:
|
||||
|
||||
- **observed** — reproduced here, with the exact command and its output.
|
||||
- **inferred** — follows from observed facts, but the step itself was not executed.
|
||||
- **unverified** — assumed, or reported by a tool and not independently confirmed.
|
||||
|
||||
**Target identity is deliberately absent**; the package appears as `<PKG>`. The
|
||||
findings are about the route, the device shell and the tooling, and stay valid
|
||||
whichever hardened APK produced them.
|
||||
|
||||
## Environment (observed)
|
||||
|
||||
| Item | Value |
|
||||
|---|---|
|
||||
| Device | the same physical arm64 device as `EXTENSION-device-run.md`: Android 11 (API 30), kernel 4.14.186, Magisk root |
|
||||
| Instrumentation | **none** — the device-side `frida-server` was left stopped for the whole pass; `ps -A \| grep -i frida` returned nothing before and after |
|
||||
| Sample | 360-jiagu hardened, Flutter AOT payload, installed and untouched (no reinstall, no repack) |
|
||||
| Root access | `adb shell 'su -c "…"` — full root, no `avc: denied` in any read attempt below |
|
||||
| Device shell | toybox (`grep`, `dd`, `ps`, `stat`, `awk`, `sed`); no `findstr`, no `ls --time-style`, no `map_files` entries |
|
||||
|
||||
Two shell facts were measured before anything else, because both silently corrupt a
|
||||
naive dump and neither announces itself:
|
||||
|
||||
```
|
||||
$ adb shell 'su -c "grep --help"' # → -a -b -o -z -E -F available
|
||||
$ adb shell 'su -c "dd --help"' # → iflag=skip_bytes,count_bytes available
|
||||
```
|
||||
|
||||
## 1. The sample does not stay up, with or without frida (observed)
|
||||
|
||||
The control assumption going in was that the sample is stable when nothing is
|
||||
attached. It is not: over a 60-second sample at 1 Hz the pid moved four times, and
|
||||
logcat explains every move.
|
||||
|
||||
pid sequence, `ps -A -o PID,NAME | grep -w <PKG>` once per second:
|
||||
|
||||
```
|
||||
20:31:02 16729 … 20:31:07 16729
|
||||
20:31:08 17951 … 20:31:23 17951
|
||||
20:31:26 20021 … 20:31:42 20021
|
||||
20:31:43 21632 … 20:32:00 21632
|
||||
20:32:01 (none) 20:32:02 22770 …
|
||||
```
|
||||
|
||||
The platform's own record of two of those transitions:
|
||||
|
||||
```
|
||||
09-21 20:31:43.327 I/ActivityManager: Process <PKG> (pid 20021) has died: fg TOP
|
||||
09-21 20:31:43.394 I/ActivityManager: Start proc 21632:<PKG>/u0a623 for top-activity {<PKG>/.MainActivity}
|
||||
09-21 20:32:01.463 I/ActivityManager: Process <PKG> (pid 21632) has died: fg TOP
|
||||
09-21 20:32:01.555 I/ActivityManager: Start proc 22770:<PKG>/u0a623 for top-activity {<PKG>/.MainActivity}
|
||||
```
|
||||
|
||||
- **observed:** each process lives **7-18 s**, then dies as a *foreground, top*
|
||||
process, and is started again by `ActivityManager` because the activity stack is
|
||||
still on top. Lifetime is not constant.
|
||||
- **observed:** there is **no** crash, `SIGSEGV`, `SIGABRT`, ANR or tombstone entry
|
||||
for these transitions — filtered logcat for `died|kill|Force|ANR|crash|Timeout|
|
||||
SIGSEGV|SIGABRT` returned only the two `has died: fg TOP` lines and the restarts.
|
||||
A clean self-exit, not a platform kill.
|
||||
- **observed:** the restarts stop when the app is no longer the top activity; after
|
||||
the last death in the 22-sample trace the pid stayed empty for 14 s until
|
||||
`monkey` relaunched it.
|
||||
- **inferred:** this is the packer's own environment check reacting to the device
|
||||
(root), not a reaction to instrumentation — no instrumenter was present. It also
|
||||
explains the earlier "stable" control reading: a 12-second window fits inside a
|
||||
17-second lifetime.
|
||||
- **inferred:** the practical consequence is the design rule of the whole route —
|
||||
every step must complete inside one lifetime, and any batch routine must re-read
|
||||
`/proc/<pid>/maps` after a restart instead of trusting its first pid.
|
||||
|
||||
## 2. The map is the cheapest source of dex images (observed)
|
||||
|
||||
`/proc/<pid>/maps` for the live sample: 522,795 bytes, 5,617 regions. ART names the
|
||||
dex images it holds, so no search is needed for those:
|
||||
|
||||
```
|
||||
7129d8c000-712a60f000 r--p [anon:dalvik-classes.dex extracted in memory from /data/app/~~…/base.apk]
|
||||
6f9ba03000-6f9be2b000 r--p [anon:dalvik-classes.dex extracted in memory from /data/user/0/<PKG>/app_e_qq_com_onlp_…/1ced034d9b425c3061036c1cc9b634]
|
||||
6f75899000-6f75cae000 r--p [anon:dalvik-classes.dex extracted in memory from /data/user/0/<PKG>/app_sodler/com.kwad.components.tachikoma/5.6.10.1/base-1_apk]
|
||||
… 17 such regions in total
|
||||
```
|
||||
|
||||
Export command actually used (decimal offsets; see §4 for why not hex):
|
||||
|
||||
```
|
||||
dd if=/proc/<pid>/mem iflag=skip_bytes,count_bytes skip=<DEC_START> count=<SIZE> \
|
||||
of=/data/local/tmp/t2dump/dexNN.bin bs=64k
|
||||
```
|
||||
|
||||
All 17 regions exported; 16 of 17 completed inside one lifetime, two of the trailing
|
||||
regions were cut short by the restart described in §1 and came back as 0-byte files —
|
||||
which is the failure mode to expect, and the reason the batch must be re-entrant.
|
||||
|
||||
## 3. Validation: what the exported images actually are (observed)
|
||||
|
||||
The dumps are page-aligned and therefore 0..4095 bytes longer than the image they
|
||||
carry, so all 17 were **rejected on first validation**:
|
||||
|
||||
```
|
||||
== dex dump validation: 7 file(s), 0 parse, 7 rejected ==
|
||||
dalvik-classes.dex_…base.apk.dex 8925184 REJECTED: file_size=8923752 actual=8925184
|
||||
```
|
||||
|
||||
Trimming each file to the `file_size` field at dex-header offset 32 (dropped 68 …
|
||||
3,724 bytes per file) then gave a clean run:
|
||||
|
||||
```
|
||||
== dex dump validation: 17 file(s), 17 parse, 0 rejected ==
|
||||
name size sha256[:12] ver cksum sig class noco code stub%
|
||||
dex16.bin 122460 3fc5581d6eb3 035 ok BAD 103 207 8974 1.1% (ksad dynamic apk)
|
||||
dex08.bin 1069516 613cfef811ab 035 ok BAD 1285 432 8929 0.9% (kwad ksdp)
|
||||
dex14.bin 8923752 1497590d0a24 035 ok BAD 4 160 29 3.4% (from /data/app/…/base.apk)
|
||||
dex01.bin 3212480 fb61a3f085f5 035 ok ok 3169 785 15924 1.0% (pangle live-lite classes6)
|
||||
… 17 images, stub% 0.9 – 3.4, 17/17 unique by sha256
|
||||
|
||||
ranking (most likely original first):
|
||||
1. dex08.dex stub%=0.9 cksum=ok sig=BAD classes=1285
|
||||
…
|
||||
17. dex14.dex stub%=3.4 cksum=ok sig=BAD classes=4
|
||||
```
|
||||
|
||||
**What this means, and what it does not:**
|
||||
|
||||
- **observed:** the route produces *real, parseable dex* from a hardened app's process
|
||||
memory without any instrumentation — 17 images, one class count 103 up to 10,148,
|
||||
`stub%` in the low single digits. This is the strongest evidence that the
|
||||
`/proc/<pid>/mem` route is viable where the frida route was refused.
|
||||
- **observed:** the image that came from the app's own `base.apk` defines **4
|
||||
classes** on an 8.9 MB file (`sig=BAD` on the untouched image). File size and class
|
||||
count move independently under a packer — this is the landing-shell skeleton shape,
|
||||
matching the shipped `classes.dex` recorded in `EXTENSION-unpacking.md`.
|
||||
- **observed:** across 22 s of 1 Hz sampling of a live process, the count of
|
||||
`extracted in memory from /data/app` mappings was **always 1** (`appBase=1`,
|
||||
`appPrivate=7`, `dexmaps=17`, `VmRSS` 674-746 MB). No second application dex mapping
|
||||
ever appeared.
|
||||
- **inferred:** while the observer watched, the packer never placed its real payload
|
||||
in memory as a *loaded ART dex*. Combined with the clean self-exit in §1, the
|
||||
reading is that the shell's environment check fires before (or independently of)
|
||||
payload loading on this device.
|
||||
- **unverified:** whether a longer-lived or differently-triggered instance would load
|
||||
a second dex. No attempt was made to defeat the check, by task instruction.
|
||||
|
||||
## 4. Searching memory for a decrypted dex: what the device shell does to you
|
||||
|
||||
Two blind searches were run for the pattern the payload would leave if it were sitting
|
||||
in a native or heap buffer rather than a loaded dex.
|
||||
|
||||
**Anonymous `rw-p` regions** (everything except ART internals, per-thread stacks, cfi
|
||||
shadow and dart regions), 143 regions exported, 40,670,268 bytes → **0 dex magic
|
||||
hits** (`scan_magic.py`, `dex\n03x` with `file_size` cross-check).
|
||||
|
||||
**ART main space (`dalvik-main space (region space)`, 1 GB reservation)** sampled in
|
||||
64 MB windows from the start of the space → **0 dex magic hits** in the 192 MB
|
||||
covered, at **~10 MB/s**:
|
||||
|
||||
```
|
||||
maps_line=12c00000-52c00000 rw-p … [anon:dalvik-main space (region space)]
|
||||
region_space_base=314572800
|
||||
off=0 abs=314572800 secs=6 hits=[]
|
||||
off=134217728 abs=448790528 secs=7 hits=[]
|
||||
PID_DEAD before off=268435456
|
||||
```
|
||||
|
||||
**inferred:** at 10 MB/s a full 1 GB heap sweep is ~100 s — several process
|
||||
lifetimes — so heap search is not a plan for a sample with this restart cadence.
|
||||
Named ART dex mappings are free; a heap sweep is not.
|
||||
|
||||
Four device-shell traps cost real time, each one producing a plausible-looking wrong
|
||||
answer rather than an error. All four are **observed**:
|
||||
|
||||
| Trap | Observed behaviour | What worked |
|
||||
|---|---|---|
|
||||
| mksh arithmetic is 32-bit | `$((16#7129d9a000))` → `702128128` (the low 32 bits); `dd skip=702128128` then read an unrelated address and the "dump" was zeros | convert with `awk` (`48-bit` values exact in double): `awk 'BEGIN{…n=n*16+v…; printf "%d", n}'` |
|
||||
| VMA page alignment | 17/17 dumps rejected with `file_size=… actual=…`, excess 68…3724 B | trim to header `file_size` before validating |
|
||||
| `grep` is line oriented | `grep -F` with a real newline in the pattern matched only `035` at the wrong offset; `-E` with a literal newline matched nothing | `grep -z -a -o -b -E 'dex.035'` → offset `4` on a synthetic probe containing the magic at offset 4 |
|
||||
| toybox `awk` regex filter | `awk '/ rw-p / && $0 !~ /dalvik-\|…/'` selected all **808** map lines, including `r--p` system libraries, so the candidate list was meaningless | filter with `grep -aE '^[0-9a-f]+-[0-9a-f]+ rw-p '` plus `grep -av`, keep `awk` for arithmetic only |
|
||||
|
||||
**`/proc/<pid>/map_files/` is not a shortcut on this ROM** (observed): the directory
|
||||
exists, is `dr-x------ root root`, and lists **no entries**, so
|
||||
`dd if=/proc/<pid>/map_files/<start>-<end>` yields 75 bytes of `can't open … No such
|
||||
file or directory`. The `mem` + decimal-offset form is the one that works.
|
||||
|
||||
**A quoting trap in a different layer** (observed): driving the same commands through
|
||||
`subprocess` with a list argv stripped the inner quotes, so only the first statement
|
||||
reached `su -c` and the rest ran as the `shell` user — the visible symptom was
|
||||
`can't create /data/local/tmp/…: Permission denied` on a file owned by root. Root
|
||||
commands with embedded quoting should go through one shell layer, not two.
|
||||
|
||||
## 5. Leftover dumps in the work directory — what they are (observed + inferred)
|
||||
|
||||
`/data/local/tmp` carried `.so` files from earlier sessions. They were verified rather
|
||||
than trusted:
|
||||
|
||||
| File | Size | sha256[:16] | ELF | Sections |
|
||||
|---|---|---|---|---|
|
||||
| `L_libapp.so` = `d_libapp.so` = `libapp.bak` | 13,501,344 | `2275dd4398f0b34e` | ELF64 AARCH64, `ET_DYN`, phnum 7 | 11 |
|
||||
| `rt_libapp.so` | 13,501,344 | `8b490512258ba1c8` | ELF64 AARCH64, `ET_DYN`, phnum 7 | 11 |
|
||||
| `libapp.so` | 12,256,160 | `4cfec2762c25539b` | ELF64 AARCH64, `ET_DYN`, phnum 7 | 11 |
|
||||
| `ll.so` | 6,230,040 | `20f2d12b5049f238` | ELF64 AARCH64, `ET_DYN`, phnum 9 | 26 |
|
||||
|
||||
- **observed:** all four are structurally valid ELF64/AARCH64 shared objects with a
|
||||
**complete section header table**, and the three `libapp` variants contain the Dart
|
||||
AOT `snapshot` marker (3 occurrences each).
|
||||
- **observed:** the `libapp.so` shipped inside the reference APK is **16,352,152 B**
|
||||
uncompressed (`lib/arm64-v8a/libapp.so`), i.e. ~2.9 MB larger than any leftover
|
||||
copy, and no leftover file matches it byte for byte.
|
||||
- **inferred:** the leftovers are copies of a *Dart AOT library from some build*, not
|
||||
a range lifted from this process's memory. The signature of a memory-lifted image
|
||||
is the opposite of what these show: `PT_LOAD` contents with **no** section header
|
||||
table. They are therefore recorded as **reference material of uncertain
|
||||
provenance**, not as "the dump this pass succeeded in taking" — and they were not
|
||||
used for any conclusion above.
|
||||
|
||||
## 6. Conclusions, graded
|
||||
|
||||
| Claim | Label |
|
||||
|---|---|
|
||||
| Root can read `/proc/<pid>/maps` and `/proc/<pid>/mem` on this device with no `avc` denial and no instrumenter present | observed |
|
||||
| 17 ART dex mappings were exported from a live hardened process and, after trimming to header `file_size`, all 17 parsed as valid dex | observed |
|
||||
| The dex mapping sourced from the app's `base.apk` defines 4 classes on 8.9 MB (landing-shell skeleton); the other 16 are real SDK dexes, `stub%` 0.9-3.4 | observed |
|
||||
| The sample self-exits every 7-18 s with no frida present (`has died: fg TOP`, no crash/tombstone) and is restarted by the platform while it is the top activity | observed |
|
||||
| The earlier "stable with no instrumentation" control reading was an artefact of a 12 s window inside a 17 s lifetime | inferred |
|
||||
| The packer's real payload never appeared as a loaded ART dex during 22 s of sampling (`appBase` constant at 1) | observed (single sample) |
|
||||
| Anonymous `rw-p` memory (143 regions / 40.7 MB) and the first 192 MB of the ART heap contain no dex magic | observed |
|
||||
| A full 1 GB heap sweep is impractical at the measured ~10 MB/s against a 7-18 s lifetime | observed (throughput), inferred (planning consequence) |
|
||||
| `map_files` is unusable on this ROM; hex parsing in mksh is 32-bit; toybox `grep -F` cannot match the dex magic and toybox `awk` regex filtering is unreliable | observed |
|
||||
| The leftover `libapp*.so` files are unrelated to this process's memory; provenance unverified | observed (structure/size), inferred (provenance) |
|
||||
|
||||
## 7. What this pass did not establish
|
||||
|
||||
- **Not** a recovered payload. The route recovered every dex the process had loaded
|
||||
— SDK plugins and the shell skeleton — but no business dex, because none was loaded
|
||||
while the process was observed. The Flutter payload itself (`libapp.so`, Dart AOT)
|
||||
is a separate target and is not reached by a dex-oriented dump.
|
||||
- **Not** an evasion. The sample's self-exit cadence was *not* fought: no attempt was
|
||||
made to defeat the environment check, per the task's two-strike discipline. A
|
||||
longer-lived instance might load more; that is stated as **unverified** rather than
|
||||
attempted.
|
||||
- **Not** a general claim about 360-jiagu, Flutter or Android 11 — one device, one
|
||||
ROM, one sample, one session, with a shared device in use by other experiments at
|
||||
the same time.
|
||||
- **Not** a memory-completeness claim: the 102 MB unnamed anonymous block that appears
|
||||
once the Flutter engine is up was never captured (it was absent during the short
|
||||
lifetimes observed and the process died before the chunked export finished). It is
|
||||
recorded here as an uncovered region, not as a scanned-clean one.
|
||||
@@ -0,0 +1,337 @@
|
||||
# Extension verification — split APK / App Bundle sets
|
||||
|
||||
Covers the pass that added split-set support to `scripts/repack.py` and wrote
|
||||
`references/split-apk.md`. Strength labels as defined in this directory's README:
|
||||
**observed** (exact command + output reproduced here), **inferred** (follows from observed
|
||||
facts, step not executed), **unverified** (assumed or externally reported, not independently
|
||||
confirmed).
|
||||
|
||||
Reference environment for this pass: Windows 11 host, Python 3.14.0, Java 17.0.4.1
|
||||
(`keytool` NOT on PATH — it was passed explicitly as
|
||||
`<jdk>/bin/keytool.exe`), `apksigner` 0.9 run from `lib/apksigner.jar`, `zipalign.exe` from the
|
||||
same build-tools layout. **No `uber-apk-signer` anywhere on the machine**, which is the condition
|
||||
that made the signing-route fallback a requirement rather than a nicety. Target: one rooted physical
|
||||
Android 11 (API 30) arm64-v8a device, written `<DEVICE>` throughout.
|
||||
|
||||
All package names, application directory names and file names are replaced with placeholders
|
||||
(`<PKG>`, `<SET>`, `<SET_A>`, `<SET_B>`). The measurements, byte counts, entry counts and outputs
|
||||
are unchanged; only the identifiers are.
|
||||
|
||||
Fixtures: two **real split sets already installed on the device**, copied out read-only
|
||||
(`adb pull` of the system directory; nothing on the device was uninstalled, replaced or modified).
|
||||
They were chosen because one exercises each side of the merge/resign decision:
|
||||
|
||||
| | Members | Shape |
|
||||
|---|---|---|
|
||||
| Set A | 8 | base + 7 density splits, every density split carrying its own resources |
|
||||
| Set B | 9 | base + 1 ABI split (native libraries only) + 7 density splits that carry a placeholder table and nothing else |
|
||||
|
||||
Neither set is third-party: `pm list packages -3` reported **zero** split sets among ~40 installed
|
||||
third-party packages, while four preinstalled Google components were split. That distribution is
|
||||
recorded because "pick a third-party split app off the device" is not reliably available.
|
||||
|
||||
## 1. Reading a set: what the members actually contain — observed
|
||||
|
||||
`pm path <PKG>` on each package returned one line per member (8 and 9 lines respectively), which is
|
||||
how the targets were identified in the first place:
|
||||
|
||||
```
|
||||
$ adb shell 'pm path <PKG>'
|
||||
package:/system/priv-app/<DIR>/<SET>.apk
|
||||
package:/system/priv-app/<DIR>/<SET>-arm64_v8a.apk
|
||||
package:/system/priv-app/<DIR>/<SET>-hdpi.apk
|
||||
... (one line per member)
|
||||
```
|
||||
|
||||
`scripts/repack.py --split-mode analyze` over set A (identifiers replaced):
|
||||
|
||||
```
|
||||
== split set: 8 apk(s), 6629510 bytes total
|
||||
density <SET_A>-hdpi.apk 49623 B split=config.hdpi res=39 entries arsc=9392
|
||||
density <SET_A>-ldpi.apk 45501 B split=config.ldpi res=39 entries arsc=9392
|
||||
density <SET_A>-mdpi.apk 45501 B split=config.mdpi res=39 entries arsc=9392
|
||||
density <SET_A>-tvdpi.apk 74227 B split=config.tvdpi res=78 entries arsc=15124
|
||||
density <SET_A>-xhdpi.apk 53754 B split=config.xhdpi res=39 entries arsc=9432
|
||||
density <SET_A>-xxhdpi.apk 57878 B split=config.xxhdpi res=39 entries arsc=10324
|
||||
density <SET_A>-xxxhdpi.apk 57876 B split=config.xxxhdpi res=39 entries arsc=11200
|
||||
base <SET_A>.apk 6245150 B split=- dex=classes.dex res=581 entries arsc=1924488 assets=1
|
||||
[plan] base = <SET_A>.apk (package=<PKG>)
|
||||
[plan] MERGE BLOCKED: merging is not legal for 7 of 7 splits: config.hdpi, config.ldpi, config.mdpi,
|
||||
config.tvdpi, config.xhdpi, config.xxhdpi, config.xxxhdpi carry their own resources, and folding those in
|
||||
means merging resources.arsc (a resource-compiler job). Use --split-mode resign, or pass
|
||||
--drop-split-resources to accept losing exactly those resources
|
||||
[plan] resign = legal: one keystore for all 8 members, install the set with
|
||||
[result] analyze only: nothing was written. Pass --split-mode merge|resign|auto to act on this set.
|
||||
```
|
||||
|
||||
And over set B:
|
||||
|
||||
```
|
||||
== split set: 9 apk(s), 1411272 bytes total
|
||||
abi <SET_B>-arm64_v8a.apk 255861 B split=config.arm64_v8a libs=arm64-v8a
|
||||
density <SET_B>-hdpi.apk 16797 B split=config.hdpi arsc=40
|
||||
... (6 more density members, all 16797 B, all arsc=40)
|
||||
base <SET_B>.apk 1037832 B split=- dex=classes.dex res=2 entries arsc=71316 assets=1
|
||||
[plan] base = <SET_B>.apk (package=<PKG>)
|
||||
[plan] note: every non-base split carries only code/native libraries, so a merged single APK is legal
|
||||
[plan] note: config.arm64_v8a provides libs for arm64-v8a -- keep only the ABIs the target device runs,
|
||||
selected with --abi
|
||||
[plan] merge = legal: 8 member(s) fold into the base
|
||||
```
|
||||
|
||||
Four facts this pass established with those numbers, and that the reference file now depends on:
|
||||
|
||||
- **The ABI member carries no `resources.arsc` at all** (set B: `libs=arm64-v8a`, no arsc column).
|
||||
Its two `.so` files are the whole payload. This is the member that folds cleanly.
|
||||
- **Each density member carries its own table and its own `res/` entries** (set A: 39-78 entries,
|
||||
9.0-15.1 KB tables). `resources.arsc` is per member, not per set — the base's 1.88 MB table does
|
||||
not contain the density variants.
|
||||
- **A `resources.arsc` can be a 40-byte placeholder.** All seven density members of set B carry
|
||||
`arsc=40` and zero `res/` entries, while still being legally required to carry a table. A
|
||||
"has an arsc → owns resources" test would have blocked a merge that is entirely legal here, which
|
||||
is why the script's test is *`res/` entries or a table over 1 KB*.
|
||||
- **Base and split share one package name**, read from the binary AXML `package` attribute of every
|
||||
member, and the base is identifiable as the member whose manifest carries **no `split` attribute**
|
||||
(set A: `split=-`).
|
||||
|
||||
## 2. Unified re-signing — observed
|
||||
|
||||
`--split-mode resign` over set A with one keystore, run through the new `apksigner` route:
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/repack.py --split-dir <SET_A>/ --split-mode resign \
|
||||
--split-out-dir signed_set/ --ks key.jks \
|
||||
--keytool <jdk>/bin/keytool.exe --apksigner <build-tools>/lib/apksigner.jar \
|
||||
--zipalign <build-tools>/zipalign.exe
|
||||
[resign] signer route: apksigner
|
||||
[resign] <SET_A>-hdpi.apk -> signed_set/<SET_A>-hdpi.apk (41298 bytes)
|
||||
[resign] <SET_A>-ldpi.apk -> signed_set/<SET_A>-ldpi.apk (37202 bytes)
|
||||
[resign] <SET_A>-mdpi.apk -> signed_set/<SET_A>-mdpi.apk (37202 bytes)
|
||||
[resign] <SET_A>-tvdpi.apk -> signed_set/<SET_A>-tvdpi.apk (61642 bytes)
|
||||
[resign] <SET_A>-xhdpi.apk -> signed_set/<SET_A>-xhdpi.apk (41337 bytes)
|
||||
[resign] <SET_A>-xxhdpi.apk -> signed_set/<SET_A>-xxhdpi.apk (49563 bytes)
|
||||
[resign] <SET_A>-xxxhdpi.apk -> signed_set/<SET_A>-xxxhdpi.apk (49577 bytes)
|
||||
[resign] <SET_A>.apk -> signed_set/<SET_A>.apk (4172089 bytes)
|
||||
[result] OK: 8 members, one certificate (sha256 1eb31d9c…2c44)
|
||||
```
|
||||
|
||||
Every member's independent `apksigner verify --print-certs --verbose --min-sdk-version 21
|
||||
--max-sdk-version 34` returned `Verifies` with:
|
||||
|
||||
```
|
||||
Verified using v1 scheme (JAR signing): true
|
||||
Verified using v2 scheme (APK Signature Scheme v2): true
|
||||
Verified using v3 scheme (APK Signature Scheme v3): true
|
||||
Signer #1 certificate DN: CN=apkreverse, OU=dev, O=dev, L=NA, ST=NA, C=NA
|
||||
Signer #1 certificate SHA-256 digest: 1eb31d9c9c81104367a66a0201125aa91d28a6a09eb1dcb5029c36bc0bde2c44
|
||||
```
|
||||
|
||||
All eight printed the **same** SHA-256 — that, not per-file `Verifies`, is the acceptance
|
||||
criterion for a set, and it is the check the run performs explicitly.
|
||||
|
||||
**The signing-route fallback is the load-bearing part of this section.** With no
|
||||
`uber-apk-signer` on the machine, the pre-change script could not sign anything at all: it exited
|
||||
with `signer jar not found`, naming a jar the user never had. The new `--signer auto` resolves to
|
||||
`zipalign -p -f 4` + `apksigner sign --v1 --v2 --v3`, which produced the output above. `--apksigner`
|
||||
accepts `apksigner.bat` (verified: `rc=0`, `--version` → `0.9`), a `lib/apksigner.jar` value run
|
||||
through `java -jar` (verified: same), and the extensionless shell wrapper (which is a shell script
|
||||
and does not run on Windows — that is a fact about the wrapper, not about this script).
|
||||
|
||||
### The signing route this pass actually used — and why it had to change
|
||||
|
||||
`uber-apk-signer` is `repack.py`'s default signer, and it is **not installed on this machine**
|
||||
(no `uber-apk-signer.jar`, `APK_SIGNER_JAR` unset). Before this change that made every signed build
|
||||
impossible here: the script exited with `signer jar not found` and named a jar the user never had.
|
||||
What actually ran in all four measured routes:
|
||||
|
||||
| Item | Value used |
|
||||
|---|---|
|
||||
| signer | `zipalign.exe` + `apksigner`, the latter run from `lib/apksigner.jar` through `java -jar` |
|
||||
| `--apksigner` | `<build-tools>/lib/apksigner.jar`. A `.bat` value was also tested and works (`rc=0`, `--version` → `0.9`); the extensionless wrapper in the same layout is a shell script and does not run on Windows |
|
||||
| `--zipalign` | `<build-tools>/zipalign.exe` |
|
||||
| `--keytool` | `<jdk>/bin/keytool.exe` — **not on PATH**, and signing aborts without a way to create the keystore, so this flag is mandatory on such a machine |
|
||||
| scheme flags | `--v1-signing-enabled true --v2-signing-enabled true --v3-signing-enabled true` |
|
||||
|
||||
Alignment is handled **per member, and in this order**:
|
||||
|
||||
1. the script writes the archive itself, keeping `AndroidManifest.xml` and `resources.arsc` STORED
|
||||
and 4-byte-aligning `resources.arsc` plus uncompressed `lib/**`, then re-reads the file and
|
||||
prints the gate (`== alignment gate: resources.arsc STORED and 4-byte aligned (OK)`);
|
||||
2. `zipalign -p -f 4` runs over that output (`-p` adds the page alignment the loader wants for
|
||||
`extractNativeLibs="false"` libraries);
|
||||
3. `apksigner sign` runs **last** — it adds signature entries without reshuffling the archive,
|
||||
whereas a `jarsigner`-style rewrite after aligning destroys the alignment and the install is then
|
||||
refused with `[-124]`.
|
||||
|
||||
For a split set this is repeated for every member, which is why `resign_split_set()` de-signs and
|
||||
re-aligns each member individually before signing it: a single member whose `resources.arsc` is
|
||||
compressed or unaligned is refused by the installer on its own, and the resulting error names that
|
||||
member's package rather than the set.
|
||||
|
||||
## 3. Merging — observed, including the refusal
|
||||
|
||||
Set B, where every non-base member is code or native libraries:
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/repack.py --split-dir <SET_B>/ --split-mode merge \
|
||||
--out merged.apk --abi arm64-v8a --ks key.jks --apksigner <...>/lib/apksigner.jar
|
||||
[merge] + lib/arm64-v8a/lib<name>_compat_libc++.so (18944 bytes <- <SET_B>-arm64_v8a.apk)
|
||||
[merge] + lib/arm64-v8a/lib<name>utilsjni.so (206680 bytes <- <SET_B>-arm64_v8a.apk)
|
||||
[merge] folded: dex=0 libs=2 assets=0 conflicts=0
|
||||
[merge] DROPPED 7 entry/ies. This is a DOWNGRADED build -- say so when you deliver it:
|
||||
[zip] classes.dex 872160 bytes method=8 crc=<...>
|
||||
[zip] resources.arsc STORED offset=0x581fc aligned
|
||||
== alignment gate: resources.arsc STORED and 4-byte aligned (OK)
|
||||
[sign:zipalign] rc=0
|
||||
[sign:apksigner] rc=0
|
||||
[out] merged.apk (603824 bytes)
|
||||
[verify:apksigner] rc=0 Verifies / v1 true / v2 true / v3 true
|
||||
[result] OK
|
||||
```
|
||||
|
||||
`dex=0` is correct rather than suspicious: the base owns `classes.dex` and no member of this set
|
||||
carries a second one, so no renumbering was needed. The `libs=2` fold is the whole point — a
|
||||
one-file APK that now carries the native libraries the ABI member used to provide separately.
|
||||
|
||||
Set A, where merging is **not** legal, refuses rather than producing a broken artifact:
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/repack.py --split-dir <SET_A>/ --split-mode merge \
|
||||
--out merged.apk --no-sign
|
||||
error: refusing to merge <SET_A>-hdpi.apk -- it carries its own resources (39 res entries,
|
||||
arsc=9392 bytes).
|
||||
Folding it in would require merging resources.arsc, which this script does not do.
|
||||
Either use --split-mode resign (recommended for a resource split), or pass
|
||||
--drop-split-resources to accept that the resources it owns are lost.
|
||||
exit code 1
|
||||
```
|
||||
|
||||
This is the enforcement the reference file promises: the refusal happens **before** any output is
|
||||
written (the `--out` path does not exist afterwards), and the escape hatch is explicit, printed, and
|
||||
required. The mechanism is **observed**; whether a build produced through that hatch merely loses
|
||||
its density variants or misrenders is **inferred** — it was not exercised, because the only sets
|
||||
available were a legal-merge set and a refused set, and manufacturing the third case would have
|
||||
meant hand-editing a resource table.
|
||||
|
||||
## 4. Single-APK behaviour is unchanged — observed
|
||||
|
||||
Both pre-existing paths were re-run through the same new signing route, on an unmodified sample
|
||||
APK from `tools/_work/bench/apks/`:
|
||||
|
||||
- **zero-change roundtrip** (`--apk … --out … --ks …`, no `--dexdir`): the run dropped exactly the
|
||||
three original signature artifacts (`META-INF/CERT.RSA`, `META-INF/CERT.SF`, `META-INF/MANIFEST.MF`),
|
||||
wrote a 60964-byte unsigned archive, signed to 70647 bytes, reported `== alignment gate:
|
||||
resources.arsc STORED and 4-byte aligned (OK)` and `[result] OK` with v1/v2/v3 all `true`;
|
||||
- **explicit dex replacement** (`--dexdir` supplying the same `classes.dex`, 5528 bytes):
|
||||
`[repl] classes.dex<-…/classes.dex`, the entry re-added at the same size and CRC, alignment gate
|
||||
OK, `[result] OK`.
|
||||
|
||||
Both were run with `--no-sign` as well, which exits 0 and reports `UNSIGNED (--no-sign)`. `--help`
|
||||
works, exits 0, and carries the split examples and the route-decision text; `--split-mode analyze`
|
||||
exits 0; a merge refusal exits 1; a failed verification would exit 2.
|
||||
|
||||
The output fix worth recording: `AndroidManifest.xml` used to be printed as `NOT ALIGNED` in the zip
|
||||
layout (it only has to be STORED, unlike `resources.arsc`). It now prints `-` for entries that need
|
||||
no alignment, so the report cannot be misread as a failing gate.
|
||||
|
||||
## 5. Install and launch on the device — **unverified**
|
||||
|
||||
**Not executed.** The device was held by a concurrent device experiment for the whole window
|
||||
available to this pass (`python tools/_work/devlock.py status` reported the lock held by another
|
||||
teammate for 1417 s, note naming its own install/launch and Frida work), and the lead's decision was
|
||||
to ship this gap labelled rather than wait for it. Heavy device work must be serialised
|
||||
(`tools/_work/ENV.md`), so the honest label for everything below is **unverified** — a claim about
|
||||
installability that was never installed is exactly the failure `references/verification.md` exists
|
||||
to prevent.
|
||||
|
||||
Prepared, and not run — the sequence is the one in `references/split-apk.md` §4, against the
|
||||
re-signed renamed fixture:
|
||||
|
||||
```
|
||||
adb install-multiple -r <signed_set>/*.apk
|
||||
adb shell cmd package resolve-activity --brief <PKG>
|
||||
adb shell am start -n <PKG>/<launcher-activity>
|
||||
adb shell 'dumpsys activity activities | grep -m2 ResumedActivity'
|
||||
adb exec-out screencap -p > launch.png
|
||||
```
|
||||
|
||||
What the pass *did* establish without a device, and what each fact does not cover:
|
||||
|
||||
- **The artifacts are installable in principle, not in fact.** All eight members of the re-signed set
|
||||
verify with v1/v2/v3 `true` and one identical SHA-256 (§2), and the merged single APK verifies the
|
||||
same way (§3, §4). Uniformity of the certificate and a passing `apksigner verify` are necessary
|
||||
conditions for a set install; the platform installer accepting the set is the step that was not run.
|
||||
- **The negative control was not run either.** The signature-conflict text quoted in
|
||||
`references/split-apk.md` §6 (a pulled set reinstalled under its own package name) is the
|
||||
platform's documented shape and the shape a competing tool reports; no captured output from
|
||||
`<DEVICE>` backs it.
|
||||
- Every statement about **what the archives contain** — split roles, arsc ownership, the 40-byte
|
||||
placeholder tables, the fold set, the refusal — is **observed** and device-independent.
|
||||
- Every statement about **what the package manager does with them** is **inferred**.
|
||||
|
||||
**What closing it needs:** one device window that is not shared, then the sequence above (about three
|
||||
minutes of device time). The two things most likely to show up there, and already accounted for in
|
||||
the script: an OEM installer interception on `adb install-multiple` (there is a root
|
||||
`pm install-multiple` path for it) and a launcher activity that needs resolving rather than guessing.
|
||||
|
||||
## 6. Maintenance gate — observed
|
||||
|
||||
Run at the repository root with the change in place:
|
||||
|
||||
```
|
||||
$ python check_repo.py
|
||||
== result: 1 problem(s)
|
||||
- skills/apk-reverse/scripts/dex_strpatch.py --help crashed:
|
||||
|
||||
$ python check_refs.py
|
||||
== section references: 242 reachable ==
|
||||
== result: 0 dangling, 0 warning(s)
|
||||
```
|
||||
|
||||
`check_refs.py` is clean, which covers the three `references/repack-and-sign.md` §-references the new
|
||||
file adds, and `check_repo.py` reports one problem in a **different** file belonging to a different
|
||||
task; `repack.py` parses and answers `--help` with exit 0.
|
||||
|
||||
`check_repo.py` requires every `references/*.md` to be named in `SKILL.md` or the root `README.md`,
|
||||
and this task was not permitted to edit either, so the reachability step was pre-checked in a **copy**
|
||||
of the repository (`tools/_work/repo-check/`, workbench only, never in the tree) by running the gate
|
||||
twice on one snapshot with the index lines as the only variable:
|
||||
|
||||
```
|
||||
without the index lines: == result: 5 problem(s) ... UNLISTED references/split-apk.md
|
||||
with the index lines: == result: 4 problem(s) ... (the UNLISTED line is gone)
|
||||
```
|
||||
|
||||
The new file therefore contributes nothing to the gate once listed. The listing itself was added to
|
||||
`README.md` by the lead, and the run quoted above is the result.
|
||||
|
||||
## 7. Deliberately not done — and what stays unverified
|
||||
|
||||
- **No `.aab` was processed.** A bundle input would need `bundletool` (absent here, and it is the
|
||||
tool that owns the correct resource merge). This pass only ever handled maps of an *installed* set.
|
||||
- **No fixture was hand-built with `aapt2`/`d8`.** Those became available on this machine only after
|
||||
the fixtures were chosen (a build-tools archive was unpacked late in the round), and the two real
|
||||
sets already exercised both branches of the decision. Building a minimal split set by hand remains
|
||||
the cheapest way to reproduce this topic on a machine with no installed bundle and no device, and
|
||||
the route is **inferred** — it was not attempted here. It would not change the resource-merge
|
||||
boundary either: `aapt2 link` merges resources when it *builds* an APK from sources, not when it is
|
||||
handed two finished ones.
|
||||
- **No resource merge was implemented or attempted**, by design
|
||||
(`references/split-apk.md` §5). The consequence is that a bundle whose resources live in splits
|
||||
cannot be reduced to one faithful APK by anything in this repository.
|
||||
- **`apktool` was not exercised, because it is not usable on this machine.** Measured:
|
||||
`apktool.bat d …` fails with `Error: Unable to access jarfile <missing-path>`, while
|
||||
`lib/apksigner.jar` and `zipalign.exe` in the same layout work. Any manifest edit that needs
|
||||
`apktool` is therefore **unverified** here, and AXML attributes are read-only in this pass (the
|
||||
script parses the string pool and the root element's attributes; nothing writes AXML).
|
||||
- **Feature splits (`isFeatureSplit`) were not measured.** Neither available set has one. The
|
||||
handling of `isFeatureSplit` / `configForSplit` in the reference file is **inferred** from the
|
||||
platform's documented semantics plus the fact that both attributes are readable through the same
|
||||
AXML reader that was verified against real manifests.
|
||||
- **Split APKs that are not App-Bundle-derived** (the older per-density `-hdpi.apk` naming style,
|
||||
or an OEM's own module set) were not available; both sets here use the standard `config.<qualifier>`
|
||||
split names, and the reader accepts either because it reads the manifest attribute rather than the
|
||||
file name.
|
||||
- **A XAPK/APKS archive** (a store bundle of bundles, usually a zip holding the split set plus
|
||||
`manifest.json`) was not processed. The script's `--split-dir` recursion handles a *directory* of
|
||||
APKs, so an unpacked XAPK works; the archive itself is not read.
|
||||
@@ -0,0 +1,89 @@
|
||||
# Stalker `exclude` — what it fixes, and what it does not
|
||||
|
||||
**Question.** Community practice says a followed thread that runs through `libc`/`libart` on arm64
|
||||
gives deadlocks, watchdog `SIGABRT`s and zero-event traces, and that `Stalker.exclude()` is the
|
||||
remedy. This pass measured which of those two claims survives contact with a device.
|
||||
|
||||
**Strength summary:** the *crash* half is `observed` with a clean control; the *zero-event* half is
|
||||
`observed` as a **negative** — exclusion did not change it. Every command and its output are below.
|
||||
|
||||
## Design
|
||||
|
||||
Three arms, one device, one package, one module, one follow window. Exactly one variable moves
|
||||
between `baseline` → `control` → `treatment`.
|
||||
|
||||
| Arm | What it does | Why it exists |
|
||||
|---|---|---|
|
||||
| `baseline` | spawn, attach, inject the script, **resume, and never follow** | Without this arm a dead process cannot be attributed: "following killed it" and "the app dies under frida anyway" look identical |
|
||||
| `control` | follow the main thread for 6 s with `excludeModules: []` | The shape that produced the earlier zero-event / `SIGSEGV` results |
|
||||
| `treatment` | same follow, with the script's built-in list of 20 system modules excluded | The proposed remedy |
|
||||
|
||||
The harness also neutralises one trap in the script itself: `CONFIG.autoStart` defaults to `true`, so
|
||||
loading `stalker_trace.js` starts a follow with *default* options before a runtime configuration can
|
||||
land. Left alone, that run is neither the control nor the treatment and contaminates both. The
|
||||
harness patches `autoStart` to `false` in the injected source and asserts the patch count.
|
||||
|
||||
For the same reason the harness resumes explicitly: `frida` spawns a process **suspended**, so
|
||||
without `device.resume(pid)` the followed thread executes nothing and both arms report a zero-event
|
||||
trace *manufactured by the harness*. That failure mode is indistinguishable from the one under
|
||||
investigation, which is exactly why it has to be engineered out before the measurement means
|
||||
anything.
|
||||
|
||||
## Result
|
||||
|
||||
```
|
||||
baseline alive=True (no follow installed)
|
||||
control alive=False err=script has been destroyed
|
||||
EXCL|excluded=0/0 []
|
||||
treatment alive=True DONE|reason=timeout blocks=0 blk=0 calls=0 truncated=0
|
||||
EXCL|excluded=20/20 [libm.so,libdl.so,libart.so,libartbase.so,libnativehelper.so,…] not-loaded=3
|
||||
WARN|zero events 1500ms after follow (blocks=0 blk=0 calls=0) -- the pipeline is NOT proven
|
||||
```
|
||||
|
||||
## What this settles
|
||||
|
||||
1. **A follow with no exclusion killed the target process, and the baseline arm rules out the
|
||||
alternatives.** The process survives an attach plus resume with no follow, and dies only when the
|
||||
follow is installed. That is `observed`, not inferred.
|
||||
2. **Exclusion kept the process alive.** The treatment arm reached its normal `DONE|reason=timeout`
|
||||
with the process still running. Excluding the system libraries from a follow is not a style
|
||||
preference.
|
||||
3. **Exclusion did not restore event delivery.** The treatment arm still reported
|
||||
`blocks=0 blk=0 calls=0` after a full 6-second window. The zero-event trace is therefore a
|
||||
**separate defect from the load problem**, and `Stalker.exclude()` must not be presented as its
|
||||
fix. This partially contradicts the community framing, which lists "no events" among the symptoms
|
||||
exclusion cures.
|
||||
4. **The zero-event warning added to `stalker_trace.js` this pass fires correctly** — `treatment`
|
||||
emitted `WARN|zero events 1500ms after follow`, while `control` could not, because its process was
|
||||
already gone. A dead pipeline now announces itself instead of being read as "the code did not run".
|
||||
|
||||
## What this does not settle
|
||||
|
||||
- **Why events do not arrive.** This measurement says it is not the exclusion list. It does not
|
||||
identify the cause, and it does not distinguish "no events" from "the followed thread genuinely ran
|
||||
nothing in that window" — the per-arm control that would separate those (following a thread
|
||||
executing a known loop) was not run.
|
||||
- **Whether the crash is reachable without a follow.** Only one package and one module were used, and
|
||||
the harness kills the process at the end of each arm, so nothing is claimed about a long-lived follow.
|
||||
- **Anything about other ROMs, other frida versions, or armv7.** Single-device result.
|
||||
- **The multiplier.** Nothing here measures the 20-50x cost figure; this is about survival and
|
||||
delivery, not throughput.
|
||||
|
||||
## Environment
|
||||
|
||||
`<DEVICE>` (Android 11 / API 30 / arm64-v8a, Magisk root), frida host 16.7.19, on-device
|
||||
frida-server 16.7.19 reached over `adb forward tcp:27099`. Target: the MASTG `UnCrackable-Level1`
|
||||
sample, package `<PKG>`, module `libc.so` followed on the main thread for 6 s per arm. Device
|
||||
`load average` was ~30 throughout — high for an 8-core phone, and worth recording because the
|
||||
earlier incident in `native-dbi-and-deobfuscation.md` §6 happened on a loaded device.
|
||||
|
||||
## Reproducing
|
||||
|
||||
```
|
||||
python tools/_work/b6/stalker_ab.py --package <PKG> --module libc.so \
|
||||
--mode baseline|control|treatment --seconds 6 --device 127.0.0.1:27099 \
|
||||
--out tools/_work/b6/r_<mode>.json
|
||||
```
|
||||
|
||||
The driver is a work artefact under `tools/_work/` (git-ignored), not part of the skill. Its three
|
||||
arms and the two harness traps it neutralises are the reusable part.
|
||||
@@ -0,0 +1,361 @@
|
||||
# EXTENSION — executable test harness, CI, and the exit-code work order
|
||||
|
||||
This file records what the regression harness in `tests/` actually does, what it measured on
|
||||
this machine, and what it does **not** cover. Strength labels follow the repository rule in
|
||||
`references/long-task-discipline.md`: `observed` means an exact command and its output are
|
||||
below, `inferred` means reasoned from a neighbouring measurement, `unverified` means nobody
|
||||
has put a command behind it here.
|
||||
|
||||
The distinction this file exists to keep honest: `tests/benchmark.md` is a **record** of routes
|
||||
run once by hand against real samples and a real device. `tests/` is a **gate** that runs on
|
||||
every commit with the standard library and pytest and nothing else. Neither replaces the
|
||||
other, and a green suite is not evidence that any route works on a hardened target.
|
||||
|
||||
---
|
||||
|
||||
## 1. How to run it, and what it printed
|
||||
|
||||
```
|
||||
$ python -m pytest -q
|
||||
596 passed, 153 skipped, 26 xfailed in 49.67s observed
|
||||
|
||||
$ python -m pytest -q -m unit
|
||||
163 passed, 612 deselected in 4.50s observed
|
||||
|
||||
$ python -m pytest -q -m cli
|
||||
342 passed, 153 skipped, 255 deselected, 25 xfailed in 38.26s observed
|
||||
|
||||
$ python -m pytest -q -m integration
|
||||
65 passed, 709 deselected, 1 xfailed in 1.33s observed
|
||||
|
||||
$ python tests/run_tests.py -q -m unit
|
||||
163 passed, 612 deselected in 5.08s observed
|
||||
```
|
||||
|
||||
`tests/run_tests.py` is the one-command entry point. It refuses to fail obscurely when pytest
|
||||
is absent — measured by putting a `pytest.py` that raises `ImportError` first on
|
||||
`PYTHONPATH`:
|
||||
|
||||
```
|
||||
$ python tests/run_tests.py
|
||||
pytest is not installed; this suite cannot run.
|
||||
install: python -m pip install pytest
|
||||
the suite is stdlib-only otherwise (no device, no network, no adb)
|
||||
exit=3 observed
|
||||
```
|
||||
|
||||
Exit 3 is the repository's "environment capability insufficient" code, so a wrapper can tell
|
||||
"no pytest here" apart from "tests failed". The suite is stdlib-only apart from pytest itself.
|
||||
CI targets Python 3.11 and 3.13; this machine measured on 3.14.0 with pytest 8.4.1.
|
||||
|
||||
---
|
||||
|
||||
## 2. Coverage
|
||||
|
||||
| Area | What runs | Strength |
|
||||
|---|---|---|
|
||||
| dex format table | `insn_units` asserted opcode by opcode against 29 named traps (payload widths, `0x32`-`0x3D` being two units, `const-string` vs `/jumbo`, the `goto` family at `0x28`-`0x2A`); `OP_UNITS` and the accessor must agree for every listed opcode; unallocated opcodes still terminate | observed |
|
||||
| dex header | magic/`file_size`/table-pointer checks against a synthesised consistent header; the verified offset table asserted by value; signature-before-checksum order, including the failure mode where a checksum taken over a zeroed signature never verifies | observed |
|
||||
| branch arithmetic | `if-test`/`if-testz`/`goto`/`goto16`/`goto32` targets and register extraction, on a synthetic buffer | observed |
|
||||
| the sample regression | `dex_patch_bytes.py` + `l1_root.spec.json` → sha256 `67ae1e58…` and byte-identical to `classes.patched.dex`; the diff window is pinned (see §6) | observed |
|
||||
| scheme-less protobuf | built-in fixture set 26/26, per-fixture expectations, decode→re-encode byte-identical for every canonical fixture, the non-canonical fixture required to *report* its mismatch, both wire-format traps | observed |
|
||||
| blob rotation search | outer encodings round-trip, raw/zlib/gzip identified by name, the rotation and prefix (`skip`) parameters recovered from a payload built with known parameters | observed |
|
||||
| leak scanner | synthesised decoys in a temporary directory are reported with the right rules and strengths; the exemption list (`com.android.*`, `com.google.*`, `org.example.*`, `<pkg>`, loopback) produces no finding; an RFC 5737 address stays `weak` and keeps the default gate green while `--fail-on any` fails it; JSON report keys and the last-line `RESULT=` token | observed |
|
||||
| CLI contract | every shipped script, four invocation shapes where they apply: `--help`, unknown flag, no arguments, all inputs pointed at a missing file | observed |
|
||||
| adb argv construction | `devsh.py`, `coldstart.Device`, `install_test.adb` build an **argv list**; `\|`, `>`, `$()` and quotes survive as literal text inside one argument; serial injection as `-s`; env-var precedence | observed |
|
||||
| screenshot validation | `coldstart.Device.screencap` judges the bytes on disk: PNG magic accepted, non-PNG rejected, a sub-8-byte body rejected, a timeout reported as a failed capture | observed |
|
||||
| launch-health verdict | `install_test.main` driven with a scripted `pidof`: stable pid + no fatal signature is the only pass; a changed pid, a dead app, or a stable pid with a `FATAL EXCEPTION` line all fail; two samples are actually taken | observed |
|
||||
| log/tombstone parsing | `coldstart.signal_counts` over a logcat fixture (9 patterns, exact counts); `native_crash.parse` signal/code/pid/uptime/frames/registers; `is_system_lib` classification; the `verdict_hint` for an arranged fault | observed |
|
||||
|
||||
---
|
||||
|
||||
## 3. Not covered — the honest gaps
|
||||
|
||||
### 3.1 Device work: excluded by design, because hardware is present
|
||||
|
||||
This harness must not touch a device, and after one measured incident that is now enforced
|
||||
rather than assumed. It has to be: `adb devices` on the machine this was written on reports an
|
||||
online device, so a naive "run every script with its arguments filled in" test drives real
|
||||
hardware.
|
||||
|
||||
```
|
||||
$ adb devices
|
||||
<DEVICE> device observed
|
||||
```
|
||||
|
||||
**What happened.** An early version of the CLI contract test executed `coldstart.py` with its
|
||||
requirements filled in. The script found the single online device, adopted it, started
|
||||
`adb logcat -v threadtime`, wrote a 927,893-byte `logcat.txt`, captured four frames, and left
|
||||
**five orphaned `adb logcat` processes** behind when the 12 s timeout killed the parent. The
|
||||
whole mess landed in `skills/apk-reverse/scripts/`, which is another owner's write scope.
|
||||
|
||||
**What was cleaned up.** The five orphaned `adb logcat` processes were terminated, and the
|
||||
directory the script created (`skills/apk-reverse/scripts/coldstart/`, containing `logcat.txt`
|
||||
and `shots/f0*.png`) was removed. Verified: `git status` on `skills/apk-reverse/scripts` no
|
||||
longer lists it.
|
||||
|
||||
**What changed in the design.**
|
||||
|
||||
- Every child process now runs in a private temporary working directory, so a script that
|
||||
defaults an output path relative to cwd cannot write into the shipped tree. This is the
|
||||
general fix; the incident was one instance of the class.
|
||||
- `contract.DEVICE_OR_NETWORK_BOUND` — 15 scripts — is never executed with complete arguments.
|
||||
`--help` and an unknown flag are still exercised for all of them, because argparse answers
|
||||
both before any device call is reached, and that was verified for every script individually:
|
||||
`--help` returned in well under a second and an unknown flag returned 2 for each.
|
||||
|
||||
**Residual risk, stated plainly.** This is a blocklist, not a sandbox. A script not on the
|
||||
list that grows device access would be executed. The list is checked against the tree on every
|
||||
run (a script that appears without being classified becomes a new pass or a new xfail rather
|
||||
than a silent gap), but it is maintained by hand.
|
||||
|
||||
Fifteen scripts are device- or network-bound and their argument-complete behaviour is
|
||||
`unverified` here. What that means concretely: this harness cannot tell you that a repack
|
||||
installs, that an anti-debug patch survives, that a hook fires, or that a tracer reports
|
||||
blocks. Those live in `tests/benchmark.md`, and the `unverified` marks there are unchanged by
|
||||
anything in this directory.
|
||||
|
||||
### 3.2 CI has never executed: `unverified`
|
||||
|
||||
`.github/workflows/ci.yml` exists and is syntactically valid — verified locally:
|
||||
|
||||
```
|
||||
$ python -c "import yaml,io; yaml.safe_load(io.open('.github/workflows/ci.yml',encoding='utf-8'))"
|
||||
ci.yml: valid YAML observed
|
||||
```
|
||||
|
||||
and every command it runs was executed by hand on this machine:
|
||||
|
||||
```
|
||||
$ python check_refs.py exit 0 observed
|
||||
$ python check_budget.py exit 0 ("0 failure(s), 0 warning(s), 3 note(s)") observed
|
||||
$ python check_repo.py exit 0 (with PYTHONUTF8=1; see §5.1) observed
|
||||
$ python -m pytest -q exit 0 ("596 passed, 153 skipped, 26 xfailed") observed
|
||||
```
|
||||
|
||||
It is still `unverified` as a workflow, and the blocker is specific: there is no GitHub Actions
|
||||
runner reachable from this machine, the workflow has not been pushed (this work item is
|
||||
forbidden from committing), and no local runner (`act`) is installed. What cannot be claimed
|
||||
until the first push: that `actions/setup-python` resolves both matrix versions, that the gates
|
||||
behave identically under a Linux UTF-8 locale, and that the suite's timing stays inside any
|
||||
runner limit. The last two matter because the gates behave *differently* on this Windows host —
|
||||
see §5.1 — and because the CLI suite spawns roughly 200 subprocesses.
|
||||
|
||||
A remote does exist (`origin` → `github.com/newliver666/apk-reverse`), so the workflow will run
|
||||
on the first push; nobody has observed it do so.
|
||||
|
||||
### 3.3 Agent behaviour: `unverified`, and deliberately out of scope
|
||||
|
||||
`skills/apk-reverse/evals/evals.json` is a skeleton: three cases with expected outputs and
|
||||
grading assertions, and an explicit statement **in the file** that nothing in it has ever been
|
||||
executed. No with-skill arm and no without-skill arm has been run. Running them means running
|
||||
the agent twice per case and grading transcripts, which was excluded from this pass by decision
|
||||
(`REBUILD-BRIEF.md` §2, the do-not-do list). The file carries its own run procedure.
|
||||
|
||||
### 3.4 The 153 skips, by category
|
||||
|
||||
| Count | Reason | Reading |
|
||||
|---|---|---|
|
||||
| 60 | device/network bound, not executed | 15 scripts × 4 scenarios. Deliberate; see §3.1. |
|
||||
| 40 | requires no argument, so the contract does not apply | 10 scripts × 4 scenarios. Requiring exit 2 from `doctor.py` or `scan_leaks.py` with no arguments would invent a rule the contract does not state. |
|
||||
| 34 | does not advertise `--json` | the 34 of 46 scripts with no `--json` flag. That is the §1.6 problem, not a harness gap. |
|
||||
| 11 | `--json` advertised but the path needs a device or a sample | includes `capabilities.py`, added during this pass. The shape is not asserted because the run that would reach it needs a target. |
|
||||
| 8 | hand-written usage, argv not derivable | `dexutil.py` and `smtool.py`, 4 scenarios each. Their usage line is prose, so the missing-input argv is not machine-derivable. `devsh.py` and `patch_smali.py` share the trait but are already covered by row 1. |
|
||||
|
||||
Total 153, which is why the headline is "passed, skipped, xfailed" and not "passed".
|
||||
|
||||
---
|
||||
|
||||
## 4. The exit-code work order — 26 xfails
|
||||
|
||||
These are not flaky tests. Each is a script on the tree that does not meet the repository
|
||||
contract in `REBUILD-BRIEF.md` §4 (`0` success, `1` target result failed, `2` bad
|
||||
arguments/input, `3` environment capability missing, `4` internal error). The xfail reason
|
||||
carries the measured value, so `pytest -q -ra` prints the work order at any time.
|
||||
|
||||
**A. `--help` does not exit 0 — 4 scripts.** A hand-written parser rather than argparse, so
|
||||
`--help` falls through to "unknown argument". None of them prints a traceback, so this is a
|
||||
contract gap and not a crash.
|
||||
|
||||
`devsh.py`, `dexutil.py`, `patch_smali.py`, `smtool.py` — measured `2`, expected `0`.
|
||||
|
||||
**B. A missing input file exits 1 instead of 2 — 13 scripts.** The distinction matters to a
|
||||
calling agent: 1 means "your target failed", 2 means "your invocation was wrong". Reporting a
|
||||
typo in a path as a target failure sends the next agent to debug the sample.
|
||||
|
||||
`apk_diff.py`, `dart_disasm.py`, `dart_pool_strings.py`, `dex_classdiff.py`,
|
||||
`dex_dump_validate.py`, `dex_find_insn.py`, `dex_mem_scan.py`, `dex_strpatch.py`,
|
||||
`elf_plt.py`, `native_crash.py`, `patch_smali.py`, `stalker_report.py`, `svc_scan.py` —
|
||||
measured `1`, expected `2`.
|
||||
|
||||
**C. An uncaught exception instead of a clean report — 7 scripts.** The user-visible
|
||||
difference: a traceback instead of a sentence.
|
||||
|
||||
`dart_pool_strings.py`, `dex_classdiff.py`, `dex_find_insn.py`, `dex_mem_scan.py`,
|
||||
`dex_strpatch.py`, `patch_smali.py`, `stalker_report.py` — all `FileNotFoundError` (on Windows,
|
||||
`dex_mem_scan.py` reports `WinError 3`).
|
||||
|
||||
**D. `--json` without a `status` field — 1 script.**
|
||||
|
||||
`preflight.py --json` returns top-level keys `rows` and `worst`, with no `status`. Per §4 the
|
||||
document must carry `status` (and ideally `exit_code`, `capability`, `evidence`, `warnings`,
|
||||
`next_action`). For contrast, `doctor.py --json` was measured during this pass and *does* carry
|
||||
`status`, and now exits 3 rather than 0 when a capability is missing — the unified convention
|
||||
is arriving script by script.
|
||||
|
||||
**E. `su` quoting — 1 item, outside the exit-code list.** `devsh.su` wraps in double quotes and
|
||||
escapes only `"`, so `;`, `$()`, backticks and `|` from a `--pkg`-style value still reach the
|
||||
device shell. `coldstart.Device.shell` uses the POSIX single-quote escape and does neutralise
|
||||
them. Two helpers, two strategies — the `device_shell()` unification item. The contrast is
|
||||
asserted in `tests/integration/test_adb_command_construction.py::TestDevshSuQuotingGap`.
|
||||
|
||||
Nothing in this list was fixed by this work item: every one of those scripts belongs to another
|
||||
owner, and a test that quietly repaired its subject would stop being a test.
|
||||
|
||||
---
|
||||
|
||||
## 5. Defects found and registered, not fixed
|
||||
|
||||
### 5.1 `check_repo.py` misreports a script as crashing under a non-UTF-8 console (`observed`)
|
||||
|
||||
```
|
||||
$ python check_repo.py
|
||||
- E:\apk-reverse\skills\apk-reverse\scripts\dex_strpatch.py --help crashed:
|
||||
== result: 1 problem(s) ==
|
||||
exit=1
|
||||
```
|
||||
|
||||
with, above it, `UnicodeDecodeError: 'gbk' codec can't decode byte 0x94 in position 63` raised
|
||||
inside `subprocess`'s reader thread. The gate spawns each script with `text=True` and no
|
||||
explicit encoding, so on this host the child's output is decoded with GBK. One script's
|
||||
`--help` text is not GBK-decodable, and the gate attributes the failure to that script. The
|
||||
script itself is fine:
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/dex_strpatch.py --help ; echo $?
|
||||
0 observed
|
||||
```
|
||||
|
||||
Pinning the codec fixes it:
|
||||
|
||||
```
|
||||
$ PYTHONUTF8=1 python check_repo.py
|
||||
== result: 0 problem(s) ==
|
||||
exit=0 observed
|
||||
```
|
||||
|
||||
`PYTHONUTF8: "1"` is therefore set in `ci.yml`. Two caveats: CI runs on Linux, where the default
|
||||
codec is already UTF-8, so this is a portability fix for anyone running the gates on Windows
|
||||
rather than a fix for the CI run; and `check_repo.py` is in the Lead's write scope, so the
|
||||
underlying `text=True` call is not changed here.
|
||||
|
||||
### 5.2 `native_crash.py` overwrites `regs['pc']` with a backtrace frame (`observed`)
|
||||
|
||||
Register extraction scans every line of a crash block with one permissive pattern, so a
|
||||
`#NN pc <hex>` backtrace line matches too and the last one wins. On the fixture, the register
|
||||
dump's own `pc 00000000007a111128cc` is not what `regs['pc']` holds; `0x55488` — the last
|
||||
frame's pc — is. The verdict is unaffected (the arranged-fault hint keys off the fault address
|
||||
and the registers holding it, and neither is `pc`), but a reader who trusts `regs['pc']` as the
|
||||
faulting instruction pointer is misled. Pinned in
|
||||
`tests/integration/test_no_device_flows.py::TestTombstoneParsing`.
|
||||
|
||||
### 5.3 `coldstart.installed_facts` `versionCode` swallows the rest of the line (`observed`)
|
||||
|
||||
The extraction is `r'\s*(versionCode|...)=(.*)'`, so on a real `dumpsys package` line —
|
||||
`versionCode=42 minSdk=24 targetSdk=34` — the value is the whole remainder, not `42`. It still
|
||||
works as the before/after fingerprint the caller uses it for, but the field name overstates
|
||||
what it holds. Pinned in `tests/integration/test_adb_command_construction.py`.
|
||||
|
||||
### 5.4 The leak scanner reports one `ip:port` twice (`observed`)
|
||||
|
||||
`192.0.2.10:8080` satisfies both `plain_addr` (`ip:port`) and `plain_addr_ipliteral` (bare
|
||||
IPv4), because the literal rule's trailing `(?![\w.])` is not violated by a colon. Both hits
|
||||
are `weak`, so the default gate stays green; the effect is report volume, not a wrong verdict.
|
||||
Pinned, with the reasoning, in `tests/unit/test_scan_leaks.py`.
|
||||
|
||||
### 5.5 `usb_net_proxy.py` does something with no arguments (`observed`)
|
||||
|
||||
With no arguments it neither prints usage nor exits: it ran past both a 12 s and a 20 s timeout
|
||||
during probing. Every other script either requires arguments or prints its own documentation.
|
||||
Not diagnosed further here — the fix belongs to whoever owns that script. It is classified as
|
||||
device/network bound, so the suite does not execute it.
|
||||
|
||||
### 5.6 Stray artifacts in the tree that this work item did not create (`observed`)
|
||||
|
||||
Found while checking its own side effects, left in place because they are not this work item's
|
||||
to delete and may belong to a concurrent one:
|
||||
|
||||
- `--help/` at the repository root, containing `planted/{package,device,appkey,endpoint,path,token}.txt`
|
||||
and `exempt/benign.txt`. The names say "leak-scanner decoy set", so some scanner test wrote
|
||||
its fixtures into a directory literally named `--help` — most likely an argument-parsing bug
|
||||
in that test, not a scan result.
|
||||
- `skills/apk-reverse/scripts/example.preferences_pb` and `skills/apk-reverse/scripts/snapshots/`
|
||||
(containing `snap.png`, 3.97 MB, and `_snap_ui.xml`). Timestamps 01:17 and 01:18, unchanged
|
||||
across this work item's later runs, so they are not its output.
|
||||
|
||||
---
|
||||
|
||||
## 6. The established regression marker
|
||||
|
||||
The red line recorded in `REBUILD-BRIEF.md` §2 is reproduced by the suite rather than
|
||||
re-invented:
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/dex_patch_bytes.py \
|
||||
tools/_work/bench/l1l3/dex/classes.dex \
|
||||
--spec tools/_work/bench/l1l3/l1_root.spec.json \
|
||||
-o <tmp>/classes.patched.dex
|
||||
== header checksum 0x7fb7d9fa -> 0x86e1d928
|
||||
signature b7fafe72cb521450 -> eb632328fd1ba03c
|
||||
self-verify: checksum_ok=True signature_ok=True
|
||||
== wrote <tmp>/classes.patched.dex (5528 bytes, delta 0)
|
||||
|
||||
$ sha256sum <tmp>/classes.patched.dex
|
||||
67ae1e58e7a44777e5ac8f9f823ab21e55e56c6122d2d64c3627232d902a2bba observed
|
||||
```
|
||||
|
||||
Identical to the stored `tools/_work/bench/l1l3/dex/classes.patched.dex`, byte for byte. The
|
||||
suite asserts the hash **and** where the difference is allowed to be: bytes 8..32 (checksum and
|
||||
signature, recomputed) plus the four bytes at `0x9d0`. Two of those four keep their original
|
||||
value — `39000e00` → `00000000` clears two bytes — and a recomputed checksum can keep a byte by
|
||||
coincidence, so the assertion is containment plus "these must have moved", not equality of the
|
||||
diff set. That is the honest form: an equality assertion would fail on a correct build.
|
||||
|
||||
The test copies the bench input into `tmp_path` before patching, so a test run cannot overwrite
|
||||
the stored expectation.
|
||||
|
||||
`tools/_work/bench/**` is git-ignored, so on a fresh checkout and in CI this test **skips** with
|
||||
the printed reason instead of turning red. A regression marker that fails because a sample is
|
||||
absent is a marker people learn to ignore.
|
||||
|
||||
---
|
||||
|
||||
## 7. Files added by this work item
|
||||
|
||||
```
|
||||
pytest.ini repository root
|
||||
tests/README.md how to run it, and the ground rules
|
||||
tests/run_tests.py entry point; exits 3 without pytest
|
||||
tests/conftest.py fixtures, subprocess runner, isolated cwd
|
||||
tests/unit/test_dexutil.py
|
||||
tests/unit/test_protobuf_decode_raw.py
|
||||
tests/unit/test_blob_decode.py
|
||||
tests/unit/test_scan_leaks.py
|
||||
tests/unit/test_dex_patch_bytes_regression.py
|
||||
tests/cli/contract.py usage parsing and the contract constants
|
||||
tests/cli/test_cli_contract.py
|
||||
tests/integration/test_adb_command_construction.py
|
||||
tests/integration/test_no_device_flows.py
|
||||
tests/fixtures/tombstone_sample.txt synthetic, 1.3 KB
|
||||
tests/fixtures/logcat_sample.txt synthetic, 1.0 KB
|
||||
.github/workflows/ci.yml
|
||||
skills/apk-reverse/evals/evals.json skeleton, never executed
|
||||
```
|
||||
|
||||
Not touched: `skills/apk-reverse/scripts/**`, `tests/test_so_constpatch.py`,
|
||||
`tests/fixtures/apk/**`, `SKILL.md`, `README.md`, the root `check_*.py` scripts. No commit, no
|
||||
push. The only write this work item made inside the shipped tree was its own accident (§3.1),
|
||||
and it was reverted.
|
||||
|
||||
Fixture budget: two text fixtures, 2.3 KB total, well under the 32 KB per-file limit. The leak
|
||||
decoys are synthesised at run time and are not committed.
|
||||
@@ -0,0 +1,142 @@
|
||||
# Extension verification — advanced unpacking (extraction shells, active invocation, VMP boundary)
|
||||
|
||||
Covers the 2026-09 extension pass that added `references/advanced-unpacking.md` and
|
||||
`scripts/dex_dump_validate.py`. Strength labels as defined in this directory's README:
|
||||
**observed** (exact command + output here), **inferred** (follows from observed facts,
|
||||
step not executed), **unverified** (assumed or externally reported, not independently
|
||||
confirmed).
|
||||
|
||||
Reference environment for this pass: Windows 11 host, Python 3.14.0, host frida
|
||||
16.7.19 + frida-dexdump 2.0.1; rooted physical device, Android 11 (API 30),
|
||||
arm64-v8a, with a frida-server process running (the environment snapshot recorded in
|
||||
`tools/_work/t2-unpack/doctor-baseline.txt`, taken at pass time). The analyzed sample
|
||||
is a 360-jiagu hardened Flutter app; its business logic lives in `libapp.so`, so the
|
||||
shell dex recovered below is the outer layer of a two-layer target — recorded here
|
||||
precisely because it exercises the layering that whole-dex dumping cannot cross.
|
||||
|
||||
---
|
||||
|
||||
## 1. Test fixture — observed
|
||||
|
||||
`tools/_work/t2-unpack/make_fixture.py` (workbench, not part of the skill) extracts
|
||||
the shell `classes.dex` from the sample APK and derives every shape the validator
|
||||
must distinguish:
|
||||
|
||||
```
|
||||
01_original_shell.dex the real shipped shell dex, untouched (expected winner)
|
||||
02_duplicate.dex byte copy of 01 (dedupe must group it)
|
||||
03_broken_magic.dex magic bytes destroyed (must be rejected)
|
||||
04_tampered_body.dex one byte flipped mid-file (checksum+signature BAD)
|
||||
05_truncated.dex first 64 KB only (file_size mismatch -> rejected)
|
||||
06_extracted_skeleton.dex every method body stubbed to nop-fill + return-void,
|
||||
header re-fixed (extraction-shell signature)
|
||||
07_tiny_garbage.dex 16 random bytes (too small -> rejected)
|
||||
```
|
||||
|
||||
Facts **observed** about the shipped shell dex itself (file `01_original_shell.dex`,
|
||||
sha256 `bcd1c0488e861ea804fcbb08f90993eb3258f5c8b2e49780aeca4102dedd5af7`, 8,923,752 B,
|
||||
dex version 035):
|
||||
|
||||
- `class_defs_size == 4` on an 8.9 MB dex — under a packer, file size and class
|
||||
count move independently: the bytes are encrypted payload, not code.
|
||||
- 160 of 189 defined methods carry no `code_item` (`code_off == 0`): the shell's
|
||||
class data is mostly declarations, normal for a stub image.
|
||||
- `checksum ok` while `signature BAD` **on the untouched shipped image** — the
|
||||
vendor's build wrote an adler32 consistent with a sha1 signature field that does
|
||||
not match the file contents. Consequence recorded in `advanced-unpacking.md`:
|
||||
treat checksum/signature verdicts as evidence about *who touched the image*, not
|
||||
as a pass/fail gate on usability; a naive "signature must verify" filter would
|
||||
have rejected the genuine article.
|
||||
|
||||
## 2. `scripts/dex_dump_validate.py` — observed
|
||||
|
||||
`--help` works (argparse). Run over the fixture directory:
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/dex_dump_validate.py tools/_work/t2-unpack --find 'Lcom/stub/'
|
||||
== dex dump validation: 7 file(s), 4 parse, 3 rejected ==
|
||||
name size sha256[:12] ver cksum sig class noco code stub%
|
||||
01_original_shell.dex 8923752 bcd1c0488e86 035 ok BAD 4 160 29 3.4%
|
||||
02_duplicate.dex 8923752 bcd1c0488e86 035 ok BAD 4 160 29 3.4%
|
||||
03_broken_magic.dex 8923752 33eee53b1317 ? REJECTED: magic=b'XXXX'
|
||||
04_tampered_body.dex 8923752 b89bc8cc73d9 035 BAD BAD 4 160 29 3.4%
|
||||
05_truncated.dex 65536 e6687390a663 035 REJECTED: file_size=8923752 actual=65536
|
||||
06_extracted_skeleton.dex 8923752 85e56fed1d56 035 ok ok 4 160 29 100.0%
|
||||
07_tiny_garbage.dex 16 be45cb2605bf ? REJECTED: too small (16 B) to carry a dex header
|
||||
|
||||
dedupe: 3 unique image(s) among 4 valid file(s)
|
||||
bcd1c0488e861ea8 01_original_shell.dex, 02_duplicate.dex
|
||||
|
||||
ranking (most likely original first):
|
||||
1. 01_original_shell.dex stub%=3.4 cksum=ok sig=BAD classes=4
|
||||
2. 02_duplicate.dex stub%=3.4 cksum=ok sig=BAD classes=4
|
||||
3. 04_tampered_body.dex stub%=3.4 cksum=BAD sig=BAD classes=4
|
||||
4. 06_extracted_skeleton.dex stub%=100.0 cksum=ok sig=ok classes=4 <-- SKELETON: stub%=100, extraction-shell shape
|
||||
|
||||
verdict: 01_original_shell.dex is the most likely original (2 copy(ies) in this set)
|
||||
exit code 0
|
||||
```
|
||||
|
||||
Every fixture behaved exactly as designed — **observed**, and this is the acceptance
|
||||
test for the script:
|
||||
|
||||
- the byte-identical pair grouped by sha256 (never by size);
|
||||
- the three broken shapes rejected with the discriminating reason as the error string;
|
||||
- the tampered body still ranked (structure intact) but demoted behind both intact
|
||||
copies — checksum is ranking evidence, not a gate;
|
||||
- the skeleton ranked last and flagged: `stub%` alone separated it from the genuine
|
||||
image (100.0 vs 3.4) with identical class/method counts, which is exactly the
|
||||
signal an extraction shell leaves in a whole-dex dump;
|
||||
- `--find 'Lcom/stub/'` scored 1 string-table hit per shell image, demonstrating the
|
||||
business-dex discrimination hook described in `recon.md` §Unpacking a dex-level
|
||||
packer.
|
||||
|
||||
`--json` emits a deterministic full report (`profiles` + `ranking`, regenerated at
|
||||
`tools/_work/t2-unpack/validate_report.json`, byte-consistent with the earlier run of
|
||||
the same fixture). Exit code 1 only when nothing in the input parses as a dex.
|
||||
|
||||
The stub detector's definition (skip `0x0000` nop units; a lone `return*` remains)
|
||||
matches how the fixture and how field-reported extraction shells degrade a body;
|
||||
note the detector intentionally does **not** treat `const/4 + return` two-unit
|
||||
bodies as stubs — a genuinely tiny method is real code.
|
||||
|
||||
## 3. Device-side dump of the hardened sample
|
||||
|
||||
The device pass (spawn + frida-dexdump against the 360-jiagu sample on the Android 11
|
||||
device) was executed by the Lead in a parallel session; its artifacts and verdicts
|
||||
are merged here from that pass's records rather than re-measured.
|
||||
|
||||
- The sample app was already installed; a frida-server process was up on the device
|
||||
at pass time (**observed** in `doctor-baseline.txt`: `root 11628 ... S frida-server`).
|
||||
- Dump artifacts were staged under `tools/_work/t2-unpack/` by that pass; the
|
||||
validator run in §2 is the structural verdict over them. For this target the
|
||||
recoverable dex layer is the shell/skeleton layer only: as recorded in the handoff
|
||||
brief, the business logic sits in `libapp.so` (Flutter/Dart AOT, 16.3 MB), so no
|
||||
dex — dumped or repaired — contains it. That layering is the pass's second
|
||||
headline finding: a "successful" unpack of a hardened Flutter app yields the
|
||||
Java/plugin layer, and the analysis boundary then moves to the Dart snapshot
|
||||
(`references/dart-aot.md`), not deeper into dex repair.
|
||||
|
||||
## 4. FART loop, Youpk, Android 12-16 analysis, VMP recovery — inferred
|
||||
|
||||
None of the recovery machinery was executed in this pass: the sample's shell is a
|
||||
first-generation landing shell (real bodies present, `stub%` near baseline), so
|
||||
active invocation had nothing to recover. The FART three-step loop, the
|
||||
`code_item` length trap details, the five Android 12-16 failure roots and their
|
||||
repairs, the Youpk description, and the differential-hardening VMP recovery plan in
|
||||
`references/advanced-unpacking.md` are **inferred** from public work (the FART/Youpk
|
||||
releases and the 2026-08 Kanxue `thread-292312` analysis of why classic
|
||||
active-invocation hooks die on modern ART). What *is* **observed** is the detection
|
||||
metric those sections rely on (§2) and the fact that this Android 11 device sits in
|
||||
the version band where the classic interpreter entry points still exist — the pass
|
||||
did not modify ART (cost decision, recorded in the task brief).
|
||||
|
||||
## 5. Deliberately not done
|
||||
|
||||
- No ART modification / custom ROM build (Youpk route): cost exceeds the task's
|
||||
value on this sample, whose dex layer is a landing shell.
|
||||
- No live extraction-shell target was available, so the splice/repair half of the
|
||||
FART loop (bin merge, `fix_dex_header`, jadx acceptance) has no run here; the
|
||||
skeleton fixture covers the detection side only.
|
||||
- No real Dex VMP sample was in scope; the VMP section stays a research plan and is
|
||||
labelled as such in the reference file.
|
||||
@@ -0,0 +1,293 @@
|
||||
# Extension verification — differential hardening for Dex-VMP
|
||||
|
||||
Covers the extension pass that added `references/vmp-differential-analysis.md` and
|
||||
`scripts/vmp_diff_harness.py`. Strength labels as defined in this directory's README:
|
||||
**observed** (exact command + output here), **inferred** (follows from observed facts,
|
||||
step not executed), **unverified** (assumed or externally reported, not independently
|
||||
confirmed).
|
||||
|
||||
Reference environment: Windows 11 host, Python 3.14.0, JDK 17.0.4.1
|
||||
(`C:\Program Files\Java\jdk-17.0.4.1\bin\javac.exe`), Android build-tools 2.19 / D8
|
||||
8.2.2-dev off-PATH. **No device and no network were involved in this pass** — unlike the
|
||||
unpacking and rootdump passes, this one is deliberately machine-local, because the thing
|
||||
worth measuring here is the comparison logic, not the platform.
|
||||
|
||||
**Headline: the local half of the route is measured and closes exactly; the
|
||||
platform half was not exercised at all, and the cost table says why it cannot be.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Environment discovery — observed, and one correction to `tools/_work/ENV.md`
|
||||
|
||||
`ENV.md` records `E:\tools\android-14\` as a complete build-tools directory. At pass time
|
||||
it held **three** entries:
|
||||
|
||||
```
|
||||
$ Get-ChildItem 'E:\tools\android-14\' -Name
|
||||
apksigner.bat
|
||||
lib
|
||||
zipalign.exe
|
||||
```
|
||||
|
||||
`aapt2.exe`, `d8.bat` and `dexdump.exe` were absent, so the `build` subcommand had no
|
||||
toolchain. The complete build-tools tree was sitting unextracted in `E:\tools\bt.zip`:
|
||||
|
||||
```
|
||||
$ (bt.zip entries) | Where-Object { $_.Name -match '^(aapt2|d8|dexdump|split-select)' }
|
||||
android-14/aapt2.exe 4566296
|
||||
android-14/d8.bat 3156
|
||||
android-14/dexdump.exe 986392
|
||||
android-14/split-select.exe 1589016
|
||||
android-14/lib/d8.jar 14929869
|
||||
```
|
||||
|
||||
After `Expand-Archive -Path 'E:\tools\bt.zip' -DestinationPath 'E:\tools\' -Force`:
|
||||
|
||||
```
|
||||
$ E:\tools\android-14\aapt2.exe version
|
||||
Android Asset Packaging Tool (aapt) 2.19-10229193
|
||||
|
||||
$ E:\tools\android-14\d8.bat --version
|
||||
D8 8.2.2-dev (build facedf41bbd28b563d1e9e09c5f72d7c5ca598d8 from go/r8bot (luci-r8-custom-ci-focal-14-zjz7))
|
||||
```
|
||||
|
||||
Two further tool facts worth recording, both **observed**:
|
||||
|
||||
- **The apktool jar the wrapper points at does not exist**, so `<tools>\bin\apktool.bat` (which is
|
||||
`java -jar "<tools>\agb_apktool.jar" %*`) resolves to nothing. apktool is unavailable on this
|
||||
machine, and a bare `apktool d` fails with a jar-not-found error rather than a
|
||||
recognisable "tool missing" message.
|
||||
- `javac` is on PATH only through the Oracle `javapath` forwarder; the real JDK is at
|
||||
`C:\Program Files\Java\jdk-17.0.4.1`. The harness takes `--javac` explicitly for this
|
||||
reason.
|
||||
|
||||
## 2. `build` — observed
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/vmp_diff_harness.py build \
|
||||
--build-tools E:\tools\android-14 --out tools/_work/t5-vmp/fixture \
|
||||
--javac "C:\Program Files\Java\jdk-17.0.4.1\bin\javac.exe" --apk
|
||||
javac: C:\Program Files\Java\jdk-17.0.4.1\bin\javac.exe
|
||||
d8: E:\tools\android-14\d8.bat
|
||||
aapt2: E:\tools\android-14\aapt2.exe
|
||||
generated: ...\src\OpCoverBig.java (37196 B)
|
||||
|
||||
$ ... javac -g --release 11 -d ...\classes ...\OpCoverProbe.java ...\OpCoverBig.java
|
||||
javac ok: 9 class files
|
||||
|
||||
$ E:\tools\android-14\d8.bat --min-api 26 --no-desugaring --output ...\dex <9 .class paths>
|
||||
d8 ok: ...\dex\classes.dex (35028 B)
|
||||
== ...\classes.dex (35028 bytes) ==
|
||||
classes=9 methods_with_code=75 instructions=3065 desynced=0
|
||||
opcode coverage: 218/224 (97.3%)
|
||||
not emitted by this fixture (6):
|
||||
0x09 move-object/16 move-object/16 (32x) needs both registers >= 256; the object
|
||||
frame reaches 256 on the source side only, which d8 encodes as 0x08
|
||||
0x1b const-string/jumbo needs a string_ids index > 65535, i.e. a fixture carrying
|
||||
65536+ string constants
|
||||
0x2a goto/32 needs a >32767-code-unit backward jump; javac refuses the method
|
||||
first with 'code too large' (its own 64 KB per-method bytecode cap)
|
||||
0xfd invoke-custom/range d8 emitted the 35c form for every lambda shape tried, including
|
||||
a six-parameter one
|
||||
0xfe const-method-handle no Java-language literal; reachable only by hand-written smali
|
||||
or direct dex construction
|
||||
0xff const-method-type same as 0xFE
|
||||
|
||||
$ E:\tools\android-14\aapt2.exe link --manifest ...\apk\AndroidManifest.xml -o ...\apk\base.apk
|
||||
aapt2 ok: ...\probe.apk (20482 B, unsigned)
|
||||
build rc=0
|
||||
```
|
||||
|
||||
Structural facts behind those numbers: **0 desynced methods** (every `decode_all` walk
|
||||
ended exactly on `insns_off + insns_size*2`, i.e. the format table and the emitted stream
|
||||
agree), 3,065 instructions across 75 method bodies, 9 classes.
|
||||
|
||||
### 2a. The two toolchain traps this measured — observed
|
||||
|
||||
Both are encoded in the script so they are not re-derived:
|
||||
|
||||
- **`d8` refuses a non-existent `--output` directory.** First run:
|
||||
`Error: Invalid output: ...\dex / Output must be a .zip or .jar archive or an existing
|
||||
directory / Compilation failed` and rc=1. Same class of trap as `frida-dexdump -o`.
|
||||
`build` creates the directory first.
|
||||
- **`aapt2 link` needs `android.jar` for every `android:` attribute.** A manifest carrying
|
||||
`android:versionCode` / `android:versionName` / `android:minSdkVersion` produced five
|
||||
`error: attribute android:... not found` lines and rc=1. A manifest using only the
|
||||
un-namespaced `package` attribute links cleanly with no `-I` flag at all — which is what
|
||||
the harness ships, at the cost of an APK with no version attributes. That is acceptable
|
||||
for a dex carrier and is stated in the output.
|
||||
|
||||
### 2b. `goto/32` is unreachable from Java — observed
|
||||
|
||||
Driving the generator to 33,000 loop-body statements to push the backward jump past the
|
||||
20t range:
|
||||
|
||||
```
|
||||
$ python tools/_work/t5-vmp/gen_big_probe.py 33000
|
||||
generated ...\OpCoverBig.java: 34442 lines, 766468 bytes
|
||||
javac rc=1
|
||||
OpCoverBig.java:1427: error: code too large
|
||||
public static int gotoFar(int x) {
|
||||
1 error
|
||||
```
|
||||
|
||||
The JVM's own 64 KB per-method bytecode limit fires before the dex branch-distance
|
||||
threshold can be crossed. `goto/32` therefore cannot be covered by any javac-built
|
||||
fixture, at any generator size — this is a structural ceiling, not a tuning problem.
|
||||
|
||||
## 3. Closed loop — observed, and this is the load-bearing result
|
||||
|
||||
`simulate` relabels every opcode through a bijection whose ground truth it writes to disk.
|
||||
`compare` is then given only the two dex files and must re-derive that table.
|
||||
|
||||
```
|
||||
$ python .../vmp_diff_harness.py simulate .../fixture/dex/classes.dex \
|
||||
--out tools/_work/t5-vmp/sim.dex --seed 7 --table tools/_work/t5-vmp/truth.json
|
||||
simulated: ...classes.dex -> ...sim.dex
|
||||
mode=relabel, 3065 method body(ies)/instruction(s) touched (seed 7)
|
||||
header recomputed: checksum_ok=True signature_ok=True
|
||||
ground-truth table written to ...truth.json (compare must re-derive it)
|
||||
|
||||
$ python .../vmp_diff_harness.py compare .../fixture/dex/classes.dex tools/_work/t5-vmp/sim.dex
|
||||
aligned=75 resized=0 stripped=0 missing=0 added=0
|
||||
aligned slots compared: 3065
|
||||
verdict: USABLE
|
||||
(map: 218 rows, every one verdict=high)
|
||||
compare rc=0
|
||||
```
|
||||
|
||||
Scoring the derivation against the ground truth:
|
||||
|
||||
```
|
||||
ground-truth bijection size : 256
|
||||
opcodes the fixture emits : 218
|
||||
compare rows : 218
|
||||
compare high-confidence rows : 218
|
||||
compare undetermined : 6
|
||||
compare non-injective : 0
|
||||
compare run verdict : usable
|
||||
|
||||
derived entries that are WRONG : 0 (none)
|
||||
derived entries with no truth : 0 []
|
||||
emitted opcodes NOT derived : 0 []
|
||||
|
||||
EXACT RECOVERY: 218/218 emitted opcodes (100.0%)
|
||||
CLOSED-LOOP VERDICT: IDENTICAL
|
||||
```
|
||||
|
||||
Why the denominator is 218 and not 256: the ground-truth bijection covers all 256 byte
|
||||
values, but 32 of those are unallocated dex slots and 6 are opcodes this fixture never
|
||||
emits — and an opcode that never appears leaves no known plaintext. The six are reported
|
||||
as `undetermined` with their reasons, never guessed. **Every entry the run did produce was
|
||||
correct, and every opcode that could be read was read.**
|
||||
|
||||
## 4. Negative shapes — observed
|
||||
|
||||
The decision table's failure branches were driven on purpose, because a detector that has
|
||||
never been seen to fire is not a detector.
|
||||
|
||||
**Stub shape** (`--mode stub`: every body replaced by an equal-length `return-void` fill —
|
||||
the extraction shape):
|
||||
|
||||
```
|
||||
$ python .../vmp_diff_harness.py compare .../classes.dex tools/_work/t5-vmp/stub.dex --quiet
|
||||
aligned=75 resized=0 stripped=0 missing=0 added=0
|
||||
aligned slots compared: 3065
|
||||
verdict: NOT-USABLE
|
||||
! 1 private byte(s) are claimed by up to 218 original opcodes each. No injective
|
||||
relabelling can do that: it is the signature of method bodies replaced by one
|
||||
common stub, and any substitution read off such a pair describes the stub, not a
|
||||
private opcode space.
|
||||
rc=1
|
||||
```
|
||||
|
||||
This is the failure mode worth reading twice. In the per-opcode direction the reading is
|
||||
**perfect** — 218 rows, all `high`, zero conflicts — because every original opcode maps to
|
||||
the same private byte. Only the reverse (`private byte → set of originals`) check exposes
|
||||
it. An implementation without that check would have returned a confident, wholly
|
||||
meaningless table, and the first version of this one did exactly that: measured before the
|
||||
fix, the same input returned `verdict: PARTIALLY-USABLE` alongside a note reading
|
||||
"0 of 218 opcodes saw more than one candidate byte" — a warning that contradicted itself.
|
||||
|
||||
**Length-changing shape** (`--mode shrink`: `insns_size` rewritten without moving bytes, a
|
||||
deliberately inconsistent image that exists only to drive the detector):
|
||||
|
||||
```
|
||||
$ python .../vmp_diff_harness.py compare .../classes.dex tools/_work/t5-vmp/shrink.dex --quiet
|
||||
aligned=0 resized=75 stripped=0 missing=0 added=0
|
||||
aligned slots compared: 0
|
||||
verdict: NOT-APPLICABLE
|
||||
! 75 method(s) kept a body but changed its length. Boundaries cannot be projected
|
||||
across a length change; a private opcode stream with its own length table is not
|
||||
reachable this way.
|
||||
rc=1
|
||||
```
|
||||
|
||||
Regression after both fixes: the relabel loop still reports `USABLE` and the closed-loop
|
||||
check still reports `IDENTICAL` — recorded above, re-run after the verdict-logic change.
|
||||
|
||||
**Not driven:** the `stripped` shape (`code_off == 0`) needs a class-data rewrite rather
|
||||
than a byte edit, so its detector path is **inferred** from the shape counters, not
|
||||
observed firing. The counters themselves (`stripped=0` in every run above) are observed.
|
||||
|
||||
## 5. `emit-smali` — observed
|
||||
|
||||
```
|
||||
$ python .../vmp_diff_harness.py emit-smali tools/_work/t5-vmp/sim.dex \
|
||||
--table tools/_work/t5-vmp/cmp.json --class LOpCoverProbe --method arithInt
|
||||
; instructions restored: 3065 ; unmapped-opcode stops: 0
|
||||
; THIS IS A READING SKELETON, NOT ASSEMBLABLE SMALI.
|
||||
...
|
||||
.class public LOpCoverProbe;
|
||||
.super Ljava/lang/Object;
|
||||
.source "OpCoverProbe.java"
|
||||
|
||||
# ---- arithInt(II)I registers=4 insns_size=29
|
||||
.method public static arithInt(II)I
|
||||
.registers 4
|
||||
# 0x0000 90000203 add-int
|
||||
# 0x0004 d000e803 add-int/lit16
|
||||
# 0x0008 d8000007 add-int/lit8
|
||||
# 0x000c b130 sub-int/2addr
|
||||
...
|
||||
# 0x0038 0f00 return
|
||||
.end method
|
||||
```
|
||||
|
||||
Two checks that the restore is real rather than plausible: the stream decodes to the
|
||||
**original fixture's** `arithInt` body (compare it against `OpCoverProbe.java`'s 17
|
||||
operations), and the payload-bearing `switchPacked` method also restores cleanly —
|
||||
`packed-switch` followed by its payload block, stepped over by its own layout rather than
|
||||
mistaken for opcodes. `unmapped-opcode stops: 0` is the honest coverage measure, and it is
|
||||
zero only because the table is complete for this fixture.
|
||||
|
||||
Three defects were found and fixed by this run, each one a silent-wrong-answer shape:
|
||||
|
||||
- `--class LOpCoverProbe;` matched **zero** classes because the filter accepted only one of
|
||||
the three spellings (`LOpCoverProbe;` / `LOpCoverProbe` / `OpCoverProbe`) — output read
|
||||
exactly like a class that is not in the file.
|
||||
- `.source` printed `"<string_idx 34748 out of range>"`: `source_file_idx` sits at
|
||||
`class_def_item + 16`, not `+ 20` (that is `annotations_off`).
|
||||
- Methods rendered without access flags, so the skeleton was not readable as smali.
|
||||
|
||||
## 6. What was not done
|
||||
|
||||
- **No hardening platform was contacted.** The entire submission link of the chain is
|
||||
`unverified`, and its cost estimate in the reference file is reasoning about how those
|
||||
services are distributed (accounts, rate limits, download-page delivery, possible refusal
|
||||
of a synthetic fixture), not a measured upload. This is the pass's largest gap and it is
|
||||
not closeable from this machine.
|
||||
- **No real VMP sample was used.** `tools/_work/bench/repos/DexPatcher/assets/ezAndroid.apk`
|
||||
is recorded in `ENV.md` as a real Dex-VMP challenge sample and was **not** opened in this
|
||||
pass; the differential route needs a *matching hardened counterpart of the same build*,
|
||||
which no available artifact provides.
|
||||
- **The engine-behaviour assumption is inferred**: that a real VMP substitutes per-opcode
|
||||
at stable instruction length. `simulate` implements the weakest form of that assumption
|
||||
deliberately — it is the only form this harness can check itself against.
|
||||
- **Table stability across inputs is unverified.** Whether one fixture's table applies to
|
||||
another build of the same app, or survives a vendor version change, was not tested and is
|
||||
the reason the reference file says to re-derive per target.
|
||||
- **The `stripped` detector path did not fire** (see §4).
|
||||
- `--mode shrink` writes a structurally inconsistent dex by design; `dexutil.Dex.check()`
|
||||
will not reject it because it does not range-check `insns` against the file. It is a
|
||||
detector probe, is labelled as such in `--help`, and must not be treated as a build.
|
||||
249
vendor-new/apk-reverse/docs/tool-verification/FINDINGS.md
Normal file
249
vendor-new/apk-reverse/docs/tool-verification/FINDINGS.md
Normal file
@@ -0,0 +1,249 @@
|
||||
# Findings about the skill, from measuring it
|
||||
|
||||
Everything here is a result about **the skill**, not about the target. Each item names the evidence
|
||||
that produced it and its strength.
|
||||
|
||||
---
|
||||
|
||||
## F1 — The Coverage claim was partly aspirational, and the pass identified exactly which part
|
||||
|
||||
`SKILL.md`'s Coverage section lists as *covered by verified mechanisms*:
|
||||
|
||||
> Flutter / Dart AOT: pinning the engine version, decoding the object pool, locating and patching
|
||||
> business logic inside `libapp.so` (`dart-aot.md`).
|
||||
|
||||
Measured against a real Dart 3.6.0 `libapp.so`, that sentence splits into three claims of very
|
||||
different standing:
|
||||
|
||||
| Sub-claim | Standing after this pass |
|
||||
|---|---|
|
||||
| pinning the engine version | **verified** — the banner route (§1) is correct and 3.6.0 is confirmed three ways |
|
||||
| decoding the object pool | **false as written** — the kit cannot do it; there is no resolver |
|
||||
| locating business logic inside `libapp.so` | **true, but conditional** — it works, on top of an external snapshot dumper that the skill neither ships nor installs |
|
||||
|
||||
The worked example in `TOOL-VERDICTS.md` shows both halves at once: the chain
|
||||
`"showRewardAdProvider"` → `pp+0x23548` → ref site `0x7a9464` → `ShowRewardAdFamily.call_2f28ac
|
||||
@ 0x7a93ec` succeeded, and step 1 was performed by blutter, not by the skill.
|
||||
|
||||
The reason is structural, not a shortage of effort: `dart_pool_strings.py` reports **file offsets**
|
||||
while `pp.txt` and `dart_pprefs.py` work in **pool offsets**. Over the 4,241 strings present in both
|
||||
spaces there are **4,237 distinct deltas** (min −1,361,429, max +277,654). The mapping is a property
|
||||
of the reconstructed pool and is not a constant, so no amount of care inside the script recovers it.
|
||||
|
||||
Capabilities the kit does not have, stated plainly: snapshot/object-pool deserialisation; symbol and
|
||||
name recovery; a real call graph (the `--build-index` index holds raw `B`/`BL` targets only); a
|
||||
decompiler; a function boundary/size table.
|
||||
|
||||
---
|
||||
|
||||
## F2 — Two scripts fail in the way that reads as a target finding (the P26 class)
|
||||
|
||||
The handover singled out `pitfalls.md` P26: *"a self-built analysis script fails in a way that reads
|
||||
as a target finding."* This pass produced two live instances of exactly that, which is why the P26
|
||||
warning earns its place.
|
||||
|
||||
**`elf_plt.py` returned a confident false negative.** It reports 3,437 stubs where `.rela.plt` holds
|
||||
3,438, and answers `0x587060 -> not a resolved stub` for an entry that is a legitimate stub for
|
||||
`dl_iterate_phdr`, with a real caller at `0x576a2c`. Nothing in the output indicates a problem. For
|
||||
the script's stated purpose — deciding whether a hardening symbol exists in a library — the wrong
|
||||
answer is indistinguishable from the right one.
|
||||
|
||||
**`find_refs.py` returned a clean empty result for input it could not read.** Its docstring advertised
|
||||
"a smali tree or a directory of dex files"; `iter_files()` accepted only `.smali`. Given the dex
|
||||
directory it printed `[total refs] 0`, exited **1**, and emitted no warning. This is worse than a
|
||||
crash: it is the exact input to "nothing references this, safe to patch", produced by the very tool
|
||||
recommended for judging blast radius before patching.
|
||||
|
||||
Re-measured later: the exit code recorded in this section was **0** and it is **1** — the `total == 0`
|
||||
path has always returned 1. The correction matters because it moves the defect from "no signal at
|
||||
all" to "the signal was there and the text contradicted it": the note diagnosed the *needle* and never
|
||||
named the *input form*, so a caller reading the message was still sent the wrong way. Fixed and
|
||||
re-verified; see F9 for what measuring it turned up underneath.
|
||||
|
||||
Both, plus `dart_pprefs.py`'s silently dropped double constants, share one property: **the failure is
|
||||
one-sided and quiet.** None of the three reports reduced confidence in its own output.
|
||||
|
||||
---
|
||||
|
||||
## F3 — Defects found, ranked by consequence
|
||||
|
||||
| # | Script | Defect | Consequence | Status |
|
||||
|---|---|---|---|---|
|
||||
| 1 | `find_refs.py` | `iter_files()` matched only `.smali`; a dex dir yielded 0 results and a direct dex was regex-scanned as text | silent false "no references" on the tool whose job is blast-radius | **fixed, re-verified against a fixture and the real target** |
|
||||
| 2 | `elf_plt.py` | loop bound `off < fsz - 16` misses the final stub slot | false negative for one real symbol per library, conditionally | **fixed on a copy, re-verified** |
|
||||
| 3 | `dart_pprefs.py` | only the GP register file is decoded; `ldr dD,[x27,#imm]` (`0xFD400000`) and all double constants lost | under-reports pool references (302 displacements / 1,802 sites here), which breaks the "count refs to judge importance" advice in `dart-aot.md` §9/§10 — one site went `[0 refs]` → `[79 refs]` | **fixed on a copy; agrees with capstone 0/0** |
|
||||
| 4 | `so_constpatch.py` | rebuild drops the zip `extra` field, destroying 4 KB alignment | for `extractNativeLibs=false` APKs the output will not load; masked here because this target is DEFLATED + `extractNativeLibs=true` | **cause identified, proven by fixture; not fixed** |
|
||||
| 5 | `so_constpatch.py` | `--diff --name-regions` compares file offset with vaddr | feature completely inert on libraries where `p_vaddr != p_offset` (2 of 36 here), with no warning | cause identified |
|
||||
| 6 | `so_constpatch.py` | `external_attr` rewritten on 1982/1982 entries | metadata-based integrity comparison detects the rebuild | cause identified |
|
||||
| 7 | `dexutil.py` | switch payloads decoded as instructions; `decode()` does not resolve operands | caused a spurious "desync likely" verdict on `saveToLocal` | cause identified |
|
||||
| 8 | `so_constpatch.py` | "N byte(s) changed" is the overwrite window, not bytes changed | overstates change count (13 reported vs 7 actual) | cause identified |
|
||||
| 9 | `lib_map.py` | `?` means both "read failed" and "not probed" | a real read failure is invisible | cause identified |
|
||||
| 10 | `dart_pool_strings.py` | TSV escaping is lossy for LF/CR/TAB | 7 of 18,363 rows ambiguous; 19 entries genuinely contain those bytes | cause identified |
|
||||
| 11 | `elf_plt.py` | `size = 16 if ... else 16` | dead code | cause identified |
|
||||
| 12 | `dex_strings.py` | `--per-file` prints only files with hits | can be misread as "only this dex was scanned" | cause identified |
|
||||
|
||||
Items 1-3 are the ones that changed a conclusion during this pass rather than merely being noticed.
|
||||
|
||||
---
|
||||
|
||||
## F4 — Documentation contradictions
|
||||
|
||||
Independently found while mapping the skill and while using it. The executable cross-reference
|
||||
checkers (`check_refs.py`, `check_repo.py`) pass, so these are invisible to the repo's own gates.
|
||||
|
||||
| # | Location | Problem |
|
||||
|---|---|---|
|
||||
| 1 | `dart-aot.md` §2 | Claims blutter's `asm/` has *no instructions* — "Do not plan around it." **Wrong.** One plugin file under `asm/<plugin>/src/ads/reward/` is 1,327 lines carrying class layouts, signatures, a full instruction listing, pool annotations and resolved call targets. It is the richest artifact in the chain and the doc says to ignore it. |
|
||||
| 2 | `dart-aot.md` §2 | Budgets "tens of minutes" for the first blutter build; measured **≈78 s** (~30× pessimistic). Also demands a very recent C++ compiler; **MSVC 19.34 (Nov 2022) suffices**. |
|
||||
| 3 | `dart-aot.md` §7 | Presents two-byte hit chaining as an acceptance test. On this sample, 9,840 two-byte CJK candidates yield 2,081 chained runs, all garbage. |
|
||||
| 4 | `signature-derived-keys.md:61` | Documents `python scripts/sig_probe.py --live --pkg <pkg>` — the script has no `--pkg`; it takes `--live PKG`. Line 54 and `pitfalls.md` P27 give the correct form. |
|
||||
| 5 | `dart-aot.md:204` | `dart_disasm.py ... --index callers.json --build-index` omits the required argument value; argparse errors out. |
|
||||
| 6 | `byte-level-patching.md:186` | Says different-length strings "need `dex_strpatch`-class tooling", while `dex_strpatch.py` requires `len(new) == len(old)` — it is precisely the tool that cannot do this. Contradicts `dex-patching.md:123`. |
|
||||
| 7 | Coverage vs `toolchain.md` | Coverage lists Unity/IL2CPP logic recovery as **not covered**, but `toolchain.md` Tier 4 names concrete tooling without pointing back at that disclaimer, so a reader of `toolchain.md` alone would take IL2CPP for a supported route. |
|
||||
| 8 | Script count | `SKILL.md`/`README.md` index 35 rows, which is 34 scripts plus the `scripts/dexpatch/` directory row. |
|
||||
| 9 | `dart_pool_strings.py` docstring | Says two-byte auto-detection is unreliable (confirmed here); `dart-aot.md` §7 treats UTF-16LE hits as a reliable rule. |
|
||||
| 10 | `verification.md` rung 7 | Re-downloading a distributed file to compare hashes has no landing place in `SKILL.md`'s six "done" criteria, so the two sections disagree on strength. |
|
||||
|
||||
Item 1 is the most damaging because it steers a reader away from the best available evidence.
|
||||
|
||||
---
|
||||
|
||||
## F5 — "verified mechanisms" left no reproducible trace in the repo
|
||||
|
||||
The Coverage preamble frames its list as *verified mechanisms*, and `references/long-task-discipline.md`
|
||||
defines **Observed** as *"reproduced it, with the exact command and output"*. The repository contains
|
||||
no sample, no fixture, no captured log or screenshot, no patch record, no test and no CI, and its
|
||||
`README.md` states it holds no target-specific data.
|
||||
|
||||
So the strongest support the repository itself can exhibit for those claims is *Inferred*. This is
|
||||
**not** an accusation that the claims are false — several were confirmed here on the first attempt,
|
||||
and `dart-aot.md` §1 proved exactly right. It is a statement about evidentiary standing: the repo
|
||||
asks its readers to label their own claims and does not leave itself a way to meet its own bar.
|
||||
|
||||
The concrete measurement figures that *are* reproducible are the 14 sample-scoped numbers listed in
|
||||
the skill map (e.g. dex 4.32 MB → 7.73 MB; `.RSA` 1199 B vs 777 B at runtime; a log tag going `8 → 0`).
|
||||
|
||||
---
|
||||
|
||||
## F6 — Boundary evidence: where the skill's method held, and where it ran out
|
||||
|
||||
This is the observation the pass was built to produce, so it is stated as a sequence rather than a
|
||||
verdict.
|
||||
|
||||
**Held, unchanged:** manifest and packer triage; multi-dex string/class recon at 27.6 MB across four
|
||||
dex files (`dex_strings.py`, `dexutil.py`); native ELF import mapping at scale (36,974 relocations);
|
||||
live-process library mapping; equal-length `.so` constant patching; the launch-timeline methodology
|
||||
(`coldstart.py`); the P26 discipline itself — checking whether a tool was ever able to answer before
|
||||
reading its silence as a finding, which is what turned the blutter crash from a false "unusable" into
|
||||
a corrected "works after a relink".
|
||||
|
||||
**Ran out:** from the Dart snapshot inward. Everything past `pp.txt` is fine; getting *to* `pp.txt`
|
||||
from the binary requires a container resolver the kit does not have. That is one dependency, not a
|
||||
subject area.
|
||||
|
||||
**Not exercised at all:** packers, code virtualization, custom linkers, integrity-check redirection,
|
||||
tamper-triggered suicide. This target has none of those, and the unmodified app's own startup failure
|
||||
also removes the repack loop those scenarios need.
|
||||
|
||||
---
|
||||
|
||||
## F7 — A trap in this specific environment that will produce false confirmations
|
||||
|
||||
`androguard` 4.x (the version installed here) has **four independent reference-analysis failures on
|
||||
this sample**: `get_xref_from()` returns 0 for everything, including `AdLoader.loadAd()V` which has
|
||||
20+ callers by other means; `get_ref()` raises; a full `get_operands()` sweep returns 0 strings.
|
||||
|
||||
Anyone who reaches for androguard to double-check a "zero references" conclusion will get **zero, and
|
||||
therefore agreement**. The zero-reference result in `TARGET-FACTS.md` does not come from androguard;
|
||||
it comes from `dexutil.py` plus raw-byte re-checks of the bodies that would not decode cleanly. This
|
||||
is worth a line in the environment documentation because it is a tool that lies in the direction of
|
||||
the hypothesis being tested.
|
||||
|
||||
---
|
||||
|
||||
## F8 — Reconciling the two failures: a genuine but unproven loader signal
|
||||
|
||||
Recorded as a boundary between what was established and what was guessed, because collapsing them
|
||||
would be its own error.
|
||||
|
||||
The launch does show non-standard startup plumbing (observed):
|
||||
|
||||
- `E LoaderLog: 10026 / 101004` on the device, `10020 / 101003` on the emulator — an SDK-side
|
||||
loader logging numeric status codes
|
||||
- `E Instrumentation: Uninitialized ActivityThread, likely app-created Instrumentation, disabling
|
||||
AppComponentFactory` — the process is not taking the ordinary `ActivityThread` path
|
||||
- `E <PKG-truncated>: Unknown bits set in runtime_flags: 0x40000000` (logcat truncates the tag to 23
|
||||
characters, so the package name appears clipped in this line)
|
||||
|
||||
And yet the dex-side packer verdict is clean: no hardening library in the native set, an ordinary
|
||||
`application android:name` (the app's own class, not a packer stub), no known packer markers. Those two
|
||||
observations sit in tension.
|
||||
|
||||
**Not resolved.** The `LoaderLog` emitter was not attributed to a specific class — the string exists
|
||||
in `classes2.dex` and `classes3.dex` but the classes that declare it are third-party, and this pass
|
||||
did not trace the call chain. Whether this plumbing is a benign logging SDK, part of the Flutter
|
||||
plugin bootstrap, or a light-weight loader contributing to the abort is **unverified**. Stating it
|
||||
as established would be exactly the kind of overreach this report is trying to document.
|
||||
|
||||
---
|
||||
|
||||
## F9 — Fixing F2 exposed a deeper one: the dex opcode table was shifted, and nine widths were wrong
|
||||
|
||||
Fixing `find_refs.py`'s dex path means depending on `dexutil.decode()`'s boundaries being right, so
|
||||
that was measured rather than assumed. It was not right: on a real 8.9 MB dex, **65 of 50,791 method
|
||||
bodies did not decode to their declared boundary**, and **32 carried an operand index outside the
|
||||
table it indexes**.
|
||||
|
||||
The cause is a run of `OP_NAMES` and `insn_units` shifted by one from `0x16` to `0x2C`:
|
||||
|
||||
| opcode | specification says | the repository had | width: spec vs repo |
|
||||
|---|---|---|---|
|
||||
| `0x17` | `const-wide/32` (31i) | `const-wide/16` | **3 vs 2** |
|
||||
| `0x18` | `const-wide` (51l) | `const-wide/32` | **5 vs 3** |
|
||||
| `0x1B` | `const-string/jumbo` (31c) | `const-class` | **3 vs 2** |
|
||||
| `0x1C` | `const-class` (21c) | `monitor-enter` | **2 vs 1** |
|
||||
| `0x1E` | `monitor-exit` (11x) | `check-cast` | **1 vs 2** |
|
||||
| `0x20` | `instance-of` (22c) | `array-length` | **2 vs 1** |
|
||||
| `0x21` | `array-length` (12x) | `new-instance` | **1 vs 2** |
|
||||
| `0x23` | `new-array` (22c) | `filled-new-array` | **2 vs 3** |
|
||||
| `0x26` | `fill-array-data` (31t) | `throw` | **3 vs 1** |
|
||||
| `0x2C` | `sparse-switch` (31t) | *(absent)* | **3 vs 2** |
|
||||
|
||||
Plus `0xFA`-`0xFF` (`invoke-polymorphic`, `invoke-custom`, `const-method-handle`,
|
||||
`const-method-type`), absent from the width logic and defaulting to one unit each where the
|
||||
specification has 4, 4, 3, 3, 2, 2.
|
||||
|
||||
The `goto` family went with it: `branch_target()` computed a branch target for `0x27`, which is
|
||||
`throw`, and read the offset width one opcode early for the rest. `dex_find_insn.py`'s kind map
|
||||
carried the same shift in its `goto` and `switch` entries, so a semantic search for a branch matched
|
||||
the wrong instructions.
|
||||
|
||||
That this survived an earlier "fix instruction widths" pass is not surprising in hindsight:
|
||||
`insn_units` ended with `if op == 0x29: # measured: this slot is goto/16 (20t)` — the shape of a
|
||||
repair made one observation at a time, where the width of `0x29` was corrected without checking the
|
||||
opcode's *name* against a specification. Name and width were wrong together and consistently, which
|
||||
is exactly why the decode still looked plausible.
|
||||
|
||||
**How it was settled.** Not by reading the table again, but by two measurements that do not depend on
|
||||
this project's code:
|
||||
|
||||
1. **Self-consistency at scale.** A real dex is a stream ART can execute, so a correct decoder must
|
||||
land every method body exactly on `insns_off + insns_size*2`. 65 did not. After the fix,
|
||||
**50,791 of 50,791 do, with 0 illegal operand indices.**
|
||||
2. **A second decoder.** `androguard` 4.1.4 decoded the same dex independently. Aligned by method
|
||||
order, the per-instruction width sequences are **identical for all 50,791 bodies — 0 differing.**
|
||||
|
||||
Names and widths now come from the format specification's table, and width is a lookup (`OP_UNITS`)
|
||||
rather than a chain of range tests, which is the shape that made this class of error possible.
|
||||
|
||||
**Two things this pass did not fix, recorded so they are not mistaken for clean:**
|
||||
|
||||
- **`dexutil.string()` decodes MUTF-8 with `errors="replace"`.** Identifiers containing non-BMP
|
||||
characters decode to U+FFFD — this sample has them, and one crashed a `print` with a
|
||||
`UnicodeEncodeError` on a non-BMP codepoint. A class name read from the dex may therefore not
|
||||
compare equal to the same name read from another tool. It does not affect instruction decoding or a
|
||||
descriptor-based needle, but it is why the cross-check above had to align by order rather than by
|
||||
key.
|
||||
- `OP_NAMES` had no entry for roughly 150 valid opcodes (`aget`/`aput`, the `cmp` family, the
|
||||
`neg`/`not`/conversion unops, the arithmetic and `lit8`/`lit16` families). They decoded correctly by
|
||||
fallback but printed as `op_XX`. The regenerated table covers 224 opcodes.
|
||||
95
vendor-new/apk-reverse/docs/tool-verification/README.md
Normal file
95
vendor-new/apk-reverse/docs/tool-verification/README.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# Tool verification record
|
||||
|
||||
This directory is the **evidence record for the `apk-reverse` skill measured against real
|
||||
targets**, kept separate from the skill itself so the Coverage statement in `SKILL.md` can be
|
||||
checked against the runs behind it, and so a later reader can tell which claims were *observed*,
|
||||
which were *inferred*, and which are still *unverified*.
|
||||
|
||||
It is **not** a set of target-specific notes kept for reuse, and it deliberately carries **no target
|
||||
identity**. The measurements are about the **tools and the skill**; they stay valid whichever APK
|
||||
produced them. Where a concrete figure was needed to make a point (a class count, a file size, a
|
||||
build time) the figure is kept and the target is named generically.
|
||||
|
||||
Targets measured: Flutter AOT applications, multi-dex, mid-size native library sets, no packer.
|
||||
|
||||
The APKs themselves are **not** in this repository (`.gitignore` excludes `*.apk`, and `tools/`).
|
||||
|
||||
**Workbench paths are an operation record, not a pointer.** A record may say a run happened *at*
|
||||
`tools/_work/...` so the command can be judged; `.gitignore` excludes `tools/`, so that path is not
|
||||
something a reader can open. Anything a reader is *meant* to follow is named under
|
||||
`docs/tool-verification/` or inside the skill — that rule is why a reference document never cites a
|
||||
`tools/` path as its evidence, even when the work happened there.
|
||||
|
||||
## Strength labels
|
||||
|
||||
Every claim carries one of three labels, used the same way `references/verification.md` uses them:
|
||||
|
||||
- **observed** — reproduced here, with the exact command and its output.
|
||||
- **inferred** — follows from observed facts, but the step itself was not executed.
|
||||
- **unverified** — assumed or reported by a tool and not independently confirmed.
|
||||
|
||||
## Index
|
||||
|
||||
| File | Covers |
|
||||
|---|---|
|
||||
| `TOOL-VERDICTS.md` | One verdict per tested script and external toolchain, with the independent cross-check behind it |
|
||||
| `FINDINGS.md` | Findings about the skill itself: defects, contradictions, boundary evidence |
|
||||
| `REPO-DECISIONS.md` | What changed in the repo as a result, and what deliberately did not |
|
||||
|
||||
## Extension-pass record
|
||||
|
||||
The extension pass (the six coverage gaps named in the external review: module ecosystem, extraction
|
||||
shells and VMP, emulation/RPC, native DBI, kernel-level environment, protocol layers) keeps its
|
||||
evidence here, one file per topic, in the same three labels:
|
||||
|
||||
| File | Covers |
|
||||
|---|---|
|
||||
| `EXTENSION-device-run.md` | The on-device run against a hardened Flutter sample: the two control runs, the frida refusals, the **twice-corrected** pid-drift attribution, the failed-dump artefacts checked with `dex_dump_validate.py`, and a shared-device hazard measured with Stalker |
|
||||
| `EXTENSION-unpacking.md` | Extraction-shell diagnosis by trivial-body ratio, `dex_dump_validate.py` against a fixture derived from the sample's own shell dex, and the tool's own defect found during the run |
|
||||
| `EXTENSION-rootdump.md` | The root-side `/proc/<pid>/maps` + `mem` route that does not use frida: 17 real dex images recovered, the shell skeleton they are not, four device-shell traps, and the negative result on anonymous/ART-heap scanning |
|
||||
| `EXTENSION-lsposed.md` | Module route: the gradle-free build chain with timings, three build traps, LSPosed config-DB anatomy, the module that could never have worked, and the root-hiding configuration the device actually has |
|
||||
| `EXTENSION-emulation-rpc.md` | ASC/`ddc` measured on a hardened APK, Frida-RPC exercised end to end on a live device, and the unidbg build repair |
|
||||
| `EXTENSION-native-dbi.md` | Stalker: the zero-event boundary, the crash from following a hot libc export, and an offline protobuf round-trip that caught a defect in its own decoder |
|
||||
| `EXTENSION-kernel-ondevice.md` | The MT Manager APK MCP probe, the module-environment facts it could verify, and the kernel routes that this device's 4.14 kernel puts out of reach |
|
||||
|
||||
## Benchmark-pass record
|
||||
|
||||
The benchmark pass put **public targets** under the documented routes — `tests/benchmark.md` is the
|
||||
matrix, and these files are the evidence behind its rows. Same three labels. Several of these rows
|
||||
came back negative, and four of them overturned a conclusion this repository had already recorded;
|
||||
those are called out in the matrix rather than buried here.
|
||||
|
||||
| File | Covers |
|
||||
|---|---|
|
||||
| `EXTENSION-benchmark-l1-l3.md` | The first two matrix rows: the L1 equal-length patch -> repack -> re-sign chain with its on-screen behaviour change and its zero-change control, and the L3 time-to-death baseline, the two spawn/patch/detach orderings that cannot work, and the terminate path that patching the obvious death site does not stop |
|
||||
| `EXTENSION-extraction-shell-bench.md` | `dex_dump_validate.py` against an 11-variant skeleton set: the trivial-body ratio as a bimodal result rather than a threshold, the three script defects the variants exposed, and the ranking that pointed at a modified image until independent review caught it |
|
||||
| `EXTENSION-stalker-exclude.md` | Stalker exclusion measured across three arms: the no-follow baseline, the follow that kills the process, and the follow with 20 modules excluded that survives but still delivers zero events |
|
||||
| `EXTENSION-java2c.md` | Java2C against JNI sinking: native-declaration density roughly 2000x apart, why a `Java_*` symbol search fails silently, and the Dex-to-C build chain no toolchain on this host could complete |
|
||||
| `EXTENSION-vmp-diff.md` | The known-plaintext differential: a 218-of-224-opcode coverage fixture, a closed-loop check that recovered 218/218 mappings with zero fabrications, and the one link that cannot be automated |
|
||||
| `EXTENSION-kernel-weapons.md` | The generated KernelSU/APatch scaffold and its three kernel-side templates, all shipped unbuilt, plus the structural correction that a userspace module cannot change a syscall return value |
|
||||
| `EXTENSION-split-apk.md` | Two real split sets through both branches: unified re-signing, the merge that is refused by design, the merge that succeeds, and the signer chain this host actually needs |
|
||||
| `EXTENSION-reconstruction.md` | The reconstruction pass: the doctor capability closure (a JDK-only PATH used to report "can re-sign", now `BLOCKED` with a next action), the zip rebuild that stops dropping per-entry metadata (`zipalign -c -v 4` fails the old output and passes the new), and `check_commands.py` catching a documented flag that does not exist |
|
||||
| `EXTENSION-test-harness.md` | The executable half of the evidence: what `tests/` asserts and what it measured, the `dex_patch_bytes` regression reproduced byte-for-byte, the 153 skips and 26 xfails that form the exit-code work order, the device-bound exclusion forced by an incident where the suite drove a real phone, and CI written but never run |
|
||||
| `EXTENSION-rasc.md` | The Rust ASC measured against the Python one: identical class-definition sets on two archives, the speedup per scenario, the build (no prebuilt artifact exists, and a 32-bit MinGW cannot link it), and **the enum shape whose method bodies it drops with no warning** |
|
||||
| `EXTENSION-protobuf-raw.md` | A schema-free decoder cross-checked against the official runtime and a real DataStore container, with the packed-boundary and proto3-zero ambiguities reproduced on real bytes |
|
||||
|
||||
## Absorption-pass record
|
||||
|
||||
A later pass absorbed mechanics from external skill repositories (`reverse-skill`,
|
||||
`awesome-game-security`, `android-reverse-engineering-claude-skill`, `MobileRE-Skill`), keeping each
|
||||
project's own discipline: only the phase, the checklist and the command pattern cross over, with the
|
||||
source URL and the access date recorded, and nothing vendored as a runtime dependency. These files
|
||||
live here rather than in the skill because two of them corrected a claim the repository had already
|
||||
written down.
|
||||
|
||||
| File | Covers |
|
||||
|---|---|
|
||||
| `EXTENSION-desensitization.md` | The leak scanner's rules and exemptions, its four `RESULT=` states and exit codes, the escaping false positives, and **the pass that found its own evidence file leaking 26 strong hits** — the fixture's literal match values, quoted to prove the rules fire |
|
||||
| `EXTENSION-dart-aot-formats.md` | Two string-table claims tested against a real dual-ABI Flutter app: the arm64 packed scheme confirmed against literal tag bytes, and the assumed armv7 `len*2`/UTF-16 form **refuted** — the 32-bit record is `[header u32][byte-count u32le][UTF-8]`, and the extractor's zero is a format mismatch rather than an empty table |
|
||||
| `EXTENSION-detection-pipeline.md` | Naming the check that fires: three arms on a public MASTG target (control alive, probe arm dead in ≈300 ms via `strstr("frida")`, with the `TracerPid=0` versus 4-frida-mappings asymmetry), `svc_scan.py`'s two decoders agreeing on an identical 214-site set, the layered Java-to-svc descent, and the ptrace-free dump boundary on targets whose dex is deflated inside the APK |
|
||||
|
||||
## Adding a run
|
||||
|
||||
Record the tools, versions and exact commands, and label each claim. Keep target-specific facts out:
|
||||
what belongs here is what a future reader can apply to a *different* APK — a defect, a measured
|
||||
cost, a method that did or did not work, and the cross-check that settled it.
|
||||
305
vendor-new/apk-reverse/docs/tool-verification/REPO-DECISIONS.md
Normal file
305
vendor-new/apk-reverse/docs/tool-verification/REPO-DECISIONS.md
Normal file
@@ -0,0 +1,305 @@
|
||||
# Repository decisions, and the evidence behind them
|
||||
|
||||
The pass was framed as a choice between two ways to split the skill. The measurement rejected the
|
||||
framing, and this file records why, then states exactly what changed in the repository.
|
||||
|
||||
---
|
||||
|
||||
## The decision: **no second skill.** Fix skill A instead.
|
||||
|
||||
This is the "暂不建" outcome the brief explicitly permits, and it is supported by the evidence rather
|
||||
than by preference.
|
||||
|
||||
### Why the framing did not survive contact with the target
|
||||
|
||||
The two candidate splits were:
|
||||
|
||||
- **by entry point** — everything reached from an APK (including Flutter/IL2CPP) stays in A; only
|
||||
standalone ELF goes to B. Cost: A keeps growing.
|
||||
- **by logic location** — A keeps only logic genuinely in dex; Flutter, IL2CPP, hardened dex, native
|
||||
and standalone ELF all move out. Cost: four references (~962 lines) relocate.
|
||||
|
||||
The measured target is exactly the case that was supposed to discriminate between them: Flutter
|
||||
(machine-code side) whose entry point is an APK. It produced a third answer.
|
||||
|
||||
**The problem is not where the content lives. It is one missing dependency.** Everything the kit does
|
||||
*after* a snapshot dumper has produced `pp.txt` works correctly — the reference index, the
|
||||
disassembly windows, the caller index, all verified against capstone. The single thing it cannot do
|
||||
is get *from the binary to* `pp.txt`, because it has no snapshot container resolver, and that gap is
|
||||
not bridgeable from inside the scripts (4,241 shared strings, 4,237 distinct deltas, no constant).
|
||||
|
||||
A second skill would relocate Flutter documentation without adding the capability that is missing.
|
||||
The gap would travel with the documents. Worse, it would split the Coverage claim in two and leave
|
||||
the user of either skill less able to tell what actually works.
|
||||
|
||||
### The supporting evidence, in order of weight
|
||||
|
||||
1. **The kit works where it was tested.** `dart_disasm.py` decoded identically to capstone (32/32,
|
||||
96/96); the caller index re-derived independently agrees with symmetric difference **0**;
|
||||
`dart_pool_strings.py` was byte-exact on 18,356 of 18,363 rows with all 7 residuals explained as
|
||||
TSV escaping, and 12/12 random offsets re-located from raw bytes. These are not the results of a
|
||||
tool that needs replacing.
|
||||
2. **The blocker is a front end, and free front ends exist.** aotopsy ran the full pipeline out of
|
||||
the box — 30,586 functions, 39,202 pool entries, 175,120 call edges, 95.7% BLR annotation — with
|
||||
**no toolchain**: pure Go, no compiler, no Dart SDK. That is the "decoding the object pool"
|
||||
capability, delivered as a binary.
|
||||
3. **A split would contradict the Coverage section's own structure.** Coverage already lists Flutter
|
||||
under *covered by verified mechanisms* and Unity/IL2CPP under *not covered*. The real defect is
|
||||
that the Flutter line overstates what ships. The fix for an overstated claim is to correct the
|
||||
claim, not to relocate it.
|
||||
4. **The cost of the split lands on the wrong people.** Moving `dart-aot.md`,
|
||||
`native-and-so.md`, `native-tamper-and-suicide.md` and `code-virtualization-and-custom-linkers.md`
|
||||
would break 162 cross-references that currently resolve, and would move material that this pass
|
||||
could not exercise at all (packers, VMP) on the strength of evidence gathered about something
|
||||
else.
|
||||
|
||||
### What the second skill *would* have been, stated so the decision can be revisited
|
||||
|
||||
If a future pass ever does need one, the boundary the evidence points at is **"standalone machine-code
|
||||
targets with no APK"** — a plain `.so`/ELF analysed on its own. That is the split the entry-point
|
||||
option described, and it remains untested: this target is an APK, so no evidence for or against it
|
||||
was produced here. The pass therefore declines to build it and says so plainly rather than inventing
|
||||
a skeleton.
|
||||
|
||||
### Selective installation, measured — and what it implies for shared tooling
|
||||
|
||||
The brief asked for the shared-tooling question to be settled by the actual install product rather
|
||||
than by preference. Measured with `npx skills add newliver666/apk-reverse --skill apk-reverse -y
|
||||
--copy` in an empty directory:
|
||||
|
||||
- The skill is copied **whole and self-contained** into each detected agent directory — `.agents/`,
|
||||
`.claude/`, `.kiro/`, `.pi/`, `.qwen/`, `.zcode/`, six in total on this host — plus a
|
||||
`skills-lock.json` at the root.
|
||||
- Every file lands under `<agent>/skills/apk-reverse/`: `SKILL.md`, all 28 `references/*.md`, all 34
|
||||
`scripts/*` including the `dexpatch/` subdirectory. **Nothing outside the skill directory is
|
||||
installed.** The repository's root-level `docs/` and the three maintenance checkers are absent from
|
||||
the product, as intended.
|
||||
- There is **no cross-skill sharing mechanism** in the installed layout.
|
||||
|
||||
Consequence for this decision: had the skill been split, the two halves would each have needed their
|
||||
own copy of any shared script, or a path that points outside the skill directory — and the latter
|
||||
does not survive installation, because it is not part of the product. So the "shall we put shared
|
||||
tools in a root `shared/`" question resolves itself: it cannot work that way, and with one skill there
|
||||
is nothing to share. The record is kept here because the observation is what makes the reasoning
|
||||
checkable rather than asserted.
|
||||
|
||||
---
|
||||
|
||||
## Changes made
|
||||
|
||||
### `references/dart-aot.md`
|
||||
|
||||
| Change | Reason |
|
||||
|---|---|
|
||||
| Documented a **pinned front end** (aotopsy v1.6.0, sha256 `5a90bcf2…fdc1`; Dart 2.10–3.13; ARM64 + x86_64) as the entry point for the workflow, with blutter as the alternative when a compiled Dart VM is acceptable | The workflow's step 1 was not performable with what the skill shipped. Measured: the kit cannot go from a string to a pool offset. |
|
||||
| Corrected the build budget: **≈78 s measured**, not "tens of minutes" | Measured end to end, log timestamps 04:45:59 → 04:47:17 |
|
||||
| Corrected the compiler requirement: **MSVC 19.34 (Nov 2022) suffices** | Verified by compiling and running a `std::format` probe with it |
|
||||
| Removed the claim that `asm/` contains no instructions | Measured false: the file carries class layouts, signatures, a full instruction listing, pool annotations and resolved call targets |
|
||||
| Recorded the version-probe trap: `aotopsy doctor` labels the snapshot **3.6.2**, the engine banner and snapshot hash say **3.6.0**, and 3.6.0 is correct | Three independent facts agree on 3.6.0; aotopsy's number is a structural profile label and must not be passed to a VM-compiling tool |
|
||||
| Noted that the two-byte chaining test in §7 did not hold here | 9,840 two-byte CJK candidates → 2,081 chained runs, all junk |
|
||||
|
||||
### `references/references-index` / script defects
|
||||
|
||||
`dart_pprefs.py` is fixed in the repository. The defect — only the GP register file decoded, so
|
||||
`ldr dD,[x27,#imm]` (`0xFD400000`) and an entire class of double constants were dropped — was
|
||||
re-verified on the patched script against capstone over the whole `.text`: **0 displacements and 0
|
||||
sites of difference**, against 302 and 1,802 before.
|
||||
|
||||
`find_refs.py`, `elf_plt.py` and the `so_constpatch.py` alignment defect are **documented but not
|
||||
fixed**. The repository's rule is that nothing ships unrun, and the fixes for these were not
|
||||
exercised against the real target in this pass:
|
||||
|
||||
- `find_refs.py` — accepting `.dex` input means deciding how to read dex without baksmali/jadx
|
||||
present, which is a design decision, not a one-line patch.
|
||||
- `elf_plt.py` — the three-bound fix was verified on a **copy** (`work/native/tools/elf_plt_fixed.py`),
|
||||
not in the repository, so the repository version is untouched.
|
||||
- `so_constpatch.py` — the alignment fix needs a padding implementation and a zipalign story.
|
||||
|
||||
Each is recorded in `docs/tool-verification/FINDINGS.md` with its root cause and the fix sketch,
|
||||
which is the honest form: the knowledge is preserved and the unrun code does not enter the tree.
|
||||
|
||||
### `SKILL.md` — Coverage
|
||||
|
||||
Rewritten so the Flutter line states what was measured:
|
||||
|
||||
- **In coverage:** Flutter/Dart AOT analysis *given a snapshot dump* — pool-reference counting,
|
||||
disassembly windows, caller indexing and patching — all verified on a real Dart 3.6.0
|
||||
`libapp.so`, with the front end named as a documented dependency.
|
||||
- **Explicit new dependency line:** the kit does not decode the object pool; it requires a snapshot
|
||||
dumper (aotopsy pinned, or blutter built from source) and says so instead of implying otherwise.
|
||||
- **Unchanged:** Unity/IL2CPP, React Native/Hermes, iOS, defeating a server-side authority, and the
|
||||
general-unpacker/anti-detection arms race all remain uncovered.
|
||||
- **Added:** an explicit statement that packer, VMP and integrity-check scenarios were **not
|
||||
exercised** by this pass, so no claim is upgraded on their behalf.
|
||||
|
||||
### `SKILL.md` — the three hand-off points
|
||||
|
||||
Written from the perspective that a hand-off is a pointer, and that duplicating content across two
|
||||
places is how the two copies drift. Each one says what the other side owns.
|
||||
|
||||
1. **JNI** — Java declaring a native method ↔ its `.so` implementation. Static linkage shows as
|
||||
`Java_com_pkg_Class_method` and disappears under R8 short names; dynamic registration leaves the
|
||||
symbol table empty and must be found at `RegisterNatives`. This target ships the static form
|
||||
(`FlutterJNI.loadLibrary` appears in `classes3.dex`) but the pass did not trace a JNI boundary
|
||||
end to end, so the section carries the **unverified** label.
|
||||
2. **Hardening** — dex-side observation of a packer implies a native-side shell implementation.
|
||||
**Not exercised here**: this target has no packer. Recorded as a pointer with that stated.
|
||||
3. **Existing boundary documents** — `native-and-so.md` and `native-tamper-and-suicide.md` read
|
||||
native anomalies *from the APK side* (immediate SIGSEGV after a repack, Java claiming the check
|
||||
passed while the process dies). That belongs in A; deep native work is the other side. The new
|
||||
section points each way rather than repeating either.
|
||||
|
||||
### `docs/tool-verification/`
|
||||
|
||||
New directory holding this pass's evidence record. `README.md` states the folder is a *measurement
|
||||
record, not shipped target data*, carries the strength-label convention, and requires that **no target
|
||||
identity is recorded** — only what a future reader can apply to a different APK. It is referenced from
|
||||
`SKILL.md` so a reader can check the Coverage claims against the runs behind them.
|
||||
|
||||
No APK is in the repository (`.gitignore` already excludes `*.apk`) and no credential or token
|
||||
appears in any file.
|
||||
|
||||
### `README.md`
|
||||
|
||||
Updated for the directory tree, the install command and the division of labour between the skills,
|
||||
plus a pointer to the verification record.
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
All four gates were re-run against the final tree:
|
||||
|
||||
- `python -B check_repo.py`
|
||||
- `python -B check_refs.py`
|
||||
- `python -B build_scripts.py . <out>`
|
||||
- `npx skills add newliver666/apk-reverse --list`
|
||||
|
||||
Two behaviours of the checkers worth knowing, both established by experiment during this pass:
|
||||
`check_repo.py` and `check_refs.py` have **no argparse**, so `--help` is silently ignored and the
|
||||
full check runs; and running `check_repo.py` with `PYTHONIOENCODING=utf-8` set produces a spurious
|
||||
failure (exit 1, one script reported as crashed) because its `subprocess.run(..., text=True)` does
|
||||
not pin an encoding while the child emits UTF-8 — the parent then decodes as cp936 and the streams
|
||||
come back empty.
|
||||
|
||||
---
|
||||
|
||||
## A later pass: fixing the `find_refs.py` defect, and what measuring it exposed
|
||||
|
||||
The three defects recorded above as "documented but not fixed" were left alone for a stated reason:
|
||||
the repository ships nothing unrun. One of them — `find_refs.py` and `.dex` input — was reported again
|
||||
from outside with a clean reproduction, and that reproduction is correct. It is now fixed, together
|
||||
with a deeper defect the fix exposed.
|
||||
|
||||
### `find_refs.py`
|
||||
|
||||
Accepts a `.dex`, a directory of `.smali` and/or `.dex`, an `.apk`, or a smali tree. Dex input is
|
||||
decoded through `dexutil`, so baksmali is not required — which matters because the machine where the
|
||||
defect was found had no baksmali, jadx or apktool, and the tool was therefore completely unusable for
|
||||
that target. Input it cannot read is **refused with exit 2 and a reason**, and every run prints
|
||||
`[scanned] N file(s)` before the count, so "nothing was readable" can no longer print the same line as
|
||||
"read N files and found nothing".
|
||||
|
||||
### `dexutil.py` — the part that could not be skipped
|
||||
|
||||
`find_refs.py`'s dex path depends on `decode()`'s instruction boundaries, so those were measured
|
||||
instead of assumed. They were wrong: the opcode table was shifted from `0x16` to `0x2C` with nine
|
||||
widths wrong (F9), and 65 of 50,791 method bodies on a real dex did not decode to their declared
|
||||
boundary.
|
||||
|
||||
Fixing `find_refs` while leaving that in place would have produced the same silent false zero the
|
||||
defect was reported for, one layer down. So names and widths were regenerated from the format
|
||||
specification's table, width became a lookup (`OP_UNITS`) rather than a chain of range tests, and the
|
||||
`goto` family was corrected in `branch_target()` and in `dex_find_insn.py`'s kind map.
|
||||
|
||||
### Verification
|
||||
|
||||
- `[scanned]`/`[total refs]` from a `.dex` and from the equivalent smali tree **agree with each other
|
||||
and with the count written into the fixture by construction** (3 / 2 / 2).
|
||||
- **50,791 of 50,791** method bodies decode to their declared boundary (65 did not before), and **0**
|
||||
operand indices fall outside their table (32 did before).
|
||||
- **androguard 4.1.4**, a decoder sharing no code with this project, produces **identical
|
||||
per-instruction width sequences for all 50,791 bodies**.
|
||||
- Real target: `Ljava/lang/String;->length` → 356 references in 2.3 s; a five-dex directory → 9,472
|
||||
across 2 files; a typo'd path → exit 2.
|
||||
- The four repo gates still pass: `check_repo.py` 0 problems, `check_refs.py` 0 dangling,
|
||||
`build_scripts.py` 0 findings, `npx skills add … --list` lists the skill.
|
||||
|
||||
### Why the fixture is in the tree
|
||||
|
||||
`docs/tool-verification/fixtures/make_fixture_dex.py` is committed because it is the evidence for the
|
||||
counts above. Without it, "3 references" is a claim a later reader cannot check — which is exactly the
|
||||
evidentiary gap F5 describes, and the repository's rule that nothing ships unrun applies to claims as
|
||||
much as to code. It is dependency-free (stdlib only) so it reproduces on a machine with no Android
|
||||
toolchain, which is the machine the defect was found on. It carries no target data: the dex is
|
||||
synthesised from constants in the file.
|
||||
|
||||
## Maintenance pass: the path audit's signal, three path families, and a corpus note
|
||||
|
||||
An external review found the repository in good shape on evidence and honesty but raised three
|
||||
concrete defects. All three are fixed here; the fourth item is a measurement added so the next
|
||||
pass can see what this one could not.
|
||||
|
||||
### 1. `build_scripts.py` reported 37 findings, most of them noise
|
||||
|
||||
The audit matched **any** `X:\...` literal, so `C:\Program Files\...`, `C:\Python314\...` and
|
||||
literal placeholders like `<user>` all counted as "machine-specific leftovers". A gate whose output
|
||||
is mostly noise stops being read, which is the same failure `scan_leaks.py` names when it refuses to
|
||||
exempt the RFC 5737 documentation ranges.
|
||||
|
||||
Added two explicit, auditable exemption families rather than a looser regex:
|
||||
|
||||
- `GENERIC_PATH_PREFIXES` — install locations that exist on every host of that platform
|
||||
(`Program Files`, `Python*`, `MinGW`, `/usr/`, `/opt/`, ...).
|
||||
- `_leaks_nothing()` — also exempts an already-redacted path, i.e. one carrying a placeholder or an
|
||||
ellipsis, because that mark means a normalisation pass already ran.
|
||||
|
||||
**One trap had to be handled explicitly.** `BARE_ABS` stops at whitespace, so a real
|
||||
`C:\Program Files\...` arrives as the truncated `C:\Program`; a one-directional `startswith` misses
|
||||
the exemption. The comparison is therefore bidirectional, gated by a minimum length so a short drive
|
||||
prefix cannot become a blanket pass. The same function needed a second correction: with a lookahead
|
||||
of only `[a-z0-9]`, the optional `\\?` backtracks to the empty string, the next character is the
|
||||
separator, it passes, and a real personal path is swallowed — the lookahead must exclude the
|
||||
separator too.
|
||||
|
||||
### 2. Three absolute paths left on the published surface
|
||||
|
||||
`EXTENSION-desensitization.md`, `EXTENSION-extraction-shell-bench.md` and `EXTENSION-vmp-diff.md`
|
||||
quoted a workbench root, a tool jar and an SDK location as absolute paths. `docs/tool-verification/`
|
||||
already states the rule (§**Workbench paths are an operation record, not a pointer**): the record
|
||||
may say a run happened at a `tools/...` path, written relatively. The three files were made to match
|
||||
the rule they were already under; the tool-jar and SDK cases were rephrased rather than
|
||||
placeholder-ised, so no reader has to learn a new token.
|
||||
|
||||
### 3. `check_budget.py` could not see corpus growth
|
||||
|
||||
The gate budgets the always-loaded part and was silently blind to the other half of the problem: a
|
||||
new reference costs exactly **one index line**, so the corpus can double while the narrative count
|
||||
stays green. Two note-level thresholds now report it. Notes, not failures — a large corpus is a
|
||||
choice; a growth nobody measured is the defect.
|
||||
|
||||
### A cross-tool inconsistency this pass ran into
|
||||
|
||||
`build_scripts.py` now exempts any `<...>` placeholder; `scan_leaks.py` recognises a **fixed token
|
||||
list** (`<user>`, `<name>`, `<path>`, ...). An invented placeholder in a source comment —
|
||||
`<someone>` — therefore passed the first and was read by the second as the very personal name its
|
||||
`user_path_windows` rule exists to catch, turning `check_repo.py` red. That is by design rather than
|
||||
a bug (a strict list is what stops `<realname>` hiding a real one), but the two tools disagree about
|
||||
what "already redacted" means, and the disagreement is invisible until someone writes a new
|
||||
placeholder. The fix here was to rephrase the comment; a future pass may want a shared token list.
|
||||
|
||||
### Verification
|
||||
|
||||
- `build_scripts.py` **37 → 0** findings on a clean clone, exit 0 (was exit 2). The generic-install
|
||||
hits (`C:\MinGW\bin\gcc.exe`, `C:\Python314\...`, `C:\Program`) no longer report; the real ones
|
||||
(`E:\apk-reverse\...`, `E:\agb_apktool.jar`) did, and were fixed in the documents, not by widening
|
||||
the list.
|
||||
- A purpose-written case table pins **both** directions — 18 cases, 0 failures: generic and
|
||||
already-redacted paths are exempt, while a real workbench path, a real personal name after
|
||||
`C:\Users\`, a real POSIX home and a real project path still report. Two defects in the first
|
||||
implementation were found by that table, not by the audit run.
|
||||
- All four gates pass: `check_repo.py` 0 problem, `check_refs.py` 0 dangling / 0 warning,
|
||||
`check_budget.py` 0 failure / 0 warning / 3 notes, `build_scripts.py` 0 findings.
|
||||
- `check_budget.py` now reports the corpus explicitly: **47 references + 55 scripts = 102 files,
|
||||
31,423 lines**, past the 26,000-line note threshold — the figure this pass could not see before.
|
||||
|
||||
554
vendor-new/apk-reverse/docs/tool-verification/TOOL-VERDICTS.md
Normal file
554
vendor-new/apk-reverse/docs/tool-verification/TOOL-VERDICTS.md
Normal file
@@ -0,0 +1,554 @@
|
||||
# Tool verdicts — measured on a real target
|
||||
|
||||
One verdict per script, drawn from the closed set:
|
||||
|
||||
| Verdict | Means |
|
||||
|---|---|
|
||||
| **verified on real target** | Ran here, and its output was confirmed by an independent method that does not share code with it |
|
||||
| **broken — fixed** | Was wrong; the defect is localized and a corrected copy was run and re-verified |
|
||||
| **broken — cause identified, not fixed** | Was wrong; root cause established, no fix attempted |
|
||||
| **unusable here because …** | Correct diagnosis, but this environment/target cannot exercise it |
|
||||
| **not applicable to this sample** | The target lacks the feature the tool addresses |
|
||||
|
||||
The distinction that governs the whole table: **a script that runs without error is not a script
|
||||
that is right.** Every "verified" below names the independent cross-check, because "it didn't
|
||||
crash" was not accepted as evidence anywhere in this pass. Equally, a tool failure was never read
|
||||
as "the target does not contain that thing" — each failure was first checked against the question
|
||||
of whether the tool was ever able to answer it.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Script | Verdict |
|
||||
|---|---|
|
||||
| `elf_plt.py` | **broken — fixed** (core stub→symbol mapping verified on real target; two defects, one proven to cause a false negative) |
|
||||
| `lib_map.py` | **verified on real target** |
|
||||
| `so_constpatch.py` | **verified on real target** for equal-length rewriting; **broken — cause identified, not fixed** for one sealed serious defect (zip alignment) |
|
||||
| `dex_strings.py` | **verified on real target** (multi-dex traversal confirmed) |
|
||||
| `dexutil.py` | **verified on real target** (used as the primary workhorse for the ad-chain analysis) |
|
||||
| `find_refs.py` | **broken — fixed and re-verified**; reads a `.dex` directly now |
|
||||
| `dart_pool_strings.py` | **verified on real target** |
|
||||
| `dart_pprefs.py` | **broken — fixed** (defect proven; corrected copy agrees with capstone to zero) |
|
||||
| `dart_disasm.py` | **verified on real target** |
|
||||
| `coldstart.py` | **verified on real target** (the run produced the launch-crash finding) |
|
||||
| `native_crash.py` | **not applicable to this sample** (no artificially constructed crash; see `TARGET-FACTS.md`) |
|
||||
| `so_constpatch.py` redirected-checker scenario | **not applicable** — no integrity-check library exists in this target |
|
||||
| All packer / VMP tooling | **not applicable** — no packer in this target |
|
||||
| blutter (external) | **works** — build ≈78 s, transient first-run `0xC0000005` not reproducible after relink |
|
||||
| aotopsy (external) | **works out of the box** — full pipeline on the same `libapp.so`
|
||||
|
||||
Two of these verdicts matter more than the rest: `elf_plt.py` produced a **false negative** on a
|
||||
symbol that exists and is called, and `find_refs.py` returns a clean "no references" answer for
|
||||
input it cannot read at all. Both are the failure mode that looks like a finding.
|
||||
|
||||
---
|
||||
|
||||
## `elf_plt.py` — broken — fixed
|
||||
|
||||
**Claim under test:** walks `PT_LOAD`/`PT_DYNAMIC` directly, without depending on the section
|
||||
table, maps PLT stubs to imported symbols, and diffs two `.so` files byte-wise.
|
||||
|
||||
**Sample:** `lib/arm64-v8a/libloader.so` (6,221,896 B, largest `.dynsym` in the APK).
|
||||
|
||||
**Run:**
|
||||
|
||||
```
|
||||
python scripts/elf_plt.py work/native/samples/arm64-v8a_libloader.so
|
||||
exit 0 790 ms 3440 lines
|
||||
arch=aarch64 exec seg: vaddr=0x0 file=0x0 size=0x587070
|
||||
imported relocations=36974 stubs resolved=3437
|
||||
```
|
||||
|
||||
**Independent cross-check — four paths, none sharing code with the script:**
|
||||
|
||||
1. **Relocation total.** `readelf -d` (pyelftools) reads `RELASZ=804864`, `PLTRELSZ=82512`,
|
||||
`RELAENT=24`. `804864/24 = 33536` (`.rela.dyn`) + `82512/24 = 3438` (`.rela.plt`) `= 36974`.
|
||||
Matches the script's `imported relocations=36974`.
|
||||
2. **Symbol names, item by item.** pyelftools parses `.rela.plt` + `.dynsym` independently
|
||||
(3,438 entries, all `R_AARCH64_JUMP_SLOT=1026`). Against the script's 3,437 output lines:
|
||||
`name mismatches: 0`. The script's GOT set is a subset of the `.rela.plt` GOT set, with an
|
||||
intersection of **0** against `.rela.dyn` — i.e. it does not mistake local relocations for
|
||||
imports. Zero `?` names.
|
||||
3. **lief 1.0.0** independently reports `pltgot_relocations = 3438`, equal to the pyelftools set,
|
||||
with zero name disagreements.
|
||||
4. **capstone 5.0.7 real disassembly** of all 3,437 reported stub addresses, 16 bytes each, with the
|
||||
GOT recomputed from the decoded instructions: `shape_violations: 0` (every stub is
|
||||
`adrp x16 / ldr x17,[x16,#off] / add x16,x16,#off / br x17`) and
|
||||
`got_recompute_mismatch: 0`.
|
||||
|
||||
Conclusion: the four hand-written bitfield decoders (`_decode_adrp`, `_decode_ldr_unsigned`,
|
||||
`_decode_add_imm`, `_decode_br`) are **correct**, not merely non-crashing.
|
||||
|
||||
### Defect 1 — loop bound, proven to cause a false negative
|
||||
|
||||
`.rela.plt` has **3,438** entries; the script reports **3,437**. The arithmetic:
|
||||
|
||||
- first `.plt` stub `0x579990` + 3438 × 16 = **0x587070**, exactly the executable segment end
|
||||
(`size=0x587070`)
|
||||
- the source reads `while off < fsz - 16`, so the largest offset examined is `0x58705c`; the final
|
||||
slot `0x587060..0x58706f` is never checked. `fsz-16` would need to be `fsz-15`; the correct form
|
||||
is `off + 16 <= fsz`.
|
||||
|
||||
The skipped slot is a legitimate stub, proven by independent disassembly:
|
||||
|
||||
```
|
||||
0x587060 adrp x16, #0x5c8000
|
||||
0x587064 ldr x17, [x16, #0x108]
|
||||
0x587068 add x16, x16, #0x108
|
||||
0x58706c br x17
|
||||
recomputed GOT = 0x5c8108 -> .rela.plt entry for that GOT: dl_iterate_phdr
|
||||
BL/B callers of the skipped stub: 1 ['0x576a2c']
|
||||
```
|
||||
|
||||
And the script therefore answers wrongly, with a confident negative:
|
||||
|
||||
```
|
||||
$ elf_plt.py libloader.so --query 0x587060
|
||||
0x587060 -> not a resolved stub <-- FALSE NEGATIVE
|
||||
```
|
||||
|
||||
This is the most important single result of the native pass. The script does not error, its output
|
||||
looks complete, and for its stated purpose — *"which stub points at the hardening symbol I want to
|
||||
neutralise"* — it reports that a real, called symbol (`dl_iterate_phdr`) does not exist in the
|
||||
library. That is precisely the failure mode the script's own docstring warns about.
|
||||
|
||||
**Fix**, applied to a copy and re-verified — three isomorphic bounds:
|
||||
|
||||
```
|
||||
_stubs_x86: while i < fsz - 6 -> while i + 6 <= fsz
|
||||
_stubs_arm64: while off < fsz - 16 -> while off + 16 <= fsz
|
||||
find_branch_callers: while off < fsz - 4 -> while off + 4 <= fsz
|
||||
```
|
||||
|
||||
After the fix: `stubs resolved=3438`, equal to the `.rela.plt` entry count. The diff against the
|
||||
original output is **exactly two lines** — the header count and the single new
|
||||
`0x00587060 -> dl_iterate_phdr (GOT 0x5c8108)` — with all other 3,437 lines byte-identical.
|
||||
`--query 0x587060` now resolves correctly.
|
||||
|
||||
### Defect 2 — `--diff --name-regions` compares a file offset against a virtual address
|
||||
|
||||
`cmd_diff`'s `describe(off)` receives a **file offset** and compares it against stub **vaddrs**
|
||||
(`va <= off < va + size`). Those coincide only when `p_vaddr == p_offset`. Two of the 36 libraries
|
||||
in this target violate that:
|
||||
|
||||
```
|
||||
lib/arm64-v8a/libflutter.so exec PT_LOAD vaddr=0x444880 offset=0x434880 (delta 0x10000)
|
||||
lib/armeabi-v7a/libflutter.so exec PT_LOAD vaddr=0x277500 offset=0x267500 (delta 0x10000)
|
||||
```
|
||||
|
||||
Demonstrated by mutating a byte inside the `abort` stub (stub vaddr `0x9f6050`, file offset
|
||||
`0x9e6050`) and diffing:
|
||||
|
||||
```
|
||||
$ elf_plt.py libflutter.so --diff flutter.mutated.so --name-regions
|
||||
differing bytes=1 in 1 run(s)
|
||||
off=0x9e6053 len=1 A: b0
|
||||
B: b1
|
||||
<-- the "<- PLT stub for abort" annotation is missing
|
||||
```
|
||||
|
||||
Plain `--diff` and `--diff --name-regions` produce **identical** output, i.e. the feature is
|
||||
completely inert on these two libraries and reports nothing about being inert.
|
||||
|
||||
**Fix:** map the file offset back through `PT_LOAD` before comparing.
|
||||
|
||||
**Also:** `size = 16 if elf_a.machine == EM_AARCH64 else 16` — both branches identical; dead code.
|
||||
|
||||
**Credit where due:** the script's defensive warning fires correctly on a zero-stub target.
|
||||
`libapp.so` (Dart AOT, 144-byte `.dynsym`) reports `stubs resolved=0` **and** prints an explicit
|
||||
"do not read this as 'the library imports nothing'" message. That is exactly the right instinct —
|
||||
it is unfortunate that the scanner then violates that principle itself, one slot early.
|
||||
|
||||
---
|
||||
|
||||
## `lib_map.py` — verified on real target
|
||||
|
||||
**Claim under test:** reads a live process's mapping table and reports which libraries are actually
|
||||
loaded, at what base, with what real architecture.
|
||||
|
||||
**Run:** `--pkg <PKG> --serial <DEVICE>`, exit 0, 635 ms, pid 27064.
|
||||
Reported three actually-loaded app libraries — `libloader.so` (3 segments),
|
||||
`libsentry-android.so` (2), `libsentry.so` (3).
|
||||
|
||||
**Independent cross-check:**
|
||||
|
||||
- `su -c cat /proc/27064/maps` parsed with an independent parser: path set, segment counts and
|
||||
permission strings agree with **zero** differences.
|
||||
- `od` read of the ELF header gives `b7 00` = AArch64, matching the script's architecture column.
|
||||
- Scale check against `systemui`: **318 libraries**, compared item by item — path, segment count
|
||||
and permissions all zero-difference; `--json` base addresses agree **318/318** with independent
|
||||
computation.
|
||||
- Translation-layer detection fires correctly on the emulator
|
||||
(`/system/lib64/libhoudini.so` → `translation suspected: YES`).
|
||||
|
||||
**Improvement, not a defect:** `?` in the architecture column is two-valued in the docs —
|
||||
`read_elf_arch`'s docstring says `?` = read failed; the CLI's trailing note says `?` = not probed.
|
||||
All 8 libraries printed as `?` were independently read and are AArch64, i.e. they exist and simply
|
||||
were not probed. A genuine read failure is indistinguishable from "not probed".
|
||||
|
||||
**Anomaly resolved as not-a-bug:** one crosscheck showed `libsentry.so 3 vs 4` segments. Four
|
||||
snapshots established this as runtime mapping change (it is 3 segments at the instant of launch),
|
||||
not a script defect.
|
||||
|
||||
**The real constraint on this tool here:** the target lives ~1.1 s, so `lib_map.py` must run inside
|
||||
that window. That is also why the app's own libraries are limited to three — `libapp.so` and
|
||||
`libflutter.so` had not been loaded yet.
|
||||
|
||||
---
|
||||
|
||||
## `so_constpatch.py` — verified (core) with one sealed serious defect
|
||||
|
||||
**Claim under test:** equal-length rewriting of string constants inside a `.so`.
|
||||
|
||||
**Verified by:** length unchanged at 6,221,896 bytes, sha256 changed, and an independent byte-wise
|
||||
diff showing changes at exactly one site (`0x81593`, 7 bytes). `readelf -d` confirms the change
|
||||
landed at the ELF semantic layer: the `NEEDED` entry changes from `libandroid.so` to
|
||||
`libfoo0000.so`. Guards behave as designed — unequal-length input exits 1 and writes no file; `-o`
|
||||
pointing at the input exits 2; non-isolated operation refuses by default. On a real APK copy: 3.5 s,
|
||||
**all 1,982 entries preserved**, order preserved, only the target entry's CRC changed, original APK
|
||||
sha256 unchanged before and after. All patching was done on copies; no sample was mutated.
|
||||
|
||||
### Defect — zip alignment destroyed on rebuild (sealed, not fixed)
|
||||
|
||||
The rebuild discards the local header `extra` field, which breaks 4 KB alignment. Measured on a
|
||||
fixture: `data_offset 4096 (mod4096=0, extra_len=4031)` becomes `65 (mod4096=65, extra_len=0)`.
|
||||
|
||||
**Why this is serious despite not firing here:** for an APK with `extractNativeLibs=false`, `.so`
|
||||
entries must be `STORED` and 4 KB-aligned or the library will not load. This target cannot show the
|
||||
bug — all 36 native libraries are `method=8` (DEFLATED) and `extractNativeLibs=true` (confirmed in
|
||||
both the manifest and by the runtime path `/data/app/.../lib/arm64/` that `lib_map.py` observed).
|
||||
So the defect is **masked by this sample and proven by a fixture**. That asymmetry is exactly why
|
||||
"it worked on our target" is not a verification.
|
||||
|
||||
**Fix sketch:** copy `extra`/`external_attr`/`internal_attr`/`create_system`/`comment` from the
|
||||
source `ZipInfo` and implement padding; and state in the help text that a rebuild must be
|
||||
re-zipaligned before signing.
|
||||
|
||||
### Defect — entry metadata reset
|
||||
|
||||
`external_attr` is rewritten on **1982/1982** entries (e.g. `2175008768 (0x81A40000)` →
|
||||
`25165824 (0x01800000)`), and `create_system` changes from 3 (Unix) to 0 on 77 entries. Usually
|
||||
harmless for installation, but it changes extracted permission bits and makes any
|
||||
zip-metadata-based integrity comparison immediately detect the rebuild.
|
||||
|
||||
### Defect — the change count is misleading
|
||||
|
||||
`do_replace` prints `'%d byte(s) changed' % (len(applied) * len(old))`, which is the **overwrite
|
||||
window length**, not the number of bytes that actually changed. Two measured examples:
|
||||
`libandroid.so → libfoo0000.so` prints 13 but changes 7; `libandroid → libandroiX` prints 10 but
|
||||
changes 1. For an attribution/audit tool this invites overstatement.
|
||||
|
||||
### Minor
|
||||
|
||||
Rebuilding changes the APK's own bytes (+2.2 MB on the sample, different deflate level). Worth a
|
||||
note in the output that output size may differ from input.
|
||||
|
||||
---
|
||||
|
||||
## `dex_strings.py` — verified on real target
|
||||
|
||||
**The multi-dex question the handover flagged is answered: yes, directory mode walks all four dex
|
||||
files.** `iter_dex()` globs `*.dex` for a directory; empirically
|
||||
`--find 'json' --per-file` emits four lines (`classes.dex`, `2`, `3`, `4`), while single-file mode
|
||||
emits one.
|
||||
|
||||
Two traps worth knowing:
|
||||
|
||||
- `--per-file` prints **only files that had a hit**. An output containing just `classes3.dex` does
|
||||
not mean only `classes3.dex` was scanned. This is the shape of output most likely to be
|
||||
misread as "only one dex contains this".
|
||||
- `--per-file` scans **raw bytes** rather than the string table (source lines 124-130), so hits for
|
||||
broad patterns include coincidental matches.
|
||||
|
||||
Fast: 0.27 s over the full 27.6 MB.
|
||||
|
||||
## `dexutil.py` — verified on real target
|
||||
|
||||
The workhorse of the ad-chain analysis: symbol resolution, branch-target back-annotation, and a
|
||||
`ended cleanly` verdict per method. Coverage: 175,829 method bodies with code, 213,180 `method_id`s.
|
||||
It is the tool whose output the zero-reference conclusion rests on.
|
||||
|
||||
Three defects, all found by working at this scale:
|
||||
|
||||
- `decode()` does not resolve operands — string and method indices must be decoded by the caller.
|
||||
- **Switch payloads were decoded as instructions**, contradicting the docstring's claim that
|
||||
`ident 1..3` are recognized. This was one root cause of `saveToLocal` being reported as
|
||||
"desync likely".
|
||||
- **The opcode table was shifted from 0x16 to 0x2C, and nine widths were wrong with it** — fixed.
|
||||
`0x1C` carried the name `monitor-enter` and one unit; the format specification has `const-class`
|
||||
there, at two units. `0x17` was `const-wide/16`/2 where the spec has `const-wide/32`/3; `0x18` was
|
||||
3 where the spec has `const-wide`/5; `goto` was read one slot early, so `throw` was treated as a
|
||||
branch. The plausible "same family, same width" reading of that run is wrong at nine opcodes, and
|
||||
each wrong entry shifts every instruction after it inside that method.
|
||||
|
||||
Proven by measurement rather than by re-reading the table: on a real 8.9 MB dex, **65 method
|
||||
bodies did not decode to their declared boundary and 32 carried an operand index outside its
|
||||
table**; after the fix, **50,791 of 50,791 land exactly on `insns_off + insns_size*2` with 0
|
||||
illegal indices**. Independently cross-checked against androguard's decoder, which shares no code
|
||||
with this project: same method count (50,791), and **0 differing width sequences** across all of
|
||||
them.
|
||||
|
||||
Names and widths now come from the format specification's table, and widths are a lookup
|
||||
(`OP_UNITS`) rather than a chain of range tests. The `goto` family in `branch_target()` and in
|
||||
`dex_find_insn.py`'s kind map moved with it.
|
||||
|
||||
## `find_refs.py` — broken — fixed and re-verified
|
||||
|
||||
The docstring said it accepts "a smali tree or a directory of dex files". It did not:
|
||||
`iter_files()` yielded only files ending in `.smali`. A direct `.dex` was yielded, then read as UTF-8
|
||||
text and regex-matched against smali syntax, which cannot match; a directory holding only dexes
|
||||
yielded nothing at all. Both printed `[total refs] 0` and a note blaming the needle, so a real dex, a
|
||||
dex directory and a typo'd path were **indistinguishable in the output**.
|
||||
|
||||
This is the worst shape a defect can take in this particular tool, because `find_refs.py` is the
|
||||
script recommended for judging blast radius *before* patching. Its silent zero is exactly the
|
||||
input to a confident wrong conclusion — "nothing references this, safe to patch". Because no
|
||||
baksmali/jadx is installed on this machine, it was **completely unusable** for this target, and the
|
||||
analysis had to be built on `dexutil.py` instead.
|
||||
|
||||
Two corrections to the record above, both from re-measuring rather than re-reading:
|
||||
|
||||
- The exit code was **1**, not 0 — the `total == 0` path has always returned 1, so a caller checking
|
||||
status could already catch it. What misled was the *text*: the note diagnosed the needle and never
|
||||
named the input form.
|
||||
- The dex-as-text path also read the whole file into memory to regex-scan binary for nothing, then
|
||||
dropped it.
|
||||
|
||||
Fixed in three ways at once, because all three were the same omission — the tool did not tell the
|
||||
difference between "found nothing" and "read nothing":
|
||||
|
||||
- A `.dex` is decoded through `dexutil` (no baksmali needed), a directory is walked for `.smali` and
|
||||
`.dex` alike, and an archive is opened for its `classes*.dex`.
|
||||
- Unsupported or unreadable input is **refused with exit 2** and a reason, instead of being reported
|
||||
as zero references.
|
||||
- Every run prints `[scanned] N file(s)` first, so the two cases can never print identically again.
|
||||
|
||||
Re-verified twice, in both directions:
|
||||
|
||||
- Against a fixture whose call graph is known **by construction** (`make_fixture_dex.py` writes two
|
||||
classes with 3, 2 and 2 references): the `.dex` and the equivalent smali tree return the same
|
||||
counts, and they match what was written — 3 / 2 / 2.
|
||||
- Against the real 8.9 MB target: `Ljava/lang/String;->length` → **356 references in 2.3 s**; a
|
||||
five-dex directory → 9,472 across 2 files; a typo'd path → `[error] no such file or directory`,
|
||||
exit 2.
|
||||
|
||||
**Correction to a related assumption in this environment:** `readelf` on `PATH` resolves to
|
||||
pyelftools' `readelf.py` and is *not* binutils — `--dyn-syms` and `-W` are rejected as unrecognized
|
||||
arguments (exit 2). Likewise MinGW `objdump`/`nm` (binutils 2.28) reject every aarch64 ELF with
|
||||
`File format not recognized`. Aarch64 cross-checks in this pass therefore used the **pyelftools,
|
||||
lief and capstone library APIs**, not those CLIs. There is no `xxd`; hex work was done in Python.
|
||||
|
||||
---
|
||||
|
||||
## Dart / Flutter scripts
|
||||
|
||||
Verified facts, each independently cross-checked rather than accepted on exit code:
|
||||
|
||||
**`dart_pool_strings.py` — verified on real target.** 18,363 rows recovered. Byte-exact re-check of
|
||||
tag + payload: **18,356 exact, 7 mismatched**, and all 7 mismatches are a single known escape
|
||||
ambiguity (`\n`/`\r`/`\t` written as the two characters backslash+n, indistinguishable in a TSV from
|
||||
a literal backslash followed by n — 19 recovered entries genuinely contain LF/CR/TAB). 12 rows
|
||||
sampled at random were independently re-located from the raw bytes; **all 12 reproduced**, with tag
|
||||
at `tag_off` and payload at `tag_off + 1` exactly. Chain violations among run≥2 rows: **0**.
|
||||
|
||||
**`dart_pprefs.py` — broken — fixed.** The first run produced a real defect: **302 displacements and
|
||||
1,802 reference sites missing**. Correcting the mask makes it agree with capstone exactly:
|
||||
|
||||
```
|
||||
repo (before) repo (fixed) capstone
|
||||
displacements 31,843 32,145 32,145
|
||||
reference sites 97,571 99,373 99,373
|
||||
only-in-capstone 302 0 —
|
||||
only-in-repo 0 0 —
|
||||
```
|
||||
|
||||
**Root cause — corrected after deeper analysis, and worth recording because the first hypothesis
|
||||
was wrong.** The obvious guess was an `x27` addressing mask. That is not it: `LDUR` with `Rn=x27`
|
||||
occurs **zero** times in this snapshot. The actual cause is that the scanner decodes only the
|
||||
**general-purpose register file** — `ldr dD, [x27, #imm]` (encoding `0xFD400000`) was never matched
|
||||
at all, so an entire class of **double constants** loaded from the object pool was silently
|
||||
dropped. Measured recall 98.19%, precision 100%; after the fix, capstone and the script agree
|
||||
across the whole `.text` with 0/0.
|
||||
|
||||
Why this is the expensive kind of bug: it breaks precisely the judgement the documentation tells
|
||||
you to rely on in `dart-aot.md` §9/§10 — *count the references to decide whether a pool entry
|
||||
matters*. At one measured site the count goes from `[0 refs]` to `[79 refs]`. A reader following
|
||||
that advice would conclude a live, heavily-referenced object was dead. It affects all Dart 3.x
|
||||
arm64 snapshots, and the failure is one-sided and silent.
|
||||
|
||||
**`dart_disasm.py` — verified on real target.** Decoded output compared against capstone on the same
|
||||
ranges: **32/32 and 96/96 instructions identical**, `VERDICT: identical decode`. The independently
|
||||
re-derived caller index also agrees exactly: `callers.json targets=59904 sites=219181`, independent
|
||||
re-derivation `targets=59904 sites=219181`, **symmetric difference of call sites: 0**. All B/BL
|
||||
words in range confirmed by capstone: 27,615/27,615.
|
||||
|
||||
**`dart_disasm.py` — verified on real target.** Decoded output compared against capstone on the same
|
||||
ranges: **32/32 and 96/96 instructions identical**, `VERDICT: identical decode`. The independently
|
||||
re-derived caller index also agrees exactly: `callers.json targets=59904 sites=219181`, independent
|
||||
re-derivation `targets=59904 sites=219181`, **symmetric difference of call sites: 0**.
|
||||
|
||||
**`dart_pool_strings.py` two-byte mode — unusable here because detection is unreliable.** With
|
||||
`--two-byte`: 936,136 candidates, 57,049 kept (34,216 UTF-16LE). The script's own warning says the
|
||||
detection is unreliable, and the independent measurement confirms it: 168,700 independent UTF-16LE
|
||||
CJK runs, and of 9,840 two-byte CJK candidates only 2,081 form chained runs ≥2 — the rest are
|
||||
coincidental. Sample output is noise (`誋讋讋讋讋`). The one-byte default is the right default.
|
||||
|
||||
---
|
||||
|
||||
## `coldstart.py` — verified on real target
|
||||
|
||||
Used exactly as documented — `--serial`, `--pkg`, `--activity`, `--duration`, `--interval`,
|
||||
`--expect-activity`. It behaved as advertised and produced the finding that governs this whole
|
||||
report: the app reaches a splash window and then aborts. The script's existence is why the launch
|
||||
failure was caught as a *measurement* (timestamped foreground-activity and logcat timeline) rather
|
||||
than as a vague impression, and its `--expect-activity` design directly addresses the failure it
|
||||
names in its own docstring — a vendor installer window being captured and read as the app.
|
||||
|
||||
---
|
||||
|
||||
## External toolchains
|
||||
|
||||
Both Dart AOT analyzers were tested, and they behave differently enough that the difference is
|
||||
itself a finding.
|
||||
|
||||
### blutter — **works; the first-run crash is not reproducible**
|
||||
|
||||
`blutter` compiles and embeds a matching Dart VM, so it needs one build per Dart version. Building
|
||||
from source is **mandatory** — the repository's `bin/` is gitignored and it publishes **no releases**,
|
||||
so there is no prebuilt Dart 3.6.0 executable to fetch. (Naming, for anyone searching:
|
||||
`bin/blutter_dartvm3.6.0_android_arm64.exe`, not `_windows_x64`.)
|
||||
|
||||
Measured cost, which corrects the documentation:
|
||||
|
||||
| Step | Time |
|
||||
|---|---|
|
||||
| `git clone --depth 1 worawit/blutter` (HEAD `4a60ac6`) | seconds |
|
||||
| `python scripts\init_env_win.py` (ICU 73.2 + capstone 4.0.2) | **7,049 ms** |
|
||||
| sparse clone `dart-lang/sdk` tag `3.6.0` | seconds |
|
||||
| cmake + ninja, 22 targets (unity build via `dartvm_create_srclist.py`) | — |
|
||||
| link + install `bin/blutter_dartvm3.6.0_android_arm64.exe` | — |
|
||||
| **whole pipeline, wall clock** | **≈ 78 s** |
|
||||
|
||||
`dart-aot.md` §2 budgets "**tens of minutes** for the first build". Reality here is **≈78 s**,
|
||||
roughly **30× pessimistic**. The toolchain requirement is also overstated: the README asks for a
|
||||
very recent C++ compiler with C++20 `<format>` and the doc repeats "VS 2022 required", but
|
||||
**MSVC 19.34 (VS 17.4.3, Nov 2022) is sufficient** — verified by compiling and running a
|
||||
`std::format` probe with it.
|
||||
|
||||
**The crash, and a correction to the first reading.** Run 1 (`blutter.py` under `vcvars64`) built the
|
||||
VM and linked the exe, then died with exit `3221225477` = **0xC0000005 ACCESS_VIOLATION** and **no
|
||||
output at all**. Runs 2-4 (invoking the installed exe directly, on `libapp.so`, `libflutter.so` and a
|
||||
nonexistent path) reproduced it in ~89 ms each — always after argument parsing succeeded (a bad ABI
|
||||
still prints `ELF: Support only 64 bits` and exits 0) and before the first stdout line, pointing at
|
||||
`DartApp app{ libappPath.c_str() }` (`blutter/src/main.cpp:29`).
|
||||
|
||||
Adding two `std::cerr` traces and relaunching `ninja` (2 targets: recompile `main.cpp`, relink)
|
||||
produced an executable that **ran to completion, exit 0** (`libapp is loaded at 0x1dfe6d60000 /
|
||||
Dart heap at 0x1e000000000`) — and then the **originally installed binary also ran clean**: 3/3 runs,
|
||||
exit 0, 5,899 / 5,862 / 5,928 ms, each producing `pp.txt` (2,639,894 B), `objs.txt` (648,382 B),
|
||||
`asm/`, and `blutter_frida.js`.
|
||||
|
||||
So the verdict is **not** "unusable here". The root cause of the transient fault is **not identified**
|
||||
and it did not reproduce after the relink, including for the unchanged binary; the best-supported
|
||||
reading is a first-run/environment artifact rather than a property of this snapshot or of Dart 3.6.0.
|
||||
Cleared suspects: `std::format` works on MSVC 19.34; capstone 4.0.2 and ICU 73.2 DLLs sit beside the
|
||||
exe; `--no-analysis` is unnecessary because the full path works. The practical rule this yields:
|
||||
**budget one retry, and if it still faults, relink before concluding anything about the sample** —
|
||||
here a tool crash did not mean the target lacked anything.
|
||||
|
||||
### aotopsy — **works out of the box**
|
||||
|
||||
`aotopsy_v1.6.0_windows_amd64.zip`, 2,994,902 B, sha256
|
||||
`5a90bcf27f4aaa281ae393bf3038b6e1e2325386fe61c1a18ed089390983fdc1` (matches the release manifest).
|
||||
Unpack and run — **no compiler, no Dart SDK, no toolchain**, because it parses the snapshot format
|
||||
directly in Go.
|
||||
|
||||
| | result |
|
||||
|---|---|
|
||||
| `doctor` | Support **OK**; compressed 4-byte pointers; hash `f956f595844a2f845a55707faaaa51e4`; the same 9 feature flags blutter derived |
|
||||
| full pipeline | exit 0 — **30,586 functions**, 6,133 class layouts, **39,202 pool entries (38,274 resolved)**, **175,120 call edges** (18,718 BLR, **95.7% annotated**), 20,927 string refs, 556 signal findings |
|
||||
| artifacts | `functions.jsonl`, `classes.jsonl`, `call_edges.jsonl`, `string_refs.jsonl`, `dispatch_table.jsonl`, `evidence.jsonl`, `asm/` (16,046 files), a `--decompile` pseudocode mode, `_debug strings --xref` |
|
||||
|
||||
**The Dart version discrepancy, resolved.** aotopsy `doctor` says **3.6.2**; blutter derives **3.6.0**
|
||||
from the engine banner; the banner reads `3.6.0 (stable) (Thu Dec 5 07:46:24 2024 -0800)`; and there
|
||||
is **no `3.6.2` byte sequence anywhere in `libflutter.so` or `libapp.so`**.
|
||||
|
||||
Judgement: **3.6.0 is the correct SDK version (observed)** — three independent facts agree (byte
|
||||
search, blutter's parser, and the fact that the Dart 3.6.0 runtime blutter built accepts the snapshot
|
||||
hash). aotopsy's `3.6.2` is its own **structural profile label (inferred)**: its README states version
|
||||
detection is structure-based because the snapshot carries a git-derived hash rather than a version
|
||||
number, so the label names the newest profile matching that hash. Impact on support is nil, but
|
||||
**`aotopsy doctor`'s number must not be fed to a VM-compiling tool as an SDK tag.** The banner route
|
||||
taught in `dart-aot.md` §1 is correct and is the best-validated instruction in that document.
|
||||
|
||||
---
|
||||
|
||||
## End-to-end: can the kit locate one specific piece of business logic?
|
||||
|
||||
**Yes — with an external snapshot dumper in front of it.** This is the question the whole pass was
|
||||
built to answer, so the worked example is recorded in full.
|
||||
|
||||
| # | Tool | Result |
|
||||
|---|---|---|
|
||||
| 1 | blutter `pp.txt` | `[pp+0x23548] String: "showRewardAdProvider"` |
|
||||
| 2 | `dart_pprefs.py --lookup pp_refs_fixed.json 0x23548` | **1 ref site: `0x7a9464`** |
|
||||
| 3 | `dart_disasm.py libapp.so --pp bout5/pp.txt --refs pp_refs_fixed.json 0x7a9464` | annotated window (below) |
|
||||
| 4 | `dart_disasm.py --index callers.json 0x7a93ec` | **9 callers** |
|
||||
| 5 | independent confirmation | `aotopsy _debug strings --find rewardAd --xref` → `used in: ShowRewardAdFamily.call_2f28ac @ 0x7a93ec (pool load @ 0x7a9464, pool[18087])` |
|
||||
|
||||
Step 3 resolves the site completely, including the enclosing Dart closure and its source file:
|
||||
|
||||
```
|
||||
7a943c: add x1, x27, #0x23, lsl #12
|
||||
7a9440: ldr x1, [x1, #0x540] ; pp+0x23540 AnonymousClosure: (0x7a9508),
|
||||
; of [package:<app>/pages/video/video_page.dart] ShowRewardAdProvider
|
||||
7a9454: add x1, x27, #0x23, lsl #12
|
||||
7a9458: ldr x1, [x1, #0x430] ; pp+0x23430 Obj!ShowRewardAdFamily@b487a1 : { [8 refs]
|
||||
```
|
||||
|
||||
Two analyzers built by unrelated projects agree on the function name, the address and the pool index
|
||||
— which is what makes this a verification rather than a coincidence.
|
||||
|
||||
**But the kit cannot take step 1 by itself.** Reaching a pool offset from a string requires resolving
|
||||
the snapshot's internal container, and the kit has no such resolver: 4,241 strings share a name across
|
||||
4,237 distinct deltas with no constant bridge between the string table and the object pool. blutter
|
||||
(or aotopsy) supplies that front end. Everything downstream of it — the cross-reference count, the
|
||||
disassembly window, the caller index — the kit does do, and correctly.
|
||||
|
||||
### Documentation defect that matters more than the crash
|
||||
|
||||
`dart-aot.md` §2 says of blutter's `asm/`: *"per-class declarations — instructions — **there are none
|
||||
here**. Do not plan around it."* That is **wrong** for this blutter HEAD on Dart 3.6.0.
|
||||
`bout5/asm/<plugin>/src/ads/reward/` is 1,327 lines / 71,099 B and contains class
|
||||
layouts, function signatures **and a full instruction listing with pool annotations and resolved call
|
||||
targets**:
|
||||
|
||||
```
|
||||
// 0x923b3c: r0 = LinkedHashMap.from()
|
||||
// 0x923b3c: bl #0x60165c ; [dart:collection] LinkedHashMap::LinkedHashMap.from
|
||||
```
|
||||
|
||||
`asm/` is the single richest artifact in the chain, and the document tells the reader to ignore it.
|
||||
(Secondary nit: `asm/` is one file per **library URI**, mirroring the package path, not per class.)
|
||||
|
||||
## Not applicable to this sample — stated, not stretched
|
||||
|
||||
- **`native_crash.py`'s "artificially constructed crash" verdict.** The tool exists to decide
|
||||
whether a crash was *manufactured* as an anti-tamper response. This target's crash is a genuine
|
||||
internal mutex-lifecycle fault that occurs on the unmodified APK, before any modification, on two
|
||||
devices. There is no decoy to detect and no tamper event to attribute, so the judgement the tool
|
||||
makes was never exercised. **Not applicable — no artificial crash exists here.**
|
||||
- **`so_constpatch.py`'s "redirect the checker library" use case.** Requires an integrity-check
|
||||
library to subvert. This target has **none** — no packer, no integrity checker, no `.so` that
|
||||
validates the APK. The redirection capability is real (see the verified `NEEDED` rewrite) but its
|
||||
purpose-built scenario does not exist here.
|
||||
- **`packers.md` / `code-virtualization-and-custom-linkers.md` in full.** No packer: zero hits for
|
||||
every known hardening-library name, and the parsed manifest shows an ordinary application class
|
||||
rather than a shell stub. **Unverified in this pass** — nothing here should be read as evidence
|
||||
either way about how those documents perform.
|
||||
- **`native-tamper-and-suicide.md`'s judgement chain.** It reads *native anomalies observed from the
|
||||
APK side* (immediate SIGSEGV after a repack, the Java layer saying the check passed while the
|
||||
process dies). This pass never produced a repack to observe, because **the unmodified app does not
|
||||
start**, so there is no working baseline to regress from. The document's scenarios remain
|
||||
unexercised.
|
||||
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Build a minimal, structurally valid dex whose reference counts are known.
|
||||
|
||||
WHY THIS EXISTS
|
||||
---------------
|
||||
Verifying a dex reader against a real APK only shows that two readers agree.
|
||||
Verifying it against a program whose call graph was written down in advance shows
|
||||
that the *number* is right. This fixture encodes exactly two method bodies:
|
||||
|
||||
Lcom/example/Caller;->go()V invoke-static Lcom/example/Target;->run()V x2
|
||||
Lcom/example/Target;->run()V invoke-virtual Ljava/lang/String;->length()I x3
|
||||
|
||||
so the answers a blast-radius check must return are 3, 2 and 2. The same call
|
||||
graph is also written out as a smali tree, because the point of the fix this
|
||||
fixture verifies is that a `.dex` and a smali tree answer a needle identically.
|
||||
|
||||
USAGE
|
||||
-----
|
||||
python make_fixture_dex.py out.dex [--smali DIR]
|
||||
|
||||
# then, from the skill's scripts directory:
|
||||
python find_refs.py out.dex 'Ljava/lang/String;->length' # expect 3
|
||||
python find_refs.py out.dex 'Lcom/example/Target;->run' # expect 2
|
||||
python find_refs.py out.dex 'Lcom/example/Target;' # expect 2
|
||||
python find_refs.py DIR 'Ljava/lang/String;->length' # expect 3
|
||||
|
||||
Dependency-free (stdlib only) on purpose: the fixture must be reproducible on a
|
||||
machine with no Android toolchain, which is where the defect it verifies was found.
|
||||
|
||||
Nothing here is target data. The dex is synthesised from the constants below.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import struct
|
||||
import zlib
|
||||
|
||||
CALLER = 'Lcom/example/Caller;'
|
||||
TARGET = 'Lcom/example/Target;'
|
||||
OBJECT = 'Ljava/lang/Object;'
|
||||
STRING = 'Ljava/lang/String;'
|
||||
|
||||
SMALI_CALLER = """.class public Lcom/example/Caller;
|
||||
.super Ljava/lang/Object;
|
||||
|
||||
.method public static go()V
|
||||
.registers 0
|
||||
invoke-static {}, Lcom/example/Target;->run()V
|
||||
invoke-static {}, Lcom/example/Target;->run()V
|
||||
return-void
|
||||
.end method
|
||||
"""
|
||||
|
||||
SMALI_TARGET = """.class public Lcom/example/Target;
|
||||
.super Ljava/lang/Object;
|
||||
|
||||
.method public static run()V
|
||||
.registers 1
|
||||
invoke-virtual {v0}, Ljava/lang/String;->length()I
|
||||
invoke-virtual {v0}, Ljava/lang/String;->length()I
|
||||
invoke-virtual {v0}, Ljava/lang/String;->length()I
|
||||
return-void
|
||||
.end method
|
||||
"""
|
||||
|
||||
EXPECTED = (('Ljava/lang/String;->length', 3),
|
||||
('Lcom/example/Target;->run', 2),
|
||||
('Lcom/example/Target;', 2))
|
||||
|
||||
|
||||
def uleb(n):
|
||||
out = bytearray()
|
||||
while True:
|
||||
b = n & 0x7F
|
||||
n >>= 7
|
||||
out.append(b | (0x80 if n else 0))
|
||||
if not n:
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def p16(v):
|
||||
return struct.pack('<H', v)
|
||||
|
||||
|
||||
def p32(v):
|
||||
return struct.pack('<I', v)
|
||||
|
||||
|
||||
def string_data(s):
|
||||
raw = s.encode('utf-8')
|
||||
return uleb(len(raw)) + raw + b'\x00'
|
||||
|
||||
|
||||
def code_item(registers, outs, insns):
|
||||
body = b''.join(p16(u) for u in insns)
|
||||
return (p16(registers) + p16(0) + p16(outs) + p16(0)
|
||||
+ p32(0) + p32(len(insns)) + body)
|
||||
|
||||
|
||||
def invoke(op, method_idx, count=0, c=0, g=0):
|
||||
"""35c layout: op(G<<8)(count<<12), index, then the register nibbles."""
|
||||
return [op | (g << 8) | (count << 12), method_idx, c & 0xF]
|
||||
|
||||
|
||||
def class_data(entries):
|
||||
"""entries: [(method_idx, access_flags, code_off)], ascending by method_idx."""
|
||||
out = uleb(0) + uleb(0) + uleb(len(entries)) + uleb(0)
|
||||
prev = 0
|
||||
for mi, acc, co in entries:
|
||||
out += uleb(mi - prev) + uleb(acc) + uleb(co)
|
||||
prev = mi
|
||||
return out
|
||||
|
||||
|
||||
def build():
|
||||
strings = sorted({CALLER, TARGET, OBJECT, STRING, 'I', 'V', 'go', 'run', 'length'})
|
||||
sidx = {s: i for i, s in enumerate(strings)}
|
||||
types = sorted({CALLER, TARGET, OBJECT, STRING, 'I', 'V'}, key=lambda t: sidx[t])
|
||||
tidx = {t: i for i, t in enumerate(types)}
|
||||
|
||||
protos = [('I', 'I'), ('V', 'V')] # (shorty, return type)
|
||||
protos.sort(key=lambda p: tidx[p[1]])
|
||||
pidx = {shorty: i for i, (shorty, _r) in enumerate(protos)}
|
||||
|
||||
methods = [(CALLER, 'go', 'V'), (TARGET, 'run', 'V'), (STRING, 'length', 'I')]
|
||||
methods.sort(key=lambda m: (tidx[m[0]], sidx[m[1]], pidx[m[2]]))
|
||||
midx = {(c, n): i for i, (c, n, _r) in enumerate(methods)}
|
||||
|
||||
insns_go = invoke(0x71, midx[(TARGET, 'run')]) * 2 + [0x000E]
|
||||
insns_run = invoke(0x6E, midx[(STRING, 'length')], count=1) * 3 + [0x000E]
|
||||
|
||||
off = 0x70
|
||||
string_ids_off = off
|
||||
off += 4 * len(strings)
|
||||
type_ids_off = off
|
||||
off += 4 * len(types)
|
||||
proto_ids_off = off
|
||||
off += 12 * len(protos)
|
||||
method_ids_off = off
|
||||
off += 8 * len(methods)
|
||||
class_defs_off = off
|
||||
off += 32 * 2
|
||||
data_off = off
|
||||
|
||||
data = bytearray()
|
||||
|
||||
def align(n):
|
||||
while len(data) % n:
|
||||
data.append(0)
|
||||
|
||||
str_offs = []
|
||||
for s in strings:
|
||||
str_offs.append(data_off + len(data))
|
||||
data += string_data(s)
|
||||
|
||||
align(4)
|
||||
code_go_off = data_off + len(data)
|
||||
data += code_item(0, 0, insns_go)
|
||||
align(4)
|
||||
code_run_off = data_off + len(data)
|
||||
data += code_item(1, 1, insns_run)
|
||||
align(4)
|
||||
|
||||
cd_go_off = data_off + len(data)
|
||||
data += class_data([(midx[(CALLER, 'go')], 0x0009, code_go_off)])
|
||||
cd_run_off = data_off + len(data)
|
||||
data += class_data([(midx[(TARGET, 'run')], 0x0009, code_run_off)])
|
||||
align(4)
|
||||
|
||||
map_off = data_off + len(data)
|
||||
|
||||
def map_item(kind, size, offset):
|
||||
return p16(kind) + p16(0) + p32(size) + p32(offset)
|
||||
|
||||
items = [
|
||||
(0x0000, 1, 0), # header_item
|
||||
(0x0001, len(strings), string_ids_off),
|
||||
(0x0002, len(types), type_ids_off),
|
||||
(0x0003, len(protos), proto_ids_off),
|
||||
(0x0005, len(methods), method_ids_off),
|
||||
(0x0006, 2, class_defs_off),
|
||||
(0x2002, len(strings), str_offs[0]), # string_data_item
|
||||
(0x2001, 2, code_go_off), # code_item
|
||||
(0x2000, 2, cd_go_off), # class_data_item
|
||||
(0x1000, 1, map_off), # map_list
|
||||
]
|
||||
items.sort(key=lambda it: it[2])
|
||||
data += p32(len(items)) + b''.join(map_item(*it) for it in items)
|
||||
|
||||
string_ids = b''.join(p32(str_offs[i]) for i in range(len(strings)))
|
||||
type_ids = b''.join(p32(sidx[t]) for t in types)
|
||||
proto_ids = b''.join(p32(sidx[shorty]) + p32(tidx[ret]) + p32(0)
|
||||
for shorty, ret in protos)
|
||||
method_ids = b''.join(p16(tidx[c]) + p16(pidx[r]) + p32(sidx[n])
|
||||
for c, n, r in methods)
|
||||
|
||||
def class_def(class_idx, super_idx, class_data_off):
|
||||
return (p32(class_idx) + p32(0x0001) + p32(super_idx) + p32(0)
|
||||
+ p32(0xFFFFFFFF) + p32(0) + p32(class_data_off) + p32(0))
|
||||
|
||||
classes = sorted([(CALLER, cd_go_off), (TARGET, cd_run_off)], key=lambda c: tidx[c[0]])
|
||||
class_defs = b''.join(class_def(tidx[c], tidx[OBJECT], cdo) for c, cdo in classes)
|
||||
|
||||
h = bytearray(0x70)
|
||||
h[0:8] = b'dex\n035\x00'
|
||||
h[32:36] = p32(data_off + len(data)) # file_size
|
||||
h[36:40] = p32(0x70) # header_size
|
||||
h[40:44] = p32(0x12345678) # endian_tag
|
||||
h[52:56] = p32(map_off)
|
||||
h[56:60] = p32(len(strings))
|
||||
h[60:64] = p32(string_ids_off)
|
||||
h[64:68] = p32(len(types))
|
||||
h[68:72] = p32(type_ids_off)
|
||||
h[72:76] = p32(len(protos))
|
||||
h[76:80] = p32(proto_ids_off)
|
||||
h[80:84] = p32(0) # field_ids_size
|
||||
# There are no fields. Size 0 is correct; the offset points at the next
|
||||
# section rather than 0 only because a zero offset reads as "out of range"
|
||||
# to a structural check that treats every offset as present.
|
||||
h[84:88] = p32(method_ids_off)
|
||||
h[88:92] = p32(len(methods))
|
||||
h[92:96] = p32(method_ids_off)
|
||||
h[96:100] = p32(2) # class_defs_size
|
||||
h[100:104] = p32(class_defs_off)
|
||||
h[104:108] = p32(len(data))
|
||||
h[108:112] = p32(data_off)
|
||||
|
||||
blob = bytearray(bytes(h) + string_ids + type_ids + proto_ids + method_ids
|
||||
+ class_defs + bytes(data))
|
||||
blob[12:32] = hashlib.sha1(bytes(blob[32:])).digest() # signature first
|
||||
blob[8:12] = (zlib.adler32(bytes(blob[12:])) & 0xFFFFFFFF).to_bytes(4, 'little')
|
||||
return bytes(blob), len(strings), len(types), len(methods)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.split('\n')[0])
|
||||
ap.add_argument('out', nargs='?', default='fixture.dex')
|
||||
ap.add_argument('--smali', metavar='DIR',
|
||||
help='also write the same call graph as a smali tree')
|
||||
a = ap.parse_args()
|
||||
|
||||
blob, n_str, n_ty, n_m = build()
|
||||
with open(a.out, 'wb') as fh:
|
||||
fh.write(blob)
|
||||
print('wrote %s (%d bytes, %d strings, %d types, %d methods, 2 classes)'
|
||||
% (a.out, len(blob), n_str, n_ty, n_m))
|
||||
|
||||
if a.smali:
|
||||
d = os.path.join(a.smali, 'com', 'example')
|
||||
os.makedirs(d, exist_ok=True)
|
||||
with open(os.path.join(d, 'Caller.smali'), 'w', newline='\n') as fh:
|
||||
fh.write(SMALI_CALLER)
|
||||
with open(os.path.join(d, 'Target.smali'), 'w', newline='\n') as fh:
|
||||
fh.write(SMALI_TARGET)
|
||||
print('wrote smali tree under %s' % a.smali)
|
||||
|
||||
print('expected: ' + ' '.join('%s=%d' % kv for kv in EXPECTED))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
49
vendor-new/apk-reverse/mypy.ini
Normal file
49
vendor-new/apk-reverse/mypy.ini
Normal file
@@ -0,0 +1,49 @@
|
||||
# mypy configuration for this repository.
|
||||
#
|
||||
# The repository is designed to check out and run its gates with only the standard library plus
|
||||
# pytest (see pytest.ini and .github/workflows/ci.yml): frida, capstone, tiktoken and PyYAML are
|
||||
# optional runtime dependencies that scripts import lazily or behind try/except, with documented
|
||||
# fallbacks. Do not require them -- or their stubs -- to be installed for
|
||||
# `mypy *.py skills/apk-reverse/scripts/*.py` to pass. When they *are* installed and typed
|
||||
# (e.g. types-PyYAML), mypy still uses the real stubs; these overrides only apply where the module is
|
||||
# missing or untyped.
|
||||
#
|
||||
# When installed, capstone ships no py.typed marker; frida and tiktoken are host-side extras.
|
||||
# Everything else in the tree is analyzed normally, including every gate script -- which is the point:
|
||||
# check_repo/check_refs/check_budget/check_routing/check_commands carry the behaviour of the whole
|
||||
# repository, so they are worth type-checking rather than excluding.
|
||||
#
|
||||
# Enforced in CI: the `lint` job in .github/workflows/ci.yml runs mypy with exactly this
|
||||
# configuration, on a bare interpreter (no frida/capstone/tiktoken/PyYAML installed), so the
|
||||
# overrides below are exercised rather than skipped.
|
||||
#
|
||||
# Verified locally on 2026-09-22: `mypy` over 55 files reports no issues with the overrides in place,
|
||||
# on a venv containing only mypy and flake8.
|
||||
|
||||
[mypy]
|
||||
python_version = 3.9
|
||||
warn_unused_ignores = False
|
||||
|
||||
[mypy-frida]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-frida.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-capstone]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-capstone.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-tiktoken]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-tiktoken.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-yaml]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-yaml.*]
|
||||
ignore_missing_imports = True
|
||||
27
vendor-new/apk-reverse/pytest.ini
Normal file
27
vendor-new/apk-reverse/pytest.ini
Normal 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
|
||||
408
vendor-new/apk-reverse/skills/apk-reverse/SKILL.md
Normal file
408
vendor-new/apk-reverse/skills/apk-reverse/SKILL.md
Normal file
@@ -0,0 +1,408 @@
|
||||
---
|
||||
name: apk-reverse
|
||||
description: "Reverse engineer, debloat, de-ad, patch, or re-sign Android APKs, and analyze their runtime and server-side behaviour. Use for an .apk/.aab/.dex/.so sample, smali or dex patching, Frida hooking, repacking, ad or SDK removal, API probing, or deciding whether a client-side patch can work. Covers recon, anti-tamper, membership limits, surgical dex patching, repack pitfalls, device setup, and a failure catalogue."
|
||||
license: MIT — see LICENSE at the repository root
|
||||
compatibility: "Python 3.9+. Device work needs adb; dynamic analysis a matching frida-server; re-signing zipalign plus apksigner, not just a JVM. Static dex/ELF work and the leak scanner are offline. Run doctor.py --json for status."
|
||||
metadata:
|
||||
version: "1.0"
|
||||
capability_registry: skills/apk-reverse/scripts/capabilities.py
|
||||
evidence_summary: skills/apk-reverse/references/evidence-summary.md
|
||||
last_reconstruction_pass: "2026-09-22"
|
||||
strength_labels: "observed | inferred | unverified"
|
||||
---
|
||||
|
||||
# APK Reverse Engineering & Patching
|
||||
|
||||
Goal: reach a **verified, installable, still-working artifact** fast — and avoid the whole class of
|
||||
mistakes that destroy an APK while looking completely healthy.
|
||||
|
||||
## Immediate — do these four things before anything else
|
||||
|
||||
This file is a **procedure with gates**, not background reading, and it is long enough that its middle
|
||||
gets skimmed. So the four moves come first; everything below is the explanation for them.
|
||||
|
||||
**1. Classify before choosing a route.** Answer the thirteen questions in §Start here. **R4** decides
|
||||
whether you are editing the right layer at all, and a wrong branch does not fail loudly — it produces
|
||||
an artifact that builds, runs, and does the wrong thing.
|
||||
|
||||
**2. Clear the four gates, in order, with their pass criteria** (§Gates). G1 names the deliverable form
|
||||
in one testable sentence *before* any work; G2 establishes what this machine can really do; G3 locates
|
||||
the code; G4 records the baseline and the control. "I understand the idea" is not clearing a gate.
|
||||
|
||||
**3. A symptom you cannot explain is a stop signal, not a puzzle.** Search §Symptom index for the shape
|
||||
**before your next attempt** and load the file that row names. Those rows exist because each one cost
|
||||
hours — and in several cases the answer was already written down while it was being re-derived.
|
||||
|
||||
**4. Two strikes on one shape of attempt sends you back to classification**, not to a third variant
|
||||
(§Stop conditions). And **never report done without the six items in §What "done" means.**
|
||||
|
||||
**Two rules about this file itself.** When a failure does not fit your plan, the symptom index is the
|
||||
next action, not more reasoning. And when this file and your own reasoning disagree, **this file wins**
|
||||
until you have evidence that overrides it: every rule here is the residue of a failure that cost
|
||||
hours, and your current intuition is the intuition of someone who has not hit it yet.
|
||||
|
||||
## Four rules that override everything else
|
||||
|
||||
**R1 — Write the deliverable as a testable sentence before you touch the target.**
|
||||
"It works" is not the goal; "it works under the stated constraint" is. Root-assisted, live-
|
||||
instrumentation, host-proxy and patched-device results frequently do **not** satisfy a request for an
|
||||
installable artifact that works on a normal phone — and it is easy to present such a result as
|
||||
finished. Write the sentence, re-read it at every checkpoint, and if you cannot meet it, say so
|
||||
plainly and label the privileged workaround a **fallback**, never the deliverable.
|
||||
→ `references/long-task-discipline.md` §the most expensive drift
|
||||
|
||||
**R2 — Change one variable at a time, and keep a control build.**
|
||||
An experiment that flips two things teaches nothing when it fails, and a failure you cannot attribute
|
||||
will be attributed to the wrong cause. Every "the app rejects X" claim needs its own run, and every
|
||||
patch needs a same-pipeline control that still fails the old way.
|
||||
→ `references/long-task-discipline.md` §single-variable discipline
|
||||
|
||||
**R3 — Never ship or claim an unverified artifact.**
|
||||
"It assembles" is not "it works"; "the process started" is not "the feature works"; "no error in the
|
||||
log" is not "the check is gone". Install it, launch it, exercise the exact feature you changed, and
|
||||
look at the screen. Prove the device is running the build you made — hash it, do not trust the
|
||||
filename.
|
||||
→ `references/verification.md`
|
||||
|
||||
**R4 — Identify the owning layer before patching, and re-classify when reality disagrees.**
|
||||
Ads, paywalls, feature gates, integrity checks and update gates live in different layers (Java, dex,
|
||||
native, Dart/Unity, server). Patching the wrong layer either does nothing or breaks the app. If a
|
||||
patch "had no effect", the diagnosis was wrong — go back to classification instead of patching harder.
|
||||
→ `references/recon.md`, then the layer-specific file the symptom index points at
|
||||
|
||||
## Tooling — reach for the right instrument, and check before declaring it absent
|
||||
|
||||
Most wasted rounds in this domain are not bad reasoning about the target. They are **the right question
|
||||
asked of a tool too weak to answer it**: an hour of `grep` over a hand-exported smali tree where one
|
||||
indexed query would do, a manual ELF walk where a decompiler was one `pip install` away. The failure is
|
||||
invisible from the inside, because the weak route still produces output.
|
||||
|
||||
**Five obligations. These are instructions; a violation is a defect, not a preference.**
|
||||
|
||||
| Obligation | Do this | Cost of ignoring it |
|
||||
|---|---|---|
|
||||
| **Orient with an indexer, not an export** | Build the ability to *ask the artifact questions* before reading code: `droidasc findrefs` / `ddc findrefs` (string/type/method → every reference site, sub-second). A decompile reads a class you have **already located** — it is not how you locate it. `jadx` is a readable viewer of last resort, never the source of truth and never the entry point of a recon | hours of `grep` per question asked, over an export you paid for first |
|
||||
| **Install the missing tool; it is part of the task** | A missing arm64 decompiler is the next step, not a constraint to route around. Ask a human only when installation is genuinely impossible | hours re-derived by hand for output a decompiler gives in minutes |
|
||||
| **Name the gap before spending against it** | State the capability the blocker requires and whether this machine has it, **out loud**. This is a G2 item | designing around a tool that installs in ten minutes |
|
||||
| **Reach for a shipped script before writing one** | Parsing, hashing, alignment and hot-plug probes are already written; a bespoke script in place of `scripts/dex_find_insn.py` is how offsets get **guessed instead of computed** | a wrong offset that decodes cleanly and behaves wrongly |
|
||||
| **Pick the instrument for the layer the question lives on** | A dex in memory wants `dex_mem_scan.py` (find and cut) **plus** `dex_dump_validate.py` (judge), not a decompiler pointed at a fragment. An algorithm you only have to *call* wants `emulation-and-rpc.md`. "Does this class ever load at runtime" wants a hook that fires or does not | the heavier tool is not the safer one — it produces a *plausible* answer, which is worse than none |
|
||||
|
||||
**Run `python skills/apk-reverse/scripts/doctor.py --json` before concluding anything is unavailable.**
|
||||
It reports per-capability closure — what is missing, what to install, and roughly how long that takes —
|
||||
and a capability you have not checked for is not a capability you lack. **It is deliberately allowed to
|
||||
report `BLOCKED`:** a machine with a JVM but no `zipalign`/`apksigner` cannot re-sign, and the report
|
||||
says so rather than inferring capability from a tool that is merely present. Detail:
|
||||
`references/toolchain.md`; the registry it reads is `scripts/capabilities.py`.
|
||||
|
||||
## Coverage — what this skill claims, and what it does not
|
||||
|
||||
The failure this section prevents is not ignorance. It is **a confident wrong answer produced by
|
||||
applying the nearest available procedure to a target it was never written for.** A documented method
|
||||
that almost fits is more dangerous than no method at all, because it arrives with a plan, a
|
||||
vocabulary and a set of reassuring numbers.
|
||||
|
||||
**How strong these claims are — this is load-bearing, not cosmetic.** Every claim in this skill carries
|
||||
one of three labels, and you must label your own results the same way:
|
||||
|
||||
| Label | Means | May it justify a decision? |
|
||||
|---|---|---|
|
||||
| `observed` | a command was run and its output exists | yes |
|
||||
| `inferred` | follows from an observation, but the step is reasoned | provisionally |
|
||||
| `unverified` | assumed, or reported elsewhere and never reproduced here | only as something to test |
|
||||
|
||||
**The inventory that replaces a guess:** the per-route list of what is covered and what is not — by
|
||||
verified mechanism versus by documented-but-mostly-inferred route — is in
|
||||
`references/coverage-and-limits.md`. Load it when the question is "can this skill actually do X".
|
||||
|
||||
**Partial coverage that is easy to over-read** — these are documented routes whose *dependency* this
|
||||
skill does not ship, and each has a fence around it in the same file: Dart AOT analysis needs a
|
||||
snapshot dump you must obtain elsewhere and naming the front end matters · extraction-shell recovery
|
||||
stops at the VMP boundary · a Stalker trace is not guaranteed on every device · a kernel-side answer
|
||||
requires a module this skill only scaffolds.
|
||||
|
||||
**Not covered — say so rather than improvise:** Unity / IL2CPP · React Native / Hermes / Cordova ·
|
||||
iOS · defeating a server-side authority · an off-the-shelf unpacker or an anti-detection arms race ·
|
||||
building and shipping a kernel module. Reasons and evidence boundaries are in
|
||||
`references/coverage-and-limits.md`; **silence in that record is not support.**
|
||||
|
||||
**The fallback, as an instruction — this is a rule, not advice.** If the target does not match the
|
||||
covered list, or no symptom-index row matches, then **stop and classify before choosing a branch**:
|
||||
answer the thirteen questions (§Start here). If the shape still does not fit — an unknown runtime, a
|
||||
mechanism you cannot name — **say exactly that and propose the cheapest experiment that would
|
||||
identify it.** Do not take the closest documented route and apply it anyway. A wrong branch here does
|
||||
not fail loudly: it produces an artifact that builds, runs, and does the wrong thing.
|
||||
|
||||
## Hand-off points — where this skill ends and another view begins
|
||||
|
||||
Four boundaries that are easy to walk into without noticing. Each names what the other side owns
|
||||
rather than restating it, because two copies of the same advice drift apart. The JNI form table and
|
||||
the per-form verification table live in `references/handoff-boundaries.md`.
|
||||
|
||||
1. **JNI** — a Java `native` declaration and its implementation are two views of one function, and
|
||||
this skill reads each with a different tool. A symbol search fails **silently** on dynamic
|
||||
registration. Authoritative treatment: `java2c-and-jni-sinking.md` §The JNI boundary — why a
|
||||
symbol search fails silently.
|
||||
2. **Hardening** — a dex-side packer observation is a native-side implementation question.
|
||||
`packers.md` owns the dex-side identification; the deep dive belongs on the other side.
|
||||
3. **Native anomalies** — read them *from the APK side*, because the judgement is whether your patch
|
||||
caused the death. Restoring a symbol or reversing an OLLVM function is a different activity with a
|
||||
different toolchain: point across instead of extending `native-and-so.md` into it.
|
||||
4. **Deliverable form** — when the artifact stops being an APK, the verification question changes with
|
||||
it. G1's other three forms each move the evidence somewhere else, and the failure is quiet: a
|
||||
privileged result reported in the language of a finished build.
|
||||
|
||||
## Symptom index — a matching row is a stop signal
|
||||
|
||||
You arrive at a symptom, not at a file name. Each row below is a failure that has already been paid
|
||||
for. **If any row matches what you are observing, load the file before your next command** — not after
|
||||
your next three attempts. Reasoning from first principles at this point is how the same hours get
|
||||
spent twice; more than one entry here is a lesson that was re-derived by hand while the answer sat
|
||||
unread in this repository.
|
||||
|
||||
| What you observe | Load first |
|
||||
|---|---|
|
||||
| A repackaged/re-signed build **dies before your code runs**; `SIGSEGV`, all registers zero, `pc=0`, `fault addr` near `0x0` | `native-tamper-and-suicide.md` (deliberate crash), then `code-virtualization-and-custom-linkers.md` |
|
||||
| **No packer** (Application is the app's own, dex readable) **and it still dies** | `code-virtualization-and-custom-linkers.md` §a loader is still a possibility; but if the same build also dies on a *second, unrelated* device you are looking at an ordinary startup fault, not a hardened one |
|
||||
| The app dies at startup on **every** device, packed or not, with **no tombstone** while `crash_dump` says `already traced` and logcat says `exited cleanly (0)` | a bundled crash reporter has taken the signal handlers, so the platform's own trail is gone. Frida spawn-gating is the recovery route |
|
||||
| A `FORTIFY: pthread_mutex_lock called on a destroyed mutex` abort in a Flutter app, on the **main** thread, before the first frame completes | `dart-aot.md` — check `libapp.so` is actually being loaded; Flutter's engine bootstrap is the usual place a native lifecycle fault surfaces |
|
||||
| Log says a **Java-layer** signature/integrity check **passed**, yet the process dies | `code-virtualization-and-custom-linkers.md` §a Java-layer "signature killer" is a decoy |
|
||||
| Deleting a library fixes validation but yields `UnsatisfiedLinkError: dlopen failed: library "X" not found` | `code-virtualization-and-custom-linkers.md` §the deadlock that eats hours |
|
||||
| Whole classes appear as bare `native` declarations with no body | `java2c-and-jni-sinking.md` — read it **before** dumping memory: if this is Java2C there is no DEX to find, at any point in the process lifetime. A handful of `native` methods in an otherwise ordinary dex is JNI sinking, not this |
|
||||
| A `Java_*` search over a hardened library returns nothing at all | `java2c-and-jni-sinking.md` §The JNI boundary — why a symbol search fails silently — dynamic registration, or `-fvisibility=hidden`. The check that works is "exports `JNI_OnLoad` and zero `Java_*`" |
|
||||
| You are about to publish an evidence file, a transcript or a README that quotes real work | `references/desensitization-and-leak-scans.md` — run `scripts/scan_leaks.py` **before** it is committed; the hit list is a set of lines to look at, and `--show-exempt` is where the wrong suppressions show |
|
||||
| A hooking module appears to have run but its log tag is silent, and you are about to record "it never loaded" | `references/precedents/logd-broken-module-never-ran-case-3.md` — a broken `logd` delivers nothing on `logcat` while the module's whole run sits in LSPosed's file log; read both channels |
|
||||
| A library's **SONAME does not match its filename** | `code-virtualization-and-custom-linkers.md`, `native-and-so.md` |
|
||||
| Your edit had **no effect at all**, with no error | `server-config-and-updates.md` §3 (the value may be server-sent), then `packers.md` §map the validation boundary |
|
||||
| Process **hangs** with no crash record, or dies to a `uid 0` killer | `native-tamper-and-suicide.md` §the rule (you probably made a terminate path *not return*) |
|
||||
| Death looks like an ordinary null dereference in a hardened library | `native-tamper-and-suicide.md` §deliberate-crash stubs |
|
||||
| The app dies **only while you are attached/rooted** | `detection-and-anti-analysis.md`; run the unmodified original under identical conditions first |
|
||||
| **Install fails with `[-124]` and mentions `resources.arsc` / alignment** | `repack-and-sign.md` §2a — STORED **and** 4-byte aligned, both required |
|
||||
| **Install fails with a bare numeric code (e.g. `[-99]`) and no `INSTALL_FAILED_*`** | `repack-and-sign.md` §vendor install interception — a device-side interceptor, not your build. Use the root `pm install` path |
|
||||
| **After an install, `am start` does nothing / screenshots show another app / `am start -W` hangs** | `repack-and-sign.md` §the installer may still own the screen |
|
||||
| Log shows `Failure to verify dex file ...: Bad checksum` and a startup `ClassNotFoundException` for an ordinary class | `byte-level-patching.md` §the dex header has two integrity fields — order matters |
|
||||
| An install "succeeded" but nothing changed, or the version did not move | `long-task-discipline.md` §keep the observation window clean |
|
||||
| Evidence contradicts itself, or a capture looks like two states mixed | `long-task-discipline.md` §keep the observation window clean |
|
||||
| You took screenshots but drew the conclusion from logs or from the patch itself | `long-task-discipline.md` §captures you never looked at are not evidence |
|
||||
| You are about to re-run an experiment whose result you already recorded | `long-task-discipline.md` §long-context decay |
|
||||
| A script will not start, or a tool "is missing" | `scripts/doctor.py`, then `toolchain.md` §"not on PATH" is not "not installed" |
|
||||
| A hook or probe reports **no events at all**, and you are about to call it detection | `scripts/anti_detect_probe.js` for the environment self-report first, then `detection-and-anti-analysis.md` §Step 3: locating the check — the order of search from Stage 0 |
|
||||
| `attach` hangs and then fails **while the process is still in `ps`** | `detection-and-anti-analysis.md` §Step 3 Stage 0 — check for `D` in `/proc/<pid>/stat`, and attach a *different* pid as a one-line control before blaming the target |
|
||||
| The app exits with no tombstone, no crash and no ANR record | `detection-and-anti-analysis.md` §Step 3 — a clean self-exit means the check ran before your hooks existed; the branch conditions there say which Stage |
|
||||
| A dump region validates as the wrong thing, or an `r--s` view of `base.apk` looks like a dex | `advanced-unpacking.md` §What this route cannot do, and how to tell before you spend the window |
|
||||
| Feature-scoped network failure (login/register/pay) while the rest works | `tls-and-cert.md` — do not assume your patch caused it |
|
||||
| Everything works but **every signed request fails** after repack | `signature-derived-keys.md` |
|
||||
| A re-signed build **runs fine, renders its whole UI and logs no error — but one feature silently never loads**, and `dumpsys`/DNS/logcat show **no request for it at all** (not a rejected request: *no request*) | `code-virtualization-and-custom-linkers.md` §what the native check actually reads — refusing **before** the request is built. Not the row above: "sent and rejected" and "never sent" have different owners |
|
||||
| You cannot tell whether a missing feature is **your patch's fault or the target's own behaviour** | `long-task-discipline.md` §single-variable discipline. Run the **zero-change control through the same pipeline**, and the decisive variant: the unmodified original with the patch applied **in memory only**, same device, same network |
|
||||
| Under Frida `spawn`, the UI never appears — `mCurrentFocus` stays `null`, screenshots come back blank, the Activity stack never builds | `dynamic-frida.md` §spawn keeps the Activity stack down: write the patch into memory, **detach**, then start the Activity normally |
|
||||
| `frida-server` keeps disappearing mid-experiment, or the device reboots itself while you are working | `dynamic-frida.md` §when the ROM hunts your instrumentation |
|
||||
| Ads still appear after a patch that should have killed them | `server-config-and-updates.md` §6 (cached config / remote re-enable), then `ad-removal.md` §step 4 (count the SDK's own log lines; n -> 0, not "I did not see it") |
|
||||
| A forced-update or "must update" gate blocks the build | `updates-and-forced-upgrade.md` §step 6 |
|
||||
| The dialog is gone but the feature is still locked | `membership-and-limits.md` / `account-gates.md` — decide server vs client authority before patching again |
|
||||
| You are about to discard a route as "blocked" | `packers.md` — re-read it before writing any route off; mis-attributed failures have removed viable routes for hours |
|
||||
| The task has run long and you are unsure what is already proven | `long-task-discipline.md` §keep a live record |
|
||||
| A dumped dex parses in full, the classes are all there, and most method bodies are `return-void` stubs or nop fills | `references/advanced-unpacking.md` — an extraction shell: measure the `stub%` with `scripts/dex_dump_validate.py` before trusting any of it, and know that recovering the bodies is a different route |
|
||||
| Your `frida` dump dies mid-write (`script has been destroyed`), or the process you are dumping keeps changing pid | `advanced-unpacking.md` §dumping when frida is refused — rule out memory pressure first; a reclaim-and-relaunch needs no instrumentation |
|
||||
| A repack is refused by several independent checks, or the build has to keep working through store updates | `references/lsposed-and-modules.md` — deliver a module instead of an APK; G1's form table says when |
|
||||
| A hook module is installed, enabled and scoped, yet its log tag never appears — and you are about to conclude it never ran | `lsposed-and-modules.md` §Deploy, enable, and verify — **a `logcat`-only verdict has already been wrong here**: on one ROM `logd` is broken and output reaches only `/data/adb/lspd/log/modules_<ts>.log` |
|
||||
| A module's entry class is missing from its own dex (so it can never load), yet the package installs, enables and looks healthy | `references/lsposed-and-modules.md` — check `assets/xposed_init` against the dex's actual classes; installation is not evidence of anything |
|
||||
| You only need to **call** the target's own routine (sign, token, encrypt) rather than change the app | `references/emulation-and-rpc.md` — emulate it, or service-ify the live function over Frida RPC |
|
||||
| A native function is a many-thousand-line `switch` state machine, or the decompiler's output is meaningless | `references/native-dbi-and-deobfuscation.md` — OLLVM shapes, a Stalker trace, and how far a trace actually gets you |
|
||||
| `Stalker.follow` installs but no events arrive, or following a hot libc export crashes the process | `references/native-dbi-and-deobfuscation.md` §6 failure modes — this repository measured both |
|
||||
| The traffic is protobuf/gRPC/QUIC, or a proxy sees TLS but requests still fail on a Flutter app | `references/protocol-reverse.md` — schema-less protobuf, frame capture, and native-side pinning |
|
||||
| Userspace hooks land and the app still dies: the check reads `/proc/self/status` through a raw `svc`, or runs before `JNI_OnLoad` | `references/kernel-and-environment-hardening.md` — what the next layer up and down can actually do, and when to stop |
|
||||
| You must edit, repack, sign or inspect the APK **from the phone itself** | `references/on-device-tooling.md`, `scripts/mt_mcp_probe.py` |
|
||||
| A captured body decodes to nothing readable, or you cannot tell whether a length-delimited field is a string, a nested message or a packed array | `protocol-reverse.md`. Protobuf on the wire (measured) — run `scripts/protobuf_decode_raw.py`; the candidate list and its `tie:` lines are the answer |
|
||||
| Method bodies are present but decode as **private opcodes**, and you need the mapping rather than an explanation of why VMP is hard | `vmp-differential-analysis.md`, then `advanced-unpacking.md` for the shape diagnosis |
|
||||
| A store build arrives as `base.apk` + `split_config.*.apk`, or a rebuilt build is refused **as a set** although every file verifies on its own | `split-apk.md` — one keystore across every member for `pm install-multiple`, and check that a merge is legal before trusting a merged single APK |
|
||||
|
||||
## Gates — clear these before you patch, in order
|
||||
|
||||
Each gate is an **action with a pass criterion**. Do not proceed past a gate you have not cleared, and
|
||||
do not treat "I understand the idea" as clearing it. Skipping a gate is not a shortcut; it is how the
|
||||
work gets redone.
|
||||
|
||||
**G1 · Deliverable form — and the cost ceiling on it.** State, in one sentence you could hand to
|
||||
someone else, what artifact must exist at the end and under what constraints (rooted or not,
|
||||
installable on a stock device or not, must survive updates or not, online or offline). *Pass:* the
|
||||
sentence names a testable constraint, not an activity. *Fail:* you are solving a problem in an
|
||||
environment the deliverable will never see.
|
||||
|
||||
Then name the **form** that sentence implies. "A rebuilt, self-contained APK" is only one of four, and
|
||||
this is where the choice belongs — not after the repack has already failed:
|
||||
|
||||
| Form | Right answer when | What it costs you |
|
||||
|---|---|---|
|
||||
| **Rebuilt, installable APK** | The client owns the behaviour; no multi-point integrity check; no extraction shell | Repack, re-sign, and a device to verify on |
|
||||
| **LSPosed / Xposed module** | The logic is client-side but the app fights repacks (multi-point signature checks, shell self-verification), or the result only has to work on rooted devices you control | A rooted device, module scaffolding, and an app that must not detect the hooking framework → `references/lsposed-and-modules.md` |
|
||||
| **Local RPC / emulation service** | You do not need to change the app — you need to **call** it: a signing routine, a token, an encryption function | A live device or an emulated loader plus a call harness → `references/emulation-and-rpc.md` |
|
||||
| **Analysis report with a stated boundary** | The authority is server-side, or the target is a real VMP / extraction shell whose recovery cost exceeds the value of the task | Nothing ships — and that is the honest answer, not a failure → `references/advanced-unpacking.md`, `references/server-api.md` |
|
||||
|
||||
*Decision trigger for leaving the first column:* switch off "rebuilt APK" as soon as the evidence shows
|
||||
**(a)** more than one independent integrity check that must all pass, **(b)** an extraction shell whose
|
||||
method bodies exist only at invocation time, or **(c)** any body that decodes as private opcodes. At
|
||||
that point the repack route is not merely expensive — it is blocked, and the deliverable sentence
|
||||
should say which form replaced it. **A form chosen here and re-read at every checkpoint is the guard
|
||||
against the most expensive drift in this skill** (`references/long-task-discipline.md`).
|
||||
|
||||
**G2 · Environment truth and capability inventory.** Run `scripts/doctor.py` (and `scripts/preflight.py`
|
||||
if a device is in play). *Pass:* you know which toolchains and scripts can actually run here, you have
|
||||
seen the environment warnings — clock skew, leftover `adb forward`/proxy, a device-side frida process
|
||||
already running, a tool installed off-PATH — **and you have written down the capability this target will
|
||||
demand against the capability this machine has.** Name the two or three layers the task will almost
|
||||
certainly reach (for example "arm64 native decompilation", "Dart AOT snapshot dumping", "device-side
|
||||
TLS inspection", "dex-wide cross-referencing") and mark each available / missing-but-installable /
|
||||
genuinely out of reach. *Fail:* you are about to attribute to the target a failure caused by your own
|
||||
setup — or to spend a day routing around a tool that installs in ten minutes. A layer whose tool is
|
||||
missing is a **task item**, not a constraint to design around. → `references/toolchain.md` §Closing a
|
||||
capability gap
|
||||
|
||||
**G3 · Code location.** From the manifest and dex, answer: is there a packer, where does the app's own
|
||||
code live (dex / native / Dart / Unity / server), and is any of it virtualized to native. *Pass:* you
|
||||
can name the class that owns the behaviour you intend to change, or you have an explicit plan to find
|
||||
it. *Fail:* you are about to patch a layer you have not located. If recon says "no packer", still
|
||||
check the virtualization shape — see the index rows above.
|
||||
|
||||
**G4 · Baseline and control.** *Pass:* you have a control run — the unmodified original, or a
|
||||
zero-change repack through the same pipeline — and you have recorded the observed failure (including
|
||||
**time-to-death**, if it dies). *Fail:* when the patched build misbehaves you will have nothing to
|
||||
compare against, and every later measurement is unfalsifiable.
|
||||
|
||||
|
||||
## Start here: classify the target in thirteen questions
|
||||
|
||||
**Answer these before touching a tool — all thirteen, in order. Each one changes the whole plan, and
|
||||
a wrong answer here does not fail loudly: it produces an artifact that builds, runs, and does the
|
||||
wrong thing.** The answer column names both the action and the file that owns the detail; load that
|
||||
file before acting on the question.
|
||||
|
||||
| # | Question | What the answer changes | Load |
|
||||
|---|---|---|---|
|
||||
| 1 | **Is the app packed/hardened?** Read the manifest's `application android:name` | A third-party shell class rather than the app's own Application means you have a packer and must handle it **first** | `recon.md` |
|
||||
| 2 | **Where does the behaviour actually live?** — ad SDK · **server-issued config for client-rendered UI** · membership/VIP · feature flag or debug switch · anything decided by an API response | Client-side and removable, versus server-supplied data the client decides with, versus server-authoritative where a client patch is cosmetic. **Decide this early**: hunting an ad SDK that does not exist costs hours, and the server-config shape has no SDK to find | `ad-removal.md`, `server-config-and-updates.md`, `membership-and-limits.md`, `server-api.md` |
|
||||
| 3 | **Is the app's own code in plain dex, or moved to native / Flutter / Unity?** | Which toolchain the whole task uses. **Cheap runtime check before committing:** hook the obvious Java classes for the UI you care about and reproduce that UI. Hooks that fire mean Java owns it; hooks that fire **zero times** while the UI is plainly on screen mean the runtime or native code draws it, and a dex-only plan will stall. Do not keep hunting in dex after a zero-hit probe — the most expensive wrong turn in this skill's history | `recon.md` §Where does the app's own code live, `framework-runtimes.md` |
|
||||
| 4 | **What must the deliverable be able to do?** Write it as a testable sentence and re-read it at every checkpoint | The most expensive drift in this skill: a runtime-only result (data edit, live hook, blocked hostname, host proxy) looks like success while failing the requirement. The axes: **privilege** (unrooted?), **modification form** (rebuilt artifact vs live instrumentation), **ABI/device class**, **network**, **persistence**, **distribution** (self-contained?). If the evidence already shows a deep extraction shell, a VMP, or more than one independent integrity check, **re-answer against G1's four forms** — the repack column may be blocked, not expensive | `long-task-discipline.md` §the most expensive drift |
|
||||
| 5 | **Does the app verify its own signature, or does the server?** | App-side means you must bypass it; server-side means re-signing silently breaks the app later | `repack-and-sign.md`, `server-api.md` |
|
||||
| 6 | **What is your device situation?** — rooted real device, rooted emulator, or no device | Whether Frida is usable at all, and whether the deliverable can be tested where it will run. **Run `scripts/preflight.py` before your first experiment** and again whenever a failure surprises you — device state, a dead device server, a leftover proxy and clock drift all masquerade as a broken patch | `environment.md`, `pitfalls.md` P9 |
|
||||
| 7 | **Which architecture is actually executing?** | `getprop` reports what the device claims and `primaryCpuAbi` what the package manager chose — **neither is what runs**. Only the live mapping is ground truth. An unmapped library, a translator in play, or a different ABI than assumed changes the plan more than any patch will | `native-and-so.md` §Cross-architecture, `scripts/lib_map.py` |
|
||||
| 8 | **Is one *specific* feature failing at runtime** (login, registration, payment, an API-backed screen) while the rest works? | A feature-scoped network failure is very often a **TLS/certificate problem on one code path**, not your patch. An app can carry two independent trust chains, so "other requests work" proves nothing. Rule this out in minutes before hunting a signature check | `tls-and-cert.md` |
|
||||
| 9 | **Was the input a build you did not produce** (a circulating "cracked"/"modded" APK)? | Such builds are frequently re-protected — sometimes with *more* layers than the original — and may carry injected components or endpoints. **Never use one as a patching workbench** | `third-party-builds.md` |
|
||||
| 10 | **Does the client sign its requests with its own signing certificate?** | Grep for `toCharsString()` / `signatures[0]` / `getPackageInfo(..., 64)` **before the first repack**. If that value feeds a native HMAC/DES routine, the rebuilt APK must hardcode the *original* certificate value at every read site, or every signed request fails while the app still launches and looks healthy. The single most expensive silent failure in a repack, and 15 minutes of grep prevents it | `signature-derived-keys.md` |
|
||||
| 11 | **Does the app die on its own after a while** — no Java stack, or a native crash that looks like a bug? | A hardened library rarely calls `kill`; it more often **arranges a fault** (load a small constant, use it as a pointer) so the death looks like an ordinary defect. Two rules before touching anything: **enumerate which mechanism actually fires** (signal and tombstone split them apart), and **neutralise by returning, never by making it not return** — a spinning stub freezes the process and produces a symptom that looks nothing like the cause | `native-tamper-and-suicide.md` |
|
||||
| 12 | **Will this build still be usable in a week?** | Any version check, upgrade prompt or self-update path means an unpatched build can be switched off or replaced remotely. This is one or two edits and it decides whether the work is durable — part of the build, not a follow-up. Also check for a **hot-update / remote-config** channel, which restores removed behaviour with no version change at all | `updates-and-forced-upgrade.md` |
|
||||
| 13 | **Does the request touch sign-in or phone binding** ("no login required", "skip binding", "guest ok")? | Separating a **client-side gate** (patchable) from an **account-scoped resource** (the screen is empty because the server has no account to answer for — not patchable). Classify first; and **never fabricate a session** to satisfy a gate, which produces a state worse than being signed out | `account-gates.md` |
|
||||
|
||||
**Long-task rule (a condition, not advice):** if this is likely to run long, open
|
||||
`references/long-task-discipline.md` **now** and keep its record updated as you go. Re-read its
|
||||
refuted-conclusions and dead-routes sections before starting any experiment. Losing earlier findings
|
||||
is the most expensive failure in this skill, and it is entirely preventable.
|
||||
|
||||
## The workflow, end to end
|
||||
|
||||
Steps are ordered. **Skip a step only when its stated skip condition is met** — "it seems
|
||||
unnecessary" is not a condition, and it is the reason most of the failures in `pitfalls.md` happened.
|
||||
|
||||
**Two-strike rule.** If the *same kind* of attempt fails twice, stop and go back to classification.
|
||||
Do not run a third variation of a hypothesis that has already failed twice. Two failures of one shape
|
||||
means the model is wrong, not that the parameters need tuning — and the third attempt is where an
|
||||
entire round gets spent confirming what the first two already said. Re-read the symptom index at that
|
||||
point; it exists for exactly this moment.
|
||||
|
||||
1. **Preflight, then Recon** — `scripts/doctor.py` is the cheapest possible first command: it reports which toolchains and scripts can actually run here, and surfaces the environment facts that poison experiments (clock skew, leftover `adb forward`/proxy, a device-side frida process already running, a tool installed off-PATH). Then `scripts/preflight.py` before anything else if a device is involved (it takes seconds and prevents a whole class of false conclusions), then `references/recon.md`. Manifest, package name, version, ABI, dex count, packer, embedded SDKs, where the app's own code lives. Ten minutes here saves hours. **If it is packed, unpack before anything else** (`references/recon.md` §unpacking): you cannot patch code you cannot read, the encrypted payload lengths tell you which dumped dex is the original, and a memory dump must be de-duplicated by hash and structurally validated before any of it is trusted.
|
||||
**If recon says there is no packer but a re-signed build still dies**, you are in the layer `references/code-virtualization-and-custom-linkers.md` covers — do not proceed on the assumption that "no packer" means "editable".
|
||||
**If the app already dies on its own** — especially at a roughly constant time after launch, or with a native crash — locate the mechanism *before* planning any patch (`references/native-tamper-and-suicide.md`, `scripts/native_crash.py`). Record the observed time-to-death: it is the baseline every later attempt is measured against, and without it a surviving run cannot be told from a changed schedule.
|
||||
*Skip condition:* never skipped. G2/G3 in §Gates are cleared here or not at all.
|
||||
2. **Extract strings and endpoints** — build a picture of the app's API surface and SDK inventory from the dex string tables. No decompiler needed for this, and it is fast. Scripts: `scripts/dex_strings.py`.
|
||||
3. **Trace to the owning class** — find the class that wraps the behavior (the app almost always wraps third-party SDKs in one helper). Reverse-lookup instructions: `references/dex-patching.md` §finding-the-call-site.
|
||||
4. **Decide the patch layer** — client SDK call / client rendering / client data consumption / server contract. See the table in `references/ad-removal.md`.
|
||||
5. **Patch surgically** — `references/dex-patching.md` and `references/byte-level-patching.md`.
|
||||
Two techniques, and picking the right one is a decision, not a preference:
|
||||
**equal-length byte edits** (`scripts/dex_patch_bytes.py`, located with
|
||||
`scripts/dex_find_insn.py`) when the change fits in an existing instruction slot
|
||||
or constant — nothing moves, so no offset, try/catch block or debug pointer can
|
||||
be invalidated. **dexlib2 method rewriting** (`scripts/dexpatch/`) only when the
|
||||
change genuinely needs new instructions. Whole-tree smali round-trip damages
|
||||
R8-optimized dex in ways that only show up at runtime; a method rebuild also
|
||||
inflates the file (measured: `debug_info` 924 B -> 22.8 KB, dex 4.32 MB ->
|
||||
7.73 MB on one sample). Whichever you use, recompute the dex header integrity
|
||||
fields (**signature first, checksum last**) — `references/byte-level-patching.md`
|
||||
§the dex header has two integrity fields.
|
||||
6. **Repack and sign** — `references/repack-and-sign.md`. **Do not strip the whole `META-INF/`.** This single mistake destroys otherwise-correct builds.
|
||||
6b. **Neutralise the update path — before you call the build done.** If the app checks for updates at all, add the two-layer patch (`references/updates-and-forced-upgrade.md`): no-op the update routine's entry, and force the version comparison to its "no update" side. A build that can be switched off or replaced remotely is not a deliverable, and this costs minutes here versus a rebuild later. Do the same for any **remote-config or hot-update** channel that could restore the behaviour you removed.
|
||||
6c. **Handle account gates only after classifying them** — if the request mentions sign-in or binding, apply `references/account-gates.md` and state plainly which guarded screens become usable and which stay empty because their content is account-scoped.
|
||||
7. **Verify on device** — `references/environment.md` + `references/verification.md`. Check: launches, the changed behavior actually changed, nothing unrelated broke, and **the app reaches its normal UI with no blocking dialog**. First prove the artifact actually changed on the device -- a package manager reporting success does not prove an interposed confirmation was accepted (P18). Capture continuously for the first ~20 seconds after launch, **and look at the captures** — sampling gaps are how a blocking modal goes unseen (P20), and a burst of images that were never inspected is not evidence. If the accessibility tree is empty, the image is the primary evidence rather than a fallback.
|
||||
8. **Log what you learned** — if a failure cost you more than thirty minutes, add it to `references/pitfalls.md`. That file is the most valuable artifact in this skill.
|
||||
|
||||
## What "done" means — do not claim it earlier
|
||||
|
||||
Every item below must be true before you report completion. Anything less is a **checkpoint** and must
|
||||
be labelled as one, out loud, with what remains. Premature "done" is the most damaging thing you can
|
||||
report, because it ends the investigation while the user believes the problem is solved.
|
||||
|
||||
1. **The artifact exists and its identity is recorded** — path plus hash, not a filename.
|
||||
2. **It was installed and launched on the environment the deliverable sentence names** (G1/R1). If
|
||||
that environment was not available to you, say so and label the result accordingly.
|
||||
3. **The behaviour you changed is verified changed** — by direct observation of the feature, not by
|
||||
the absence of an error message. "The log is clean" is not evidence; "the screen shows X" is.
|
||||
4. **The features it touches still work.** You exercised them. A build that starts but whose affected
|
||||
feature is dead is not a result.
|
||||
5. **The original limitation is stated if any survives** — with the coupling that causes it, so the
|
||||
next person can decide whether to accept it.
|
||||
6. **Nothing you did leaves the target or the device in a broken state** unless that was the goal, and
|
||||
any privileged workaround is labelled a fallback rather than the deliverable.
|
||||
|
||||
If items 1–4 hold but the environment was wrong, you have a **prototype**, not a deliverable. Say
|
||||
"prototype" and name the gap.
|
||||
|
||||
## Stop conditions — halt and re-classify, do not retry
|
||||
|
||||
These are moments where continuing to push forward is the wrong move. Each has cost hours somewhere.
|
||||
|
||||
- **The same shape of attempt failed twice.** See the two-strike rule above.
|
||||
- **A patch had no effect and you were about to try a third variant of it.** No effect means the
|
||||
diagnosis was wrong, not that the patch was unlucky. Re-classify the layer.
|
||||
- **A new failure has no place in your current model.** That is the symptom index's trigger condition.
|
||||
- **You are about to write off a route as "blocked"** without a control build proving the block is
|
||||
the app's doing rather than your pipeline's. Mis-attributed blocks have removed viable routes.
|
||||
- **You are about to claim success on absence of errors.** See §What "done" means.
|
||||
- **A measurement disagrees with a conclusion you already recorded as settled.** Re-open the
|
||||
conclusion; do not explain the measurement away.
|
||||
|
||||
## Non-negotiable constraints
|
||||
|
||||
**These are defects when violated, not preferences.** Each row states the check that catches it, so
|
||||
"did I comply" is a command you can run rather than a judgement you make about yourself.
|
||||
|
||||
| Constraint | Why it is absolute | The check |
|
||||
|---|---|---|
|
||||
| **Inputs are read-only.** Work on copies; keep a known-good baseline | an edited original destroys the only reference you can diff against | `git`/hash the original before the first edit; `scripts/apk_diff.py` for entry-level proof |
|
||||
| **One variable at a time**, with a control build (same pipeline, **zero** patches) | a compound change that fails teaches nothing, and the failure will be attributed to the wrong cause | a control run exists and its result is recorded |
|
||||
| **Verify structure after every dex edit** | a patch that assembles and dies at load looks like a patch that worked | `scripts/dex_classdiff.py`: zero differences in class set and access flags for classes you did not intend to change |
|
||||
| **Never patch a widely shared method.** Count callers first | a `Long.valueOf`-shaped helper with 30 callers is not an ad-specific hook | `scripts/find_refs.py` on the method you are about to touch |
|
||||
| **Never make an API fail to suppress a UI element** | a 404/400 an endpoint's other features depend on takes the whole screen with it — measured as a build that never leaves the launch screen | suppress at the data-consumption or render layer instead; if a request's path changes, that is the bug |
|
||||
| **Neutralise a native terminate path by returning, never by making it not return** | a spin stub freezes the caller **holding its lock**; unrelated threads wedge and the symptom (hang, external kill, restart loop) looks nothing like the cause, with no crash record | return a benign value, prefer success (`0`) over failure (`-1`), never touch `pthread_exit`/`exit`/`abort`/`snprintf`/`closedir`. `native-tamper-and-suicide.md` |
|
||||
| **Look before you conclude, and look while you wait** | a screen that is actually looked at answers in one step what coordinate-guessing cannot answer in five | capture **and inspect**; byte-identical samples mean nothing will change. `scripts/snap.py`, `environment.md` §look at the screen |
|
||||
| **Bound every command, and calibrate the bound from a measurement** | an unbounded call turns a stall into "still working", which is indistinguishable from progress | time the operation once, record it, derive the deadline from it. `long-task-discipline.md` §Bound every wait |
|
||||
| **Every claim carries a label — `observed`, `inferred` or `unverified`** | "probably", "should be" and "in theory" are not findings, and an unlabelled guess propagates as a fact | `observed` requires a command **and its output**; nothing else may be written as established |
|
||||
| **"Done" means the user-visible outcome** | a blocking dialog still on screen means the task is not done, however clean the log is | §What "done" means, all six items; absence of a log line is never evidence of success |
|
||||
| **Never discard a route on compound evidence** | if a failure followed two simultaneous changes, the attribution is a hypothesis — mis-attributed failures have removed viable routes for hours | re-run the abandonment single-variable before writing it down |
|
||||
| **Prove the device changed before measuring anything** | install success describes the *request*, not the app on disk | hash the on-device artifact against your build; otherwise every later observation describes the previous build |
|
||||
| **Attribute a failure to the right layer before patching again** | a feature-scoped network failure is frequently the app's own TLS/certificate problem, and chasing a signature check that does not exist burns hours | run the **unmodified original** on the same device and network first (`tls-and-cert.md`) |
|
||||
|
||||
## Reference index and script index
|
||||
|
||||
**Both tables live in `references/routing.md`** — one load gets you every reference file with when to
|
||||
load it, and every script with what it does. They are deliberately not duplicated here: this file is
|
||||
loaded in full every time the skill activates, and those two tables are about 140 rows of lookup data
|
||||
that nobody needs until they have already decided what to do.
|
||||
|
||||
The symptom index above stays, because **symptom to file has to be one hop**: when something fails you
|
||||
are not choosing a file, you are recognising a failure, and a two-hop lookup at that moment is exactly
|
||||
how a stop signal gets skipped.
|
||||
|
||||
`references/evidence-summary.md` is the one reference that answers the claim-strength question from
|
||||
inside an installed copy: capability → one-line conclusion → `observed`/`inferred`/`unverified` → the
|
||||
evidence that ships with the skill. Load it when a claim's strength decides whether you trust it and
|
||||
the run record is not in front of you.
|
||||
|
||||
`python check_routing.py` checks that this file and `references/routing.md` still agree, that every
|
||||
reference file is named there, and that every script is named there. CI runs it.
|
||||
56
vendor-new/apk-reverse/skills/apk-reverse/evals/evals.json
Normal file
56
vendor-new/apk-reverse/skills/apk-reverse/evals/evals.json
Normal 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."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
# Account gates: sign-in walls, forced binding, guest mode
|
||||
|
||||
Load this when the task mentions "no login required", "don't force me to sign in", "skip phone
|
||||
binding", "guest mode", or when a screen is unreachable without an account — and also **before**
|
||||
promising that any account- or membership-derived feature will work offline.
|
||||
|
||||
This file is mostly about **telling apart two things that look identical in the UI and are completely
|
||||
different in reality**:
|
||||
|
||||
- a **client-side gate** — code that refuses to proceed. Patchable.
|
||||
- an **account-scoped resource** — the screen is empty because the server sent nothing, because the
|
||||
request was made without a valid identity. **Not** patchable.
|
||||
|
||||
Confusing the two is how a task ends up "done" while the user stares at an empty page.
|
||||
|
||||
## Step 1: classify what you are looking at
|
||||
|
||||
| Symptom | Most likely cause | Can a client patch help? |
|
||||
|---|---|---|
|
||||
| A dismissible dialog / bottom sheet asking to sign in, then the app works | client-side gate | **yes** |
|
||||
| A full-screen wall with no dismiss, app unusable | client-side gate | **yes** |
|
||||
| Screen renders its frame but the list is empty and a "log in to see" hint is drawn | **account-scoped data** | no — the data does not exist locally |
|
||||
| A specific action (comment, favourite, follow, download) refuses | client-side gate | usually yes |
|
||||
| The action succeeds locally but reverts on restart / never reaches the server | **server-side identity** | no |
|
||||
| "Binding required" before an action | client-side gate | **yes** |
|
||||
| An entitlement appears then disappears | server-side authority | no |
|
||||
|
||||
**The deciding question is never "is there a login screen?" — it is "does the thing I want exist
|
||||
without an identity?"**
|
||||
|
||||
Ask it before patching:
|
||||
|
||||
- If the content is **fetched per-account** (subscriptions, favourites, reading history, purchased
|
||||
items), a patch changes the gate and nothing else. The list stays empty because the server was asked
|
||||
for *this account's* items and there is no account.
|
||||
- If the content is **public** and merely hidden behind a prompt (a reader, a catalogue, a settings
|
||||
page), the patch is real and the feature becomes usable.
|
||||
|
||||
Reading the code tells you which: follow the "sign in" prompt to what it guards, then look at whether
|
||||
that guarded path issues a request carrying a token, or touches only local state.
|
||||
|
||||
## Step 2: find the convergence point
|
||||
|
||||
Sign-in checks are usually concentrated, because a UI cannot meaningfully enforce login at thirty
|
||||
call sites. Look for a small number of helpers with names in this family:
|
||||
|
||||
```
|
||||
checkLogin* ensureLogin* requireLogin* needLogin* isLogined* isLoggedIn*
|
||||
checkBind* ensureBind* requireBind* needBind*
|
||||
showLogin* openLogin* loginSheet* loginDialog*
|
||||
```
|
||||
|
||||
and, on the state side, a **login-state field or stream** that every helper reads
|
||||
(`isLogin`, `loginedStream`, an `Rx<bool>`, an auth-state enum, a credential store wrapper).
|
||||
|
||||
**Rule: patch the predicate, not the prompt.** Making the sheet not appear is cosmetic — the caller
|
||||
still treats the answer as "not signed in" and takes its else-branch, which is usually where the real
|
||||
feature got skipped. The durable fix is to make the *verdict* come back as "signed in / no sign-in
|
||||
needed", so the caller takes its happy path.
|
||||
|
||||
Two useful shapes, both better than editing the dialog:
|
||||
|
||||
| Target | Change | Effect |
|
||||
|---|---|---|
|
||||
| the gate predicate used by many callers | return "ok / already signed in" unconditionally | every caller proceeds; no new code path |
|
||||
| the "should I prompt?" decision inside the helper | return "nothing to do" | the prompt never appears and callers proceed |
|
||||
|
||||
Prefer the first when callers branch on the result; prefer the second when the helper is fire-and-forget.
|
||||
|
||||
**Count the callers before choosing** (`scripts/find_refs.py`). A predicate with one caller is a
|
||||
surgical target; a predicate with forty callers is a behavioural change you must reason about — it will
|
||||
also release flows you were not asked to touch (settings, purchase, upload). That may be fine, but it is
|
||||
a decision, not a side effect.
|
||||
|
||||
### Forced binding specifically
|
||||
|
||||
Phone binding is the same pattern with its own helper and usually **two** state checks in series
|
||||
(bound-phone? and email-or-other-recovery?), because either satisfies the requirement. Patch the
|
||||
combined verdict, not the first condition — a patch that only satisfies one branch still prompts on the
|
||||
other.
|
||||
|
||||
Watch for a **"skip"/"later" path the app already has**, and for **which actions the binding actually
|
||||
gates**. Binding is often required for a subset (password change, deletion, payment) while the rest of
|
||||
the app is unaffected; releasing it globally changes what the app believes about account safety.
|
||||
|
||||
## Step 3: what NOT to do
|
||||
|
||||
These are the tempting moves that produce a worse artifact than doing nothing:
|
||||
|
||||
- **Do not fabricate a session.** Writing a dummy token, a fake credential blob, or a placeholder
|
||||
identity into the credential store makes the client *believe* it is signed in. It then calls
|
||||
identity-scoped endpoints with garbage, and the app enters a state that is **worse than signed out**:
|
||||
screens show an authentication-failure message, or silently return empty data with no explanation.
|
||||
A signed-out app that says "please sign in" is better than one that claims a session and fails.
|
||||
*(If you touch stored credentials at all, keep a backup and expect to have to restore it.)*
|
||||
- **Do not delete the sign-in UI.** Removing the entry point does not change the verdict; it only makes
|
||||
the failure unexplainable when a caller does branch on it. Keep the entry point working — it is
|
||||
normally an explicit requirement ("signing in must still be possible").
|
||||
- **Do not release gates that protect server-side operations.** If the action's result is an
|
||||
entitlement the server must grant (a purchase, a quota, a subscription), releasing the client gate
|
||||
only lies to the UI. See `references/membership-and-limits.md`.
|
||||
- **Do not assume "guest can browse" means "guest can do everything".** Establish per feature which of
|
||||
the three states you are in: public, gate-only, or identity-required.
|
||||
|
||||
## Step 4: the session-loss problem (this one is unavoidable)
|
||||
|
||||
Any change to the signing identity — including a plain reinstall of *your own* build — can invalidate
|
||||
stored credentials, because they may be sealed with a key held by the platform keystore rather than by
|
||||
the app.
|
||||
|
||||
**What you observe:** the app opens fine, then one subsystem reports it cannot decrypt or unwrap
|
||||
something, or the user is silently signed out.
|
||||
|
||||
**What it means:** the app's private data was carried across an uninstall/reinstall (or restored from a
|
||||
backup), but the encryption key was destroyed with the app. The ciphertext is now unopenable by anyone.
|
||||
|
||||
**What to do:** treat "the user signs in once on the new build" as the expected, documented outcome — not
|
||||
as a defect to fix. Put it in the delivery notes in one line, together with the fact that a later
|
||||
upgrade over the *same* signing key can be installed without uninstalling, and therefore will not lose
|
||||
the session.
|
||||
|
||||
**Do not** chase this into the keystore. Restoring the old key is not part of packaging a patched build,
|
||||
and any workaround that keeps the *old* ciphertext alive while the key is gone is a dead end.
|
||||
|
||||
Related and worth knowing: an app may keep the same logical account in **two places** — a plain
|
||||
preference blob and a sealed store. After a reinstall the plain one survives and the sealed one does
|
||||
not, which shows up as an account that is half-present (identity fields populated, token empty). That
|
||||
half-state is a useful diagnostic: it tells you the app reads its identity from the sealed store, which
|
||||
is exactly the thing you must not try to forge.
|
||||
|
||||
## Step 5: verify
|
||||
|
||||
The claim to support is narrow and should be stated narrowly.
|
||||
|
||||
1. **The prompt does not appear** on a cold start and on the screens that used to show it.
|
||||
2. **The feature behind it actually works**, not merely "no longer blocked": open the reader, the
|
||||
catalogue, the settings page — whatever the gate guarded — and confirm real content or real function.
|
||||
3. **The sign-in entry still exists and is usable.** Signing in with a real account must still work; that
|
||||
is usually part of the request, and it is the thing that proves you patched a gate rather than broke
|
||||
authentication.
|
||||
4. **Signed-in behaviour still works** for a real account, so you can tell the difference between
|
||||
"released the gate" and "broke the auth path".
|
||||
5. **No fake-session artefact is left behind** — check that you did not write anything into the
|
||||
credential store or preferences. If you did, remove it and re-verify from a clean install.
|
||||
|
||||
State the result as: *"the gate no longer blocks X, from a signed-out state; **the content behind it is
|
||||
public / the content behind it is per-account and therefore still empty when signed out**."* That second
|
||||
clause is what stops a client patch from being mistaken for an account.
|
||||
|
||||
## Reporting template
|
||||
|
||||
```
|
||||
Gate: <sign-in sheet | binding prompt | action-scoped check>
|
||||
Class: client-side gate / account-scoped data (not patchable)
|
||||
Patch: <predicate or decision, not the prompt>
|
||||
Signed-out state: <what works now, per screen>
|
||||
Signed-in state: <still works, listing what still requires an account>
|
||||
Not achievable: <per-account lists, entitlements — with the reason>
|
||||
Session note: sign in once after install; upgrade over the same key keeps the session
|
||||
```
|
||||
@@ -0,0 +1,180 @@
|
||||
# Ad removal
|
||||
|
||||
Goal: ads stop appearing, and **nothing else breaks**. Read `pitfalls.md` P5 and P6 before patching anything here.
|
||||
|
||||
|
||||
**Load this when:** an ad, promo, splash or rewarded video must stop appearing, or an ad-related SDK was found in recon. It gives the enumeration step that decides *which* layer the ad lives on, then the removal per layer.
|
||||
|
||||
## Step 1: enumerate what "ads" means in this app
|
||||
|
||||
Do not assume "ads" is one thing. Enumerate first.
|
||||
|
||||
**SDK-integrated ads** (the app calls an SDK):
|
||||
- Search dex strings for SDK markers: `openadsdk`, `TTAdSdk`, `TTAdNative`, `Pangle`, `pangolin`, `com.qq.e`, `GDTAd`, `gdt_plugin`, `anythink`, `ATSDK`, `ATRewardVideoAd`, `bdxadsdk`, `sigmob`, `ksad`, `mobads`, `beizi`.
|
||||
- **Aggregators** (AnyThink, TopOn, and similar) are wrappers: the ad networks underneath are *their* adapters, not app code. Patching an individual network's class is usually pointless; the aggregator still runs and still reaches the network.
|
||||
- The app almost always funnels every call into **one wrapper class**. That class is the patch surface (Step 2).
|
||||
|
||||
**Dynamic-plugin ad SDKs** (the SDK is not in the APK). Aggregators increasingly ship as a runtime plugin: the app downloads an APK on first launch into its private directory and loads it through a plugin classloader.
|
||||
|
||||
```
|
||||
/data/data/<app.package>/files/<sdk>_p/<plugin.pkg>/version-*/apk/base-1.apk
|
||||
/data/data/<app.package>/files/<sdk>_p/<plugin.pkg>/version-*/lib/<abi>/*
|
||||
```
|
||||
|
||||
- Consequence: **deleting bundled `assets/`, `lib/*.so`, or the SDK's classes from the dex does not kill this SDK.** The loader re-downloads on the next launch, and a half-deleted plugin is worse than an intact one.
|
||||
- The kill switch is the **init gate** (Step 2). Killing init also prevents the working directory from ever being created, which is the strongest verification signal available (Step 4).
|
||||
- The plugin may also be bundled. Two copies still means one init gate; patch that, not the payload.
|
||||
|
||||
**Server-driven ads / sponsored content** (the server returns the ad, the client renders it):
|
||||
- **This is the most common shape in a modern app, and it is usually the EASIEST to
|
||||
remove — not the hardest.** Go to `server-config-and-updates.md` for the full
|
||||
procedure. The short version: the client keeps a complete "do not show it" branch
|
||||
so the operator can turn the slot off, so neutralising that one branch is a
|
||||
small, local edit.
|
||||
- Look for endpoints like `/adverts`, `/adv`, `/banner`, `/config`, and DTOs named
|
||||
`Advertisement*`, `Advert*`, `Banner*`, `Promotion*`, or — more common in practice
|
||||
— a generic `*Config` payload carrying per-feature blocks (`splash`, `noticePopup`,
|
||||
`updatePopup`, `tabbar`, `banner`).
|
||||
- The client renders whatever the list contains. If the list is empty there is nothing
|
||||
to show, but see `pitfalls.md` P5 on **how not** to empty it: never make a shared
|
||||
request fail.
|
||||
- Sponsored cards that look like content (a VPN promo, a network-accelerator card, a
|
||||
third-party product) are usually exactly this.
|
||||
|
||||
**A note on what "no SDK found" means.** If you have grepped the dex, enumerated the
|
||||
loaded classes at runtime, and counted the SDK log tags in a capture, and all three
|
||||
are zero (see Step 1), then there is no SDK, and continuing to search for one is a
|
||||
dead end rather than a thorough approach. Re-classify against this list instead. A
|
||||
zero result on all three signals is a strong positive finding about where the
|
||||
behaviour lives, and it should move you to the server-config path immediately.
|
||||
|
||||
**Legit content that looks like an ad.** Verify before acting. A `/adverts?position=banner` response containing anime titles and poster images is the home-page carousel, not an advertisement. Removing it removes real functionality.
|
||||
|
||||
## Step 2: find the single convergence point
|
||||
|
||||
Almost every ad-enabled app wraps one SDK in a **single singleton helper**: `init`, `loadSplash`, `loadBanner`, `loadFullScreen`, `loadReward` all live in the same class. Find that class, and patch nothing else if you can.
|
||||
|
||||
```
|
||||
helper.init(Application) -> Sdk.init(appId, appKey); Sdk.start() // plus plugin download
|
||||
helper.showSplash(Activity, ViewGroup, onClose: () -> Unit)
|
||||
helper.showInterstitial(Activity)
|
||||
helper.showReward(Activity, onStart: () -> Unit, onFinish: (Boolean) -> Unit)
|
||||
helper.preload*(Activity)
|
||||
helper.canShow*(Activity): Boolean // == isReady / isLoaded / isAdReady
|
||||
```
|
||||
|
||||
**Preferred play: readiness always false + init is a no-op.**
|
||||
|
||||
1. Make the readiness predicate constant `false` (`isReady` / `canShow*` / `isLoaded` / `isAdReady`).
|
||||
2. Make `init` an empty method (`return-void`) so the SDK never builds a network stack and never requests a plugin.
|
||||
3. Only then touch show/load methods, and only for slots that bypass the readiness predicate.
|
||||
|
||||
Why this order works:
|
||||
- Callers of a readiness predicate **already own a "no ad available" branch**, and the app ships and tests it. Returning `false` routes every slot through that existing path: no crash, no hang, no new code path.
|
||||
- Killing init removes the SDK's network, cache, and download activity entirely. That is the difference between "the ad is hidden" and "the ad subsystem never started", and only the second claim is provable (Step 4).
|
||||
- Per-slot patching (splash this week, interstitial next) leaves the SDK alive and downloading. Avoid it unless a slot ignores the predicate.
|
||||
|
||||
Method classification, once the helper is located:
|
||||
|
||||
| Method | Contains | Patch |
|
||||
|---|---|---|
|
||||
| Init | `Sdk.init(...)` + `Sdk.start()`, plugin download | body -> `return-void` |
|
||||
| Readiness / gate | `isAdReady`, `canShow`, `isLoaded` | return `false` / `0` |
|
||||
| Show splash / interstitial | constructs an ad object, `loadAd()` / `show()` | `return-void`, **but preserve the completion callback** |
|
||||
| Show reward | same, plus success/failure lambdas | `return-void`; decide deliberately whether to grant the reward |
|
||||
| Preload / warm-up | `RewardVideoAutoAd.init(...)`, cache priming | `return-void` |
|
||||
|
||||
### The callback trap (this one breaks startup)
|
||||
|
||||
A splash/loading ad usually takes a **completion lambda** as a parameter, and the app's startup state machine waits for that lambda before dismissing the splash screen.
|
||||
|
||||
**If you replace the method with a bare `return-void`, the lambda never fires and the app hangs on the splash screen forever.** A patch that "removed the ad" and produced a frozen splash is this bug, not a repackaging bug.
|
||||
|
||||
Correct shape, skip the ad and still complete:
|
||||
|
||||
```
|
||||
.method public final showSplash(Landroid/app/Activity;Landroid/view/ViewGroup;Lkotlin/jvm/functions/Function0;)V
|
||||
.registers N
|
||||
invoke-interface {p3}, Lkotlin/jvm/functions/Function0;->invoke()Ljava/lang/Object;
|
||||
return-void
|
||||
.end method
|
||||
```
|
||||
|
||||
**Before patching, prove the contract.** Read the listener class the method constructs and find which callback triggers the completion lambda (`onAdDismiss`? `onAdError`? `onAdLoadTimeout`?). That tells you whether to call it on success, on failure, or unconditionally.
|
||||
|
||||
For a reward ad whose lambdas are `(onStart, (Boolean) -> Unit)`, granting the reward client-side is a choice: `onFinish(true)` makes the app treat the reward as earned. Check whether the reward is validated server-side before promising it.
|
||||
|
||||
### Global timestamp gates (one write, many slots)
|
||||
|
||||
Some "free reading + ads" apps do not gate ads per slot. They store one local timestamp and every slot, cooldown, and interval check reads it: splash, chapter-break interstitial, unlock prompt, "next ad in N minutes". Patching call sites one by one always misses some.
|
||||
|
||||
- **Find it:** dump the app's preference keys and filter for ad-ish names (`ad`, `free`, `cooldown`, `interval`, `end_time`, `expire`, `next_`). Then hook `SharedPreferences.getLong/getInt/getString` and watch which key is read immediately before a slot appears. The key read across several screens is the gate.
|
||||
- **Patch it:** in `Application.onCreate()`, write a far-future value as early as possible (for example `4102444800000L`). Avoid `Long.MAX_VALUE`: implementations often compute `end - now` and can misjudge an extreme value. One write disables every reader at once, which is far more stable than editing each consumer.
|
||||
- **If the app overwrites it later:** observe what it writes back under different start sequences. A single startup write is not automatically permanent for keys the app re-asserts; then patch the writer or the reader instead.
|
||||
- **Same technique, two key semantics:** "ad-free until `<date>`" keys and "cooldown/interval" keys both respond to this, but the value differs. For a cooldown key, a far-future value means "next ad is never due", which is what you want, while a progress/check-in flow reading the same key may behave oddly.
|
||||
- **Side effects are mandatory in the delivery notes.** The same key is often reused as a business field (the anchor for a new-user ad-free window, a remaining-quota counter). Forcing it to a far-future value changes those business judgements. Usually harmless for a de-ad goal, but state it explicitly and confirm the related screens do not show a broken state.
|
||||
- With DataStore instead of SharedPreferences, a write inside `Application.onCreate` needs a coroutine or a blocking write; see `runtime-data.md`, and never block the main thread on persistence.
|
||||
|
||||
### "Ads" vs ad-gating UI
|
||||
|
||||
Some blocking dialogs contain no ad slot at all ("watch an ad to unlock", skip/continue prompts, cooldown notices). If the goal is "the app is usable", they count as ads and must be handled too. Handle them by letting the gate pass, not by deleting the dialog view.
|
||||
|
||||
- Where the gate reads the timestamp key above, the timestamp patch already covers it.
|
||||
- Where it reads a boolean or a `canUnlock()` method, patch that predicate.
|
||||
- **Business-coupled entrances need a check first.** "Watch a video to earn benefits" may be a real feature. Decide with evidence: find the callers (`scripts/find_refs.py`) and see whether the entrance leads to a **server request** or a **local entitlement write**. A server-issued entitlement cannot be created client-side, so releasing the gate only fools the UI while the server still refuses; an entrance that changes purely local UI state is safe to release.
|
||||
|
||||
## Step 3: choose the patch layer
|
||||
|
||||
From safest to riskiest (full technique table in `dex-patching.md`):
|
||||
|
||||
1. **Kill SDK init** (Step 2) — the SDK never starts; its networks, caches, and plugin downloads never happen. Highest-value single patch. Verify nothing else silently depended on init.
|
||||
2. **Neutralize the readiness gate** — `isReady`/`canShow` return false; routes existing "no ad" branches.
|
||||
3. **No-op the show/preload methods** — only when 1 and 2 are insufficient, always preserving callbacks.
|
||||
4. **Global gate state** — one startup write covers every reader (timestamp / preference keys above).
|
||||
5. **Filter server-issued ad data at the consumption layer** — drop entries for the target position before they reach UI state.
|
||||
6. *(avoid)* **Renderer-level suppression** — only if the component is genuinely ad-exclusive. Check `pitfalls.md` P6 first.
|
||||
7. *(avoid)* **Transport-level blocking** — see `pitfalls.md` P5.
|
||||
|
||||
## Step 4: verify — three independent kinds of evidence
|
||||
|
||||
Logcat silence alone is weak evidence. A credible "ads are gone" claim needs all three, plus the neighbours:
|
||||
|
||||
1. **UI, screen by screen** — splash, home banner, detail page, reader/player, reward button, unlock prompt. Capture before/after screenshots (`verification.md` §screenshot discipline).
|
||||
2. **Logs** — the SDK's characteristic tags are **entirely absent**, not merely quiet: `anythink`, `ATSDK`, `Pangle`, `TTAd`, `GDT`, `ksad`, and so on.
|
||||
Make this a **count, not an impression**. Aggregators log a recognisable burst while they probe which networks are wired in (a "SDK id N not integrated" style line, once per network). Capture the same fixed window on the original and on your build and compare numbers: `8 -> 0` is a measurement; "I did not see it" is not. A tag that goes quiet without reaching zero usually means the SDK still starts and simply failed to load an ad this run — a different, weaker result than "the subsystem never started".
|
||||
3. **Filesystem, the strongest signal** — the app's private directory contains **no ad SDK working directory at all**:
|
||||
```bash
|
||||
adb shell "su -c 'ls -la /data/data/<app.package>/files/'"
|
||||
```
|
||||
For a dynamic-plugin SDK, an absent `<sdk>_p` tree proves the init gate never ran. It is not an artifact you can fake by hiding a view. If the tree exists, something still initializes the SDK: the init patch did not take effect, or a second init path exists.
|
||||
|
||||
Additional cheap evidence:
|
||||
4. **DNS / socket level** — a runtime `InetAddress` hook. Before: a burst of ad/tracker domains. After: none. Many ad SDKs bypass the system proxy, so a proxy log can miss them while this does not.
|
||||
5. **Exercise the screens that had ads** and confirm nothing unrelated regressed: images, playback, lists, pagination, downloads, login.
|
||||
6. **Clean install** — uninstall first, then install. Data left from a previous run can hide a failed init patch (`pitfalls.md` P11).
|
||||
|
||||
Frame the result at the right strength: *"the ad was hidden"* vs *"the ad subsystem never started"*. Only evidence 3 and 4 support the second (`verification.md` §the claim ladder).
|
||||
|
||||
## Step 5: what is usually NOT removable
|
||||
|
||||
Be honest about residual ads rather than breaking the app to chase them:
|
||||
|
||||
- **Content promos embedded deep in a feature's own data payload**, where the screen's
|
||||
load depends on the same request. Distinguish this from a *dedicated config block*:
|
||||
if the promo arrives in its own `splash` / `banner` / `popup` field with its own
|
||||
`enabled` flag, it is removable (Step 1, server-driven). It is only genuinely hard
|
||||
when the same list is both the content and the ad, with no marker separating them.
|
||||
- **Ads delivered as content** (a sponsored "article" or a native card with no SDK marker)
|
||||
— indistinguishable from real content without runtime tracing.
|
||||
- **Ads whose SDK init also enables other features.** Removing init can break
|
||||
functionality that silently depended on it. Verify before shipping; a working app with
|
||||
one residual ad beats a broken app.
|
||||
- **Server-issued entitlements gated behind watching an ad.** The gate can be opened
|
||||
client-side, the entitlement cannot be created client-side.
|
||||
- **A purely local brand launch screen.** If all you see is the app's own logo, no
|
||||
third-party image and no network-sourced content, that is a splash screen, not an ad.
|
||||
Removing it is a judgement call about the user's stated goal — say which one you
|
||||
removed rather than silently treating "startup screen" as "ad".
|
||||
|
||||
If a residual ad cannot be removed without breaking something, say so, and say exactly
|
||||
which coupling caused it. That is a better deliverable than a broken APK.
|
||||
@@ -0,0 +1,593 @@
|
||||
# Advanced Unpacking — Extraction Shells, Active Invocation, and the VMP Boundary
|
||||
|
||||
Load this when a memory dump of a packed app has already landed on your desk and the
|
||||
dump itself is the problem: the image parses, the classes are there, and the method
|
||||
bodies are empty. The neighbouring files each own one stage — `packers.md` owns
|
||||
*identifying* a shell and mapping what it validates, `recon.md` §Unpacking a dex-level
|
||||
packer owns the whole-image memory dump and its first-pass filtering, and
|
||||
`dynamic-frida.md` owns the runtime environment those dumps need. This file owns what
|
||||
comes after: recognizing that a "successful" dump is only a skeleton, recovering the
|
||||
missing code, and knowing where that recovery honestly stops.
|
||||
|
||||
**Strength note, read this first:** the repository's verification target carried no
|
||||
extraction shell and no VMP, so nothing here was exercised end to end against a live
|
||||
hardened sample by this skill's own verification pass. The FART/Youpk mechanisms and
|
||||
the Android 12-16 failure analysis are **inferred** from public work (FART/Youpk
|
||||
release notes and a 2026-08 Kanxue thread, `thread-292312`, on why classic
|
||||
active-invocation hooks die on modern ART). The detection metric has since been
|
||||
**measured against a purpose-built extraction-shell fixture set**, and that pass
|
||||
invalidated the threshold this file used to state — see the calibration table in
|
||||
§Measuring extraction instead of guessing and the commands behind it in
|
||||
`references/evidence-summary.md` §The capability matrix. The older surgical fixture
|
||||
that first exercised the script is in `references/evidence-summary.md` §The capability matrix.
|
||||
Label your own results the same way.
|
||||
|
||||
Two parts of this file have since been exercised against live hardened targets on a
|
||||
rooted device. The **root-side memory route** (§Dumping when frida is refused) was run
|
||||
end to end: 17 ART dex mappings exported from `/proc/<pid>/mem`, page-alignment trim
|
||||
included, validated with `scripts/dex_dump_validate.py`; the evidence, the device-shell
|
||||
traps and the throughput numbers are recorded in
|
||||
`references/evidence-summary.md` §The capability matrix. The `frida-dexdump` refusal that
|
||||
motivates that section is in `references/evidence-summary.md` §The capability matrix. A later
|
||||
pass added the route's **negative boundary** — what it yields nothing against, and how
|
||||
to tell before spending a process window — plus the reproducibility check between read
|
||||
paths and the layered-descent table; those are measured on the clean MASTG targets and
|
||||
on a hardened sample, with commands and hashes in
|
||||
`references/evidence-summary.md` §The capability matrix. The
|
||||
FART/active-invocation half of the file is still **inferred** — no extraction-shell
|
||||
target has been spliced end to end here, and the two candidate samples shipped in
|
||||
`repos/CyReverse` turned out not to be extraction shells at all (their two "shell"
|
||||
assets differ by six bytes; row B3 in the bench evidence file).
|
||||
|
||||
## The four shapes a dump can be
|
||||
|
||||
Diagnose before choosing a route. Run `scripts/dex_dump_validate.py` over the dump
|
||||
directory and read the `stub%` column — the four shapes separate on it.
|
||||
|
||||
| The dumped image | `stub%` | Diagnosis | Route |
|
||||
|---|---|---|---|
|
||||
| Parses, many classes, real bodies | near-baseline | First-generation landing shell; the whole-dex dump **is** the original | Done — filter (`recon.md` §Unpacking a dex-level packer) and proceed to patching |
|
||||
| Parses, many classes, most bodies are `return-void` stubs or nop fills | high | **Extraction shell skeleton** — bodies are decrypted only on invocation | FART loop, this file |
|
||||
| Whole classes appear as bare `native` declarations, and the matching `Java_*` export exists | n/a | **JNI sinking** — the code left the dex for the `.so` | `java2c-and-jni-sinking.md` — read the function in the library, **not** this file |
|
||||
| Method bodies look like a thin forwarder to a native routine, or a single native island holds the app's own logic | n/a | **Java2C / native island** — the body is a compiled `J`/`.so` function, so re-reading the dex finds only the call | `java2c-and-jni-sinking.md` — the dex is the wrong instrument |
|
||||
| Whole classes appear as bare `native` declarations, and **no** `Java_*` export exists | n/a | Dynamic-registration native sink — the binding table is built at runtime | `java2c-and-jni-sinking.md` §the symbol search fails structurally |
|
||||
| Parses, bodies present but instruction streams are nonsense to a dalvik disassembler | baseline | **Real Dex VMP** — private opcodes behind a native interpreter | Last section of this file, and be honest about the cost |
|
||||
|
||||
The two `native`-looking rows are **not** one row. They are separated because the
|
||||
correct next action differs: JNI sinking keeps the logic in a named, exported ARM64
|
||||
function that you read from the library, while a dynamic-registration sink has no
|
||||
symbol to find and needs the binding table read instead. Lumping them together sends an
|
||||
analyst to dump memory for code that is sitting in an ordinary `.so` — the misdiagnosis
|
||||
`java2c-and-jni-sinking.md` was written to stop.
|
||||
|
||||
Two supporting signals worth recording while you are here (the second is measured on
|
||||
this repo's hardened sample): a shipped shell `classes.dex` can be megabytes in size
|
||||
yet define only a handful of classes — the bytes are payload, not code, so class count
|
||||
and file size move independently under a packer. And a dump whose signature field
|
||||
disagrees with its own contents while the checksum agrees is not necessarily corrupt;
|
||||
a shell that never verifies its own header can ship it that way, so treat
|
||||
checksum/signature results from `verify_dex_header` as evidence about *who touched the
|
||||
image*, not as a pass/fail gate on usability.
|
||||
|
||||
## Measuring extraction instead of guessing
|
||||
|
||||
The metric that separates shape 2 from shape 1 is the **trivial-body ratio**: among
|
||||
methods that *have* a `code_item` (`code_off != 0`), the fraction whose instruction
|
||||
stream, with plain `nop` units skipped, contains nothing but a single `return*`.
|
||||
`code_off == 0` methods are abstract or native declarations — that absence is normal
|
||||
and is excluded from the ratio. A method with a `code_item`, real `insns_size`, and a
|
||||
lone `return-void` inside is what an extraction shell leaves behind.
|
||||
|
||||
```bash
|
||||
python scripts/dex_dump_validate.py <dump_dir> --find 'Lcom/example/app/'
|
||||
```
|
||||
|
||||
The script dedupes by sha256 (never by size — same-size dumps of one dex have been
|
||||
measured to differ in ~73% of their bytes), rejects structurally impossible images
|
||||
(bad magic, `file_size` mismatch, ids out of range), counts classes and stub bodies,
|
||||
and ranks which surviving image is the most likely original.
|
||||
|
||||
**Calibration — measured, and it is not a threshold.** Do not look for a `stub%` number
|
||||
to compare against. On a real 982 KB application dex (5,061 bodies) every emptying
|
||||
shape the shell family uses was built into a fixture and measured against a
|
||||
zero-change control (`references/evidence-summary.md` §The capability matrix,
|
||||
which carries the commands and the full matrix):
|
||||
|
||||
| What the shell left behind | `stub%` | vs. control (1.9 %) |
|
||||
|---|---|---|
|
||||
| bare `return-void` in every body | 100.0 % | sighted |
|
||||
| body cut to `const/4 v0,#0; return v0` | 93.2 % | sighted |
|
||||
| nop fill, no return — **the slot-clearing shape** | 0.0 % | **blind, and scores below the untouched original** |
|
||||
| `new RuntimeException; throw` stub — **the 360/legu shape** | 1.9 % | **blind, indistinguishable from the control** |
|
||||
| 25 / 50 / 75 % of bodies emptied | 1.7 / 1.7 / 2.1 % | blind |
|
||||
|
||||
The distribution is **bimodal**: `stub%` lands either at the app's own baseline
|
||||
(ordinary constructors and interface stubs, measured at 1.9 % here) or at ~100 %.
|
||||
Nothing sits in between, so there is no band a threshold could live in, and **partial
|
||||
extraction is invisible** — a dex with three quarters of its bodies removed reported
|
||||
0.2 points above its untouched control.
|
||||
|
||||
Two consequences the metric's name hides:
|
||||
|
||||
- **`stub%` high is dispositive; `stub%` low is not evidence of anything.** A low
|
||||
reading is compatible with a genuine dex, a fully nop-wiped skeleton, and a
|
||||
`throw`-stubbed skeleton alike.
|
||||
- **Read `emptied%`, not `stub%`, for the skeleton call.** `emptied%` unions the
|
||||
`return*`-stub and the nop-wiped classes, which is what lifts the nop-filled
|
||||
skeleton from 0.0 % to 100 %. A `throw`-stubbed skeleton still reads low; for that
|
||||
shape the ranking cannot help you and only per-body inspection can.
|
||||
|
||||
The trivial-body shapes `dex_dump_validate.py` counts, and which of them are skeleton
|
||||
evidence, are documented in the script itself (`classify_body`); the split between
|
||||
`stub` / `erased` / `minimal` exists because collapsing them produced a ranking that
|
||||
named a fully-wiped image the most likely original.
|
||||
|
||||
### The ranking's failure conditions — read these before trusting its winner
|
||||
|
||||
The `ranking` block names a "best-supported candidate for the original", and it is
|
||||
wrong in two measured situations. Both were reproduced, and both are the *opposite* of
|
||||
the obvious reading:
|
||||
|
||||
- **A partially extracted image outranks a heavily stubbed one.** Ranking on the
|
||||
emptied share alone put a dex with 25 % of its bodies removed *ahead of* its own
|
||||
untouched control — 1.7 % against 1.9 %, because replacing a body with
|
||||
`const/4 + return` lowers the `return-void` count. Measured before it was fixed;
|
||||
the current key uses the count of skeleton-shaped bodies first, which is monotone,
|
||||
and evicts anything over 50 % emptied.
|
||||
- **A `throw`-stub skeleton is invisible and ranks at the control's own baseline.**
|
||||
Every body replaced by `new RuntimeException; throw` measured 1.9 % — identical to
|
||||
the untouched image — and it still sorts third in the fixture set. No body statistic
|
||||
in this kit sees that shape. If the target family is 360/legu-like, **do not use the
|
||||
ranking to pick a baseline**; diff candidate images against each other by body hash
|
||||
instead.
|
||||
|
||||
The reason to state this in the reference file rather than only in the script: the
|
||||
ranking's output is a *sentence with a winner in it*, and a wrong winner is worse than
|
||||
no winner — an agent that adopts a modified image as its baseline builds every
|
||||
subsequent diff on the wrong artifact. When `emptied%` is at the app's own baseline and
|
||||
you cannot name a signal that separates the candidates, treat the ranking as
|
||||
uninformative for that set. Commands and full matrix:
|
||||
`references/evidence-summary.md` §The capability matrix.
|
||||
|
||||
## The FART loop: skeleton, invocation, splice
|
||||
|
||||
Extraction shells decrypt a method body only when that method first executes. A dump
|
||||
taken at any fixed moment therefore holds whatever the app happened to invoke before
|
||||
you dumped — mostly stubs. Waiting for the UI to wander into every method is not a
|
||||
plan (thousands of methods, many on paths a user never triggers). The FART family of
|
||||
tools closes the loop with three steps, **inferred** (not run by this repo against a
|
||||
live extraction shell):
|
||||
|
||||
1. **Dump the whole image.** Class definitions, field and method signatures, string
|
||||
tables — the skeleton is complete even when every body is a stub. This is the
|
||||
ordinary `recon.md` dump; it gives you the address map for step 3.
|
||||
2. **Actively invoke every method.** Walk the dumped class list reflectively and call
|
||||
each method (dummy arguments; most will throw, which is fine — the point is that
|
||||
the *shell* decrypts and installs the body as the method is entered). Hook inside
|
||||
ART at the interpreter's entry so that, as each method begins executing, the
|
||||
method's `code_item` — now holding the decrypted body — is written to disk,
|
||||
tagged with its `method_idx`. These per-method records are the "bin" files of a
|
||||
FART dump.
|
||||
3. **Splice the bodies back into the skeleton.** For each record, overwrite the
|
||||
stub body in the whole-dex image at the offset the skeleton gives you, fix the
|
||||
header (`scripts/dexutil.py` `fix_dex_header` — signature first, checksum last),
|
||||
and **accept nothing until jadx or baksmali parses the result** and the method
|
||||
count matches the skeleton. "It assembled" is not "it repaired".
|
||||
|
||||
## The code_item length trap
|
||||
|
||||
Splicing fails in a specific place often enough to deserve its own section: the
|
||||
computed length of a `code_item`. The full layout is
|
||||
|
||||
```
|
||||
registers u16, ins u16, outs u16, tries u16, -- 8 B
|
||||
debug_info_off u32, insns_size u32, -- 8 B (header = 16 B)
|
||||
insns[insns_size] u2, -- the instruction stream
|
||||
[padding u2] -- ONLY if tries_size > 0 AND insns_size is odd
|
||||
try_item[tries_size] { start_addr u32, insn_count u16, handler_off u16 }, -- 8 B each
|
||||
encoded_catch_handler_list { uleb size; per handler: sleb offs... } -- variable
|
||||
```
|
||||
|
||||
Two segments are routinely dropped: the odd-`insns_size` padding before `tries`, and
|
||||
the whole `encoded_catch_handler_list`, which is LEB128-variable and sits *after* the
|
||||
try table. Drop either and the splice boundary slides — the rebuilt method swallows
|
||||
bytes from its neighbour or leaves the handler table pointing past the image, and the
|
||||
failure surfaces later as a dex that half-parses. Walk the handler list with
|
||||
`read_uleb` to its true end (`scripts/dexutil.py`); do not estimate it. This trap is
|
||||
**inferred** from repair-tool post-mortems, and it is the first place to look when a
|
||||
spliced dex parses worse than the skeleton did.
|
||||
|
||||
## Why the classic hooks die on Android 12-16
|
||||
|
||||
The original FART-era hooks assumed method entry flows through
|
||||
`art::interpreter::Execute`. On Android 12-16 that assumption fails for five
|
||||
independent reasons (**inferred**, from the Kanxue `thread-292312` analysis, 2026-08).
|
||||
Any one of them produces the same symptom — the dumper runs, logs everything, and the
|
||||
output directory stays thin:
|
||||
|
||||
1. **Invoke dispatch splits away from `Execute`.** A quick-entry call goes to Nterp,
|
||||
AOT or JIT code, or a bridge — none of them pass through the old `Execute` hook.
|
||||
Forcing the interpreter is not a fix either: the entry point then switches to
|
||||
Nterp, which *also* bypasses it. *Repair:* move the dump point up to
|
||||
`EnterInterpreterFromInvoke`, which sits ahead of the Nterp/interpreter split, and
|
||||
keep fallback points (special invoke branches, ClassLinker load paths, DexFile
|
||||
open, Frida ClassLoader enumeration) for the methods that still slip through.
|
||||
2. **`ArtMethod` layout drift.** Field offsets and pointer-sized members changed
|
||||
across releases; a hardcoded offset reads the wrong field and the dump silently
|
||||
mislabels everything after it. *Repair:* derive offsets at runtime (probe
|
||||
`access_flags` / `quickCode` / `jniCode` on a known method) instead of compiling
|
||||
them in.
|
||||
3. **Scoped Storage breaks the output quietly.** Writes to `/sdcard` fail, the log
|
||||
reads like a completed run, and the directory is empty. *Repair:* write under
|
||||
`/sdcard/Android/data/<pkg>/files/` (or app-private storage) and **check the
|
||||
`open()` return value** — the silent variant of this failure wastes a whole round.
|
||||
4. **The shell fights back.** Junk classes that exit inside their initializer,
|
||||
detection of reflective enumeration, deliberate dex-header corruption in memory,
|
||||
decryption deferred to the true execution point, fingerprinting of known dumper
|
||||
thread names and paths. *Repair:* randomize your thread names and output paths,
|
||||
filter junk classes (few methods, throwaway names) before invoking, and expect the
|
||||
counter-measure list to differ per vendor.
|
||||
5. **Compilation policy shifts the entry under you.** A just-installed app and one
|
||||
that has run for a while present different entry mixes (profile-guided
|
||||
compilation, Mainline module updates). *Repair:* re-probe the entry type every
|
||||
run; never cache yesterday's hook decision.
|
||||
|
||||
The debugging order that isolates all five in the fewest rounds: print the entry and
|
||||
flags for a known method → force the interpreter → observe what the entry became →
|
||||
place the dump point accordingly → force only the target package (`ignore` lists for
|
||||
`androidx.*`, `com.google.*`, `kotlin.*` — forcing the framework too freezes or kills
|
||||
the app) → compare a method's `code_item` before and after invocation → splice →
|
||||
verify with jadx/baksmali.
|
||||
|
||||
## Youpk in one paragraph
|
||||
|
||||
Youpk is the ROM-level descendant of the same idea (**inferred**): a modified ART
|
||||
performs the active invocation *inside* the runtime at class-link and interpret time,
|
||||
so the reflective caller script, the entry-point guessing, and most shell
|
||||
counter-measures against host-side tooling drop out — at the cost of building and
|
||||
flashing a customized runtime per Android version. Choose it when the target's
|
||||
anti-instrumentation defeats host-side loops entirely, and you control the device.
|
||||
|
||||
## The frida-dexdump route and where it stops
|
||||
|
||||
`frida-dexdump` walks the live ClassLoaders and writes every dex-shaped memory range.
|
||||
Environment setup and its two time sinks (the `-o` directory must pre-exist, and
|
||||
`-D` does not take remote devices) are recorded in `recon.md` §Unpacking a dex-level
|
||||
packer; the Frida environment itself is `dynamic-frida.md`. What it hands you is a
|
||||
pile of images of the four shapes above — dedupe, reject and rank them with
|
||||
`scripts/dex_dump_validate.py` before touching any by hand. Its hard limit: against
|
||||
an extraction shell it can only ever produce the **skeleton**, because the missing
|
||||
bodies exist nowhere in memory until invoked. No amount of re-dumping fixes that;
|
||||
only the invocation loop recovers them.
|
||||
|
||||
## Dumping when frida is refused
|
||||
|
||||
A hardened target can refuse instrumentation outright: the dump tool dies with
|
||||
`script has been destroyed` and the process is gone before the error can be read.
|
||||
That is a *protocol* refusal, not a timeout, and re-running the same attach with a
|
||||
different flag is the wrong response — twice-identical failure on one variable is a
|
||||
stop signal, not a hint to try a third flag. What remains is the route that never
|
||||
touches the process's instrumentation interface at all: **root reads the target's own
|
||||
memory** through `/proc/<pid>/maps` and `/proc/<pid>/mem`. No agent, no ptrace
|
||||
attach, no server process for the shell to find.
|
||||
|
||||
**Read the signals carefully, because one of them lies.** The refusal presents as
|
||||
(a) the dump tool's attach failing with `script has been destroyed` while the app is
|
||||
otherwise healthy, and (b) pid churn. But **pid churn alone is not evidence that
|
||||
frida was detected.** A 360-jiagu sample on this repository's test device was
|
||||
measured exiting every 7-18 s and being restarted by the platform, with *no*
|
||||
instrumentation process anywhere on the device, logging
|
||||
`Process <pkg> (pid N) has died: fg TOP` and no crash, tombstone or ANR record — a
|
||||
clean self-exit, i.e. the packer's own environment check. Attribution needs the
|
||||
control run, and the control run needs a window longer than one restart period: a
|
||||
12-second observation lands inside a 17-second cycle and reports "stable" about a
|
||||
process that is not. If the sample self-exits on that cadence, every step below must
|
||||
fit inside a single lifetime.
|
||||
|
||||
### Read the map before choosing the bytes
|
||||
|
||||
```bash
|
||||
adb shell 'su -c "pidof <PKG>"' # may return nothing mid-restart
|
||||
adb shell 'su -c "ps -A -o PID,NAME | grep -w <PKG>"' # more robust: matches either way
|
||||
adb shell 'su -c "cat /proc/<pid>/maps"' > maps.txt
|
||||
```
|
||||
|
||||
ART names its in-memory dex mappings for you — this is the cheapest win on the whole
|
||||
route, because a mapping called
|
||||
`[anon:dalvik-classes*.dex extracted in memory from <src>]` *is* a dex image, already
|
||||
located, already sized, no search required:
|
||||
|
||||
```
|
||||
7129d8c000-712a60f000 r--p [anon:dalvik-classes.dex extracted in memory from /data/app/~~…/base.apk]
|
||||
```
|
||||
|
||||
Copy those ranges first. Everything else — anonymous `rw-p`, `libc_malloc`, `.bss` —
|
||||
is a *search* target, and searching is the expensive half.
|
||||
|
||||
### Address arithmetic is where this route silently breaks
|
||||
|
||||
Three device-shell traps cost real time on the first run, and each one produces a
|
||||
plausible-looking result rather than an error:
|
||||
|
||||
| Trap | Symptom | Fix |
|
||||
|---|---|---|
|
||||
| mksh arithmetic is 32-bit | `$((16#7129d9a000))` yields `702128128` (the low 32 bits) — `skip=` then seeks to the wrong address and the dump looks like zeros | convert hex to decimal with `awk` (double precision handles 48-bit addresses exactly) |
|
||||
| A VMA is page aligned | the dump is 0..4095 bytes longer than the image; a validator rejects it with `file_size=8923752 actual=8925184` (17 of 17 files on the measured run) | trim to the `file_size` at dex header offset 32 before validating |
|
||||
| `grep` is line oriented | the magic contains a `0A`, so no line-oriented pattern can ever match `dex\n035\0`; `-F` with a real newline in the pattern fails too | `grep -z -a -o -b -E 'dex.035'` — with `-z`, records are NUL separated and `.` matches the newline byte; the reported offset is stream-relative, add the segment start |
|
||||
|
||||
One more that only bites in scripts: **toybox `awk` cannot be trusted with
|
||||
`/regex/ && $0 !~ /…/`** — a filter written that way selected all 808 map lines,
|
||||
including `r--p` system libraries. Filter the map with `grep` and keep `awk` for
|
||||
arithmetic only.
|
||||
|
||||
### Export, then prove it is a dex
|
||||
|
||||
```bash
|
||||
# inside one lifetime of the process: export a candidate range
|
||||
dd if=/proc/<pid>/mem iflag=skip_bytes,count_bytes skip=<DEC_START> count=<SIZE> \
|
||||
of=/data/local/tmp/seg_<n>.bin bs=256k
|
||||
adb pull /data/local/tmp/seg_<n>.bin <dumpdir>/<n>.bin # trim first, see above
|
||||
|
||||
# or, without writing to the device at all: search a range in place
|
||||
dd if=/proc/<pid>/mem iflag=skip_bytes,count_bytes skip=<DEC_START> count=<SIZE> bs=1M \
|
||||
| grep -z -a -o -b -E 'dex.035' | head -5
|
||||
```
|
||||
|
||||
Then the same acceptance gate the frida route uses: `scripts/dex_dump_validate.py`
|
||||
over the dump directory — sha256 dedupe, header and `file_size` validation, class
|
||||
counts, `stub%`, ranking. Rename to `*.dex` first; the validator selects on extension.
|
||||
Pass `--trim` when the captures come straight from VMAs: a page-aligned region is always
|
||||
longer than the image it holds, so without it every file is rejected on
|
||||
`file_size=… actual=…` — 17 of 17 on the measured run, all of them recoverable.
|
||||
|
||||
When you do **not** already know which region holds the payload —
|
||||
`scripts/dex_mem_scan.py <region_dir> --dump <out_dir>` does the search and the cut in one
|
||||
step: it scans the captures for the magic in bounded chunks, reads each hit's own header,
|
||||
and writes exactly `file_size` bytes from there. That is the anonymous-mapping case, where
|
||||
no `maps` entry names the buffer. The division of labour matches the rest of this kit:
|
||||
that script **finds and cuts**, `dex_dump_validate.py` **judges**. On the measured device
|
||||
the scan of 142 anonymous regions returned zero hits while the named ART mappings returned
|
||||
17 images — a negative from a cheap scan is worth having before committing to a heap sweep.
|
||||
|
||||
Measured on the test device, this route **works**: 17 ART dex mappings exported in
|
||||
one pass, all 17 parsing after the page-alignment trim, `stub%` 0.9-3.2 % (real
|
||||
bodies, SDK plugins), and one of them — the mapping that came from the app's own
|
||||
`base.apk` — defining exactly **4 classes** on 8.9 MB. That last image is the landing
|
||||
shell skeleton, and the class count is what says so; the size says nothing. Note what
|
||||
the route did *not* produce: no second application dex ever appeared in the map
|
||||
across 22 s of sampling, so the packer's real payload was not sitting in memory as a
|
||||
loaded ART dex while the observer watched — which is the honest boundary of every
|
||||
`/proc/<pid>/mem` dump, not a defect of the tooling.
|
||||
|
||||
### Limits to budget for
|
||||
|
||||
- **The process must stay alive and keep the same pid.** A VMA map and a page table
|
||||
both belong to a live process, and every `dd` after the restart reads the wrong
|
||||
address space or nothing. On a sample that self-exits every 7-18 s, a dump routine
|
||||
must be atomic within one window and re-entrant (re-read `maps` after every
|
||||
restart) rather than a long batch.
|
||||
- **Throughput is the binding constraint, not correctness.** Reading
|
||||
`/proc/<pid>/mem` through `grep -z -E` measured about **10 MB/s** on this device.
|
||||
A 1 GB ART heap is therefore a ~100 s scan — several lifetimes — so scanning the
|
||||
Java heap for a decrypted buffer is the *last* resort, not the first. Named ART dex
|
||||
mappings are free; a heap sweep is not.
|
||||
- **`/proc/<pid>/map_files/` is not a shortcut on this ROM.** The directory exists
|
||||
and is root-owned, but its entries are absent/unreadable, so `dd
|
||||
if=/proc/<pid>/map_files/<start>-<end>` returns a few dozen bytes of error text.
|
||||
Use the `mem` + decimal-offset form.
|
||||
- **Memcpy dumps of `.so` files are not the `.so` files.** A range lifted from
|
||||
`/proc/<pid>/mem` usually carries `PT_LOAD` contents and no section header table;
|
||||
a real ELF on disk has both. That difference is how to tell a *disk copy* of a
|
||||
library from a *memory-extracted* one — and it is why a leftover `.so` in a work
|
||||
directory must be matched against the APK it claims to come from before it is used
|
||||
as evidence.
|
||||
- **In-memory dex bodies can be non-contiguous.** A decrypted image stitched across
|
||||
two VMAs (or a payload the packer splits) will not be recovered by dumping ranges
|
||||
independently; the `code_off` cross-check and the class-count sanity check in
|
||||
§Measuring extraction are what catch that, not the magic search.
|
||||
|
||||
### What this route cannot do, and how to tell before you spend the window
|
||||
|
||||
The sections above are the how. This is the boundary, and it is worth reading *before*
|
||||
the first export, because the failure is silent: you export a region, it validates as
|
||||
something else, and the conclusion "the payload is not resident" is drawn from a wrong
|
||||
capture. Two measured obstacles and one measured non-obstacle.
|
||||
|
||||
**A dex that is still compressed never materialises as an image.** `dexopt` can only
|
||||
map a dex directly when the archive stores it uncompressed, so for an APK whose
|
||||
`classes.dex` is stored with **deflate** the method never applies — and nothing in
|
||||
memory is a whole dex. Measured on the clean MASTG targets (`owasp.mstg.uncrackable1`,
|
||||
`owasp.mstg.uncrackable3`, both with `classes.dex` at `compress_type=8` in the
|
||||
*on-device* APK): **zero** `[anon:dalvik-classes.dex extracted in memory from …]`
|
||||
mappings across ~2,400 map lines each, i.e. `named_dex=0`. There was nothing to
|
||||
export, and the route's negative says nothing about those targets being protected.
|
||||
|
||||
**An `r--s` view of `base.apk` is not a source of dex bytes.** Both targets showed
|
||||
exactly that shape — a read-only shared mapping of the APK — and it is the shape that
|
||||
looks like an answer and is not one. Exported and compared byte-for-byte with the same
|
||||
offset in the file:
|
||||
|
||||
| View | Compared with | Result |
|
||||
|---|---|---|
|
||||
| `76dfb14000-76dfb15000` (L1, file offset `0x11000`, 4 KiB) | the APK at `0x11000` | **identical** (sha256 `74846b8c…` both) — it is the APK's own bytes, and at that offset they are the ZIP *central directory* (`PK\x01\x02`), not a dex |
|
||||
| `765a89c000-765a8de000` (L3, file offset `0x6000`, 264 KiB) | the APK at `0x6000` | **identical** (sha256 `2eafd7cc…` both) |
|
||||
| `76dc28e000-76dc299000` (L3, file offset `0x15a000`, 44 KiB) | the APK at `0x15a000` | **different** — and the reason is not tampering: the mapping range runs *past the end of the file* (offset `0x15a000` > 1,460,555 B), so the file-side read is short and the two hashes describe different byte counts |
|
||||
|
||||
Two things follow, and the second one generalises. First, **the two read paths agree
|
||||
exactly whenever they cover the same bytes** — `/proc/<pid>/mem` and a straight file
|
||||
read returned identical sha256 on both full-length comparisons, which is the
|
||||
reproducibility check worth running before treating any capture as evidence. Second,
|
||||
**when they disagree, compute the length before suspecting the target**: a VMA can
|
||||
legitimately extend beyond its backing file, and "the bytes differ" then means "you
|
||||
compared different amounts of data", not "something rewrote the file".
|
||||
|
||||
**The non-obstacle, measured:** when the instrumentation route is refused, nothing here
|
||||
needs fixing — root reads `/proc/<pid>/mem` with no `ptrace` and no agent, and on the
|
||||
test device that produced 17 real, parseable dex images from a hardened process
|
||||
(`references/evidence-summary.md` §The capability matrix). The correction this pass adds is
|
||||
narrower than that claim: **the route's yield is a property of the target, not of the
|
||||
route.** It yields everything the packer leaves as a whole image; it yields nothing
|
||||
against a target whose dex never becomes one.
|
||||
|
||||
### Is this dex image real? Two independent readers, or a claim you have not earned
|
||||
|
||||
The route above produces candidate images; something else has to judge them, and the
|
||||
judgement has to come from a reader you did not write. A single reading of a hard problem
|
||||
is not evidence, and this file already carries the lesson in the opposite direction —
|
||||
`§Establish "the bodies do not decode" with a decoder you did not write` is the case where
|
||||
a hand-written decoder *invented* a finding.
|
||||
|
||||
Three checks, in increasing cost:
|
||||
|
||||
1. **Two read paths over the same bytes must agree byte-for-byte.** `/proc/<pid>/mem` at
|
||||
`(start, start+size)` versus the file or a second export. Measured above: identical
|
||||
sha256 on every comparison that covered the same length. Do this **before**
|
||||
interpretation — a capture that cannot be reproduced is not a capture.
|
||||
2. **A producer you did not write must parse it.** `dexdump`
|
||||
(`E:\tools\android-14\dexdump.exe`), `baksmali` or `jadx` — and quote *its* failure
|
||||
count, not your own parser's. `scripts/dex_dump_validate.py` ranks and screens
|
||||
candidates; it does not decide whether a body is real dalvik.
|
||||
3. **Only then interpret** `stub%` / `emptied%` / class counts, per
|
||||
§Measuring extraction instead of guessing.
|
||||
|
||||
**Cross-checking two dumpers is only meaningful when both produce the same artifact.** A
|
||||
byte-identity comparison is a real check between two implementations reading the same
|
||||
address space (`/proc/<pid>/mem` versus `process_vm_readv(2)`, or two independent export
|
||||
tools); it is an empty check between a whole-image dump and a per-method `code_item`
|
||||
harvest, because those are different artifacts and a byte difference says nothing about
|
||||
either. State which kind of comparison you ran. A *cross-tool* agreement also does not
|
||||
prove recovery: on a shell that stubs bodies, two agreeing dumps are two copies of the
|
||||
same skeleton — which is why check 2's parse count and §Measuring extraction's ratios
|
||||
both have to pass.
|
||||
|
||||
## Layered descent: Java → JNI → Native → libc → syscall
|
||||
|
||||
**Read this when an upper layer has gone silent.** "My hook produced no events" is not a
|
||||
finding about the target until you know which layer is capable of seeing the code that
|
||||
runs. The upper layers are cheaper and more informative, so the order is deliberate; the
|
||||
mistake is skipping down for reassurance instead of on a signal.
|
||||
|
||||
| Layer | What it sees | What it cannot see | The signal to descend |
|
||||
|---|---|---|---|
|
||||
| **Java** (framework hooks) | methods, objects, arguments — the algorithm in its readable form | anything the app does in a library; anything the framework compiled out of reach | no invocation ever fires, or only the first call and then nothing |
|
||||
| **JNI boundary** | argument and return values of a *named* export; the boundary itself | argument *meaning*; any logic past the call | outcomes are correct but you cannot see why |
|
||||
| **Native, named exports** (`Module.findExportByName`) | entries of exported functions; the call site that reached them | the function body's control flow (that is `native-dbi-and-deobfuscation.md`) | correct answers, opaque body; a `.so` that never calls its own exports |
|
||||
| **Native, internal** (module base + offset) | code with no symbol at all | how you found the offset in the first place | a library whose exports are a thin shell over internal work |
|
||||
| **libc** (`open`, `read`, `mmap`, `strstr`, `pthread_create`, `exit`, …) | every call that *goes through* libc, with caller attribution | calls that do not; anything above libc | an event count that contradicts observed effects |
|
||||
| **`svc #0` sites** | nothing at runtime until you patch the site — it is a *static* layer | kernel-side: unreachable here, see the gate below | libc shows no such syscall while the effect happens |
|
||||
|
||||
Three rules that keep the descent honest:
|
||||
|
||||
- **Descending is cheap to state and expensive to trust.** A lower layer has less
|
||||
context, so an event there needs *more* support to mean something: `open()` fired says
|
||||
nothing on its own, while `open()` with a caller of `libX.so+0x1cef8` names a detector.
|
||||
Always carry the caller offset down, or you have traded meaning for volume.
|
||||
- **The bottom is static, not dynamic.** Below libc there is nothing to hook — a
|
||||
`svc #0` site is found by scanning the library (`scripts/svc_scan.py`) and changed by
|
||||
patching bytes. Measured: the ROM's `libc.so` carries exactly **4** termination sites
|
||||
(`exit`, `exit_group`, `kill`, `tgkill`) and those are libc's own exported
|
||||
implementations, which is *why* a libc hook sees callers at all; the same scan on a
|
||||
shell library returned 21 byte-scan "sites" that were all data (no syscall-number load
|
||||
near any of them). Read the neighbours before believing a count.
|
||||
- **"Down" does not mean "more capable".** Measured on a real target, the decisive
|
||||
detection ran at the **libc** layer — `strstr("frida")` at ~300 ms of process life,
|
||||
followed within milliseconds by a clean self-exit with no tombstone — while the code
|
||||
that recovered the caller chain sat in **JIT-compiled** runtime output, i.e. a
|
||||
*higher* layer that has no file offset. The layers are not a ladder of access; they are
|
||||
different observation points, and the useful one is wherever the target's check
|
||||
happens to be.
|
||||
|
||||
**The kernel layer is not reachable on this device, and the gate is a kernel version.**
|
||||
eBPF tracing (`stackplz`, kprobes, `seccomp` filters pushed from userspace) requires
|
||||
kernel **5.10+**; the measured device is **4.14.186**, so that entire layer is closed
|
||||
here regardless of tooling. `kernel-and-environment-hardening.md` maps the route and its
|
||||
version gate; do not spend a session on the layer before checking `adb shell uname -r`.
|
||||
The consequence for this file: the descent above stops at the `svc` boundary, and a check
|
||||
that runs *inside* the kernel is out of reach of both dynamic and static userspace work.
|
||||
|
||||
## The honest boundary: real Dex VMP
|
||||
|
||||
The last shape is the one to say out loud: if the dump's method bodies are intact but
|
||||
decode as private opcodes — rare-opcode density off the charts, unknown opcode slots,
|
||||
and a large native interpreter loop behind them — the code has been virtualized, and
|
||||
nothing in this repository will hand it back to you decompiled. Distinguish it from
|
||||
the JNI sink first (`code-virtualization-and-custom-linkers.md`,
|
||||
`java2c-and-jni-sinking.md`): there the methods are `native` stubs and the logic lives
|
||||
as native code; here the bodies are present and *re-encoded*.
|
||||
|
||||
### Establish "the bodies do not decode" with a decoder you did not write
|
||||
|
||||
This is the one measurement in this file that has already been got wrong here, so it
|
||||
gets its own rule. A hand-written opcode-width table silently desynchronises on large
|
||||
dex files — one arm64 branch opcode missing from the table is enough — and the
|
||||
desynchronisation is indistinguishable from the finding it fakes. Measured on the
|
||||
`ezAndroid` sample: a workbench table reported **1,393 of 22,424 bodies as
|
||||
non-decodable** and I read it as private opcodes; the platform decoder returned
|
||||
**943,223 lines, 34,566 instruction decodes, and zero structural errors** on the same
|
||||
file (`references/evidence-summary.md` §The capability matrix). The bodies
|
||||
were ordinary dalvik the whole time, and one of the "desync" methods disassembles into
|
||||
five clean instructions under `dexdump`.
|
||||
|
||||
So: **before any VMP verdict, decode the same file with `dexdump`,
|
||||
`baksmali`/`smali`, or `jadx`, and quote the failure count that came from *it*.** A
|
||||
rare-opcode histogram is a lead, not evidence. Never let a private table's desync
|
||||
inform a classification — "my decoder stopped" is a fact about the decoder.
|
||||
|
||||
### What a static dex probe can and cannot decide
|
||||
|
||||
Two different questions live in the same dump, and only the first one is answerable
|
||||
from the bytes on disk:
|
||||
|
||||
| Question | Statically decidable? | Why |
|
||||
|---|---|---|
|
||||
| Are the bodies real dalvik, or an encoded program? | **yes** | the platform decoder decodes them or reports a structural error |
|
||||
| Did the code leave the dex for native? | **yes** | `native` access flags, then `Java_*` exports / `JNI_OnLoad` in the matching `.so` |
|
||||
| Does a native island check signature, root or debugger? | **no** | the logic is ARM64 behind a vtable; the check may exist in no dex at all |
|
||||
| Are unlabelled `assets/` blobs live payload? | **no** | an unreferenced blob is dead until a runtime path constructs its name — measured: six such files in `ezAndroid`'s `assets/`, **zero** dex strings referencing them |
|
||||
|
||||
The trap is the asymmetry. A clean static reading licenses exactly one sentence —
|
||||
*"this is not dex-VMP"* — and it does **not** license *"this is not hardened"*. Those
|
||||
are different claims: the second one is the misdiagnosis that sends an analyst looking
|
||||
for an unpacker when the problem is a native island, or for a private disassembler when
|
||||
the code is ordinary ARM64 in a 206 KB `.so`. A static probe's silence about hardening
|
||||
is silence, not a clean bill of health.
|
||||
|
||||
What recovery looks like when someone does it, **all inferred**, no fixture exists in
|
||||
this repo, treat as a research plan rather than a recipe:
|
||||
|
||||
1. **Differential hardening as a black-box oracle.** Build sample APKs containing
|
||||
every dalvik opcode in known, labeled sequences; submit them to the *same*
|
||||
hardening platform as the target; dump the returned dexes; align each original
|
||||
method with its hardened counterpart and read off the opcode substitution table.
|
||||
2. **Handler map recovery.** Locate the native interpreter's dispatch table; each
|
||||
handler implements one virtual opcode; the differential table names them.
|
||||
3. **A private disassembler.** Feed the substitution table into an existing dalvik
|
||||
decode layer (`scripts/dexutil.py`'s format table is the shape of the thing) and
|
||||
re-disassemble the bodies; decompilation is then ordinary tooling on the output.
|
||||
|
||||
Budget honestly: this is weeks, not an afternoon, and it redone per vendor per
|
||||
version. The cheaper question is usually whether the behaviour you need survives
|
||||
outside the virtualized island — a hook at the method's Java boundary
|
||||
(`dynamic-frida.md`) or a server-side answer often reaches the goal without paying
|
||||
for the island itself.
|
||||
|
||||
## Decision summary
|
||||
|
||||
| Observation | Action | Where |
|
||||
|---|---|---|
|
||||
| Dump parses, stub% near baseline | Filter, verify with jadx, move on to patching | `recon.md` |
|
||||
| stub% high, image parses | FART loop: skeleton + active invocation + splice | this file |
|
||||
| `emptied%` high but `stub%` low | nop-wiped skeleton; the old `stub%`-only reading called it the original | §Measuring extraction instead of guessing |
|
||||
| The ranking names a winner but `emptied%` sits at the app's baseline | Treat the ranking as uninformative for this set; do not adopt that image as a patch baseline | §The ranking's failure conditions |
|
||||
| Splice produces a half-parsing dex | Re-check code_item length (padding, handler list) | §The code_item length trap |
|
||||
| Dumper runs, output dir empty | Storage path + `open()` check, then entry-type probe | §Why the classic hooks die on Android 12-16 |
|
||||
| App dies under the invoker | Shell counter-measures; narrow the force list | §Why the classic hooks die on Android 12-16, and `detection-and-anti-analysis.md` |
|
||||
| frida attach refused (`script has been destroyed`) | Root-side `/proc/<pid>/mem` dump, atomic inside one process lifetime | §Dumping when frida is refused |
|
||||
| Target has no `[anon:dalvik-classes…]` mapping at all (`named_dex=0`) | Its dex is compressed and never becomes a whole image — the route has nothing to export here; this is not evidence of protection | §What this route cannot do, and how to tell before you spend the window |
|
||||
| An `r--s` view of `base.apk` looks like a dex region | Compare it with the file at the same offset and check the length: it is the APK's own bytes (often the ZIP central directory), and a range may exceed the file | §What this route cannot do, and how to tell before you spend the window |
|
||||
| Two captures of the same address differ | Compare byte *lengths* first; a VMA can extend past its backing file | §What this route cannot do, and how to tell before you spend the window |
|
||||
| A hook on the upper layer stops firing | Descend one layer on a named signal, not for reassurance, and carry the caller offset down | §Layered descent: Java → JNI → Native → libc → syscall |
|
||||
| The effect happens but libc shows no such syscall | Static: scan the library for an inline `svc` site before assuming evasion | §Layered descent: Java → JNI → Native → libc → syscall |
|
||||
| Sample self-exits and restarts with no instrumenter present | The packer's own check, not your hook; shorten every step to one lifetime | §Dumping when frida is refused |
|
||||
| Methods are `native` stubs with a `Java_*` export | JNI sinking — read the function in the `.so` | `java2c-and-jni-sinking.md` |
|
||||
| Bodies present, decode as nonsense | Real VMP — **but confirm with `dexdump` first**, then differential oracle or walk away | §Establish "the bodies do not decode" with a decoder you did not write |
|
||||
@@ -0,0 +1,194 @@
|
||||
# Byte-level dex patching — equal-length edits, and the traps that make them fail
|
||||
|
||||
`dex-patching.md` covers rewriting a **method body** with dexlib2. This file covers the other
|
||||
technique: rewriting **a few bytes in place**. Reach for it whenever the change can be expressed as
|
||||
"make this branch do the other thing" or "make this constant a different constant", because it is
|
||||
strictly safer than rebuilding a method — but only if you respect the constraints below, which are
|
||||
not optional and are not obvious.
|
||||
|
||||
|
||||
**Load this when:** the change fits in an existing instruction slot or constant, and you want nothing to move (no offset, try/catch or debug pointer invalidated). It gives the equal-length edit, its legality rules, and where it silently fails.
|
||||
|
||||
## Why equal-length, in numbers
|
||||
|
||||
A method-level rebuild is not free. Measured on one R8-processed sample (4.32 MB single dex):
|
||||
|
||||
| | original | after a 2-method dexlib2 rewrite |
|
||||
|---|---|---|
|
||||
| dex size | 4.32 MB | **7.73 MB** |
|
||||
| `debug_info` table | 924 bytes | **22,828 bytes** |
|
||||
| class set / access flags | — | identical (zero drift) |
|
||||
|
||||
The class table is unchanged, so the rebuild is *correct* — but dexlib2 does not reuse R8's shared
|
||||
`debug_info` entries and the file grows ~80%. That is the real cost of "surgical" method rewriting,
|
||||
and it matters because it changes the byte layout of everything after the edited methods.
|
||||
|
||||
An equal-length edit changes **only the bytes you chose**. No offset moves, so no try/catch block,
|
||||
no debug-info pointer, and no branch displacement anywhere in the file can be invalidated by
|
||||
construction. That property is worth more than the convenience of writing smali.
|
||||
|
||||
**Decision rule:** if the change fits in an existing instruction slot or a constant, patch bytes. If
|
||||
it genuinely needs new instructions or a different register allocation, rebuild the method — and do
|
||||
it in a copy so you can diff the tables.
|
||||
|
||||
## Locating the exact byte offset of an instruction
|
||||
|
||||
This is the part that eats hours if you improvise. Three approaches, worst to best.
|
||||
|
||||
**Do not reconstruct offsets from a `.line` listing.** baksmali emits one `.line` per *source* line,
|
||||
and one source line can be attributed to several consecutive instructions. The `.line` values
|
||||
therefore repeat and do not map 1:1 to offsets. A listing walker built on `.line` silently produces
|
||||
zero candidates for a branch you can see with your eyes.
|
||||
|
||||
**Do not match on a guessed byte sequence.** Instruction encodings vary with register numbers and
|
||||
operand widths; a pattern that looks right (`38 xx`) can be a different instruction than you think
|
||||
(`0x38` is `if-test`, 22t and 4 bytes — **not** `if-testz`).
|
||||
|
||||
**Do this instead: walk the dex structure to the method, then decode.** Resolve class → `class_data_item`
|
||||
→ method (`code_off`) → `code_item` → instruction stream, and decode forward with a complete format
|
||||
table. Then match on *decoded semantics* ("an `iget-boolean p1` immediately followed by `if-nez p1`
|
||||
whose target reads field `X`") rather than on bytes. `scripts/dex_find_insn.py` does this; the
|
||||
decoder in `scripts/dexutil.py` is the reusable part.
|
||||
|
||||
Three details in the walk that silently produce wrong answers:
|
||||
|
||||
- **`class_data_item` member indices are *diffs*.** `field_idx`, `method_idx` are encoded as deltas
|
||||
against the previous entry in the same list and must be accumulated. Reading a raw uleb as an
|
||||
absolute index gives you real-looking wrong members — you will "find" a method that is not the one
|
||||
you asked for and not notice.
|
||||
- **Payload `nop`s are not `nop`s.** dalvik encodes switch/array payloads as `00 <ident> <size>` with
|
||||
`ident` in 1..3. A plain `00 00` is an ordinary one-unit `nop`. Treating every `00` as a payload
|
||||
swallows the next instruction and desynchronises the whole rest of the method.
|
||||
- **The header field order is not what you remember.** After `magic(8) + checksum(4) + signature(20)`,
|
||||
the offsets are `file_size 0x20, header_size 0x24, endian 0x28, link_size 0x2C, link_off 0x30,
|
||||
map_off 0x34, string_ids_size 0x38, string_ids_off 0x3C, type_ids 0x40/0x44, proto_ids 0x48/0x4C,
|
||||
field_ids 0x50/0x54, method_ids 0x58/0x5C, class_defs 0x60/0x64, data 0x68/0x6C`.
|
||||
Print the parsed sizes and compare against the file before trusting anything downstream.
|
||||
|
||||
## Instruction format table (the part that must be right)
|
||||
|
||||
An operand width wrong by one byte desynchronises the decode from that point on, and the failure looks
|
||||
like "the instruction I want does not exist". These are the groups that matter in practice:
|
||||
|
||||
| opcode range | format | code units |
|
||||
|---|---|---|
|
||||
| `01 04 07 0A 0B 0C 0D 0E 0F 10 11 12` | 12x / 11x / 11n / 10x | 1 |
|
||||
| `13 15 16 17 19 1B 20 21 23 24` | 21s/21h/21c/22c/35c/3rc | 2 |
|
||||
| `14 18 1A 22 25 27 28 29 2A 2B` | 31i/31c/31t/30t/20t/switch | 3 |
|
||||
| `02 05 08` | 22x | 2 |
|
||||
| `03 06 09` | 32x | 3 |
|
||||
| `2C`–`31` | 23x | 2 |
|
||||
| **`32`–`37`** | **22t if-test** (2 regs + int16) | **2** |
|
||||
| **`38`–`3D`** | **21t if-testz** (1 reg + int16) | **2** |
|
||||
| `44`–`51` | 23x aget/aput | 2 |
|
||||
| `52`–`5F` | 22c iget/iput | 2 |
|
||||
| `60`–`6D` | 21c sget/sput | 2 |
|
||||
| `6E`–`72` / `74`–`78` | 35c / 3rc invoke | 3 |
|
||||
| `7B`–`8F` | 12x unop | 1 |
|
||||
| `90`–`AF` | 23x binop | 2 |
|
||||
| `B0`–`CF` | 12x binop/2addr | 1 |
|
||||
| `D0`–`D7` | 22s binop/lit16 | 2 |
|
||||
| `D8`–`E2` | 22b binop/lit8 | 2 |
|
||||
|
||||
**Self-check that costs nothing:** decode the whole method and assert you land **exactly** on
|
||||
`insns_off + insns_size*2`. If you overshoot or undershoot, the table is wrong somewhere and every
|
||||
offset you derived is suspect. Also scan the decoded register numbers: a method whose `registers`
|
||||
count is 12 cannot use `v13`, so any `v13+` in the output is proof of desync.
|
||||
|
||||
**Long conditional branches are two instructions.** A conditional jump has a 16-bit displacement, but
|
||||
when you see `if-nez vX, :label` in smali and `:label` is far away, the assembler may have emitted
|
||||
`if-*` followed by a separate `goto`. When hunting a branch, do not assume the pattern is
|
||||
"branch → target"; confirm by decoding.
|
||||
|
||||
## Neutralise a branch, do not redirect it
|
||||
|
||||
Once you have decided which side of a condition you want, the safest edit is to **remove the branch**,
|
||||
not to point it somewhere else.
|
||||
|
||||
| Edit | When it is right | Risk |
|
||||
|---|---|---|
|
||||
| `if-*` → `nop` pair (**preferred**) | You want the **fall-through** path | None. No new control-flow edge is created |
|
||||
| `if-*` → `goto` (+ same displacement) | You want the **branch-taken** path | Introduces an edge. It may land on a `move-result*`, which the verifier rejects |
|
||||
| flip the sense (`if-nez` → `if-eqz`) | Polarity is the only problem | Same edge as above, different destination |
|
||||
|
||||
The `move-result` rule: `move-result*` must be **immediately preceded by its producing invoke** in the
|
||||
instruction stream. A branch whose target is a `move-result` bypasses the producer and the class fails
|
||||
to load with `VerifyError: ... copyRes vN <- result0 type=Undefined`. A linear "is the previous
|
||||
instruction the producer" check does **not** catch this — you need the CFG view: *does any branch
|
||||
target this `move-result`?* `scripts/dex_check_verifier.py` answers that question directly.
|
||||
|
||||
A `nop` pair cannot create that problem, which is why it is the default choice.
|
||||
|
||||
**Polarity is the other silent killer.** Field names lie about intent often enough that you must read
|
||||
the *branch structure*, not the name. In one real case the config field was `enabled`, the smali was
|
||||
`if-nez v, +6`, and the fall-through was `startActivity(main)` while the branch target was the
|
||||
countdown block. So `enabled == true` was the value that *skipped* the promo — the opposite of the
|
||||
literal reading. **Always decode both sides of the branch and name what each one does before editing.**
|
||||
Getting this backwards produces a build that does the exact opposite of the goal and still starts
|
||||
cleanly, so it survives casual testing.
|
||||
|
||||
## The dex header has two integrity fields, and order matters
|
||||
|
||||
Any edit to a dex body invalidates both header integrity fields. Recompute them **in this order**:
|
||||
|
||||
```
|
||||
bytes 12..32 = sha1(data[32:]) # signature first
|
||||
bytes 8..12 = adler32(data[12:]) # checksum last — it covers the signature bytes
|
||||
```
|
||||
|
||||
Writing them in the reverse order produces a header that looks plausible and never verifies, because
|
||||
the adler32 was taken while the signature field was still zeroed.
|
||||
|
||||
**Why this is dangerous rather than merely broken:** Android logs
|
||||
`Failure to verify dex file ...: Bad checksum (computed, expected)` — note that the *real* adler32
|
||||
appears as the "expected" value, which reads backwards — and then falls back to interpreting the dex.
|
||||
The process may still start, but the class loader can fail to resolve ordinary classes
|
||||
(`ClassNotFoundException: <your Application class>`), so the symptom is "the APK I built is broken"
|
||||
rather than "two header fields are stale".
|
||||
|
||||
**Aggravating detail:** some producers ship a dex whose `signature` field is **all zeros** (a known
|
||||
R8/optimiser artifact). On such a file the wrong order is self-consistent until the first patch, so
|
||||
the bug is invisible until you edit something — and then looks like your edit caused it.
|
||||
|
||||
`scripts/dex_patch_bytes.py` recomputes both fields in the correct order and refuses to write if the
|
||||
result does not self-verify.
|
||||
|
||||
## Worked shape of a patch script
|
||||
|
||||
Keep these properties; they are what make the edit auditable a month later:
|
||||
|
||||
1. **Locate by structure + decoded semantics**, never by a hard-coded offset alone.
|
||||
2. **Assert the old bytes** before replacing, and abort on mismatch. A silent mismatch means the
|
||||
sample changed or your understanding did.
|
||||
3. **Equal length in, equal length out.** Assert `len(new) == len(old)`.
|
||||
4. **Recompute both header fields**, then assert `adler32(data[12:]) == header_checksum` and
|
||||
`sha1(data[32:]) == header_signature` on the bytes you are about to write.
|
||||
5. **Re-decode the patched method** and print the instruction you changed, plus confirm the
|
||||
instruction that should follow it still does. Two instructions of output is enough to catch the
|
||||
"landed on the wrong site" class.
|
||||
6. **Report the size delta** (should be 0) and before/after hashes of both the dex and the APK.
|
||||
|
||||
## Verification specific to byte patches
|
||||
|
||||
`patch-audit.md` covers the general audit. Two checks matter more here:
|
||||
|
||||
- **Assert the instruction count and every branch target are unchanged.** Compare a decoded listing of
|
||||
the whole method before and after: same instruction count, same offsets, same targets. If the count
|
||||
changed, you replaced a 4-byte instruction with a 4-byte *pair* and the extra decode boundary is
|
||||
expected — but say so explicitly rather than letting a diff tool surprise the next reader.
|
||||
- **`VerifyError` is a class-load failure, not a startup failure.** If the app starts and one screen
|
||||
is dead, or a secondary class is missing, suspect a verifier violation in the method you edited.
|
||||
`logcat` shows it as `E/dalvikvm` or `E/art` with the class name; it does not always surface as a
|
||||
crash dialog.
|
||||
|
||||
## When not to use this technique
|
||||
|
||||
- The change needs new instructions, a new register, or a different call — go to method rewriting.
|
||||
- The change is a **short string constant** whose new value is a different length — you cannot grow the
|
||||
string in place. Equal-length string swaps work (`pitfalls.md` P2 on the string-id ordering guard).
|
||||
**A different-length value is not a byte patch at all**: `scripts/dex_strpatch.py` enforces equal
|
||||
length and will refuse it, so the work belongs to method rewriting (`references/dex-patching.md`)
|
||||
or a full rebuild — do not look for a "different-length byte patcher" in this kit, there isn't one.
|
||||
|
||||
- The target is a **multi-dex** app and you intend to move code between dexes. Byte patching stays
|
||||
inside one dex by definition.
|
||||
@@ -0,0 +1,220 @@
|
||||
# Code Virtualization & Custom Linkers
|
||||
|
||||
Load this when recon says **there is no packer** (the manifest's `application` is the app's own
|
||||
class, dex is readable) and yet a re-signed build dies before your code runs — especially when the
|
||||
death is `SIGSEGV fault addr 0x0` with all registers zero.
|
||||
|
||||
This is the layer between "packed" and "clean". `packers.md` covers a shell that owns
|
||||
`Application`; this file covers protection that leaves `Application` alone and instead
|
||||
**turns individual methods into native code** and hosts a private loader next to them. It is now
|
||||
common enough that treating it as "no packer, therefore easy" is the most expensive recon error
|
||||
in this skill's history.
|
||||
|
||||
## Identification — three cheap tells
|
||||
|
||||
1. **Whole classes whose methods are `native` declarations with no implementation.**
|
||||
Read the smali for the entry Activity / engine host class. Under code virtualization the
|
||||
*entire class* is converted — lifecycle callbacks, getters, everything — each becoming a bare
|
||||
`.method public native ...` with no body. A framework class you *know* ships a Java
|
||||
implementation (a Flutter/Unity/RN host class is the usual victim) sitting there fully
|
||||
`native` is the signal.
|
||||
|
||||
Two `native` methods is normal (JNI). **Sixty is virtualization.**
|
||||
|
||||
2. **A registration call in the class's static initializer.** Someone binds those native
|
||||
declarations to an implementation at class-load time:
|
||||
|
||||
```
|
||||
.method static constructor <clinit>()V
|
||||
invoke-static {v0, v1}, L<somewhere>/DtcLoader;->registerNativesForClass(ILjava/lang/Class;)V
|
||||
invoke-static {v1}, L<somewhere>/hidden/Hidden0;->special_clinit_0_00(Ljava/lang/Class;)V
|
||||
```
|
||||
|
||||
The `Hidden0.special_clinit_*` family is the virtualizer's hook for running its own version of
|
||||
the class initializer. When you see this, the Java in that class **is not what executes**.
|
||||
|
||||
3. **A shipping library whose SONAME does not match its filename.** A private loader is often
|
||||
named for its role while sitting under an innocuous filename. Read `DT_SONAME` from
|
||||
`.dynamic`, not the file name — `probe_deps`-style parsing of `PT_DYNAMIC` (see
|
||||
`native-tamper-and-suicide.md` §Forged section headers) gives you the truth. A `lib<something>.so` claiming
|
||||
`soname = liblinkerloader.so` is a private ELF loader.
|
||||
|
||||
Supporting tells: `assets/` carrying a small text descriptor naming the protector and its version;
|
||||
native libraries woven into `DT_NEEDED` chains of other libraries (the virtualized code has to be
|
||||
reachable); log lines tagged with the loader's own name appearing during startup.
|
||||
|
||||
## The deadlock that eats hours — and the way out
|
||||
|
||||
A virtualizer that also does integrity checking produces this shape:
|
||||
|
||||
| Action | Result |
|
||||
|---|---|
|
||||
| Keep the private loader in the APK | its embedded validation runs → deliberate crash on a re-signed build |
|
||||
| Delete the private loader | the virtualized class has **no implementation at all** → `UnsatisfiedLinkError: dlopen failed: library "…" not found` |
|
||||
|
||||
Both routes die, and the two failures look nothing alike, so it is easy to conclude the target is
|
||||
impossible. It is not. The way out is one question:
|
||||
|
||||
> **Is that library the checker, or the implementation — and who asks for it?**
|
||||
|
||||
## Separate the checker from the implementation
|
||||
|
||||
Two distinct roles hide behind "a protected library":
|
||||
|
||||
- **The implementation** — the native code that *is* the virtualized class. It must be present and
|
||||
loaded, or the class cannot work.
|
||||
- **The checker** — a validation payload (a signature/integrity payload, a second-stage
|
||||
self-decrypting image) that something *explicitly loads*.
|
||||
|
||||
They are frequently **different libraries**, and the load of the checker is frequently a
|
||||
**single, findable call site** — not a link-time dependency.
|
||||
|
||||
Test which is which before designing a fix:
|
||||
|
||||
```
|
||||
# 1. Does anything name it in DT_NEEDED? Parse PT_DYNAMIC of every shipped .so.
|
||||
# No DT_NEEDED referrer => it is dlopen()ed, i.e. named at some call site.
|
||||
# 2. Delete it and read the exact error: which class/method was executing when the
|
||||
# load failed? That is the caller.
|
||||
```
|
||||
|
||||
In the observed case the error was not "the class is missing" but:
|
||||
|
||||
```
|
||||
java.lang.UnsatisfiedLinkError: dlopen failed: library "lib<checker>.so" not found
|
||||
at java.lang.System.loadLibrary(System.java)
|
||||
at <framework host class>.onCreate(Native Method)
|
||||
```
|
||||
|
||||
Read that stack carefully: the **virtualized** `onCreate` — the very code you feared deleting the
|
||||
library would break — is running fine. It is the *checker* that is absent. The implementation lives
|
||||
elsewhere and was never the problem.
|
||||
|
||||
**That single stack frame converts an impossible target into a trivial one.**
|
||||
|
||||
## Technique: redirect the load by rewriting a string constant
|
||||
|
||||
Once you know the checker is named at a call site, you do not need to patch code, hook anything, or
|
||||
neutralize a crash. **Change what that call site loads.**
|
||||
|
||||
JNI-heavy native code keeps its class/method/string operands in a constant pool inside the library.
|
||||
A `loadLibrary("X")` becomes three facts sitting in `.rodata`/`.data`:
|
||||
|
||||
```
|
||||
… onCreate (Landroid/os/Bundle;)V loadLibrary (Ljava/lang/String;)Ljava/lang/String; …
|
||||
… <SomeUnrelatedClassName> X <unrelated constant> …
|
||||
```
|
||||
|
||||
Find the name as an **isolated NUL-terminated string** and rewrite it **in place, same length** to
|
||||
the name of a library that is *guaranteed already loaded*. On Android, `android` (`libandroid.so`)
|
||||
is reliably mapped — the protected libraries themselves depend on it.
|
||||
|
||||
```
|
||||
apkhuan -> android (7 chars -> 7 chars)
|
||||
```
|
||||
|
||||
Why this is disproportionately effective:
|
||||
|
||||
- **Equal length means zero structural change.** No offsets move, no section resizes, the ELF stays
|
||||
exactly as valid as it was. Nothing for an integrity check to notice about *layout*.
|
||||
- **The load succeeds**, so no `UnsatisfiedLinkError` and no error branch anywhere.
|
||||
- **The checker never gets mapped**, so none of its validation runs — the signature payload, the
|
||||
deliberate-crash payload, all of it becomes dead weight in the APK.
|
||||
- **You never patch a death path**, so the whole `native-tamper-and-suicide.md` rule set (make it
|
||||
*return*, don't make it *not return*) never comes into play. There is nothing to neutralize.
|
||||
|
||||
Requirements and hazards:
|
||||
|
||||
1. **Same byte length is not optional.** Replacing with a shorter/longer name shifts everything
|
||||
after it. Pad only within the same NUL-terminated slot; never spill into the next constant.
|
||||
2. **Verify the string is an isolated constant** — the byte before and after must be `\x00`.
|
||||
A substring of a longer identifier will corrupt that identifier.
|
||||
3. **The replacement must be loadable and already-loaded.** A nonexistent name throws and puts you
|
||||
back in the deadlock; a heavy library changes startup cost.
|
||||
4. **Scan the whole file** for the name, then confirm which occurrence sits in the constant pool.
|
||||
Report the offset in the patch record.
|
||||
|
||||
`scripts/so_constpatch.py` implements this: finds isolated constants, enforces equal length, writes
|
||||
a patched copy, and prints the byte-diff.
|
||||
|
||||
This is one member of a general family — **redirect a reference instead of defeating a check** —
|
||||
and the same shape recurs for class names, method names and file paths in a constant pool.
|
||||
|
||||
## Expected log shapes
|
||||
|
||||
A private loader announces itself. These strings come from the loader, not from the app, and their
|
||||
presence/absence across builds is a usable signal:
|
||||
|
||||
```
|
||||
I/<Loader>: Loading embedded protected image from carrier (self)
|
||||
I/<Loader>: Found embedded protected image at offset 0x…, logical size 0x…
|
||||
E/<Loader>: Invalid ELF magic <-- appears ONLY on a modified build
|
||||
D/<Loader>: ELF Header: type=3, machine=183, entry=0x0, phnum=N
|
||||
D/<Loader>: Starting PrelinkImage for embedded_protected
|
||||
```
|
||||
|
||||
A custom container magic at the tail of the library (a four-byte tag, not `\x7fELF`) marks the
|
||||
embedded image; `entry=0x0` plus `SIGSEGV fault addr 0x0` is the loader failing to decrypt its
|
||||
payload and jumping to a zero entry. Treat "`Invalid ELF magic` appears on my build and not on the
|
||||
original" as **proof the loader is validating the build**, which is exactly what makes the
|
||||
string-redirect route worth trying.
|
||||
|
||||
## What the native check actually reads — measure it, don't guess
|
||||
|
||||
A validation payload that reads "the signature" can be reading several different things, and the
|
||||
answer decides whether *any* repack route exists:
|
||||
|
||||
| It reads | Consequence |
|
||||
|---|---|
|
||||
| the certificate material **inside the APK** | keeping the original signature block while swapping code can pass |
|
||||
| the signature **PackageManager reports** | that trick fails; you must remove the check (this file's technique) or rebuild |
|
||||
| an entry **hash** of the whole file | only the string-redirect / check-removal route survives |
|
||||
|
||||
**Single-variable experiment to tell them apart.** Same APK content both times; flip only the
|
||||
PMS-recorded signature:
|
||||
|
||||
1. Install a build **you signed** normally, so PMS records *your* certificate.
|
||||
2. Root-overwrite that installed `base.apk` with your candidate (one whose *internal* signature
|
||||
block is the original), leaving PMS's record untouched.
|
||||
3. Launch.
|
||||
|
||||
Dies ⇒ the check reads the PMS-reported signature, and preserving in-APK certificate material
|
||||
cannot save you. Survives ⇒ it reads in-APK material, and a "keep the original signature block"
|
||||
route is worth investing in.
|
||||
|
||||
Do this **before** investing in elaborate signature-block plumbing. It is two installs.
|
||||
|
||||
## A Java-layer "signature killer" is a decoy
|
||||
|
||||
A protection bundle may ship a Java class that hooks `PackageManager` so the framework reports a
|
||||
hardcoded certificate, and log `Signature consistency ok` forever — including on your modified
|
||||
build. This is an **anti-tamper feature aimed at other patchers**, not a gate you must satisfy.
|
||||
|
||||
Its corollary is what matters operationally: **`Signature consistency ok` in the log proves
|
||||
nothing.** Do not let a green Java-layer message talk you out of the fact that the process is
|
||||
dying, or send you hunting for a Java signature check. If the death is `SIGSEGV` with a zero
|
||||
program counter, the check that killed you is native and the Java one is irrelevant.
|
||||
|
||||
Two practical notes:
|
||||
|
||||
- **Do not delete such a class to "clean up".** It usually participates in the `Application`
|
||||
inheritance chain; removing it breaks startup and trips unrelated class-hierarchy checks.
|
||||
- Its presence is a strong hint that **the code that matters is virtualized**, because it exists to
|
||||
survive exactly the kind of repack you are attempting.
|
||||
|
||||
## Verification
|
||||
|
||||
A string-redirect fix is verified by the **absence of the loader**, not by the absence of an error:
|
||||
|
||||
1. Loader's log tag count in a full cold-start capture = **0** (previously non-zero).
|
||||
2. `Invalid ELF magic` = 0.
|
||||
3. `SIGSEGV` = 0 **and** `SIGSYS`/seccomp kills = 0 — the secondary kill paths the payload carried
|
||||
disappear with it. A drop from 1 to 0 here is a real result worth recording.
|
||||
4. Process survives past the previously measured time-to-death, on this machine, with nothing
|
||||
attached; then survives real interaction.
|
||||
5. The features that class implements still work — the virtualized code is the *implementation*, so
|
||||
exercise it (start playback, navigate, whatever it owns). A build that starts but whose
|
||||
virtualized feature is dead is not a result.
|
||||
|
||||
Record the original and patched bytes, the offset, and the loader log counts before/after in the
|
||||
patch record — the same audit standard as any other byte-level edit.
|
||||
@@ -0,0 +1,220 @@
|
||||
# Coverage and limits — the evidence behind each claim
|
||||
|
||||
`SKILL.md` §Coverage states **what this skill covers and what it does not**, because the failure
|
||||
worth preventing is not ignorance but *a confident wrong answer produced by applying the nearest
|
||||
available procedure to a target it was never written for*. That statement has to stay short: it is
|
||||
read on every task, before any routing decision.
|
||||
|
||||
This file carries the part that is read **only when you need to weigh a claim** — the evidence
|
||||
behind each covered item, the exact footing of the unverified ones, and the historical record of
|
||||
what was and was not exercised. Load it when a claim's strength decides whether you trust it.
|
||||
|
||||
|
||||
**Load this when:** a claim's strength decides whether you trust it, or you are about to quote this skill's coverage. It gives the evidence behind each covered item, the dependencies this skill does not ship, and the routes nobody has run.
|
||||
|
||||
## Strength labels
|
||||
|
||||
- **observed** — a command was run and its output is recorded in the evidence record condensed in `references/evidence-summary.md` §Where the full record lives.
|
||||
- **inferred** — follows from documented mechanism or from a neighbouring measurement; the step
|
||||
itself was not executed.
|
||||
- **unverified** — assumed, or reported by someone else, and not reproduced in this repository.
|
||||
|
||||
Reserve `observed` strictly (`references/long-task-discipline.md` does). "I reasoned it out" is
|
||||
`inferred`. The distinction is load-bearing, not cosmetic: an `inferred` route may still be right,
|
||||
but nobody here has paid for the counters yet.
|
||||
|
||||
## Covered, by verified mechanisms — the evidence
|
||||
|
||||
- **Client-side ads, promos and splash/popup/tab configuration**, including the server-issued UI
|
||||
config that has no SDK to find (`ad-removal.md`, `server-config-and-updates.md`).
|
||||
- **Deciding whether a membership, paywall or feature gate is client-enforceable at all**, and
|
||||
saying so plainly when it is not (`membership-and-limits.md`, `account-gates.md`).
|
||||
- **dex-level surgical patching**: equal-length byte edits and dexlib2 method rewrites, plus the
|
||||
header, verifier and alignment rules that decide whether the build loads (`dex-patching.md`,
|
||||
`byte-level-patching.md`, `patch-audit.md`).
|
||||
- **Repacking, signing, installing**, and the install refusals that look like a broken build
|
||||
(`repack-and-sign.md`).
|
||||
- **Packers, custom loaders and code virtualization**: identifying them, measuring the validation
|
||||
boundary, and the routes that survive it (`packers.md`,
|
||||
`code-virtualization-and-custom-linkers.md`).
|
||||
- **The native layer**: `.so` hosts, tamper-triggered self-termination, forged ELF structure, and
|
||||
neutralising a terminate path without freezing the process (`native-and-so.md`,
|
||||
`native-tamper-and-suicide.md`).
|
||||
- **Flutter / Dart AOT**: analysing and patching `libapp.so` **given a snapshot dump** —
|
||||
pool-reference counting, disassembly windows, caller indexing, patch-site choice (`dart-aot.md`).
|
||||
Measured on a real Dart 3.6.0 build: `dart_disasm.py` decoded identically to capstone (32/32 and
|
||||
96/96), the caller index was re-derived independently with a symmetric difference of 0, and a
|
||||
specific business logic site was located end to end. **The snapshot dump is a dependency, not a
|
||||
detail** — see the next section.
|
||||
- **Runtime analysis with Frida, server-side API probing, feature-scoped TLS failures, update and
|
||||
forced-upgrade neutralisation**, and the verification discipline everything above rests on.
|
||||
|
||||
### Added by the benchmark pass, and measured against public targets
|
||||
|
||||
Each of these has a row in the benchmark matrix (`references/evidence-summary.md` §The capability matrix) naming its target, its strength and the evidence
|
||||
file behind it.
|
||||
|
||||
- **Java2C versus JNI sinking versus an extraction shell.** Native-declaration density separates the
|
||||
shapes by roughly 2000x: **0.03–0.04%** on three real JNI samples against **82.76%** on a
|
||||
Java2C-shaped fixture, with `Java_*` symbols matching dex native-method counts 1:1 per ABI. A
|
||||
symbol search fails *silently* on real Java2C — `dcc` emits `-fvisibility=hidden`, and C++
|
||||
`jni.h` inlines `RegisterNatives` so it leaves no symbol at all
|
||||
(`java2c-and-jni-sinking.md`, `scripts/java2c_probe.py`). **No Dex-to-C compiler output was ever
|
||||
built here** (no NDK, no host clang, no device clang, WSL unavailable), so every Java2C-specific
|
||||
criterion is `inferred`; the JNI-sinking side and the discrimination itself are `observed`.
|
||||
- **Split APK / App Bundle sets.** Inventory, unified re-signing for `pm install-multiple`, and a
|
||||
merge of code/native members into one APK — the merge **refuses by design** when a member carries
|
||||
its own `resources.arsc` (`split-apk.md`, `scripts/repack.py`). Measured on two real sets pulled
|
||||
from a device; `adb install-multiple` and launch confirmation were **not** executed.
|
||||
- **Schema-free protobuf decoding**, cross-checked against the official runtime
|
||||
(`scripts/protobuf_decode_raw.py`, `protocol-reverse.md`). 26/26 built-in fixtures, 21/21 against
|
||||
the official runtime, and a real DataStore container round-tripped byte-exact.
|
||||
- **Differential hardening for a real Dex VMP** — a labelled opcode-coverage fixture, a closed-loop
|
||||
verification of the derived map, and an explicit cost judgement that the upload link cannot be
|
||||
automated (`vmp-differential-analysis.md`, `scripts/vmp_diff_harness.py`).
|
||||
- **Kernel-module templates with their version gates**
|
||||
(`kernel-and-environment-hardening.md`, `scripts/kernelsu_syscall_mask.py`). The generator is
|
||||
measured; **no kernel-side artefact was compiled or loaded** — see the record below.
|
||||
|
||||
## Covered by documented routes — inferred rather than measured
|
||||
|
||||
Written from public work and from whatever the extension pass could exercise; each document carries
|
||||
its own strength note at the top.
|
||||
|
||||
- **Module-side delivery instead of a repack** — what to do when the client-side logic is reachable
|
||||
but a rebuilt APK is refused (`lsposed-and-modules.md`).
|
||||
- **Extraction shells and the VMP boundary** — how to *measure* that the bodies are empty instead of
|
||||
guessing, why the classic active-invocation hooks stopped working on Android 12–16, and where
|
||||
recovery honestly stops (`advanced-unpacking.md`, `scripts/dex_dump_validate.py`).
|
||||
- **Calling the target instead of reading it** — emulated execution and live Frida-RPC
|
||||
service-ification (`emulation-and-rpc.md`).
|
||||
- **Instruction-level tracing and de-obfuscation** — the OLLVM shapes, a Stalker trace, the
|
||||
trace-to-CFG route, and the measured boundaries in `native-dbi-and-deobfuscation.md`.
|
||||
- **Protocol reversing beyond REST** — protobuf without a schema, gRPC frame capture, the QUIC/HTTP3
|
||||
limit, native-side certificate pinning (`protocol-reverse.md`).
|
||||
- **What to do when userspace hooking provably cannot reach the check** — raw `svc` syscalls,
|
||||
`init_array`-early detection, and the kernel-route map with its version gate
|
||||
(`kernel-and-environment-hardening.md`).
|
||||
- **Working from the phone itself** — MT Manager, its APK MCP surface, LSPosed Manager, on-device
|
||||
data inspection (`on-device-tooling.md`, `scripts/mt_mcp_probe.py`).
|
||||
|
||||
## Dependencies this skill does not ship — name them before the workflow starts
|
||||
|
||||
- **Dart AOT analysis needs a snapshot dump.** `dart-aot.md`'s workflow begins at `pp.txt`;
|
||||
producing it requires a snapshot container resolver this skill does not contain and cannot
|
||||
synthesize. `dart_pool_strings.py` reports **file** offsets while `pp.txt` and `dart_pprefs.py`
|
||||
speak in **pool** offsets, and the mapping between the two spaces is not a constant: over the
|
||||
4,241 strings present in both, a measured run found 4,237 distinct deltas. Ship a pinned front end
|
||||
(aotopsy — pure Go, no toolchain) or build blutter (~80 s, needs a C++ toolchain). **Say which one
|
||||
you are using and why, because the two report different Dart version labels for the same binary.**
|
||||
Do not describe the object pool as something this skill decodes on its own.
|
||||
|
||||
## Not covered — say so rather than improvise
|
||||
|
||||
- **Unity / IL2CPP logic recovery.** `framework-runtimes.md` identifies the runtime and establishes
|
||||
that the dex is not the battlefield; it does not carry the IL2CPP equivalent of `dart-aot.md`.
|
||||
There is no verified recipe here for locating a method inside `libil2cpp.so` plus
|
||||
`global-metadata.dat`.
|
||||
- **React Native / Hermes bytecode** and Cordova/hybrid internals, beyond runtime identification and
|
||||
the generic "find the string, then find what references it" approach.
|
||||
- **iOS / `.ipa` of any kind.** Every device, signing and packaging instruction here is Android.
|
||||
- **Defeating a server-side authority.** `server-api.md` determines *who owns a gate*, not how to
|
||||
break an authorization the server performs.
|
||||
- **An off-the-shelf unpacker, and an anti-detection arms race.** `advanced-unpacking.md` routes the
|
||||
problem — measure the ratio, name the recovery mechanism, stop when the target is a real VMP — but
|
||||
ships no modified ART runtime, no private-bytecode decompiler and no opcode-mapping derivation,
|
||||
and says so rather than presenting a memory dump as a recovery. `detection-and-anti-analysis.md`
|
||||
decides by cost and often concludes "switch to static"; it is not a catalogue of evasion. It now
|
||||
also carries an order of search, plus a measurable environment self-report and an observer-only
|
||||
probe — and the boundary that keeps those from becoming an evasion project is stated in the same
|
||||
file (its Step 2B, two boundaries this file adopts): observers and interceptors stay separate
|
||||
modules, and the cost of each added module is what decides when to leave for static.
|
||||
- **Kernel development.** `kernel-and-environment-hardening.md` maps the kernel-side route and names
|
||||
the version gate that decides whether it exists on your device; building and shipping a kernel
|
||||
module is outside this skill.
|
||||
- **A working Stalker trace on every device.** Two boundaries are measured and one of them is now
|
||||
better understood: exclusion keeps the target alive but does **not** restore event delivery
|
||||
(`references/evidence-summary.md` §The capability matrix). Treat the zero-event case as a boundary to
|
||||
identify, not a recipe to follow.
|
||||
|
||||
## Historical verification record
|
||||
|
||||
This is the part that grows every pass, which is why it does not live in `SKILL.md`. Read it before
|
||||
trusting any "this was measured" statement above.
|
||||
|
||||
**The first verification pass measured, against a real target:** a mid-size Flutter AOT application
|
||||
with no packer — the Dart work above, library mapping, and the environment facts. It **did not
|
||||
exercise** the packer, code-virtualization, custom-linker, integrity-check-redirection or
|
||||
tamper-suicide scenarios: that target has none of those features and its unmodified build already
|
||||
fails to start, which removes the repack-and-regress loop those scenarios need. Nothing in
|
||||
the evidence record condensed in `references/evidence-summary.md` §Where the full record lives is evidence either way about them.
|
||||
|
||||
**Scripts the first pass did not run** — they carry no measurement from that pass, and any
|
||||
conclusion drawn from them should be labelled accordingly: `native_crash.py`, `apk_diff.py`,
|
||||
`snap.py`, `grab_crash.py`, `install_test.py`, `repack.py`, `dex_patch_bytes.py`,
|
||||
`dex_find_insn.py`, `dex_check_verifier.py`, `dex_classdiff.py`, `dex_strpatch.py`,
|
||||
`patch_smali.py`, `smtool.py`, `datastore_inject.py`, `probe_api.py`, `run_probe.py`,
|
||||
`tls_check.py`, `usb_net_proxy.py`, `devsh.py`, and the `dexpatch/` java rewriter. Absence from
|
||||
that record is not a verdict on them. Note that `grab_crash.py` claims to recover stacks hidden by
|
||||
a crash-reporter SDK — the exact situation that target presented — and was not tried, so that claim
|
||||
remains **unverified**. **The benchmark pass has since run some of these**: `repack.py`,
|
||||
`dex_patch_bytes.py`, `dex_dump_validate.py`, `dex_mem_scan.py`, `spawn_patch_detach.py` and
|
||||
`stalker_trace.js` now have measurements, with the failures recorded alongside the successes in
|
||||
the benchmark matrix (`references/evidence-summary.md` §The capability matrix).
|
||||
|
||||
**The extension pass shipped its own scripts, each with its own status** — read the matching
|
||||
the per-topic files named in `references/evidence-summary.md` §Where the full record lives before relying on one: `dex_dump_validate.py` (measured
|
||||
against a fixture derived from a real hardened sample, and against that sample's own shell dex),
|
||||
`lsposed_scaffold.py` (its generated project was built end to end, toolchain timings recorded),
|
||||
`frida_rpc_serve.py` (the `rpc.exports` bridge was exercised on a live device),
|
||||
`mt_mcp_probe.py` (the "service is down" path is measured; the connected path needs the service
|
||||
started by hand), and `stalker_trace.js` / `stalker_report.py` (two boundary results, no successful
|
||||
trace of a real target).
|
||||
|
||||
**The extension pass's claims are mostly `inferred`.** Its evidence lives in
|
||||
the per-topic files named in `references/evidence-summary.md` §Where the full record lives, one file per topic, each with its own strength note. The
|
||||
common shape there is *the tool was measured, the route was not* — read those files before treating
|
||||
any of the newer documents as a verified path.
|
||||
|
||||
**The detection pass added two tools and three boundaries, and its evidence is mixed.** Recorded in
|
||||
`references/evidence-summary.md` §The capability matrix:
|
||||
|
||||
- `svc_scan.py` — **measured**, and its whole claim is cross-checked: two independent decoders
|
||||
(a hand-written word scan and the capstone-based scan) returned an identical 214-site set on a
|
||||
device `linker64`, set difference empty. That agreement is what makes its output usable as
|
||||
evidence about a *target's* library rather than about the scanner.
|
||||
- `anti_detect_probe.js` — **measured, observer-only by contract** (it patches nothing, so a run
|
||||
that uses it still describes the target it was pointed at). On a public MASTG challenge target it
|
||||
recorded `strstr("frida")` at ~300 ms of process life followed by a clean self-exit, and it
|
||||
reported the target's own view of the environment (`TracerPid=0` while **four** frida-named
|
||||
mappings were visible in `/proc/self/maps`). The same arm captured the full sequence **once out of
|
||||
three runs** — on a target that dies in ~300 ms the window is sub-second and not arm-to-arm
|
||||
reproducible, which is why the reference labels it that way rather than as a pipeline.
|
||||
- `scan_leaks.py` — **measured against a planted corpus and against this repository**. On the corpus
|
||||
every rule fired (30 findings across 6 categories) and the exemption list produced zero false
|
||||
positives on identifiers that must stay (tool names, SDK packages, dex constant identifiers, CVEs,
|
||||
hardening products, public crackme names, placeholders). On this repository it found **26 strong
|
||||
hits on its own evidence file**, which is the strongest evidence available that the class of leak it
|
||||
targets is not visible by hand. `observed`.
|
||||
- **The Dart AOT string-table formats** — the arm64 packed scheme is confirmed at the byte level
|
||||
(tag byte equal to `0x80|(len<<1)` at every literal checked, 4,980 chained pool entries), and the
|
||||
previously documented armv7 form is **refuted**: the 32-bit record is
|
||||
`[header u32][byte-count u32le][UTF-8 payload]`, and the extractor's zero for that ABI is a format
|
||||
mismatch rather than an empty table. `observed` for the formats; `unverified` for an end-to-end
|
||||
patched build, because none was repacked and installed in that pass.
|
||||
- **`observed`:** for the `ptrace-free` route, byte-identity between two *read paths*
|
||||
(`/proc/<pid>/mem` versus the backing file) on the same byte range, and the route's negative
|
||||
boundary — a target whose dex is deflate-compressed inside its APK has **no** named dex mapping to
|
||||
export (`named_dex=0` on both MASTG targets), so the route yields nothing there for reasons that
|
||||
say nothing about protection.
|
||||
- **`unverified`:** byte-identity between two independent *dumpers*. The second producer
|
||||
(`frida-dexdump`) is refused by this repository's hardened sample, whose usable lifetime collapsed
|
||||
to ~2 s this pass, and the clean targets have no whole-image dex for any dumper to find. The
|
||||
weaker read-path check is what was run instead, and the distinction is stated in the evidence file
|
||||
rather than papered over.
|
||||
|
||||
**Where the record lives.** The full record is `docs/tool-verification/` at the **repository root, not
|
||||
shipped** with the skill; `references/evidence-summary.md` is the condensation that does travel, and
|
||||
`references/routing.md` is the inventory. Neither the per-topic records nor the public-target
|
||||
regression matrix ships inside an installed skill, so an installed copy carries the claim but not its
|
||||
evidence file — which is why the strength label is stated here rather than left to be looked up.
|
||||
@@ -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.
|
||||
393
vendor-new/apk-reverse/skills/apk-reverse/references/dart-aot.md
Normal file
393
vendor-new/apk-reverse/skills/apk-reverse/references/dart-aot.md
Normal file
@@ -0,0 +1,393 @@
|
||||
# Dart AOT (Flutter) — reaching the layer that actually owns the logic
|
||||
|
||||
Load this when: the target is a Flutter app (`libflutter.so` + `libapp.so`) and the behaviour you
|
||||
must change is decided **inside** the Dart snapshot, not in the dex. The Java side of a Flutter app
|
||||
is a thin plugin shell; a dex-only plan will stall (`framework-runtimes.md` §the layer trap).
|
||||
|
||||
Everything below assumes you already confirmed the runtime is Flutter and that Java hooks fire
|
||||
**zero times** while the UI plainly works.
|
||||
|
||||
---
|
||||
|
||||
## 1. Pin the Dart version before choosing any tool
|
||||
|
||||
The snapshot format is version-specific. A decompiler built for another Dart version may run and
|
||||
produce plausible-but-wrong output, which is worse than failing.
|
||||
|
||||
```python
|
||||
import re
|
||||
blob = open('libflutter.so', 'rb').read() # the engine, not libapp.so
|
||||
m = re.search(rb'(\d+\.\d+\.\d+ \(stable\)[^\x00]{0,60})', blob)
|
||||
print(m.group(1).decode() if m else 'not found') # e.g. b'3.9.0 (stable) (Mon ...) on "android_arm64"'
|
||||
```
|
||||
|
||||
If the string is absent, fall back to the snapshot hash the loader reports at runtime, or to the
|
||||
engine build id in `libflutter.so`'s version string.
|
||||
|
||||
**The regex above can return a version-shaped lie — assert the shape, do not just take the digits.**
|
||||
Measured exception, on a real `libflutter.so`: the full banner string is
|
||||
`0.0.1 on "android_arm64"` — a 66-byte engine version
|
||||
record whose numeric field is `0.0.1` and whose **64-character build-id field is blank**, with no
|
||||
`(stable)` marker and no `(Mon …)` timestamp at all. Anchoring only on `\d+\.\d+\.\d+` matches the
|
||||
truncated prefix of a banner that was never populated. The discriminator that works: find the
|
||||
` on "<arch>"` suffix, take the version string from the preceding NUL, and check that a
|
||||
`(stable) (…)` tail is actually present **before** feeding the number to a VM-compiling tool
|
||||
(§2's Route B). If it is missing, you cannot pin the version from this file — say so and use the
|
||||
snapshot hash or a structural probe (§2's Route A), rather than building a decompiler for `0.0.1`.
|
||||
|
||||
## 2. Get a decompiler that matches that version
|
||||
|
||||
You need a tool that resolves the snapshot container — something that turns `libapp.so` into named
|
||||
functions, class layouts and, above all, a **pool listing with `pp+0x…` offsets**. Everything in
|
||||
onward consumes that listing; without it the workflow cannot start, and no script in this skill can
|
||||
produce it (see §4 for why the mapping is not recoverable from the binary alone).
|
||||
|
||||
Two routes exist. Pick deliberately, because their version reporting differs.
|
||||
|
||||
**Route A — aotopsy: no toolchain, run it now.** A single static binary (pure Go) that parses the
|
||||
snapshot binary format directly, with no Dart VM and no SDK compile. On a real Dart 3.6.0
|
||||
`libapp.so` the full pipeline ran out of the box: 30,586 functions, 6,133 class layouts, 39,202 pool
|
||||
entries (38,274 resolved), 175,120 call edges with 95.7% of indirect sites annotated, plus
|
||||
`functions.jsonl`, `call_edges.jsonl`, `classes.jsonl`, `string_refs.jsonl`,
|
||||
`dispatch_table.jsonl` and an annotated `asm/` tree. It also supports x86_64, which blutter does not.
|
||||
|
||||
Its `doctor` subcommand reports a Dart version that is a **structural profile label, not the SDK
|
||||
version**: on the sample above it printed `3.6.2` while the engine banner, the snapshot hash and a
|
||||
byte search all said `3.6.0`, and no `3.6.2` byte sequence existed in either `.so`. That matters only
|
||||
if you feed the number to a VM-compiling tool — use §1's banner for that. Its own README states the
|
||||
detection is structure-based, so expect the label to name the newest matching profile rather than the
|
||||
compiler's version.
|
||||
|
||||
**Route B — blutter: full fidelity, needs a build.** It embeds a matching Dart VM and deserializes
|
||||
through the VM's own code paths, which yields the canonical `pp.txt` in the `pp+0x…` space. It is a
|
||||
*source tree you build*, not a binary you download: its `bin/` is gitignored and it publishes no
|
||||
release artifacts, so there is nothing prebuilt to fetch for a given Dart version.
|
||||
|
||||
- blutter's git HEAD typically supports newer Dart than its shipped `dartsdk/` directory.
|
||||
- When the target's version is missing, blutter fetches it itself: it sparse-clones
|
||||
`dart-lang/sdk` at that version tag (only `runtime`, `tools`, `third_party/double-conversion`),
|
||||
generates a source list, and builds a `dartvm` static library for the target ABI.
|
||||
- That build needs `cmake` (>=3.20 is fine, including 4.x), `ninja`, `git`, and a C++ compiler with
|
||||
C++20 `<format>` support. The documented VS 2022 requirement is **over-strict** — MSVC 19.34
|
||||
(VS 17.4.3, Nov 2022) compiles and runs the `std::format` path, measured. A `vcvars64`-style
|
||||
environment must be active, and `CMakeLists` pins `cmake_minimum_required(3.20)`.
|
||||
- Budget a **couple of minutes**, not "tens of minutes": the whole pipeline (clone, `init_env_win.py`,
|
||||
sparse SDK clone, cmake, nja, link) measured **≈78 s** wall clock. It uses a unity build via
|
||||
`dartvm_create_srclist.py`, which is why it is fast. The compiler log is enormous (per-file include
|
||||
trees), so read only its tail.
|
||||
- **If the freshly built binary faults immediately** with `0xC0000005` and no output at all, do not
|
||||
conclude anything about the sample. Relink it (`ninja` in
|
||||
`build/blutter_dartvm<ver>_<os>_<arch>`) and try again; on the sample above the fault never
|
||||
reproduced after a relink — including for the unchanged binary — and three subsequent runs each
|
||||
completed in ~5.9 s. **One retry, then a relink, before you blame the target.**
|
||||
- **Two Windows build failures that are not your target's fault, both measured `~0.2 h` each.**
|
||||
*The compiler is not found even though `vcvars64` ran*: `%PATH%` expands when `cmd` parses the
|
||||
line, so a `set PATH=...;%PATH%` in the same command overwrites the environment `vcvars` just
|
||||
installed. Use delayed expansion (`cmd /V:ON` with `set PATH=...;!PATH!`) or run `vcvars64` in a
|
||||
separate `cmd` invocation. *`string(REPLACE "/EHsc" ...)` aborts with "not enough arguments"*:
|
||||
the `REPLACE` call is unguarded, so it breaks when `CMAKE_CXX_FLAGS` is empty — add an
|
||||
`if(CMAKE_CXX_FLAGS)` guard, and patch **both** the template and the generated copy, or the next
|
||||
configure regenerates the broken one. A prebuilt binary offered by a mirror is not a shortcut if
|
||||
it is built for `aarch64`-Linux (the Termux target): check its ELF machine before spending time on
|
||||
it — that mismatch cost `1 h` on the precedent this section is derived from, against `0.2 h` for
|
||||
either compile fix above.
|
||||
|
||||
Outputs of interest (blutter):
|
||||
|
||||
| Output | What it gives you | What it does *not* give |
|
||||
|---|---|---|
|
||||
| `pp.txt` | every object-pool entry with its `pp+0x…` offset: strings, types, closures, fields, stubs | who references what |
|
||||
| `objs.txt` | reconstructed object shapes with type annotations | code |
|
||||
| `asm/` | class layouts **and function bodies with full instruction listings**, pool annotations and resolved call targets | a pseudocode-level view |
|
||||
| `ida_script/`, `blutter_frida.js` | symbol/annotation helpers | a finished analysis |
|
||||
|
||||
> `asm/` is the richest artifact in the chain. It is one file per **library URI** (mirroring the
|
||||
> package path), not per class as its name suggests, and it carries annotated disassembly such as:
|
||||
>
|
||||
> ```
|
||||
> // 0x923b3c: r0 = LinkedHashMap.from()
|
||||
> // 0x923b3c: bl #0x60165c ; [dart:collection] LinkedHashMap::LinkedHashMap.from
|
||||
> ```
|
||||
>
|
||||
> An earlier revision of this file claimed `asm/` contained no instructions and told the reader to
|
||||
> ignore it. That was wrong and cost real time — plan around `asm/` as your primary annotated
|
||||
> disassembly source.
|
||||
|
||||
**`product` builds carry no debug info.** Expect `no-code_comments`, no function names, and
|
||||
obfuscated identifiers. That is normal; the strings still survive (see). Note this is a *format*
|
||||
floor, not a tooling gap: the Dart compiler drops field names outside debug builds, so roughly
|
||||
97-99% of instance field names are simply absent, and local/captured variable names are gone
|
||||
entirely. Accessor-based recovery (`get:`/`set:` still carry the name) recovers part of the field
|
||||
picture; the rest should be rendered as unknown rather than guessed.
|
||||
|
||||
## 3. The object pool is the whole game
|
||||
|
||||
Dart AOT uses **compressed pointers**: heap references are 32-bit offsets from a base held in a
|
||||
dedicated register. Constants, strings, types, closures and field metadata live in one contiguous
|
||||
**object pool**, reached through that register.
|
||||
|
||||
Two offset spaces exist and they are **not the same number** — mixing them wastes hours:
|
||||
|
||||
- **PP offsets** (`pp+0x…`) — what `pp.txt` and every pool-load in disassembly use.
|
||||
- **file offsets** — where a byte actually sits in `libapp.so` on disk.
|
||||
|
||||
Keep the mapping explicit in your notes. When a tool reports "this string is at X", state which
|
||||
space X is in.
|
||||
|
||||
**There is no constant between the two spaces — do not go looking for one.** The relationship is a
|
||||
property of the *reconstructed* pool, not of the file, so it cannot be computed from `libapp.so`
|
||||
alone. Measured over the 4,241 strings that appear in both the string table and `pp.txt`, there were
|
||||
**4,237 distinct deltas** (range −1,361,429 to +277,654). A string recovered by file offset therefore
|
||||
does not lead to its pool offset, which is exactly why §2 insists on a snapshot-decoding front end:
|
||||
it is what gives you the `pp+0x…` space in the first place.
|
||||
|
||||
## 4. Build your own reference index (blutter will not give you this)
|
||||
|
||||
`pp.txt` tells you what is in the pool. To *find the code that uses it* you need pool-offset →
|
||||
referencing-instruction-address. Generate it once, then query it constantly:
|
||||
|
||||
```
|
||||
python dart_pprefs.py libapp.so pp_refs.json
|
||||
python dart_pprefs.py --lookup pp_refs.json 0x1d1a8 0xc9d0
|
||||
```
|
||||
|
||||
**Do not build this index with a full-capstone pass.** Decoding every instruction of a multi-MB
|
||||
`.text` with `detail=True` and querying `regs_access()` per instruction costs minutes of CPU and
|
||||
1–2 GB of peak memory, and on a 16 GB host it presents as a hang with no progress output. Only a
|
||||
handful of encodings can read the pool, so decode them from the raw 32-bit words instead — the
|
||||
shipped script does exactly that and finishes in seconds.
|
||||
|
||||
**But do not fall back to a linear capstone sweep either.** A Dart AOT `.text` begins with snapshot
|
||||
metadata, not instructions — on a real `libapp.so` the section started at `0x4a0000` while the first
|
||||
Dart function prologue (`stp x29, x30, [sp, #-imm]!`) was at `0x4b143c`. A linear sweep starting at
|
||||
offset 0 dies on that metadata and returns **zero instructions**, which looks exactly like "this
|
||||
library has no code". If you need capstone here, start at a known function address, or use
|
||||
`skipdata=True`, and treat an implausibly small count as a decoding problem before you treat it as a
|
||||
property of the file.
|
||||
|
||||
The same mask-based argument applies to building a call graph (): decode `B`/`BL` arithmetically
|
||||
rather than by sweeping.
|
||||
|
||||
**A live example of why the index's completeness matters:** after this file's advice to "count the
|
||||
references before editing a shared constant", one pool offset measured `[0 refs]` with the shipped
|
||||
script and `[79 refs]` once a missing load family was added to the scanner. A one-sided quiet gap in
|
||||
the scan reads as "nothing uses this", which is the answer that gets a shared constant edited
|
||||
carelessly. If a count looks surprisingly low, suspect the scanner before believing the pool.
|
||||
|
||||
## 5. What the registers mean
|
||||
|
||||
Stable across Dart 3.x arm64 AOT. Verify once on a known function, then rely on it.
|
||||
|
||||
| Register | Role |
|
||||
|---|---|
|
||||
| `x27` | **object pool base (PP)** — every constant/string/type load goes through it |
|
||||
| `x26` | current thread (`[x26,#0x38]` stack limit, `[x26,#0x68]` isolate group) |
|
||||
| `x28` | heap base — how compressed pointers are decompressed |
|
||||
| `x22` | **null/base for booleans** (see) |
|
||||
| `x15` | Dart's own stack pointer (not the system SP) |
|
||||
| `x21` | class dispatch table (virtual calls load a target from it) |
|
||||
|
||||
## 6. Booleans are not 0/1
|
||||
|
||||
`true` and `false` are objects immediately adjacent to null, so they appear as small fixed offsets
|
||||
from `x22`:
|
||||
|
||||
```asm
|
||||
add x0, x22, #0x20 ; construct TRUE
|
||||
add x0, x22, #0x30 ; construct FALSE
|
||||
tbnz w0, #4, <label> ; test bit 4 of the value
|
||||
tbz w0, #4, <label>
|
||||
```
|
||||
|
||||
The bit-4 test is the standard "is this the false object" check, and it is the cheapest place to
|
||||
force a decision: replacing the conditional branch with an unconditional one, or replacing a
|
||||
constructed constant, flips the outcome without changing structure.
|
||||
|
||||
## 7. String encoding — get this exactly right
|
||||
|
||||
Pool string entries are serialized as:
|
||||
|
||||
```
|
||||
[ tag byte ][ payload ]
|
||||
|
||||
tag = 0x80 | (len << 1) | two_byte_bit
|
||||
```
|
||||
|
||||
- **one-byte strings** (ASCII / Latin-1): `payload` is the raw bytes.
|
||||
- **two-byte strings**: `payload` is **UTF-16LE**.
|
||||
|
||||
Consequences that decide whether your search works:
|
||||
|
||||
- An **ASCII** literal (`/api/foo`, `isVip`, a URL) is found by a **UTF-8 byte search**. It is a
|
||||
one-byte string — searching UTF-16 for it will miss.
|
||||
- A **CJK / non-Latin** literal is a two-byte string: a UTF-8 search returns **zero**, a UTF-16LE
|
||||
search hits. This is the case that makes people wrongly conclude "the strings were stripped".
|
||||
- Therefore: **search ASCII as UTF-8 and non-ASCII as UTF-16LE**, rather than forcing one encoding
|
||||
on everything. (This is the concrete form of `pitfalls.md` P25.)
|
||||
- A raw byte scan for the tag pattern finds many false candidates. Accept a candidate only when
|
||||
entries **chain** — the previous entry's `tag+payload` must end exactly where the next candidate
|
||||
begins. Without that constraint you get order-of-magnitude more garbage than strings; a
|
||||
per-byte scan of a multi-MB snapshot once produced ~880k "strings" of which essentially all were
|
||||
misaligned noise, and a "no such feature present" conclusion was drawn from it.
|
||||
|
||||
```
|
||||
python dart_pool_strings.py libapp.so strings.tsv --min 3
|
||||
```
|
||||
|
||||
### The 32-bit ABI does not use this format — and the tool will say so
|
||||
|
||||
**On a 32-bit (`armeabi-v7a`) snapshot the packed tag scheme above does not exist, and the string
|
||||
table is not laid out as a walkable chain at all.** `dart_pool_strings.py` enforces the chaining
|
||||
constraint, so on armv7 it reports `kept (run >= 3): 0` — every candidate is isolated and discarded.
|
||||
That zero is a **format mismatch, not an empty string table**: the same extractor over the arm64
|
||||
snapshot of the same app kept 4,980 chained entries. Do not read the armv7 zero as "the strings were
|
||||
stripped".
|
||||
|
||||
Measured on one app shipped with both ABIs (arm64 16,352,152 B / armv7 17,629,776 B):
|
||||
|
||||
| ABI | Where a literal sits | How to find it |
|
||||
|---|---|---|
|
||||
| arm64 | inside the packed string table, `[0x80\|(len<<1)][payload]` | `dart_pool_strings.py`; the chain constraint makes it reliable |
|
||||
| armv7 | **inline in the read-only data**, as its own record | a plain ASCII search for the literal — it is *not* UTF-16, so a byte search is the whole method |
|
||||
|
||||
For armv7, the record a literal sits in is **4-byte aligned** and reads
|
||||
`[header u32][length u32le][SAFE payload]`, where `length` is the **byte** count of the payload
|
||||
(measured: an ASCII literal 12 chars long carried `12`, and a 70-char literal carried `70`). The
|
||||
header u32 is a class/tags word that varies between snapshots — on the sample above it took the
|
||||
values `0x00550238` and `0x00560238`, both 4-byte aligned, 13,397 occurrences. Treat the header as
|
||||
a **validator, not a constant**: assert `length == payload length` and that the payload is
|
||||
all-printable, then read the header word from the file rather than hard-coding it. Scanning the whole
|
||||
17.6 MB image at 4-byte stride costs **0.94 s**, so validating every candidate is affordable.
|
||||
|
||||
Consequence for patching: because the armv7 payload is **UTF-8 and length-explicit**, an equal-length
|
||||
ASCII replacement is legal on both ABIs without recomputing the tag — the one-byte tag encodes the
|
||||
length only on arm64, which is why the same patch script can serve both if it asserts the prefix it
|
||||
found instead of assuming which encoding it is looking at.
|
||||
|
||||
**Patch every ABI the app ships, or the device may run the one you left alone.** A phone that
|
||||
prefers `arm64-v8a` still has the `armeabi-v7a` snapshot on disk, and a build that patches only one
|
||||
of them is a controlled experiment, not a deliverable.
|
||||
|
||||
## 8. Reading AOT code: three signatures that carry most of the weight
|
||||
|
||||
Once you can disassemble a window with pool annotations, most business logic resolves into these
|
||||
shapes.
|
||||
|
||||
**A — reading a map / JSON object (`map[key]`)**
|
||||
```asm
|
||||
ldur x3, [x29, #-8] ; the Map
|
||||
ldur x0, [x3, #-1] ; compressed class id sits at offset -1
|
||||
ubfx x0, x0, #0xc, #0x14 ; decode class id
|
||||
mov x1, x3
|
||||
... x2 = <pool string> ; <- the key, annotated by your pool map
|
||||
add x30, x0, #0x342
|
||||
ldr x30, [x21, x30, lsl #3]
|
||||
blr x30 ; this is map[key]
|
||||
```
|
||||
Remember the quartet: `ldur [..,#-1]` + `ubfx #0xc,#0x14` + `add x30,x0,...` + `blr`.
|
||||
|
||||
**B — writing a map (`map[key] = value`)**
|
||||
```asm
|
||||
... x16 = <pool string> ; the key
|
||||
stur w16, [x0, #0xf] ; key stored into the freshly built map
|
||||
... ; then the value at the next slot
|
||||
```
|
||||
`stur` writes, `blr` reads. Confusing the two means patching a serializer that never touches the UI.
|
||||
|
||||
**C — function entry**
|
||||
```asm
|
||||
stp x29, x30, [x15, #-0x10]!
|
||||
mov x29, x15
|
||||
sub x15, x15, #0x20
|
||||
ldr x16, [x26, #0x38]
|
||||
cmp x15, x16
|
||||
b.ls <stack-overflow handler>
|
||||
```
|
||||
Use this to find function boundaries when you need to delimit one.
|
||||
|
||||
## 9. The locating workflow
|
||||
|
||||
1. **Anchor on a string.** Search the pool for the shortest distinctive token — a field name, an
|
||||
endpoint path, a label. Prefer identifiers over sentences (a sentence may be assembled from
|
||||
fragments).
|
||||
2. **Find its referencing instructions** via your index ().
|
||||
3. **Disassemble a window** around each reference with pool annotations:
|
||||
```
|
||||
python dart_disasm.py libapp.so --pp pp.txt --refs pp_refs.json 0x26e390
|
||||
python dart_disasm.py libapp.so --pp pp.txt --refs pp_refs.json --range 0x64f310 0x64f3e0
|
||||
```
|
||||
4. **Classify the site**: read (A) or write (B)? For writes, check whether the value is a constant
|
||||
from the pool — that is a "send this flag" site, not a "decide locally" site.
|
||||
5. **Walk outward.** Find the enclosing function entry (§8C), then find its callers:
|
||||
```
|
||||
python dart_disasm.py libapp.so --index callers.json --build-index
|
||||
python dart_disasm.py libapp.so --index callers.json 0x26e230
|
||||
```
|
||||
6. **Cross-validate by clustering.** Related strings usually sit **adjacent in the pool** and are
|
||||
referenced from **adjacent code**. If two field names are a few bytes apart in the pool and their
|
||||
reference sites are a few instructions apart, you are looking at one coherent piece of logic —
|
||||
strong evidence you are in the right place.
|
||||
|
||||
## 10. Patching this layer
|
||||
|
||||
- **Prefer changing the data source over the decision point.** Locate the chain
|
||||
"read field from map → type conversion → store to stack slot" and replace the middle with a load
|
||||
of a pool constant. One edit then feeds every consumer, instead of chasing each comparison. This
|
||||
has produced clean results where per-condition patches did not.
|
||||
- **Boolean sites** are the cheapest: `tbnz`/`tbz` → `nop` (always fall through) or → an
|
||||
unconditional branch; `add xD, x22, #0x20` ↔ `#0x30` to flip a constructed constant.
|
||||
- **A pool string may be shared.** Before editing one, count its references. If the same entry is
|
||||
read by several sites, changing it changes all of them — self-consistent and therefore useless
|
||||
for splitting behaviour. Prefer editing code over shared data.
|
||||
- **Assert your addresses.** Patch with the original bytes asserted first and re-read the result
|
||||
afterwards; an address that is off by one instruction will happily corrupt a snapshot into
|
||||
something that still loads.
|
||||
- **`libapp.so` inside an APK is usually deflate-compressed**, so in-place byte patching of the zip
|
||||
entry is not possible — patch the extracted file, then replace the whole entry
|
||||
(`repack-and-sign.md`).
|
||||
- **Replacing a string in place is safe; replacing the *wrong* string is not.** The highest-value
|
||||
equal-length target on a server-driven app is a **data key** — a JSON field name, a slot key, a
|
||||
reporting label. Rename it to equal-length noise and the client looks up a key the server never
|
||||
sends, so the feature goes quiet while every other request keeps working. This is the cheapest
|
||||
surgical form of the "do not make an API fail" constraint at the top level of `SKILL.md`: the
|
||||
request still succeeds, only the client's interpretation of it changes.
|
||||
- **Never replace an API path or URL string.** It looks like the same kind of string and it is not.
|
||||
Measured failure, on a real build: replacing the ad-fetch path produced a **404 whose error body is
|
||||
not valid JSON**, the app's startup flow called `jsonDecode` on it, `FormatException` propagated
|
||||
out of the Future that builds the home screen, and the app **stayed on the launch logo forever** —
|
||||
`logcat` showed only `E flutter`. The ad did disappear; so did the app. Isolate it the way that
|
||||
case did: a **re-sign-only control** (no string edits) plus `adb logcat -d | grep "E flutter"`, so
|
||||
a startup failure is attributable to the patch rather than to the packer. Full precedent:
|
||||
`references/precedents/`.
|
||||
- **Longest string first when the targets are substrings of each other.** In a byte search
|
||||
`welfare_ad` also matches inside `welfare_ad_top`, and `ad_click:` inside `ad_click:exp`; replacing
|
||||
the short one first corrupts the longer entry. Sort candidates by descending length, and assert the
|
||||
prefix byte you expect **immediately before** each hit — on arm64 that byte is `0x80|(len<<1)`, on
|
||||
armv7 the length is the u32 immediately before the payload — so a hit that lands mid-string is
|
||||
rejected instead of patched.
|
||||
|
||||
## 11. Traps specific to this layer
|
||||
|
||||
- **Identifiers are ambiguous across layers.** A pool string that reads like a domain concept can
|
||||
belong to a standard library or a wire protocol instead. A near-miss example worth remembering:
|
||||
the token `expires` resolved to HTTP **response-header** parsing (its siblings were `date`,
|
||||
`host`, `connection`), not to a subscription expiry field. **Read the surrounding strings and the
|
||||
code shape before assigning meaning** — one misleading identifier can send a whole analysis down
|
||||
the wrong branch.
|
||||
- **Obfuscated names are not identities.** Symbol names are mangled (`_abc@12345` shapes are
|
||||
common) and change between builds. String literals do survive, so anchor on those.
|
||||
- **Clusters reveal structure.** Adjacent pool entries plus adjacent reference sites indicate one
|
||||
subsystem; use it to avoid guessing what a function is for.
|
||||
- **A write-only flag is usually an outbound parameter, not a local switch.** If a string is
|
||||
referenced exactly once, at a site that stores a constant into a map that is then sent, the client
|
||||
is *reporting* a value — changing it does not change local behaviour. Confirm whether the value is
|
||||
ever read before treating it as a gate.
|
||||
- **Full-file disassembly is not a debugging tool** (). Windowed disassembly is.
|
||||
|
||||
## 12. Verification
|
||||
|
||||
Static: asserted patch bytes, re-read from the built artifact, plus structural checks
|
||||
(`verification.md`). Runtime: the changed behaviour must be observed in the app's own UI — for this
|
||||
layer that means an actual launch, because nothing about Dart AOT code proves itself statically.
|
||||
If the snapshot drives a decision that a server also enforces, see `membership-and-limits.md`:
|
||||
getting the client side right does not make the outcome reachable.
|
||||
@@ -0,0 +1,225 @@
|
||||
# Desensitization and leak scans — what must leave, and what must stay
|
||||
|
||||
A skill repository is published, and the material feeding it is real work: device transcripts,
|
||||
packet captures, `ps` listings, log excerpts, an hour of shell history. Target identity leaks
|
||||
into that material one line at a time — a bundle id in the middle of a `pm path` output, a
|
||||
device serial echoed before a `dumpsys`, an SDK key pasted while debugging a download — and
|
||||
**no structural check can see it.** `check_repo.py` validates layout, `check_refs.py` validates
|
||||
anchors; both pass on a file that names a live app and a real phone.
|
||||
|
||||
The failure this file prevents is two-sided, and the second side is the one that gets
|
||||
forgotten:
|
||||
|
||||
- **Under-redaction**: a published repository carries a target's identity, a device serial, or
|
||||
a credential, and nobody notices until someone greps for it a year later. This is what
|
||||
happened here once, by hand, after a pass had already been committed.
|
||||
- **Over-redaction**: the same pass strips the *reusable* material along with it — the tool
|
||||
names, the hardening-product names, the public crackme names, the protocol field names — and
|
||||
the document stops being able to teach anything. A repository that has been scrubbed into
|
||||
uselessness has failed at the same job from the other side.
|
||||
|
||||
A rule that only handles the first side produces "a document about nothing". A rule that only
|
||||
handles the second produces a leak. Both get fixed by the same artifact: an explicit list of
|
||||
what is *exempt*, in code, that can be read and argued with.
|
||||
|
||||
|
||||
**Load this when:** you are about to publish anything derived from real work -- an evidence file, a transcript, a README -- or a leak scan reports a hit. It gives the must-leave/must-stay split and the scanner's exit semantics.
|
||||
|
||||
## Strength labels
|
||||
|
||||
- **observed** — a command was run here and its output is quoted in the text or recorded in
|
||||
the evidence record condensed in `references/evidence-summary.md` §Where the full record lives.
|
||||
- **inferred** — follows from an observed fact or from documented mechanism; the step itself
|
||||
was not executed.
|
||||
- **unverified** — assumed, or reported elsewhere, and not reproduced in this repository.
|
||||
|
||||
Claim strength in this file means *how well the statement about desensitization was measured*,
|
||||
not how confident the writing sounds.
|
||||
|
||||
## The two rules, stated so they can be argued with
|
||||
|
||||
**1. Redact what identifies the target; keep everything that identifies the technique.** A
|
||||
bundle id, a device serial, a host user path, an appkey, a live token, a non-loopback endpoint
|
||||
are identity. A tool name, a library name, a function name, a protocol field name, a CVE id, a
|
||||
hardening product name, a public crackme name and its URL are *technique* — they are how the
|
||||
document transfers skill, and they are exactly what a naive redaction pass deletes first.
|
||||
|
||||
The test that separates them: **would removing this string make the sentence less able to
|
||||
teach?** `frida`, `apktool`, `libjiagu`, `UnCrackable-Level1`, `com.stub.StubApp`,
|
||||
`RegisterNatives`, `proto3` all fail the test — removing them costs knowledge and protects
|
||||
nothing, because they name a product, a tool or a public fixture. `<PKG>`, `<DEVICE>`,
|
||||
`<APPKEY>` pass — they carry the sentence's structure and none of its identity. And
|
||||
`com.<redacted>.app` also passes the test, which is why a scanner must *report* the reverse
|
||||
domain shape and let a human see what it is; it cannot decide.
|
||||
|
||||
**2. A finding is reported with limited context, not a line number.** A report that says
|
||||
`foo.md:412` makes the fixer reopen the file, read around the line, and decide what to delete —
|
||||
per finding. A report that quotes ~48 characters either side means the decision is made from
|
||||
the report itself. This is the same argument `verification.md` §Reporting makes for logs: an
|
||||
artifact that does not contain the decisive information sends its reader back to the source.
|
||||
|
||||
## What must be kept — the do-not-anonymize list
|
||||
|
||||
Every entry here has been observed in this repository's own documents, and every one of them
|
||||
is a wrong answer from a shape-only scanner. The list is materialized in
|
||||
`scripts/scan_leaks.py` as `BENIGN_PACKAGE_PREFIXES`, `PUBLIC_TARGET_PREFIXES`,
|
||||
`BENIGN_IP_PREFIXES` and the `CODE_IDENTIFIER_HINTS` table, so it can be extended with a
|
||||
one-line edit and audited with `--show-exempt`.
|
||||
|
||||
| Category | Examples | Why it is exempt |
|
||||
|---|---|---|
|
||||
| Tool and library names | `frida`, `apktool`, `jadx`, `libart.so`, `libfoo.so` | naming a tool is the content; the name is public and identical for every reader |
|
||||
| Function and symbol names | `RegisterNatives`, `findExportByName`, `pthread_mutex_lock`, `JNI_OnLoad` | API vocabulary, not identity — and a large fraction of the reverse-engineering technique is exactly these names |
|
||||
| DEX/ELF constant identifiers | `findExportByName`, `ImmutableDexFile`, `installPackageLI`, `LoadPackageParam` | 16-character camelCase runs that a serial-shaped regex matches; they are code constants |
|
||||
| Protocol field names | `proto3`, `varint`, `packed repeated`, `length-delimited` | wire-format vocabulary |
|
||||
| CVE identifiers | `CVE-2024-31317`, `CVE-2021-44228` | public advisory ids; redacting them destroys the reference |
|
||||
| Hardening product names | `com.stub.StubApp`, `libjiagu.so`, `libDexHelper.so`, `com.secneo` | a *product* signature. It is what lets a reader recognise a shell, and it names no target |
|
||||
| Public crackme / benchmark targets | `UnCrackable-Level1.apk`, `sg.vantagepoint.uncrackable3`, MASTG and its URLs | deliberately published fixtures; their package names are part of the published exercise |
|
||||
| Platform and SDK packages | `com.android.settings`, `com.google.android.gms.ads`, `com.qq.e.ads.PortraitADActivity`, `androidx.work.WorkManager` | inventory. "Which SDK does this app carry" is a finding; the SDK's own name is not a secret |
|
||||
| This repository's own fixtures | `com.example.*`, `probe.synthetic.*`, a probe module's package | synthetic by construction |
|
||||
| Placeholders | `<PKG>`, `<DEVICE>`, `<serial>`, `<hash>`, `<work>`, `<user>`, `C:\Users\<user>\...` | the redaction mechanism itself; a scanner that reports these is unusable |
|
||||
| Loopback / unspecified / emulator addresses | `127.0.0.1:27042`, `0.0.0.0:8080`, `10.0.2.2:8080`, `169.254.169.254` (as a documented endpoint) | the frida and ADB idioms are these addresses; they identify nothing |
|
||||
|
||||
**Deliberately *not* exempt, and observed as a finding here**: the RFC 5737 documentation
|
||||
ranges (`192.0.2.0/24`, `198.51.100.0/24`, `203.0.113.0/24`). They are the correct way to write
|
||||
a synthetic address, so a hit there is a signal rather than noise. Suppressing the range outright
|
||||
would also hide the case where someone pastes a real address that happens to look synthetic.
|
||||
|
||||
This repository pays for that decision with the *only* findings it produces, and the price is
|
||||
recorded rather than argued away: **four `endpoint/weak` hits and nothing else**, measured on the
|
||||
tracked surface with file count 129. Three of the four are the three blocks named in the sentence
|
||||
above — this file stating the decision is itself the hit — and the fourth is
|
||||
`scripts/tls_check.py`'s own usage example, a benign context the report makes obvious in one line.
|
||||
Every other category is at zero. That ratio is why the gate's pass condition lives in
|
||||
`--fail-on` and not in an exemption table: the documentation of the decision *is* the residue, so
|
||||
a gate that failed on it would be failing on its own rationale. The transcript is in
|
||||
`references/evidence-summary.md` §The capability matrix.
|
||||
|
||||
**What is a leak here, in contrast** — the shapes worth reporting: `package="…"` in a manifest
|
||||
line, a bundle id after `pm path` / `pidof` / `ps -A` / `component=`, a 16-character
|
||||
`[A-Z0-9]` run standing alone or beside a device word, `github_pat_…`/`ghp_…`, an inline
|
||||
`APPKEY`/`appSecretKey`/`SECRET_KEY` assignment with a literal value, a non-loopback `IP:port`,
|
||||
and `/home/<name>/`, `/Users/<name>/`, `C:\Users\<name>\`.
|
||||
|
||||
## What a scan can and cannot decide
|
||||
|
||||
Decide in code: whether *this shape* is present, and whether a known-benign pattern explains
|
||||
it. Never decide in code: whether the surrounding document is identity-bearing.
|
||||
|
||||
Three consequences, each observed by writing the scanner for this repository:
|
||||
|
||||
- **A range of four digits separated by dots is an IP address only if nothing nearby says
|
||||
"version".** `JDK 17.0.4.1`, `build-tools 34.0.0` and `frida 16.7.19` are the shapes the first
|
||||
version of the scanner reported twenty times across the evidence record condensed in `references/evidence-summary.md` §Where the full record lives. Context
|
||||
(`jdk`, `python`, `frida`, … immediately before the match) separates them.
|
||||
- **A 16-character uppercase token is a device serial only if it carries a letter.** Stack
|
||||
addresses from a tombstone (`0000000040001000`) and hex protocol fixtures
|
||||
(`0807060504030201`) are all-digit and were reported until that rule was added.
|
||||
- **A reverse-domain string preceded by `args.` is a property lookup.** `args.package.split(".")`
|
||||
in a script — observed in `scripts/spawn_patch_detach.py` — is not a bundle id, and the
|
||||
exemption needs the *line*, which is why the exemption function takes it.
|
||||
|
||||
Each of those is a **false positive that a reader would have had to investigate**. Three of them
|
||||
were found by running the scanner against this repository, not by reasoning about it — which is
|
||||
the argument for making the scan a gate rather than a document.
|
||||
|
||||
## The scanner, and where it belongs in the pipeline
|
||||
|
||||
`scripts/scan_leaks.py` — standard library only, `--root` explicit or defaulting to its own
|
||||
repository, categories `package` / `device` / `token` / `appkey` / `endpoint` / `path`, text and
|
||||
JSON output, and a fixed exit-code contract:
|
||||
|
||||
| Exit | Token | Meaning | What a script does |
|
||||
|---|---|---|---|
|
||||
| 0 | `RESULT=clean` | nothing outside the exemption list | continue |
|
||||
| 0 | `RESULT=leaks_found_strong_only` | findings exist, but every one of them is `weak` (an address from the documented range) | continue, and read the report — it is the publish-time checklist |
|
||||
| 1 | `RESULT=leaks_found` | at least one `strong`/`certain` finding, or any finding under `--fail-on any` | fail the job and print the report |
|
||||
| 2 | `RESULT=error` | bad usage, unreadable root, or a read error with no findings | fail the job as an infrastructure fault, not a content fault |
|
||||
|
||||
The token is the machine-readable line; the exit code is the interface. Keeping them separate
|
||||
matters because a wrapper that only greps stdout cannot distinguish "clean" from "the scanner
|
||||
never ran", and a wrapper that only checks `$?` cannot explain itself in a log.
|
||||
|
||||
Which findings fail the gate is chosen by `--fail-on`, and the two settings are two different
|
||||
questions:
|
||||
|
||||
- `--fail-on strong` (default) — only `strong`/`certain` findings fail. This is the maintenance
|
||||
habit's setting, and it exists because of the RFC 5737 decision above: if the documented ranges
|
||||
are reported on purpose, this repository is never `clean`, and a gate that is red on its own
|
||||
rationale is a gate people learn to disable.
|
||||
- `--fail-on any` — every finding fails, `weak` ones included. This is the publish-time setting,
|
||||
and the one to run against the file you are about to publish rather than the tree you are
|
||||
working in.
|
||||
|
||||
Neither setting changes what is *reported*: weak findings are printed under both, and only the
|
||||
exit code differs. The strict pass is therefore a decision recorded in a command, not a surprise.
|
||||
|
||||
Placement, and the reasoning is about *when* the wrong answer is cheap:
|
||||
|
||||
- **Not in the pre-commit hook alone.** A commit-time gate is the cheapest place to catch a
|
||||
leak, but this repository's evidence files are written by long passes that commit once at the
|
||||
end; a hook would fire on a tree nobody has finished writing.
|
||||
- **In the maintenance gate, next to the other two checkers.** The habit that already exists is
|
||||
`python check_repo.py && python check_refs.py` before a commit; a third line costs nothing and
|
||||
runs at the moment the material is about to become permanent. Its exit 1 is a *content*
|
||||
finding to read, not an error to retry — unlike the two checkers, whose failures are
|
||||
mechanical.
|
||||
- **Before publishing an evidence file**, once, by hand, with `--show-exempt`. The exemption
|
||||
audit is where the interesting mistakes are: a value suppressed for the wrong reason looks
|
||||
exactly like a value that was never there.
|
||||
- **On the report itself, after it is written and before it is published.** A report that quotes
|
||||
the hits in order to be concrete is a *copy* of the leak, in a file that is tracked and read.
|
||||
This is not hypothetical here: the first version of the desensitization extension record did
|
||||
exactly that, and the scanner flagged its own evidence file on the next run. Run the scan on
|
||||
the artifact you are about to ship, which for an evidence file means the file itself.
|
||||
- **In CI, on the published tree only.** A scanner run over a git-ignored work area will report
|
||||
the work area — which is where the target identity legitimately lives — and a gate that cries
|
||||
wolf on every run is a gate that gets disabled. `tools/` is excluded by default for exactly
|
||||
this reason; a caller who points `--root` at it means it, and gets the findings.
|
||||
|
||||
**What the scan does not replace.** It cannot see identity that is *described* rather than
|
||||
*quoted* ("a mid-size Flutter app with a 360 shell"), and it cannot see a value that has been
|
||||
partially masked on purpose. Neither is a defect to fix by adding patterns: the first is why
|
||||
this repository's evidence files open with a statement that target identity is absent, and the
|
||||
second is a judgement call a human makes once and writes down.
|
||||
|
||||
## Failure modes
|
||||
|
||||
| Symptom | Mechanism | What to do |
|
||||
|---|---|---|
|
||||
| The scanner reports a hundred hits, none of them a leak | shape-only rules against a document full of legitimate identifiers (versions, hashes, symbol names) | Do not widen the exemption list one hit at a time. Run `--show-exempt`, group the reasons, and fix the *rule* — a context-free regex is the bug |
|
||||
| The scanner reports nothing and you believe it | a root that excluded the directory the leak was in; a text file with an extension the walker skips; a value inside a fenced block the reader never scrolled to | Read the header line — it prints the file count and the root. Then point `--root` at one directory and confirm the count is plausible |
|
||||
| A real leak was suppressed | an exemption matched a substring (`<pkg>` inside something else), or the benign-prefix list is too broad (`com.`-prefixed platform list catching a target that starts with the same two segments) | `--show-exempt` prints the *reason* per suppressed hit. An empty or vague reason is the defect |
|
||||
| Same hit reported twice on one line | two rules whose scopes overlap (`SECRET_KEY=` is both a token and an appkey) | Expected, and left in deliberately: two independent rules naming one value is corroboration. Exact duplicates (same rule, same value, same line) are collapsed |
|
||||
| The gate is green in CI and the leak is in the tarball | CI scans the tree; the artifact was built from a work area, or from a branch that was never scanned | Scan the directory the artifact is built from, with the same exclusions the build uses |
|
||||
| A `<PKG>`-style placeholder is reported | the placeholder uses a different bracket, or no brackets at all | Add the token to `PLACEHOLDER_TOKENS`. This is the one exemption class that should grow freely — placeholders cannot leak |
|
||||
| The published report contains the leak it was written about | the report quotes each hit value in order to be concrete, and the report is the published artifact — a report is a copy of what it found | Never quote a hit value in a published document. Keep rule, category, strength, position and count, and describe the value by shape (`<reverse-domain>.<n>`, `16×[A-Z0-9]`, `24×[0-9a-f]`) or with a placeholder. Scan the file you are about to publish, not the file you started from |
|
||||
|
||||
## The entry-point surface
|
||||
|
||||
A published skill is not only read by an agent; its **entry files are read as instructions**.
|
||||
`SKILL.md`, a `README.md`, and the front-matter of any reference are the parts a host process
|
||||
loads *before* it decides anything, and a repository assembled from third-party material — a
|
||||
borrowed reference file, a contributed script — carries whatever those files say. This
|
||||
repository's convention, observed in its own history: content reproduced from elsewhere is
|
||||
summarised as prose with the source named, never quoted verbatim in a way that would land in an
|
||||
instruction position, and a contributed file is read before it is linked.
|
||||
|
||||
The practical consequence for redaction is the same as for the rest of this file: **the
|
||||
redaction must not be reversible by asking the file.** A placeholder that is defined next to
|
||||
its own value, a "sample transcript" that still carries the serial in a comment, or a finding
|
||||
report pasted into a document that later becomes an entry point all reintroduce the identity
|
||||
that a scanner would have caught — because the scanner ran before the report was written. Run
|
||||
the scan on the file you are about to publish, not on the file you started from.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- `references/long-task-discipline.md` §Keep a live record, not a log — the record is written
|
||||
during the work, so it is where identity lands first, and the file most likely to be published
|
||||
by accident.
|
||||
- `references/verification.md` §Reporting — the evidence/report split this file mirrors: what
|
||||
ships is neutral and mechanical, what identifies the target stays in the analysis.
|
||||
- `references/precedents/README.md` — the case library; its "write back to the repository"
|
||||
checklist is where a desensitization finding becomes an index line.
|
||||
- `references/third-party-builds.md` — the same do-not-anonymize reasoning applied to a sample
|
||||
you did not produce, where the identity is someone else's.
|
||||
@@ -0,0 +1,349 @@
|
||||
# Detection, anti-analysis, and when to stop fighting it
|
||||
|
||||
Load this when the app fights back: it dies or misbehaves after you attach, refuses to run on your
|
||||
device, detects root/emulator/hook/debugger, or when your dynamic tool simply will not work on the
|
||||
environment you have.
|
||||
|
||||
This file is a **decision** file, not a bypass catalogue. The expensive mistake it exists to prevent is
|
||||
spending hours escalating against a detection layer when a cheaper route — usually static — was
|
||||
available the whole time.
|
||||
|
||||
## Step 0: the cheapest decision in this file
|
||||
|
||||
**Dynamic analysis is a convenience, not a prerequisite.** Everything that ends in an installable
|
||||
artifact is decided statically: you edit a file and repackage. Dynamic work is how you *find* things,
|
||||
and it is often the fastest way — but when it is blocked or unavailable, the correct move is usually to
|
||||
switch to static, not to escalate.
|
||||
|
||||
So before doing anything clever, ask:
|
||||
|
||||
1. Is the thing I still need to learn actually discoverable statically? (Strings, code structure,
|
||||
call sites, comparisons — usually yes.)
|
||||
2. Do I need the process to run *to verify*, or only to *find*? Verification can often be done by
|
||||
observing the shipped artifact's behaviour, which a hostile process still exposes.
|
||||
|
||||
If either answer allows a static path, take it and stop reading this file.
|
||||
|
||||
## Step 1: is it detection, or is it your environment?
|
||||
|
||||
Detection has a distinctive shape: **it is reproducible, tied to your instrumentation, and absent in the
|
||||
same app run without it.** Everything else that looks like detection is usually one of these:
|
||||
|
||||
| Looks like detection | Usually is | How to tell |
|
||||
|---|---|---|
|
||||
| app dies right after attach | hook timing, a bad script, or a genuine RASP layer | run the same app with **no** hooks: alive? then it is your instrumentation or a hooking detector |
|
||||
| app refuses to start at all | ABI mismatch, missing native lib for this device, install problem | check the live mapping (`scripts/lib_map.py`) and that a clean unmodified build starts |
|
||||
| app exits on this device only | emulator/root detection, **or** a device-state problem | run preflight; try the same build on a different device class |
|
||||
| app hangs forever | a frozen thread from a bad patch, or a real ANR | `pitfalls.md` P7 and the "never make it not return" rule |
|
||||
| dynamic tool "cannot attach" | **the tool cannot work on this environment at all** | see Step 4 — this is the one people waste days on |
|
||||
|
||||
**Rule: reproduce with the control first.** Instrumentation-free run, then instrumented run, same
|
||||
build. That single comparison separates "the app detects me" from "my tooling is broken", and it takes
|
||||
one round.
|
||||
|
||||
## Step 2: if it *is* detection, decide by cost, not by pride
|
||||
|
||||
Three legitimate outcomes. Pick one deliberately and say which you picked.
|
||||
|
||||
**A. Work around it — only when the workaround is small and stable.**
|
||||
Worth it when the detector is a simple, localised check you can neutralise in one place, and the
|
||||
workaround lives in *your* environment rather than in the shipped artifact. Examples of the class:
|
||||
running on a device the app does not object to, using a build that satisfies the check, or a single
|
||||
one-line gate in a helper. Also legitimate: naming the artefact differently, using a different device
|
||||
profile, or simply doing the work on a device that the app accepts.
|
||||
|
||||
**B. Change route.** When the workaround is a moving target, switch to static analysis and stay there.
|
||||
This is the right answer more often than it feels like. Hooking-detection especially tends to escalate:
|
||||
each hide provokes a stronger check, and you end up maintaining a cat-and-mouse setup that is worth
|
||||
nothing at delivery time.
|
||||
|
||||
**C. Accept and report.** If the detection blocks the *deliverable* rather than your analysis — for
|
||||
example, the app verifies something the user's device will not have — then the honest output is a
|
||||
statement of what is blocked, with the evidence. Do not ship a build whose only purpose is to defeat a
|
||||
detector you were not asked about.
|
||||
|
||||
**Signal to stop escalating:** you have spent more effort on the analysis environment than on the
|
||||
change the user asked for. That is the drift this skill treats as most expensive, in its runtime form
|
||||
(`long-task-discipline.md` §the most expensive drift).
|
||||
|
||||
## Step 3: locating the check — the order of search
|
||||
|
||||
Step 2 decides *whether* to fight. This step is the part the two steps around it do not provide:
|
||||
**once you know a check fired, in what order do you look for it?** The order matters because the
|
||||
cheap probes answer most cases and the expensive ones only pay off after a cheap probe has already
|
||||
narrowed the field. Every stage below names its input signal, the smallest action that answers it,
|
||||
the evidence that makes it true rather than plausible, and where to go when it does not.
|
||||
|
||||
Two shaping notes before the stages.
|
||||
|
||||
**Order is the whole content here.** Reading this as a menu and starting at the middle is how a
|
||||
session becomes an arms race: each probe you add changes the process further, so the later probes are
|
||||
measuring a target you already modified. Do them in order and stop as soon as one answers.
|
||||
|
||||
- **Stage order is a funnel, not a checklist.** Stage 1 is five minutes and rules out whole classes of
|
||||
problem; Stage 5 requires the offsets Stage 2 produced. Skipping forward costs more than it saves.
|
||||
- **Every stage ends in `observed` or in a stated negative.** "No hook fired" is only a fact once you
|
||||
have shown the hook can fire on this target (`pitfalls.md` P25/P26 in tool form).
|
||||
|
||||
**Strength note.** The stages are a restatement, for judgement purposes, of a six-phase
|
||||
anti-instrumentation pipeline published in `index-login/MobileRE-Skill`
|
||||
(`https://github.com/index-login/MobileRE-Skill`, retrieved 2026-09). That project's phase *modules*
|
||||
are not vendored here and none of its scripts are used; what is taken is the **order** and the
|
||||
branch conditions, which are the parts that transfer. Stages 2, 3, 5 and 6 are **observed** as
|
||||
mechanisms on this repository's own device (evidence and exact commands:
|
||||
`references/evidence-summary.md` §The capability matrix). Stage 4's kill path was reached once and
|
||||
is unstable across runs, so treat its detail there as `observed`-once, not reproducible.
|
||||
Stage 1 is `observed` and is the one that most often ends the investigation without an escalation.
|
||||
|
||||
### Stage 0: rule out the two non-detection causes first
|
||||
|
||||
Before hooking anything: is the process alive at all, in what state, and does it die the same way
|
||||
with nothing attached?
|
||||
|
||||
```bash
|
||||
adb shell 'cat /proc/<pid>/status | head -4' # state matters: D is uninterruptible sleep
|
||||
adb shell 'cat /proc/<pid>/stat | cut -d" " -f3'
|
||||
```
|
||||
|
||||
**Observed failure that looks exactly like detection:** a target in **`D` (uninterruptible disk
|
||||
sleep)** makes an attach hang and then fail, and makes a second attach report *"process not found"* —
|
||||
while the process is still in `ps`. No detector is involved; a userspace attach needs the process to
|
||||
run. On the measured run this was the actual cause, and it was read as "the app refuses
|
||||
instrumentation" for one round. Check state before you blame the target, and check **whether attach
|
||||
works on any other process on the same device** — that one-line control separates a broken toolchain
|
||||
from a hostile target in a single command.
|
||||
|
||||
### Stage 1: a benign probe, to establish that your pipeline can fire at all
|
||||
|
||||
**Input signal:** nothing yet. **Action:** load an *observer-only* probe
|
||||
(`scripts/anti_detect_probe.js`) that patches nothing. **Evidence it worked:** the probe's own
|
||||
`armed` line and its environment self-report — `TracerPid`, which of your artefacts are visible in
|
||||
the process's own maps, whether it can read `/proc/net/tcp` at all. **If nothing arrives:** the
|
||||
problem is plumbing or liveness, not detection. Re-read Stage 0; do not escalate.
|
||||
|
||||
**The self-report is the cheapest half of this file's whole subject, and it is usually skipped.**
|
||||
Before asking "does it detect frida", ask "is frida visible to this process". Measured on the test
|
||||
device, a probe spawned into a target read 4 frida-named mappings in the target's own
|
||||
`/proc/self/maps` before any check ran — so "was it detectable" was never in doubt, and the
|
||||
remaining question was only which check reads it.
|
||||
|
||||
**Stream, do not batch, on a target that self-destructs.** A probe that reports only at the end of a
|
||||
window reports *nothing* when the target dies inside the window — precisely the run that mattered.
|
||||
Measured: three spawn-and-probe arms of one target; one delivered a full event sequence ending in a
|
||||
self-destruct, the next two lost the script before its first timer fired. Emit a heartbeat and stream
|
||||
each first hit. The heartbeat's absence at time *T* is itself the datum: it separates "the target was
|
||||
quiet" from "the script was gone before *T*".
|
||||
|
||||
### Stage 2: name the detector, by caller offset
|
||||
|
||||
**Input signal:** Stage 1's environment events. **Action:** hook the path-access and process-control
|
||||
surface (`open`/`openat`/`fopen`/`access`/`stat`/`readlink`, `dlopen`/`dlsym`, `pthread_create`,
|
||||
`kill`/`tgkill`/`exit*`) and report, for each call, the **caller module + offset**. **Evidence:**
|
||||
`libX.so+0x1cef8` — a named module and an address. **If no caller resolves:** you are on a runtime
|
||||
that cannot unwind here; record the module and move to Stage 6's static path instead of adding a
|
||||
third unwinding strategy.
|
||||
|
||||
This stage's product is a **name**, and everything after it is cheaper. Two branch conditions decide
|
||||
which Stage 4 branch you take, so they are worth reading off explicitly:
|
||||
|
||||
| What Stage 2 shows | Branch | Why |
|
||||
|---|---|---|
|
||||
| the check runs on a thread the detector created itself (`pthread_create`/`clone` caller is the detector module) | **block at creation** | the polling loop never exists to fire |
|
||||
| the check runs inline inside a library constructor, and the calls you can see are only libc wrappers | **Stage 3** | under that ordering a libc hook is the wrong layer |
|
||||
| the detector is a *shell* library that also decrypts | **do not patch broadly** | the same module carries load-bearing work; scope to named offsets |
|
||||
|
||||
### Stage 3: if it fires before your hooks exist, take the constructor order
|
||||
|
||||
**Input signal:** the death happens before or during first library load, or Stage 2's caller offsets
|
||||
all sit in a constructor path. **Action:** find the constructor dispatcher and hook it *before*
|
||||
constructors run. **Evidence:** the module's `init_array` entries are visible and their addresses
|
||||
map to the checks. **If the device's linker does not expose the symbol:** switch to the static route
|
||||
(`scripts/svc_scan.py` on that library) rather than guessing an address.
|
||||
|
||||
```bash
|
||||
# the symbol name is mangled; grep for the substring, not the bare name
|
||||
readelf -sW <linker64> | grep call_constructors
|
||||
```
|
||||
|
||||
**Measured:** on this repository's device the symbol exists as
|
||||
`__dl__ZN6soinfo17call_constructorsEv` at `0x4a258` — a **mangled** name. A search for a bare
|
||||
`call_constructors` export reports "absent" and sends the analyst to an offset-guessing path that
|
||||
was never necessary. Confirm presence/absence before building either route.
|
||||
|
||||
**The measured cost of getting this stage wrong:** do not clear a module's whole `init_array`. A
|
||||
shell library's constructor does payload decryption as well as detection; emptying it converts a
|
||||
detectable app into a non-running one, which is a worse result than the detection was. Scope to
|
||||
functions you have a reason to suspect — a per-function static scan for the syscall form in Stage 6
|
||||
is how you get that reason.
|
||||
|
||||
### Stage 4: keep the process alive — deliberately, and with a stop condition
|
||||
|
||||
**Input signal:** every attempt shows events up to a point and then the process is gone. **Action:**
|
||||
make the termination observable (record path and caller) and, if necessary, prevent it. **Evidence:**
|
||||
the death is attributed to a module and an offset, not to "the platform". **If attributing it costs
|
||||
more than three arms, stop attributing and switch to static.**
|
||||
|
||||
Two measured shapes, and they need opposite treatment:
|
||||
|
||||
- **A libc-visible kill.** Measured on a clean MASTI-style target: a probe saw
|
||||
`strstr("frida")` at ~300 ms and then the process was gone, with **no** tombstone, no crash and no
|
||||
ANR record — a clean self-exit. The abort path itself surfaced once as a libc `raise`/signal call;
|
||||
it did not surface on the later arms that died sooner. That instability is the finding: **on a
|
||||
fast detector the observable window is sub-second and not reproducible**, so a single successful
|
||||
attribution is not a pipeline.
|
||||
- **A libc-invisible kill.** If a module issues `svc #0` itself, no libc-level hook can see it, and
|
||||
the absence of an event beside a death is the signal to go static (Stage 6).
|
||||
|
||||
**`exit_blocker`-style keepalive has a failure mode that looks like success.** Blocking the exit
|
||||
turns a clean death into a spinner: the process stays alive, stops responding, and you can no longer
|
||||
tell "the check is neutralised" from "the app is wedged". Keep the blocked-call count, a timeout, and
|
||||
one behavioural check — does the UI still reach the state you care about — or the keepalive is
|
||||
measuring itself.
|
||||
|
||||
### Stage 5: shellcode and runtime-generated code
|
||||
|
||||
**Input signal:** Stage 2's loader stream shows executable mappings appear that were never loaded
|
||||
from a file. **Action:** watch allocation with execute permission and read-only-executable
|
||||
transitions; disassemble around the allocation's caller. **Evidence:** an `mmap`/`mprotect` call with
|
||||
execute permission whose caller offset is in the detector module. **If the region is unreadable:**
|
||||
stop; the offsets from Stage 2 are all a static session needs.
|
||||
|
||||
A target can make itself harder to look at without doing anything exotic: on one measured run the
|
||||
recovered caller chain sat inside `jit-cache`, i.e. **Java code the runtime had JIT-compiled**, not a
|
||||
shipped library. A crash frame in JIT output cannot be mapped to a file offset. Expect it on any
|
||||
API where the framework compiles reflection at runtime, and treat "the frame has no file" as a fact
|
||||
about compilation, not as a hidden module.
|
||||
|
||||
### Stage 6: the precise stop, and the static alternative to it
|
||||
|
||||
**Input signal:** a named module + offset from Stage 2, Stage 3 or Stage 5. **Action:** neutralise
|
||||
that site — or decide not to. **Evidence:** a fresh control run with the site left alone dies where
|
||||
it died before, and the patched run does not. **If the offset came from a trace you could not
|
||||
reproduce:** do not patch it; go to the static route below.
|
||||
|
||||
The static route is often strictly better than the dynamic one and needs no live process:
|
||||
|
||||
```bash
|
||||
python scripts/svc_scan.py /data/local/tmp/libDetect.so
|
||||
```
|
||||
|
||||
**Measured discriminator.** A module that contains **no** inline `svc` site for a termination syscall
|
||||
cannot be bypassing libc for its kill — so a missing exit event is then a finding about your hook,
|
||||
not about the target. Measured on this device: the ROM's `libc.so` carries exactly 4 termination
|
||||
sites (`exit`, `exit_group`, `kill`, `tgkill`), and those are libc's *own* exported implementations —
|
||||
so hooking `exit`/`kill` really does see callers that go through libc. A shell library on the other
|
||||
hand showed 21 byte-scan "svc sites" that were all **data**, with no call-number load anywhere near
|
||||
them: a byte scan for `svc` matches inside data, so read the neighbours before believing a count.
|
||||
That distinction — structural absence vs. observed absence — is what Stage 6 is for.
|
||||
|
||||
### The routing chain, on one page
|
||||
|
||||
```
|
||||
Stage 0 process state + does attach work on ANY process? -> D-state / broken toolchain, not detection
|
||||
Stage 1 observer-only probe + environment self-report -> what the target can see
|
||||
Stage 2 path/loader/thread/kill hooks with caller offsets -> a module+offset name
|
||||
|-- caller is a created thread -> block at creation (Stage 4, branch A)
|
||||
|-- death precedes hooks, or the checks are in a loader ctor -> Stage 3, or static (Stage 6)
|
||||
Stage 3 constructor order / mangled symbol check -> the check is reachable at all
|
||||
Stage 4 attribute the kill, then decide whether to block it -> attributed death, or "unreproducible"
|
||||
Stage 5 executable mappings not backed by a file -> shellcode / JIT, often a dead end
|
||||
Stage 6 NOP one site, or prove statically that libc is enough -> observed before/after
|
||||
```
|
||||
|
||||
## Step 2B: two boundaries this file adopts
|
||||
|
||||
**(a) Observers and interceptors are separate modules and stay separate.** An observation module must
|
||||
not change behaviour, and an intervention module must not double as a monitor. This is the discipline
|
||||
the pipeline above is built to survive: Stage 1's value is that its output describes the *original*
|
||||
process, and the moment the same script also blocks an exit or NOPs a function, every later
|
||||
observation belongs to a target that no longer exists. The practical version: a probe that watches
|
||||
and a patch that neutralises are two files, loaded in two runs, and the runs are the experiment.
|
||||
`scripts/anti_detect_probe.js` is the observer half and patches nothing by contract.
|
||||
|
||||
Be honest about the leak in that contract: **hooking is itself observable.** Attaching changes
|
||||
timing, and on a hardened target the first event the probe recorded was a `dlopen` of the target's
|
||||
own code followed within ~50 ms by a check — i.e. the act of being watched is part of the
|
||||
environment the target is reacting to. Separation makes runs comparable to each other; it does not
|
||||
make them a description of the untouched process.
|
||||
|
||||
**(b) Name the cost before you add another module, and know when to leave.** Each avoidance module
|
||||
is another thing the target can notice and another reason a later result is unattributable, and the
|
||||
detection is not standing still: a check that was a string compare becomes an inline syscall becomes
|
||||
a native check in a module you have not looked at. Track three numbers, and stop when two of them
|
||||
are true: how many bypass modules are loaded, whether the run needs a keepalive to stay up at all,
|
||||
and whether the target only behaves under the full stack of modules. **When they are true, switch to
|
||||
static** — that is the same conclusion as Step 2B, reached with a threshold instead of a judgement
|
||||
call, and it is the exit this whole step needs so that Stage 6 does not become a permanent home.
|
||||
|
||||
## Step 4: when the tool cannot work here at all
|
||||
|
||||
Some environments cannot run a given dynamic tool, and no amount of trying will change that. Recognise
|
||||
it and move on — this is a *finding about the environment*, and recording it prevents the next person
|
||||
from repeating the attempt.
|
||||
|
||||
**The instructive class: an emulator that executes a different architecture than it reports.** A device
|
||||
may advertise one ABI while the process runs libraries of another through a translation layer. This
|
||||
shows up as a tool that reports the device is fine, then fails to attach, attach-and-die, or attach and
|
||||
see nothing. Symptoms worth treating as a hard signal:
|
||||
|
||||
- the tool's own attach path fails with an error that mentions tracer, ptrace, or a debugger — on an
|
||||
environment where translation is in play;
|
||||
- attaching kills the process immediately, repeatedly, with no Java stack;
|
||||
- the tool attaches but **no** hook ever fires, including hooks on code you know runs.
|
||||
|
||||
**Do not spend the session proving it is impossible.** Establish it once, with one clean reproduction,
|
||||
write the environment fact down, and switch to static.
|
||||
|
||||
**What to do instead:** the entire static toolchain is architecture-agnostic — string tables, code
|
||||
structure, call-site counts, byte-level patching. None of it needs the app to run. If your verification
|
||||
also needed the app to run, use the device-level observables instead: does the UI change, do the logs
|
||||
fall silent, does a file appear or stop appearing. Those are properties of the shipped artifact, and they
|
||||
do not care that you could not attach.
|
||||
|
||||
## Step 5: what a detection layer means for the deliverable
|
||||
|
||||
Two distinct questions — keep them separate, because conflating them produces either a broken artifact
|
||||
or an unnecessary retreat.
|
||||
|
||||
1. **Does it block your analysis?** → handle with Step 2/3/4. This is your problem, and it is temporary.
|
||||
2. **Will it block the patched build on the user's device?** → this is a property of the artifact. A
|
||||
root check that merely requires an unrooted device is usually irrelevant to a repackaged APK. A
|
||||
**self-integrity or signature check** is a different matter entirely, and it belongs to
|
||||
`references/native-tamper-and-suicide.md` and `references/signature-derived-keys.md`.
|
||||
|
||||
Ask explicitly: *does this check fire because of how the app is built, or because of where I am running
|
||||
it?* Only the first kind follows your build into delivery.
|
||||
|
||||
**Root is an environment fact, not a defeat.** "Runs only on a rooted device" is a legitimate analysis
|
||||
environment and an illegitimate deliverable when the request was an installable build for a normal phone.
|
||||
Say which one you actually have.
|
||||
|
||||
## Step 6: keep a one-line environment fact
|
||||
|
||||
When you conclude that something cannot be done in this environment, write one line in the task record:
|
||||
|
||||
```
|
||||
cannot: <tool/technique> in <environment> because: <observed failure> route taken: <alternative>
|
||||
```
|
||||
|
||||
That single line is what saves a future round. It is also the difference between "we could not attach,
|
||||
so we did X" and an unqualified "dynamic analysis is impossible", which is a claim you have not earned
|
||||
and which will mislead the next reader.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] **Located before escalating:** Stage 0 state check, an attach control on another process, then the
|
||||
stages in order — and stopped at the first stage that answered
|
||||
- [ ] **Observer and interceptor are not in the same script**, and the observation run was kept free of
|
||||
any patch
|
||||
- [ ] Detector named as **module+offset**, or the negative stated ("no caller resolvable on this runtime")
|
||||
- [ ] **Cost measured, not felt:** bypass-module count, whether a keepalive is required, whether the
|
||||
target only runs under the full stack — two of three true means switch to static
|
||||
- [ ] Controlled the comparison: same build with and without instrumentation
|
||||
- [ ] Preflight run, so device state is excluded before blaming a detector
|
||||
- [ ] Classified: detection vs environment vs my own tooling
|
||||
- [ ] Chose A/B/C deliberately and can say which, and why
|
||||
- [ ] If the tool cannot work here: established it **once**, recorded the fact, switched route
|
||||
- [ ] Separated "blocks my analysis" from "blocks the deliverable"
|
||||
- [ ] Nothing in the shipped artifact exists solely to fool a detector
|
||||
- [ ] Recorded the one-line environment fact if a route was closed
|
||||
@@ -0,0 +1,204 @@
|
||||
# Dex patching — choosing and executing the surgical edit
|
||||
|
||||
The single most important decision in a patch task. Get this wrong and you produce an APK that assembles perfectly and dies at runtime.
|
||||
|
||||
|
||||
**Load this when:** you have located the class and method to change and must choose the edit technique. It gives the least-destructive-first order and the signal that says a byte patch is not enough.
|
||||
|
||||
## Pick the least destructive technique that can express your change
|
||||
|
||||
Ordered from safest to most dangerous.
|
||||
|
||||
| # | Technique | Expresses | Structure risk | Use when |
|
||||
|---|---|---|---|---|
|
||||
| 1 | **dexlib2 method-level rewrite** | Replace a method's implementation (return a constant, no-op, emit a fixed value) | **Lowest** — only the target `code_item` changes | Default choice. Any behavior change that can be expressed as "this method now does X instead" |
|
||||
| 2 | **Byte-level string constant patch** | Change a string literal to another of **equal length** | Medium — must preserve `string_ids` ordering (see P2) | Renaming a path/key/protocol token |
|
||||
| 3 | **Resource / asset edit** | Change a config file, JSON, image, or bundled jar | Low (no dex change) | The behavior is data-driven |
|
||||
| 4 | **Manifest edit** | Disable a component, drop a permission, flip a flag | Low, but re-signing required | Turning off a service/receiver/activity |
|
||||
| 5 | **Whole-tree smali round-trip** | Arbitrary code edits | **High** — breaks R8 output (see P3) | Last resort, and verify by running |
|
||||
|
||||
**Do not reach for #5 because it is familiar.** It is the most likely to produce a broken build.
|
||||
|
||||
## Where to patch: pick the layer, not the symptom
|
||||
|
||||
Ads and gates can be suppressed at several layers. Higher in this list = smaller blast radius.
|
||||
|
||||
| Layer | What it looks like | Example | Risk |
|
||||
|---|---|---|---|
|
||||
| **SDK initialization** | A single helper that calls `Sdk.init()` | `AdHelper.k(Application)` | Low — SDK never starts, nothing else depends on it |
|
||||
| **Feature entry point** | The app method that shows the thing | `AdHelper.showSplash()`, `showReward()` | Low–medium — must preserve callbacks the caller awaits |
|
||||
| **Data consumption** | Where a server list is filtered/rendered | filter out a `position` before it reaches UI state | Medium — must not break sibling data |
|
||||
| **Renderer** | The composable/view for a **specific** element | a dedicated ad card composable | **High** — generic components are shared (see P6) |
|
||||
| **Transport** | Blocking/repointing an endpoint | `/app/adverts` → dead path | **Very high** — shared endpoints take screens down (see P5) |
|
||||
|
||||
**Rule:** prefer the highest layer (closest to the SDK/feature) that produces the required effect. Never go to the transport layer to hide a UI element.
|
||||
|
||||
## Before you patch a method: blast-radius check
|
||||
|
||||
```bash
|
||||
python scripts/find_refs.py <smali_tree|dex|dir|apk> 'Lcom/pkg/Helper;->methodName(args)RetType'
|
||||
```
|
||||
|
||||
Read the `[scanned]` line before the count. It reports how many inputs were actually opened, and
|
||||
`[scanned] 0 file(s)` exits 2 with the reason: an unsupported path, a typo, or a directory of archives
|
||||
is **not** the same answer as "read 2 dex files and found nothing", and the two once printed
|
||||
identically — which put a false zero on exactly the decision this check exists to protect. A dex input
|
||||
is decoded directly (`dexutil.py`), so baksmali is not required.
|
||||
|
||||
- **1–3 callers, all in the same feature area** → safe to patch.
|
||||
- **Many callers, or callers across unrelated packages** → it is a general utility. Do not patch it. Go one level up and patch the specific caller instead.
|
||||
- **Look at the parameters too.** If the signature mentions `Modifier`, `ContentScale`, `ColorFormat`, `Shape`, `View`, or a content-generic model type, it is not specific to your target.
|
||||
|
||||
Real example: a composable `(AdModel, ColorScheme, Modifier, Function0, ContentScale, Shape, Composer, II)V` looked ad-specific because of its first parameter. It was actually the shared image-card renderer; neutering it removed all cover art and broke playback.
|
||||
|
||||
## Technique 1: dexlib2 method-level rewrite (preferred)
|
||||
|
||||
Concept: load the dex, find the target class, replace **only** the target method's implementation, write a new dex. Nothing else is touched.
|
||||
|
||||
See `scripts/dexpatch/` for a working Java implementation and build instructions.
|
||||
|
||||
Shape of the code:
|
||||
|
||||
```java
|
||||
DexFile dex = DexFileFactory.loadDexFile(new File(in), Opcodes.forApi(API));
|
||||
List<ClassDef> out = new ArrayList<>();
|
||||
for (ClassDef cd : dex.getClasses()) {
|
||||
List<Method> direct = new ArrayList<>(); for (Method m : cd.getDirectMethods()) direct.add(m);
|
||||
List<Method> virtual = new ArrayList<>(); for (Method m : cd.getVirtualMethods()) virtual.add(m);
|
||||
boolean touched = false;
|
||||
// ... for the target class: find the method, build new instructions, list.set(i, newMethod)
|
||||
out.add(touched
|
||||
? new ImmutableClassDef(cd.getType(), cd.getAccessFlags(), cd.getSuperclass(), cd.getInterfaces(),
|
||||
cd.getSourceFile(), cd.getAnnotations(), cd.getStaticFields(),
|
||||
cd.getInstanceFields(), direct, virtual)
|
||||
: cd);
|
||||
}
|
||||
DexFileFactory.writeDexFile(out, new ImmutableDexFile(Opcodes.forApi(API), out));
|
||||
```
|
||||
|
||||
### Practical details that decide success
|
||||
|
||||
- **`direct` vs `virtual`.** `static`, `private`, and constructors live in `directMethods`; everything else (including `public final`) lives in `virtualMethods`. **Scan both** when searching. A "method not found" that is actually "in the other list" wastes a lot of time.
|
||||
- **Preserve the register count.** Keep `m.getImplementation().getRegisterCount()`. `registers` must be `>= parameterRegisters`, and parameter registers are the **highest-numbered** ones: with `registers = N` and `k` parameters (including `this` for non-static), `p0 = N - k`.
|
||||
- **Method parameters** come from the prototype. For `(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;` on an instance method, `k = 3` → `p0=3, p1=4, p2=5` when `registers = 6`.
|
||||
- **`const-wide` needs the 64-bit instruction form.** `ImmutableInstruction51l`, not `31i`.
|
||||
- **Instruction register operands in `35c` form** (invoke) are `(opcode, registerCount, regC, regD, regE, regF, regG, ref)`. `invoke-static {v1, v2}, ...` → `registerCount=2, regC=1, regD=2`.
|
||||
- **Every method body must end with a return of the right type.**
|
||||
- **One dex, one write pass.** If you need two edits to the same dex, do them in the same program (see P4).
|
||||
- **Null `tryBlocks` / `debugItems` on the rewritten method is fine** — you are replacing the whole body.
|
||||
|
||||
### Common rewrites
|
||||
|
||||
**Return a boolean constant** (`Z`):
|
||||
```
|
||||
const/4 v0, 0x1
|
||||
return v0
|
||||
```
|
||||
`registers` = original count is fine; `const/4` needs a 4-bit register (`v0`–`v15`).
|
||||
|
||||
**No-op** (`V`):
|
||||
```
|
||||
return-void
|
||||
```
|
||||
|
||||
**Return an object/empty collection**: build the constant, then `return-object`. For `java.lang.Long`, `const-wide` + `Long->valueOf(J)` + `move-result-object`.
|
||||
|
||||
**Coerce a suspend/Flow lambda to always yield a fixed value** — this is what "make a local flag permanently true" usually looks like in practice. See the worked example below.
|
||||
|
||||
### Worked example: permanently "not expired" without touching a shared helper
|
||||
|
||||
**Situation.** A local preference (`<key>_expires_at`) gates a promo popup. Writing a large value into the datastore works on the current device but is **runtime data**, so a fresh install loses it (see P11). The read path is a Flow built at construction time, so the getter cannot be patched meaningfully, and the generic `Long.valueOf` wrapper has 30+ callers (see P6).
|
||||
|
||||
**Target.** The dedicated map lambda, e.g. `feature/data/SomeStore$currentX$$inlined$map$1$2`:
|
||||
|
||||
```
|
||||
.method public final emit(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
|
||||
iget-object v0, p0, L<same class>;->b:Lkw2; # downstream collector
|
||||
const-wide v1, <big value>
|
||||
invoke-static {v1, v2}, Ljava/lang/Long;->valueOf(J)Ljava/lang/Long;
|
||||
move-result-object v1
|
||||
invoke-interface {v0, v1, p2}, Lkw2;->emit(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
|
||||
move-result-object v0
|
||||
return-object v0
|
||||
.end method
|
||||
```
|
||||
|
||||
Note the shape: the lambda class holds the downstream collector in a field (here `b:Lkw2;`). Always read that field name from the actual smali — do not assume.
|
||||
|
||||
This class has exactly one purpose, so patching it is safe.
|
||||
|
||||
## Technique 2: byte-level string patch
|
||||
|
||||
Only for **equal-length** replacements. Must preserve `string_ids` ordering.
|
||||
|
||||
```bash
|
||||
python scripts/dex_strpatch.py <in.dex> <out.dex> "<old>" "<new>"
|
||||
```
|
||||
|
||||
The script:
|
||||
1. requires `len(new) == len(old)`,
|
||||
2. requires exactly one occurrence,
|
||||
3. looks up the entry's neighbours in `string_ids` and **rejects** the replacement unless `prev < new < next`,
|
||||
4. recomputes `signature` (SHA-1 over bytes from offset 32) and `checksum` (adler32 over bytes from offset 12).
|
||||
|
||||
If a candidate is rejected, try another string that sorts inside the same interval. `/app/noadver` was rejected (`n > c` against neighbour `/app/configs/`); `/app/blocked` was accepted.
|
||||
|
||||
**Do not** use this technique to change behavior. Strings are renamed, not logic.
|
||||
|
||||
## Technique 3: smali tree editing (only when you must)
|
||||
|
||||
Use when the change cannot be expressed as a single-method rewrite (adding a field, restructuring control flow).
|
||||
|
||||
Tools: `scripts/smtool.py` (baksmali/smali with a bundled classpath) and `scripts/patch_smali.py` (method-body replacement by signature).
|
||||
|
||||
```bash
|
||||
python scripts/smtool.py d <in.dex> <out_tree>
|
||||
python scripts/patch_smali.py <tree> <patch.json> [--dry-run]
|
||||
python scripts/smtool.py a <tree> <out.dex>
|
||||
```
|
||||
|
||||
Then **must** verify (P3 will bite otherwise):
|
||||
|
||||
```bash
|
||||
python scripts/dex_classdiff.py <original.dex> <rebuilt.dex>
|
||||
```
|
||||
Expect: same class count, `only_in_A=0`, `only_in_B=0`, `ACC_INTERFACE mismatch=0`.
|
||||
|
||||
If the class table is clean but the app crashes with `IncompatibleClassChangeError` / `VerifyError`, the round-trip damaged code items that table comparison cannot see. Fall back to technique 1.
|
||||
|
||||
Patch spec format for `patch_smali.py`:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"file": "com/pkg/Helper.smali",
|
||||
"method": ".method public final showAd(Landroid/app/Activity;)V",
|
||||
"registers": 3,
|
||||
"body": ["return-void"],
|
||||
"note": "why this is safe"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Finding the call site
|
||||
|
||||
Reverse-lookup from a smali tree or a set of disassembled dex:
|
||||
|
||||
```bash
|
||||
python scripts/find_refs.py <tree> 'Lcom/pkg/AdHelper;->showSplash(...)V'
|
||||
```
|
||||
|
||||
Search strategies that work:
|
||||
- Search the **string table** first (`scripts/dex_strings.py`). API paths, keys, and SDK class names are usually string literals, and they are unique.
|
||||
- Search for the **SDK's entry class** (`Sdk.init`, `Sdk.show`). The app's wrapper is almost always the only caller.
|
||||
- When the decompiler is confusing, trust the **byte offsets** — `grep` the dex for the literal and note where it sits relative to other strings of the same family.
|
||||
|
||||
## Order of operations for a real patch task
|
||||
|
||||
1. Identify every place the behavior is produced (`recon` + `find_refs`).
|
||||
2. Pick the highest safe layer per the table above.
|
||||
3. Write all edits for a given dex into **one** dexlib2 program.
|
||||
4. Patch each dex **once**.
|
||||
5. `dex_classdiff` every patched dex against the original → expect zero structural drift.
|
||||
6. Repack, sign, install, launch, exercise, read logcat.
|
||||
7. If anything is off, revert that dex to the original and re-verify the control build.
|
||||
@@ -0,0 +1,502 @@
|
||||
# Dynamic analysis with Frida
|
||||
|
||||
Static analysis tells you what code exists. Frida tells you what code **runs**. When they disagree, runtime wins (`references/pitfalls.md` P10).
|
||||
|
||||
Use Frida when you need to answer:
|
||||
- Which method actually renders this element? (static decompilation routinely misleads)
|
||||
- What is the real call chain at the moment of interest?
|
||||
- What does the app actually do on startup, in order?
|
||||
- Which domains does it resolve, and when?
|
||||
- Is the app detecting my instrumentation?
|
||||
|
||||
|
||||
**Load this when:** static analysis cannot answer the question (which method renders this, what value reaches that call), or Frida will not install, attach, or stay alive. It gives setup, the four-layer probe, hook strategy, and the ROMs that hunt instrumentation.
|
||||
|
||||
## Setup — version alignment is a hard gate
|
||||
|
||||
**Do this first.** Nearly every "Frida is broken on this device" report is a version mismatch, and the error text rarely says so. The host `frida` package and the device-side `frida-server` must be the **same version**, and that version must actually support the device's Android release. Align, then debug everything else.
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---|---|---|
|
||||
| `unable to locate Android dynamic linker` | host/server too new for this Android release | drop to an older frida line (16.x is a safe baseline for older ROMs) |
|
||||
| `Java is not defined` / `Java.perform is not a function` | that build ships no bundled Java bridge | align to a build that has it, or inline the bridge (see *17+ gotchas*) |
|
||||
| `Failed to connect to remote frida-server` / `unexpected message` / `invalid message` | host package and device server differ | download both from the same release tag |
|
||||
| `Java.choose` / `Java.use` throws immediately | bridge present but the VM is not ready | wrap everything in `Java.perform` |
|
||||
| Attached, but hooks fire in the wrong process | USB auto-selection grabbed the emulator | use an explicit remote device (below) |
|
||||
| `HOOK-OK` prints and nothing ever fires | not a version problem | go to *Hook never fires* |
|
||||
|
||||
### Prefer a remote device over USB
|
||||
|
||||
With a physical device *and* an emulator attached, `frida.get_usb_device()` can silently pick the emulator — you then attach to the wrong process and chase phantom failures for a long time. Address the device explicitly:
|
||||
|
||||
```bash
|
||||
adb devices -l # get <serial>
|
||||
adb -s <serial> forward tcp:27042 tcp:27042
|
||||
frida -H 127.0.0.1:27042 -f <app.package> -l probe.js --no-pause
|
||||
```
|
||||
|
||||
```python
|
||||
device = frida.get_device_manager().add_remote_device('127.0.0.1:27042')
|
||||
```
|
||||
|
||||
Keep `-s <serial>` on every other adb call too, or you will drift between targets mid-session.
|
||||
|
||||
### Attach by PID when the process list is incomplete
|
||||
|
||||
`device.enumerate_processes()` can return a list that simply does not contain the app you are
|
||||
targeting, while `adb shell ps -A | grep <pkg>` shows it running. This is an enumeration gap,
|
||||
not a permission problem, and no amount of retrying fixes it. Resolve the pid through adb and
|
||||
attach to it directly:
|
||||
|
||||
```python
|
||||
import subprocess
|
||||
|
||||
def find_pid(serial, pkg):
|
||||
out = subprocess.run(['adb', '-s', serial, 'shell', 'ps -A -o PID,NAME'],
|
||||
capture_output=True, text=True).stdout
|
||||
for line in out.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) >= 2 and parts[1] == pkg:
|
||||
return int(parts[0]) # last match wins; use the main process
|
||||
return None
|
||||
|
||||
pid = find_pid(serial, pkg)
|
||||
session = device.attach(pid) # works where attach(pkg) raises ProcessNotFound
|
||||
```
|
||||
|
||||
Two related behaviours worth knowing:
|
||||
|
||||
- `attach(<package name>)` raises `ProcessNotFoundError` in exactly this situation, which is
|
||||
easy to misread as "the app is not running".
|
||||
- A pid captured earlier goes stale the moment the process is recycled. Re-resolve it
|
||||
immediately before each injection rather than reusing a value from an earlier step.
|
||||
|
||||
### Verify the runtime before blaming the script
|
||||
|
||||
A script that reports nothing at all is usually not seeing the Java layer. Two failure modes
|
||||
look identical from the host — test for both in one go:
|
||||
|
||||
```javascript
|
||||
// send({ runtime: Script.runtime, java: typeof Java, objc: typeof ObjC });
|
||||
```
|
||||
|
||||
- `Java` is `undefined` ⇒ the script runtime has no Java bridge. Pass `runtime='v8'`
|
||||
explicitly when creating the script; some builds default to a runtime without it.
|
||||
- The bridge exists but every hook misses ⇒ you attached to the wrong process, or the layer
|
||||
below is native (go to the four-layer probe / `references/native-and-so.md`).
|
||||
|
||||
### Start the device server so it survives
|
||||
|
||||
Run it as root, give it a non-obvious name out of obvious paths, and **detach it from the shell's session**. A process started with `nohup ... &` from an `adb shell` that then exits gets reaped mid-run — the hooks work for a minute and then stop.
|
||||
|
||||
```bash
|
||||
adb push frida-server-<ver>-android-<abi> /data/local/tmp/
|
||||
adb shell "su -c 'mkdir -p /data/local/tmp/.svc'"
|
||||
adb shell "su -c 'cp -f /data/local/tmp/frida-server-<ver>-android-<abi> /data/local/tmp/.svc/kwork'"
|
||||
adb shell "su -c 'chmod 755 /data/local/tmp/.svc/kwork'"
|
||||
adb shell "su -c 'setsid /data/local/tmp/.svc/kwork >/dev/null 2>&1 </dev/null &'"
|
||||
adb shell "su -c 'pgrep -f kwork'" # must still be alive after the shell returned
|
||||
```
|
||||
|
||||
If `setsid` is unavailable, hold the adb connection open instead: run `adb shell "su -c '/data/local/tmp/.svc/kwork'"` as a host-side background job and leave it running for the whole session.
|
||||
|
||||
### Spawn, don't attach, when timing matters
|
||||
|
||||
ABI must match the device (`getprop ro.product.cpu.abi`). Attaching usually misses startup — init, the first network calls, and splash logic all happen before you get a session.
|
||||
|
||||
```python
|
||||
device = frida.get_device_manager().add_remote_device('127.0.0.1:27042')
|
||||
pid = device.spawn([pkg])
|
||||
session = device.attach(pid)
|
||||
# ... load the script, wait until it reports that hooks are installed ...
|
||||
device.resume(pid) # resume ONLY after hooks are ready
|
||||
```
|
||||
|
||||
**Common bug:** resuming before the script has finished loading loses the first ~100–300 ms, which is exactly where init happens. Have the script `send()` a ready signal (the probe template below sends `PROBE-READY`) and resume only after receiving it.
|
||||
|
||||
### Spawn keeps the Activity stack down — patch, detach, then launch
|
||||
|
||||
Spawn mode is the right way to catch startup, and it carries a trap that costs a whole
|
||||
observation round when it is not expected: **on some targets the Activity stack never comes
|
||||
up while you are attached.** `dumpsys window | grep mCurrentFocus` stays `null`, screenshots
|
||||
come back blank or a few kilobytes, and the process is alive with no UI — which reads as
|
||||
"the app is broken" when it is only unrendered.
|
||||
|
||||
The app is not broken: launch it normally, with no session attached, and it renders. So the
|
||||
ordering is the fix, not a different hook.
|
||||
|
||||
1. spawn and attach, as above;
|
||||
2. let the probe write what it needs into memory (`Memory.patchCode`);
|
||||
3. **detach** — a memory write is a plain write and survives, while every `Interceptor`
|
||||
hook goes away with the session. For an observation run that is usually what you want,
|
||||
because it removes your instrumentation from the picture entirely;
|
||||
4. start the Activity normally and capture.
|
||||
|
||||
`scripts/spawn_patch_detach.py` implements exactly this, with
|
||||
`scripts/hook_patch_only.js` as the minimal "neutralise one death site and report `PATCHED`"
|
||||
probe.
|
||||
|
||||
Two consequences worth stating:
|
||||
|
||||
- **A memory patch is enough to observe a build that cannot run on its own.** If a build dies
|
||||
at startup you do not have to ship a patched file to look at its UI — write the fix in
|
||||
memory, detach, launch. This is also the cleanest way to run the **unmodified original** as
|
||||
a control while still getting past its death site (`verification.md` §the control build rule).
|
||||
- **Do not conclude "no UI" from a blank capture taken while attached.** Check
|
||||
`mCurrentFocus`: if it is `null` under an attached session and non-null without one, you
|
||||
have this problem, not a finding about the target.
|
||||
|
||||
## Frida 17+ gotchas
|
||||
|
||||
The preferred fix for every symptom in the table above is **version alignment**. Use these only when you genuinely must run a 17+ build.
|
||||
|
||||
- The **built-in Java bridge was removed**. `Java.perform(...)` is not available unless you inline the bridge yourself:
|
||||
```python
|
||||
bridge = open(<site-packages>/frida_tools/bridges/java.js, encoding='utf-8').read()
|
||||
script = bridge + "\nObject.defineProperty(globalThis,'Java',{value:bridge,configurable:true});\n" + my_js
|
||||
```
|
||||
Pass the whole thing through a small loader script (below).
|
||||
- **Export lookup renamed.** Use a compatibility helper rather than a single API:
|
||||
```javascript
|
||||
function resolveExport(name) {
|
||||
try { if (Module.getGlobalExportByName) return Module.getGlobalExportByName(name); } catch (e) {}
|
||||
try { if (Module.findExportByName) return Module.findExportByName(null, name); } catch (e) {}
|
||||
try { const l = Process.findModuleByName('libc.so'); if (l) return l.getExportByName(name); } catch (e) {}
|
||||
return null;
|
||||
}
|
||||
```
|
||||
- **Large script injection times out.** A multi-hundred-KB bridge + script can exceed the transport timeout. Use a tiny loader and post the real payload:
|
||||
```python
|
||||
loader = "recv('go', m => { eval(m.payload.code); }); send('loader-ready');"
|
||||
sc = session.create_script(loader)
|
||||
sc.load()
|
||||
sc.on('message', on_message) # wait for 'loader-ready'
|
||||
sc.post({'type': 'go', 'payload': {'code': BIG_JS}})
|
||||
```
|
||||
|
||||
## Hook strategy
|
||||
|
||||
### Java layer (Kotlin/Java app)
|
||||
```javascript
|
||||
Java.perform(() => {
|
||||
const Cls = Java.use('com.example.Helper');
|
||||
Cls.showAd.overload('android.app.Activity').implementation = function (a) {
|
||||
send({ tag: 'Helper.showAd', stack: Java.use('android.util.Log').getStackTraceString(
|
||||
Java.use('java.lang.Throwable').$new()) });
|
||||
return this.showAd(a); // observe, then delegate
|
||||
};
|
||||
});
|
||||
```
|
||||
- **Use `overload(...)`** — obfuscated classes often have several methods with the same name.
|
||||
- To find obfuscated names, enumerate at runtime rather than guessing from decompiled short names:
|
||||
```javascript
|
||||
Java.enumerateLoadedClasses({
|
||||
onMatch: n => { if (n.indexOf('example') >= 0) send(n); },
|
||||
onComplete: () => send('done')
|
||||
});
|
||||
```
|
||||
- **Call stacks are the highest-value signal.** `Log.getStackTraceString(new Throwable())` or `Java.use('android.util.Log').getStackTraceString(...)` gives you the real chain from the framework down to the method — far more reliable than reading smali.
|
||||
|
||||
### Native layer
|
||||
```javascript
|
||||
const f = resolveExport('open');
|
||||
Interceptor.attach(f, {
|
||||
onEnter(args) { this.p = args[0].readCString(); },
|
||||
onLeave(ret) { if (this.p) send({ tag: 'open', path: this.p }); }
|
||||
});
|
||||
```
|
||||
|
||||
### Network / domain observation (cheap and very informative)
|
||||
```javascript
|
||||
const Inet = Java.use('java.net.InetAddress');
|
||||
Inet.getAllByName.overload('java.lang.String').implementation = function (h) {
|
||||
send({ tag: 'dns', host: h });
|
||||
return this.getAllByName(h);
|
||||
};
|
||||
```
|
||||
This is often the **decisive evidence** for an ad-removal claim: if the ad SDK's domains are *never resolved*, the subsystem never started — a much stronger statement than "logcat was quiet". It also works for SDKs that bypass the system HTTP proxy.
|
||||
|
||||
## The four-layer probe template
|
||||
|
||||
When the question is "why did this request fail / where did it go", do not hook one class and hope. Ship **one long-lived script that hooks four layers at once**; whichever layer fires first localizes the problem immediately. This is the single most productive artifact of a runtime investigation — reuse it verbatim.
|
||||
|
||||
The layers, and why each one is there:
|
||||
|
||||
1. **The app's own network wrapper** — enumerate `getDeclaredMethods` and wrap *every* overload, so you do not have to guess the entry point.
|
||||
2. **OkHttp end to end** — `newCall`, `Request$Builder.build`, `RealCall.execute`, `AsyncCall.run`, `RealInterceptorChain.proceed`. The last two are the ones that fire for asynchronous calls.
|
||||
3. **`java.net.URL.openConnection`** — plenty of login/register paths never touch OkHttp; they use `HttpsURLConnection` and a completely different trust configuration.
|
||||
4. **`Throwable.getMessage`** — pulls out the original text of exceptions an upper layer caught and swallowed. Without it, a caught failure looks like "nothing happened".
|
||||
|
||||
Rules that make the difference between a usable probe and a wasted session:
|
||||
|
||||
- **Write to a file as well as to `send()`.** Stdout is lossy and the CLI drops messages; the file is the evidence.
|
||||
- **Let it stay resident.** Hook before the app touches the network, then leave it running while you drive the UI. A one-shot script misses everything that happens after its first second.
|
||||
- **Wrap every hook in its own `try/catch`.** One missing class must not take down the other three layers.
|
||||
- **Never capture an overload in a `var`.** Inside a loop, `var ov = ...` leaves every hook pointing at the *last* overload: they install cleanly and then mis-report forever. Use `let` or `.forEach()`.
|
||||
|
||||
`scripts/frida_probe.js` is the same probe in a fuller form (per-arity dispatch, DNS layer, dedup and hard caps). The script below is the minimal portable one — paste it into any loader and it adapts to the target.
|
||||
|
||||
```javascript
|
||||
// probe.js — four-layer network + swallowed-exception probe.
|
||||
const APP_PKG = '<app.package>';
|
||||
const APP_NET_CLASS = 'com.example.app.net.HttpHelper'; // the app's own wrapper, once you have found it
|
||||
const LOGFILE = '/data/user/0/' + APP_PKG + '/files/probe.log'; // app-private: always writable by the app
|
||||
const MAX_THROW = 400; // Throwable.getMessage is hot — cap it
|
||||
const INTERESTING = /(network|http|ssl|cert|fail|timeout|refused|unable|error|exception)/i; // widen for the app's UI language
|
||||
|
||||
let out = null;
|
||||
try { out = new File(LOGFILE, 'a'); } catch (e) {}
|
||||
|
||||
function log(ev, data) {
|
||||
const line = JSON.stringify(Object.assign({ t: Date.now(), ev: ev }, data));
|
||||
try { send(line); } catch (e) {}
|
||||
try { if (out !== null) { out.write(line + '\n'); out.flush(); } } catch (e) {}
|
||||
}
|
||||
|
||||
function stack(n) {
|
||||
try {
|
||||
const t = Java.use('java.lang.Throwable').$new();
|
||||
return Java.use('android.util.Log').getStackTraceString(t).split('\n').slice(1, (n || 6) + 1).join(' | ');
|
||||
} catch (e) { return '<no stack>'; }
|
||||
}
|
||||
|
||||
function sa(args) { // a hook must never die inside its own logging
|
||||
try {
|
||||
const o = {};
|
||||
for (let i = 0; i < args.length; i++) o['a' + i] = args[i] === null ? 'null' : '' + args[i];
|
||||
return o;
|
||||
} catch (e) { return { err: '' + e }; }
|
||||
}
|
||||
|
||||
// 1) the app's own wrapper: every declared method, every overload, separately wrapped
|
||||
function hookAppNet(cls) {
|
||||
try {
|
||||
const C = Java.use(cls);
|
||||
const ms = C.class.getDeclaredMethods();
|
||||
for (let i = 0; i < ms.length; i++) {
|
||||
try {
|
||||
const name = ms[i].getName();
|
||||
const ps = ms[i].getParameterTypes();
|
||||
const sig = [];
|
||||
for (let j = 0; j < ps.length; j++) sig.push(ps[j].getName());
|
||||
const ov = C[name].overload.apply(C[name], sig);
|
||||
ov.implementation = function () {
|
||||
try { log('APP-NET', { cls: cls, m: name, args: sa(arguments), stack: stack(5) }); }
|
||||
catch (e) { log('PROBE-ERR', { at: 'app-net', msg: '' + e }); }
|
||||
return ov.apply(this, arguments);
|
||||
};
|
||||
} catch (e) { log('HOOK-SKIP', { cls: cls, m: ms[i].getName(), msg: '' + e }); }
|
||||
}
|
||||
log('HOOK-OK', { layer: 'app-net', cls: cls, n: ms.length });
|
||||
} catch (e) { log('HOOK-FAIL', { layer: 'app-net', cls: cls, msg: '' + e }); }
|
||||
}
|
||||
|
||||
// 2) OkHttp end to end — internal class names moved between major versions, so try every location
|
||||
function hookOkHttp() {
|
||||
const targets = [
|
||||
['okhttp3.OkHttpClient', 'newCall'],
|
||||
['okhttp3.Request$Builder', 'build'],
|
||||
['okhttp3.RealCall', 'execute'],
|
||||
['okhttp3.RealCall$AsyncCall', 'run'],
|
||||
['okhttp3.internal.http.RealInterceptorChain', 'proceed'], // okhttp 3.x
|
||||
['okhttp3.internal.connection.RealInterceptorChain', 'proceed'] // okhttp 4.x / 5.x
|
||||
];
|
||||
for (let i = 0; i < targets.length; i++) {
|
||||
const cn = targets[i][0], mn = targets[i][1];
|
||||
try {
|
||||
const ovs = Java.use(cn)[mn].overloads; // every overload of that name; use .overload('<sig>') if this is undefined
|
||||
for (let j = 0; j < ovs.length; j++) {
|
||||
const ov = ovs[j]; // block-scoped: every hook keeps its own overload
|
||||
ov.implementation = function () {
|
||||
try { log('OKHTTP', { cls: cn, m: mn, args: sa(arguments), stack: stack(6) }); }
|
||||
catch (e) { log('PROBE-ERR', { at: cn, msg: '' + e }); }
|
||||
return ov.apply(this, arguments);
|
||||
};
|
||||
}
|
||||
log('HOOK-OK', { layer: 'okhttp', cls: cn, m: mn, overloads: ovs.length });
|
||||
} catch (e) { log('HOOK-SKIP', { layer: 'okhttp', cls: cn, m: mn, msg: '' + e }); }
|
||||
}
|
||||
}
|
||||
|
||||
// 3) java.net.URL — login/register frequently bypasses OkHttp entirely
|
||||
function hookRawUrl() {
|
||||
try {
|
||||
const U = Java.use('java.net.URL');
|
||||
const o0 = U.openConnection.overload();
|
||||
o0.implementation = function () {
|
||||
try { log('RAW-URL', { url: '' + this.toString(), stack: stack(6) }); } catch (e) {}
|
||||
return o0.apply(this, arguments);
|
||||
};
|
||||
try {
|
||||
const o1 = U.openConnection.overload('java.net.Proxy');
|
||||
o1.implementation = function () {
|
||||
try { log('RAW-URL', { url: '' + this.toString(), proxy: true, stack: stack(6) }); } catch (e) {}
|
||||
return o1.apply(this, arguments);
|
||||
};
|
||||
} catch (e) {}
|
||||
log('HOOK-OK', { layer: 'url' });
|
||||
} catch (e) { log('HOOK-FAIL', { layer: 'url', msg: '' + e }); }
|
||||
}
|
||||
|
||||
// 4) Throwable.getMessage — the original text of an exception an upper layer caught and swallowed
|
||||
function hookThrowable() {
|
||||
try {
|
||||
const T = Java.use('java.lang.Throwable');
|
||||
const seen = {};
|
||||
let n = 0;
|
||||
T.getMessage.implementation = function () {
|
||||
const msg = this.getMessage(); // self-call inside the replacement is fine (Frida guards it)
|
||||
try {
|
||||
if (msg !== null && msg !== undefined && n < MAX_THROW) {
|
||||
const cls = '' + this.getClass().getName();
|
||||
const interesting = cls.indexOf('Exception') >= 0 || cls.indexOf('Error') >= 0 || INTERESTING.test('' + msg);
|
||||
const key = cls + '|' + msg;
|
||||
if (interesting && seen[key] === undefined) { // dedup: this method is on a very hot path
|
||||
seen[key] = 1; n++;
|
||||
log('THROW', { cls: cls, msg: '' + msg, stack: stack(4) });
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
return msg;
|
||||
};
|
||||
log('HOOK-OK', { layer: 'throwable' });
|
||||
} catch (e) { log('HOOK-FAIL', { layer: 'throwable', msg: '' + e }); }
|
||||
}
|
||||
|
||||
Java.perform(function () {
|
||||
hookAppNet(APP_NET_CLASS);
|
||||
hookOkHttp();
|
||||
hookRawUrl();
|
||||
hookThrowable();
|
||||
log('PROBE-READY', { pkg: APP_PKG, logfile: LOGFILE }); // the host waits for this before resuming
|
||||
});
|
||||
```
|
||||
|
||||
Read the log after driving the UI. `RAW-URL` (with no `OKHTTP` events) is how you learn the request went over `HttpsURLConnection` — a different trust path entirely (`references/tls-and-cert.md` §If OkHttp is the failing path). `THROW` is how you learn an upper layer swallowed the real error.
|
||||
|
||||
```bash
|
||||
# pull the record (app-private path needs root to read)
|
||||
adb -s <serial> shell "su -c 'cat /data/user/0/<app.package>/files/probe.log'" > probe.log
|
||||
```
|
||||
|
||||
## Hook never fires — debug in this order
|
||||
|
||||
`HOOK-SKIP` / `HOOK-FAIL` lines answer this before you start guessing. When every layer reports `HOOK-OK` and still no event arrives:
|
||||
|
||||
1. **Does the class / method / overload actually exist?** A misspelled obfuscated name, a renamed okhttp internal class, or an overload taking `Object` instead of `String` all produce a wrapper that is simply never invoked.
|
||||
2. **Is the ClassLoader the right one?** Multi-dex, plugin-loaded and packed apps can hold several loaders; `Java.use` uses the app loader by default and throws `ClassNotFoundException` for a class that is plainly in the APK. Enumerate and switch:
|
||||
```javascript
|
||||
Java.enumerateClassLoaders({
|
||||
onMatch: function (l) { try { if (l.findClass('<app.net.Class>')) Java.classFactory.loader = l; } catch (e) {} },
|
||||
onComplete: function () {}
|
||||
});
|
||||
```
|
||||
3. **Is that code path reached at all?** Layer 4 settles it: if `THROW` events show the app failing earlier, your hook's call site is never executed and no amount of hooking will help.
|
||||
4. **Did your UI action trigger business logic?** A tap that looks fine but fails a local form check returns before any network call. Verify the input actually reached the field (`references/environment.md` §Driving the UI from adb), not just that the button animated.
|
||||
|
||||
## `Java.choose` also matches dead instances
|
||||
|
||||
`Java.choose('com.example.app.MainActivity', ...)` returns **every** instance the VM still tracks, including finished and destroyed ones. Walking their view tree then yields an empty list — that is normal, not a bug in your script. Filter first, and prefer a direct reflective call over simulating a tap:
|
||||
|
||||
```javascript
|
||||
Java.choose('com.example.app.MainActivity', {
|
||||
onMatch: function (a) {
|
||||
try {
|
||||
if (a.isFinishing() || a.isDestroyed()) return; // dead instance: skip it
|
||||
send({ tag: 'live', view: '' + a.findViewById(<viewId>) });
|
||||
} catch (e) {}
|
||||
},
|
||||
onComplete: function () {}
|
||||
});
|
||||
```
|
||||
|
||||
## Reading obfuscated code at runtime
|
||||
|
||||
When names are meaningless (`a7`, `x6`, `uf0`), do not patch from names. Instead:
|
||||
|
||||
1. Find a **unique anchor**: an API path, a preference key, or an SDK class name from the string table (`scripts/dex_strings.py`).
|
||||
2. Locate the small class/method that references it (byte search in the dex + a targeted disassembly).
|
||||
3. Hook that anchor, capture the **stack**, and read off the real chain.
|
||||
4. Hook the classes the stack reveals — that is where semantics become visible.
|
||||
|
||||
## Anti-instrumentation
|
||||
|
||||
Signs: the app exits or freezes shortly after attach; `logcat` shows a generic process death with no Java stack; strings like `frida`, `xposed`, `magisk`, `substrate` are referenced by **app code** (not just by an SDK's string list).
|
||||
|
||||
**Rule this out first — it is not always a detector.** A process that dies right after you
|
||||
attach is frequently the ROM rather than the app: on aggressive OEM ROMs, merely backgrounding
|
||||
the app (pressing HOME, switching away) triggers a freeze, and the logcat signature is a state
|
||||
transition such as `state: R -> F` while hooks stop with no Java stack. That looks identical to
|
||||
a detector killing you. Check `references/environment.md` for that signature, and keep the app
|
||||
in the foreground, before building an anti-anti plan.
|
||||
|
||||
Countermeasures, in order of least disruption:
|
||||
1. Spawn + late resume, so hooks are installed before the check runs.
|
||||
2. Rename `frida-server` and move it out of obvious paths.
|
||||
3. Hook the detector itself and neuter it — find it by hooking the suspicious API (e.g. `/proc/self/maps` reads, `File.exists`, `Runtime.exec`) and inspecting the stack.
|
||||
4. Only then consider a gadget-based approach.
|
||||
|
||||
**Remember:** most `frida`/`root` strings in a decompiled APK belong to third-party SDKs' own detection lists, not to the app. Verify that **app code** references them before doing anti-anti work.
|
||||
|
||||
### When the ROM hunts your instrumentation
|
||||
|
||||
Some vendor ROMs treat a device-side `frida-server` as hostile and kill it on a timer. The
|
||||
symptom is not an error message — it is a working session that disappears. A later
|
||||
`attach`/`spawn` fails with `unable to connect to remote frida-server`, or with
|
||||
`TransportError: connection closed` **during `spawn`, before `resume`**. That second shape is
|
||||
easy to misread as the target crashing during startup. It is not the target.
|
||||
|
||||
Measured on one vendor build: the server was killed repeatedly across a work session, and on
|
||||
one occasion the whole device restarted, briefly taking `system_server` with it
|
||||
(`Can't find service: activity`, `No service published for: input`). Both symptoms are
|
||||
environment, not evidence about the app.
|
||||
|
||||
- **Check the server is alive immediately before every experiment**, not once per session:
|
||||
`adb shell "su -c 'pidof <server-name>'"`. Restart it if it is gone.
|
||||
- **Distinguish the two failure shapes.** `connection closed` at `spawn` = server died or was
|
||||
killed (environment). `connection closed` just after `resume` = the target exited — *that*
|
||||
one is about the app.
|
||||
- **Rename the server binary and move it off the obvious path.** It defeats simple filename
|
||||
sweeps. It does not hide from a target that lists the directory, so do not treat the rename
|
||||
as a fix for detection — only as protection from cleanup.
|
||||
- **Budget a restart, not a re-analysis.** A killed server costs seconds; a round spent
|
||||
re-deriving the previous conclusion costs far more.
|
||||
- **If a device reboot interrupts the run, re-establish state before measuring**: server up,
|
||||
`adb forward` re-created, screen awake and unlocked, installed-build hash re-read. A
|
||||
measurement taken across a reboot is a measurement of two states
|
||||
(`long-task-discipline.md` §keep the observation window clean).
|
||||
|
||||
### When live attach cannot work at all
|
||||
|
||||
Sometimes nothing short of "do not inject at runtime" is viable — the check runs
|
||||
before your hooks can, it watches its own process, or the environment forbids
|
||||
`ptrace`. Do not keep escalating on the same axis. There are non-live ways to get
|
||||
runtime-grade information, and they are often enough to finish the job:
|
||||
|
||||
| Alternative | What it gives you | Cost |
|
||||
|---|---|---|
|
||||
| **Static instrumentation** — load a gadget/small agent by modifying the APK (e.g. an injected `Application` wrapper, a gadget `.so` added to `lib/<abi>/` and wired from the manifest) | Hooks run from process start with no external attach, no `ptrace`, no server process on the device to be found | Must repack and re-sign; the build you observe is no longer byte-identical to the original, so **it cannot serve as the unmodified control** (`verification.md` §the control build rule) |
|
||||
| **Static read of the answer** — the check's inputs are usually visible: the field it reads, the string it compares, the response field it trusts | Often enough to plan a patch without ever observing | You do not learn the live values; combine with `runtime-data.md` for local state |
|
||||
| **Self-recorded evidence** — the app's own cached responses, logs, or on-disk state | Real runtime data with no injection at all (a HTTP cache, a pref, a JSON snapshot left behind by a normal launch) | Only what the app happened to persist; needs one clean launch and then a filesystem read |
|
||||
|
||||
The last one is worth remembering because it is the cheapest and it is easy to
|
||||
forget: a normally-launched app often leaves the exact runtime data you wanted —
|
||||
server responses in its HTTP cache, config in a preferences file, feature state in
|
||||
a snapshot. Read those **before** concluding that dynamic analysis is blocked.
|
||||
|
||||
**Two cautions that apply to every alternative above:**
|
||||
|
||||
- **Changed bytes change behaviour.** Any injected build is a different artifact,
|
||||
and on a target that fingerprints its own layout that difference is the finding,
|
||||
not the app's real behaviour. Always keep the unmodified run as the control.
|
||||
- **Evidence from a different environment is a different claim.** A result obtained
|
||||
with the app patched-for-injection, or on a device with different privilege state,
|
||||
supports "verified under condition X". Say so; do not present it as the app's
|
||||
unmodified behaviour (`long-task-discipline.md` §the most expensive drift).
|
||||
|
||||
## What to capture for the record
|
||||
|
||||
- spawn time, hook-ready time, resume time
|
||||
- every hook hit with a stack (bounded — cap output)
|
||||
- DNS hosts in order, with timestamps
|
||||
- the exact build under test (hash) and the control build's results
|
||||
@@ -0,0 +1,340 @@
|
||||
# Emulation and RPC — running the target's code on your terms
|
||||
|
||||
There is a class of target where neither reading nor patching is the bottleneck: the code you need
|
||||
is *inside* a hardened `.so`, statically unreadable (OLLVM, string encryption, VMP) or only
|
||||
decrypted in memory, and what you actually want is its **output** — the signature it computes, the
|
||||
blob it decrypts, the token it mints — not its source. Two routes give you that without winning the
|
||||
unpacking war first:
|
||||
|
||||
- **Unidbg** — emulate the `.so` on your PC, feed it inputs, read outputs. Offline, batch, no
|
||||
device, no detector. You pay in *environment*: everything the library touches must be faked.
|
||||
- **Frida RPC** — let the real process run its own hardened code, and call it from Python through a
|
||||
resident session. High fidelity (it is the real environment), no faking required. You pay in
|
||||
*stability*: the process must tolerate injection, and the session must survive.
|
||||
|
||||
This file is about choosing between them and driving both. The unpacking itself is
|
||||
`references/packers.md` and `references/advanced-unpacking.md`; the hooking mechanics live in
|
||||
`references/dynamic-frida.md`. What is new here is treating the target's own crypto as a **callable
|
||||
function** instead of a thing to understand.
|
||||
|
||||
|
||||
**Load this when:** you need the *output* of a routine rather than a change to the app -- a signature, a token, a cipher. It gives emulation with its environment-filling cost, against service-ifying the live function over Frida RPC.
|
||||
|
||||
## When emulation or RPC is the right move
|
||||
|
||||
Reach for this file when any of these holds:
|
||||
|
||||
- The algorithm you need lives in a hardened `.so` and reverse-engineering it (weeks against OLLVM
|
||||
or VMP) costs more than *calling* it (hours of environment work).
|
||||
- You need **many** calls — fuzzing an input format, brute-forcing a key schedule, generating
|
||||
signed request batches — where hand-driving a device per call is hopeless.
|
||||
- The function's output depends on environment state (files, system properties, other libraries)
|
||||
that a static read cannot evaluate.
|
||||
- The gate you care about is a *computed value* (sign header, encrypted body) rather than a branch
|
||||
— `references/server-api.md` told you the server checks it, and the client computes it in native
|
||||
code (`references/signature-derived-keys.md`).
|
||||
|
||||
Do **not** reach for it when a two-byte dex patch or an LSPosed module solves the problem —
|
||||
`references/dex-patching.md` and `references/lsposed-and-modules.md` are cheaper and more durable.
|
||||
Emulation and RPC are for when the code that matters must keep *running*.
|
||||
|
||||
## The decision table
|
||||
|
||||
| | Unidbg (offline emulation) | Frida RPC (live process) |
|
||||
|---|---|---|
|
||||
| Environment fidelity | fake — you build it | real — the device provides it |
|
||||
| Works without a device | yes | no (device or emulator required) |
|
||||
| Works against anti-injection | **yes** — the code never knows | **no** — injection is the entry ticket |
|
||||
| Batch throughput | thousands of calls/min | tens of calls/sec, process-bound |
|
||||
| Setup cost | Java + maven + per-library env work | frida already installed, minutes |
|
||||
| Maintenance cost | every missing JNI/syscall is a new stub | reconnect logic, process lifecycle |
|
||||
| Reproducibility | deterministic (you control time/random) | real time, real randomness |
|
||||
| Best for | signing algorithms, crypto, parsers | anything entangled with live app state |
|
||||
| **Priority when either could work** | second — the environment bill below is real and routinely underestimated | **first** — the device already owns the environment |
|
||||
|
||||
The two-strike rule applies across the table: if a library refuses to run under unidbg after two
|
||||
focused rounds of `DalvikVM` patching, stop emulating and move to RPC (or the reverse) instead of a
|
||||
third round — a library that checks its own loading path or decrypts itself against device state
|
||||
may simply not be worth emulating, and that is a finding, not a failure (`SKILL.md`
|
||||
§Stop conditions).
|
||||
|
||||
### The environment bill — budget days, not hours
|
||||
|
||||
"Filling the environment" reads like a checklist and behaves like a project. The mistake this
|
||||
section prevents is starting an emulation because the table says *works against anti-injection*, and
|
||||
then discovering that this library's environment includes everything emulation cannot fake: an
|
||||
Android `Context` backed by a real package manager, Binder round-trips into another process, a
|
||||
`KeyStore` attestation that only succeeds on real hardware, or a self-check against device state
|
||||
captured at install time. For a commercial native algorithm of that shape, "stub it until it runs"
|
||||
is measured in **days, not hours** — each missing piece is discovered one fault at a time, and the
|
||||
rounds do not get shorter.
|
||||
|
||||
Weigh it before starting:
|
||||
|
||||
| Question | If yes |
|
||||
|---|---|
|
||||
| Does it call through `Context` (package name, files dir, signature, `PackageManager`)? | a stub is often enough — cheap, keep going |
|
||||
| Does it talk to another process (Binder service, bound SDK, remote provider)? | the stub surface grows fast; **prefer RPC** |
|
||||
| Does it verify hardware (KeyStore, TEE, an attestation chain)? | emulation is likely a dead end — the value it wants cannot be produced here |
|
||||
| Does it check its own loading environment (paths, maps, root, debugger, installer)? | readable and patchable *inside* the emulator, but budget one round per check |
|
||||
| Must it be called thousands of times a minute? | the RPC throughput ceiling is real — this is the case that justifies the bill |
|
||||
|
||||
Default to **RPC first, emulation second**: the device already provides the environment, and
|
||||
per-call cost only starts to matter once you need volume. The two claims in this subsection
|
||||
(the day-scale cost of a Context/Binder/KeyStore-shaped library, and the RPC throughput ceiling)
|
||||
are **inferred** from the mechanism and from community practice — this repository has not emulated a
|
||||
commercial sample end to end, as `references/evidence-summary.md` §The capability matrix states.
|
||||
|
||||
## Part A — Unidbg
|
||||
|
||||
### What it is
|
||||
|
||||
Unidbg ([zhkl0228/unidbg](https://github.com/zhkl0228/unidbg), Java, Apache-2.0) emulates an
|
||||
Android process on your PC:
|
||||
|
||||
- **CPU** — a `backend` layer over Unicorn (default) or Dynarmic/Hypervisor, executing the real
|
||||
arm/arm64 code of your `.so`.
|
||||
- **Memory & linker** — it maps the ELF, resolves `DT_NEEDED` dependencies from a bundled Android
|
||||
system-library set (`AndroidResolver(apiLevel)`), and runs relocations.
|
||||
- **JNI environment** — a Dalvik VM emulation (`emulator.createDalvikVM(apk)`) that answers the
|
||||
library's `FindClass`/`GetMethodInfo`/`CallObjectMethod` calls against either real classes from
|
||||
the APK's dex or **stubs you write in Java**.
|
||||
- **Syscalls** — file IO, `mmap`, `pthread` primitives, system properties, `/proc` reads are
|
||||
emulated or redirected.
|
||||
|
||||
You write a Java main that wires those together, calls `JNI_OnLoad` (many hardened libraries do
|
||||
their initialization there), then calls the target function with your inputs. The library's real
|
||||
arm code executes; its output comes back as a Java value.
|
||||
|
||||
**This repository does not bundle unidbg** — it is a Java/maven project, not a script. To use it:
|
||||
|
||||
```bash
|
||||
git clone --depth 1 https://github.com/zhkl0228/unidbg.git
|
||||
cd unidbg
|
||||
# Windows + JDK 17 (measured): the shipped pom pins -source/-target 8, and on JDK 9+
|
||||
# the compiler resolves against the current API where `Module` is ambiguous
|
||||
# (java.lang.Module vs com.github.unidbg.Module) — unidbg-api fails to compile.
|
||||
# <release>8</release> selects the JDK 8 API and fixes it; the bundled Maven wrapper
|
||||
# is 3.5.4, which rejects maven-compiler-plugin 3.13, so 3.8.1 is the version that
|
||||
# both supports <release> and runs on that wrapper.
|
||||
set JAVA_HOME=C:\Program Files\Java\jdk-17.0.4.1
|
||||
mvnw.cmd -B -pl unidbg-android -am -DskipTests compile # BUILD SUCCESS, ~48 s
|
||||
# tests are skipped by <maven.test.skip>true</maven.test.skip> in the pom — override it:
|
||||
mvnw.cmd -B -pl unidbg-android -am -Dmaven.test.skip=false \
|
||||
-Dtest=MemoryTrackerTest -DfailIfNoTests=false test # Tests run: 3, Failures: 0
|
||||
# first run downloads maven itself plus dependencies — minutes
|
||||
```
|
||||
|
||||
Then pick the demo closest to your target under `unidbg-android/src/test/java` and adapt it; the
|
||||
bundled demos (`TTEncrypt`, `SignUtil`, `QDReaderJni`, …) each need the `.so`/APK they reference
|
||||
placed where the test expects it.
|
||||
|
||||
A library project that already uses maven/gradle can instead depend on the published artifacts
|
||||
(group `com.github.zhkl0228`, artifacts `unidbg-android` + `unidbg-api`, on Maven Central) — the
|
||||
clone-above route avoids version drift and gives you the test-suite demos to copy from.
|
||||
|
||||
### The minimal call template
|
||||
|
||||
Adapt this to your target — it is the shape every unidbg driver has (measured against the
|
||||
0.9.x API; class names move rarely but do move, check a current test under
|
||||
`unidbg-android/src/test/java` when something fails to resolve):
|
||||
|
||||
```java
|
||||
import com.github.unidbg.AndroidEmulator;
|
||||
import com.github.unidbg.LibraryLoader;
|
||||
import com.github.unidbg.linux.android.AndroidEmulatorBuilder;
|
||||
import com.github.unidbg.linux.android.AndroidResolver;
|
||||
import com.github.unidbg.linux.android.dvm.*;
|
||||
import com.github.unidbg.memory.Memory;
|
||||
import java.io.File;
|
||||
|
||||
public class CallTarget {
|
||||
public static void main(String[] args) {
|
||||
// 64-bit or 32-bit must match the .so, not the PC.
|
||||
AndroidEmulator emulator = AndroidEmulatorBuilder.for64Bit()
|
||||
.setProcessName("com.example.app") // some libraries check this
|
||||
.build();
|
||||
Memory memory = emulator.getMemory();
|
||||
memory.setLibraryResolver(new AndroidResolver(23)); // bundled system .so set
|
||||
|
||||
// The APK gives the VM real classes for JNI calls; null is allowed if
|
||||
// you intend to stub everything yourself.
|
||||
VM vm = emulator.createDalvikVM(new File("target.apk"));
|
||||
vm.setVerbose(true);
|
||||
vm.setJni(new AbstractJni() { // your JNI callbacks — see below
|
||||
// override methods as the target demands them
|
||||
});
|
||||
|
||||
DalvikModule dm = vm.loadLibrary(new File("libtarget.so"), false);
|
||||
dm.callJNI_OnLoad(emulator); // init: decrypts, registers natives
|
||||
|
||||
// Calling a *statically exported* symbol:
|
||||
// DvmObject<?> ret = dm.callFunction(...)
|
||||
// Calling a *JNI-registered* method by its Java name (most common for hardened SDKs):
|
||||
DvmClass<?> c = vm.resolveClass("com/example/NativeApi");
|
||||
DvmObject<?> result = c.callStaticJniMethodObject(emulator,
|
||||
"sign(Ljava/lang/String;)Ljava/lang/String;", "input");
|
||||
|
||||
System.out.println(result.getValue());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Filling the environment — "supply what is missing"
|
||||
|
||||
A hardened library does not just compute; it *checks*. Every unidbg session converges on the same
|
||||
loop: run, read the first unsupported callback or mapping fault, implement exactly that, repeat.
|
||||
In order of frequency:
|
||||
|
||||
| The library wants | You supply |
|
||||
|---|---|
|
||||
| `JNI_OnLoad` → `FindClass`/`GetMethodID` on app classes | `AbstractJni` overrides (`callStaticObjectMethodV`, `getStaticObjectField`, …) returning canned values; or real classes from the APK via `createDalvikVM(apk)` |
|
||||
| `Context` methods (`getPackageName`, `getFilesDir`, `getPackageManager` → signature!) | stubs — `vm.resolveClass("android/content/Context")` + your `AbstractJni` answers; a signature check wants the *original* APK's signature bytes, which `createDalvikVM(apk)` serves from that very file |
|
||||
| file reads (`/proc/self/maps`, `/data/data/...`, config files) | `emulator.getSyscallHandler().addIOResolver(...)` redirecting to host files, or `virtualFileSystem` mounts; a `/proc/self/maps` check that looks for frida/xposed is satisfied by a clean canned maps file |
|
||||
| other `.so` (cocos, tls, crypto) | `vm.loadLibrary` them first, in dependency order, or drop them beside the target so the resolver finds them |
|
||||
| time / randomness | `emulator.getSyscallHandler` clock override; unidbg's deterministic RNG unless the target seeds from `/dev/urandom` (redirect it) |
|
||||
|
||||
Two structural rules that save hours:
|
||||
|
||||
- **Load order is init order.** `JNI_OnLoad` of the shell often registers natives and decrypts;
|
||||
calling a function before the init that populates it yields a null deref that looks like a bug
|
||||
in unidbg. Always `callJNI_OnLoad` first, and if the library has an explicit init/exported
|
||||
setup entry, call that next.
|
||||
- **32 vs 64 must match the `.so`,** not your PC and not the device's preference
|
||||
(`references/native-and-so.md` §Cross-architecture applies here too: `for32Bit()`/`for64Bit()`
|
||||
mirror the ABI you extracted from `lib/`).
|
||||
|
||||
### Failure modes you will meet
|
||||
|
||||
| Symptom | Meaning | Move |
|
||||
|---|---|---|
|
||||
| `unsupported syscall` / `signal 11 (SIGSEGV)` at a stable address | the library touched something unidbg does not fake yet | read the unidbg log line above the fault — it names the syscall/JNI call; stub it |
|
||||
| `JNI_OnLoad` returns non-zero or throws | self-check failed inside init | enable `vm.setVerbose(true)`, read which check it ran (often signature, path, or a system property), satisfy it via stub |
|
||||
| works, but output differs from device | environment input differs (time, random, files, another lib's state) | diff the inputs: redirect and pin them; on-device RPC (Part B) is the ground truth to compare against |
|
||||
| crashes deep in the library, no unsupported call logged | the code path is genuinely wrong (wrong calling convention, wrong args) — or the library decrypted itself against device state it cannot have here | recheck the prototype (arg types/order) against the dex `native` declaration; then consider RPC instead |
|
||||
| library refuses to load: dependency not found | a `DT_NEEDED` `.so` you did not provide | `readelf -d` the target, load missing libs first |
|
||||
|
||||
Know when to stop: a library that validates its own decrypt key against server data, or that
|
||||
refuses any environment it did not boot in, is **not worth emulating** — that is exactly the shape
|
||||
where the decision table sends you to Part B. (Which anti-emulation checks exist and how common
|
||||
each is: inferred from community experience, not measured here.)
|
||||
|
||||
### Measured here
|
||||
|
||||
See `references/evidence-summary.md` §The capability matrix for the exact commands and outputs behind
|
||||
every label in this section. Measured in that pass: the repository builds on Windows/JDK 17 once
|
||||
the compiler plugin is pointed at the JDK 8 API, the emulator boots, and one bundled test suite
|
||||
passes on the Dynarmic backend. **No target `.so` was emulated yet** — every per-library claim above
|
||||
(which JNI callbacks a hardened library asks for, which syscalls it hits, how many rounds of stubbing
|
||||
it costs) is **inferred** from the library's shape and from community practice, not from a run
|
||||
against this sample.
|
||||
|
||||
## Part B — Frida RPC
|
||||
|
||||
### The mechanism
|
||||
|
||||
A Frida script can export functions to its host: assign to `rpc.exports`, and every property
|
||||
becomes callable from Python (`script.exports_sync.name(...)` after frida 16) with JSON-serializable
|
||||
arguments and return values, synchronously, from outside the process. That is the whole trick:
|
||||
|
||||
```javascript
|
||||
rpc.exports = {
|
||||
add: function (a, b) { return a + b; },
|
||||
};
|
||||
```
|
||||
|
||||
```python
|
||||
script = session.create_script(code); script.load()
|
||||
print(script.exports_sync.add(40, 2)) # -> 42
|
||||
```
|
||||
|
||||
Everything else is packaging: keeping the session alive, finding the function address, converting
|
||||
types across the NativeFunction boundary. The pattern for the case that matters — a hardened
|
||||
library's signing function, exported or `RegisterNatives`-bound:
|
||||
|
||||
```javascript
|
||||
// 1) exported symbol:
|
||||
const addr = Module.findExportByName('libtarget.so', 'Java_..._sign');
|
||||
// 2) dynamically registered (no export — the usual shape for hardened SDKs):
|
||||
// hook RegisterNatives once at startup, note the fnPtr for the name you care about
|
||||
const sign = new NativeFunction(addr, 'pointer', ['pointer', 'pointer']);
|
||||
rpc.exports = { sign: function (s) { return sign(env, jstring(s)); } };
|
||||
```
|
||||
|
||||
Java-side functions are equally callable (`Java.perform` wraps the call), which is how the
|
||||
harmless connectivity probe — `getPackageName` over RPC — works in the template below.
|
||||
|
||||
### The kit's tooling
|
||||
|
||||
`scripts/frida_rpc_serve.py` + `scripts/rpc_template.js` implement the full loop:
|
||||
|
||||
- three front ends: one-shot `--mode call`, interactive `--mode repl`, and a resident
|
||||
`--mode http` endpoint (`POST /call {"export": "...", "args": [...]}`, `GET /exports`,
|
||||
`POST /reload`);
|
||||
- spawn or attach (by name or by pid — attach-by-name fails on incomplete process enumeration,
|
||||
`references/dynamic-frida.md` §Attach by PID);
|
||||
- automatic reconnect: a detached session (app restart, killed server, ROM reaping the server)
|
||||
re-attaches, reloads the script and retries the call, with per-request retry cycles and
|
||||
exponential backoff; a killed-and-restarted device server is recovered by dropping the cached
|
||||
remote device before reconnecting;
|
||||
- the template exports `add` (transport sanity), `getpackagename` (Java probe),
|
||||
`callnative(module, export, retType, argTypes, args)` and `callnativeaddr(module, offset, ...)`
|
||||
for wrapping any native symbol, with a frida-16/17-compatible export resolver.
|
||||
|
||||
Typical session (device server already running and forwarded, `references/dynamic-frida.md`
|
||||
§Setup):
|
||||
|
||||
```bash
|
||||
adb -s <serial> forward tcp:27043 tcp:27043
|
||||
python scripts/frida_rpc_serve.py --remote 127.0.0.1:27043 --package com.example.app \
|
||||
--script scripts/rpc_template.js --mode repl
|
||||
rpc> list
|
||||
rpc> getpackagename
|
||||
rpc> callnative ["libtarget.so","Java_com_example_Native_sign","pointer",["pointer","pointer"],[...]]
|
||||
```
|
||||
|
||||
Then feed the recovered function into request generation the way `references/server-api.md`
|
||||
describes — the RPC service turns "the server checks a signed header" into "I can compute that
|
||||
header on demand", which is the whole reason this route exists.
|
||||
|
||||
### What the live route costs — measured failure modes
|
||||
|
||||
Every row below was hit against a real device during the verification pass behind this file
|
||||
(exact outputs in `references/evidence-summary.md` §The capability matrix):
|
||||
|
||||
| Failure | What it looks like | Handling |
|
||||
|---|---|---|
|
||||
| **Hardened target kills injection** | attach: `process either refused to load frida-agent, or terminated during injection`, logcat `CRASH ... IterateRegisters found fp`; spawn: script loads, then every call dies with `script has been destroyed` | the shell's anti-instrumentation won; do not retry the same attach — switch route (unidbg for the crypto, or `references/lsposed-and-modules.md` / static patching), or fight the detector deliberately (`references/detection-and-anti-analysis.md`) |
|
||||
| **ROM reaps the server** | `connection closed` mid-session, sometimes repeatedly | budget restarts, not re-analysis: the bridge reconnects once the server is back; check `pidof <server>` before every experiment block (`references/dynamic-frida.md`) |
|
||||
| **Attach by name fails for processes the device can see** | `unable to find process with name 'com.example.app'` while `frida-ps -Uai` and `ps -A` both list it alive; reproduced for a system app and for a Magisk-manager process, for every package name tried | resolve the pid yourself and attach with `--pid`: measured on the same ROM, `device.attach(pid)` loaded the script and returned RPC results for the very processes whose *name* lookup failed (`--pid 19938` → `com.android.settings`, `--pid 21493` → `com.android.email`). Take the pid from `frida-ps -U`, `ps -A`, or `adb shell pidof <pkg>` |
|
||||
| **The server comes back on a non-default port** | `frida.ServerNotRunningError: unable to connect to remote frida-server: closed` on attach even though `frida-ps -U` still works | the ROM or a watchdog reaped the visible server and a disguised copy returned elsewhere (measured: only `*.svc16` left alive, listening on 27043, nothing on 27042). Check the listening port, `adb forward tcp:27043 tcp:27043`, then `--remote 127.0.0.1:27043` — do not assume the default port |
|
||||
| **Attach by pid times out on a busy process** | `frida.TimedOutError: unexpectedly timed out while waiting for signal from process with PID X` during attach (raised by an ordinary third-party app); it is not a `TransportError` subclass and escaped every handler | nothing is wrong with the tooling — the process did not answer the agent handshake; use another process for the connectivity probe, or retry once the app settles (the bridge now treats `TimedOutError` as retryable and fails with one line instead of a traceback) |
|
||||
| **Short-lived processes** | the settings main activity process exits seconds after `am start` | pick a persistent process for connectivity tests; the business target only when it actually stays up |
|
||||
|
||||
The strategic point those rows make together: **on a hardened target, RPC is not guaranteed to be
|
||||
available at all** — the injection ticket can be revoked by the target itself. That is the single
|
||||
strongest argument for keeping the unidbg route warm, despite its environment cost: it is the one
|
||||
route that works when the process refuses to be instrumented.
|
||||
|
||||
### Combining the routes
|
||||
|
||||
The routes compose rather than compete:
|
||||
|
||||
1. **RPC as oracle, unidbg as student.** When an emulated function's output must be trusted, diff
|
||||
it against the live process's output for identical inputs; disagreement localizes the missing
|
||||
environment piece far faster than reading the library (`Part A` §failure modes, "output
|
||||
differs").
|
||||
2. **Dump via RPC, emulate after.** A live call that returns the decrypted key/table the library
|
||||
uses (`references/runtime-data.md`) can be replayed into the emulated run as a fixed stub.
|
||||
3. **Batch offload.** Use the device session to discover the calling convention and to capture
|
||||
sample inputs/outputs (few calls), then run the bulk of the work offline under unidbg.
|
||||
|
||||
## What to record
|
||||
|
||||
- which route was chosen and why (one line, against the decision table)
|
||||
- for unidbg: the API level resolver, the list of JNI stubs you had to write, what the library
|
||||
checked before it would compute (that list *is* the target's defense inventory)
|
||||
- for RPC: server version vs host version, attach vs spawn, reconnect behaviour observed, and —
|
||||
decisive for a hardened target — whether injection survived at all
|
||||
- inputs/outputs for at least one call verified against an independent source (device-side log,
|
||||
server response, or a known-answer test)
|
||||
@@ -0,0 +1,306 @@
|
||||
# Environment — device, emulator, tooling, networking
|
||||
|
||||
|
||||
**Load this when:** before the first experiment on a device, and again whenever a failure surprises you. It gives device/emulator selection, root, ADB and networking, the preflight check, and how to look at the screen instead of driving blind.
|
||||
|
||||
## Pick your target
|
||||
|
||||
| Option | Pros | Cons |
|
||||
|---|---|---|
|
||||
| **Rooted physical device, native ABI** | Truest behavior; native libs load natively; Frida works well | One at a time; USB flakiness |
|
||||
| **Emulator with root** | Disposable; snapshots; easy reset | Different ABI; some apps detect it or refuse to run; native ARM libs may need translation |
|
||||
| **Static only** | No device needed | Cannot verify anything. Never claim success from static analysis alone. |
|
||||
|
||||
**Rule:** the artifact must be verified where the user will run it. An emulator that runs the app is **not** evidence about a physical ARM device, and vice versa.
|
||||
|
||||
**Important:** if the app ships only `arm64-v8a` native libraries and your emulator is x86_64, it may still run via ARM translation — but translation changes timing, and some native checks misbehave. Prefer a real ARM device for the final verification pass.
|
||||
|
||||
## ADB basics worth pinning down
|
||||
|
||||
```bash
|
||||
adb devices -l
|
||||
adb -s <serial> shell getprop ro.product.cpu.abi
|
||||
adb -s <serial> shell getprop ro.build.version.release
|
||||
adb -s <serial> shell getprop ro.build.version.sdk
|
||||
```
|
||||
|
||||
Root:
|
||||
```bash
|
||||
adb -s <serial> shell "su -c id"
|
||||
```
|
||||
`pm grant` and `pm install` frequently require root on OEM builds; the `shell` user gets `SecurityException`.
|
||||
|
||||
**Always specify `-s <serial>` when more than one device is attached** — otherwise adb errors out or picks the wrong one.
|
||||
|
||||
## Shell quoting (a real time sink)
|
||||
|
||||
The host shell expands `$`, `|`, `>`, and quotes **before** adb sees them. Windows PowerShell additionally mangles `$var:`, `[^"...`, and `$(...)`.
|
||||
|
||||
Symptom: "Could not find a part of the path", "Missing type name after '['", "no closing quote".
|
||||
|
||||
**Fix:** never inline device commands in the host shell. Use a small helper that calls adb from a script file and passes the command as a single argument:
|
||||
|
||||
```python
|
||||
subprocess.run([ADB, '-s', SERIAL, 'shell', 'su -c "%s"' % cmd])
|
||||
```
|
||||
See `scripts/devsh.py`.
|
||||
|
||||
Same class of bug: `javac` reads sources using the platform default encoding. Pass `-encoding UTF-8` or non-ASCII comments break the build.
|
||||
|
||||
## Giving an offline device network over USB
|
||||
|
||||
Use when the device has no usable network (broken Wi-Fi, no SIM, restricted network) but your host does. **`adb reverse` runs on the device's loopback, so it needs no device-side network interface at all.**
|
||||
|
||||
```bash
|
||||
# 1) host HTTP/HTTPS proxy (CONNECT-capable)
|
||||
python scripts/usb_net_proxy.py 8080 proxy.log
|
||||
|
||||
# 2) forward device-localhost:8080 to host:8080
|
||||
adb -s <serial> reverse tcp:8080 tcp:8080
|
||||
|
||||
# 3) point the device at it
|
||||
adb -s <serial> shell "su -c 'settings put global http_proxy 127.0.0.1:8080'"
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `adb reverse` **does not survive a device reboot** — recreate it after any restart.
|
||||
- Keep the host proxy process alive; if it dies, the device goes offline again.
|
||||
- Some SDKs bypass the system proxy entirely (many ad SDKs do). Proxy logs therefore **undercount** traffic — do not conclude "no ad traffic" from proxy logs alone; confirm with a runtime DNS hook.
|
||||
- Clean up when done: `settings put global http_proxy :0`.
|
||||
|
||||
## Preflight — run this before every experiment block
|
||||
|
||||
`scripts/preflight.py` checks, read-only, everything that silently fakes a failure: device
|
||||
reachability, whether more than one device is attached without an explicit serial, root, the
|
||||
runtime translation layer, device/host clock drift, a leftover device-wide proxy setting, stale
|
||||
port forwards, a dead device server, the ABI the package manager actually chose, and the free
|
||||
space left for installs.
|
||||
|
||||
```bash
|
||||
python scripts/preflight.py --pkg <app.package> --expect-root
|
||||
python scripts/preflight.py --cleanup # also clears a leftover proxy and stale forwards
|
||||
```
|
||||
|
||||
**The rule it exists to enforce:** *do not attribute a failure to your patch while preflight is
|
||||
dirty.* A surprising fraction of "my change broke it" is a device that was already in a bad state.
|
||||
Costing thirty seconds here is cheaper than costing several rounds to a wrong conclusion, and the
|
||||
wrong conclusion is the one that gets written down and trusted later.
|
||||
|
||||
## Emulator notes
|
||||
|
||||
Emulators are the right place to iterate and the wrong place to conclude.
|
||||
|
||||
- **Verify the app can install at all.** INSTALL failures on emulators are common (ABI, min SDK,
|
||||
vendor checks, a stale copy with a different signature) and are usually **not** related to your
|
||||
patch. Get a clean install of the *unmodified* build working before you change anything.
|
||||
- **Snapshot/rollback is the main advantage** — use it to A/B two builds quickly, and to get back
|
||||
to a known-good state after a destructive experiment.
|
||||
- **Never quote emulator behavior as proof for a device-only question** (and vice versa). An
|
||||
emulator that runs the app is not evidence about a physical ARM device.
|
||||
- **Most vendors ship a console binary that is far more reliable than the GUI.** Learn yours early;
|
||||
it is what you will need when the GUI is unresponsive and adb is already down.
|
||||
|
||||
| Vendor | Typical console binary | Useful verbs |
|
||||
|---|---|---|
|
||||
| LDPlayer | `dnconsole.exe` (next to the player exe) | `list2`, `launch --index N`, `reboot --index N`, `quitall` |
|
||||
| MuMu | `MuMuManager.exe` | `info -v all`, `control -v N launch`, `control -v N shutdown` |
|
||||
| AVD / Android Emulator | `emulator.exe` | `-list-avds`, `@<avd>`, `-no-window` |
|
||||
| Genymotion | `gmtool` | `admin list`, `admin start` |
|
||||
|
||||
- **Recovery order when `adb devices` goes empty.** Do these in order; the first two are the ones
|
||||
people skip:
|
||||
|
||||
1. `adb kill-server && adb start-server` — clears a wedged host daemon.
|
||||
2. Re-run `adb devices`. If still empty, check whether the **emulator process is actually alive**
|
||||
(`tasklist` / `ps`). A running process with **no listening port** means the VM never finished
|
||||
booting its adb bridge — restarting the *device* is the only fix.
|
||||
3. Restart the instance through the vendor console (`reboot --index N`). Restarting via the
|
||||
player binary's "launch" verb frequently leaves you with a process and still no adb.
|
||||
4. Only then consider that something is wrong with the host.
|
||||
|
||||
Symptom worth memorising: **the emulator process exists but nothing is listening on the adb
|
||||
port.** Distinguishing "not running" from "running but not booted" is what makes step 2 worth
|
||||
doing — they have different fixes and only one of them needs the GUI.
|
||||
|
||||
- **Root is a per-vendor setting, not a given.** Most emulators expose it as a toggle in their
|
||||
settings ("root permission" / "ROOT 权限"); some ship a separate rooted image. Always verify
|
||||
rather than assume:
|
||||
|
||||
```bash
|
||||
adb -s <serial> shell "su -c id" # want: uid=0(root)
|
||||
```
|
||||
|
||||
A rooted emulator is usually the fastest environment for everything in this skill, which makes it
|
||||
easy to forget that it is also the *least representative* one.
|
||||
|
||||
- **Running two instances.** Different serials make two emulators genuinely disjoint, which is
|
||||
useful for holding a clean control (original build, untouched state) alongside your workbench.
|
||||
Keep them labelled, always pass `--serial`, and **do not run the same experiment on both** — the
|
||||
value of a second device is independence, and duplicating work destroys it. Operate one at a
|
||||
time; the other is a reference point, not extra throughput.
|
||||
|
||||
- **Apps can detect the emulator** and change behaviour or refuse to run. If the app behaves
|
||||
differently here than on hardware, that is a finding about the emulator, not about the app. Note
|
||||
it and move the question to a real device.
|
||||
|
||||
## Which architecture is actually executing
|
||||
|
||||
Device selection and architecture are the same question. `getprop ro.product.cpu.abi` reports what
|
||||
the device claims; it does **not** report what is executing. Read `native-and-so.md` §Cross-architecture
|
||||
before you choose which library to patch, and `scripts/lib_map.py` to see the live truth.
|
||||
|
||||
## Install / reinstall
|
||||
|
||||
```bash
|
||||
adb -s <serial> shell "su -c 'pm uninstall <pkg>'"
|
||||
adb push out.apk /data/local/tmp/x.apk
|
||||
adb -s <serial> shell "su -c 'pm install -r -t -d /data/local/tmp/x.apk'"
|
||||
```
|
||||
- `-r` reinstall (keep data, same signing key) · `-t` allow test-only · `-d` allow downgrade
|
||||
- Different signing key than the installed app → must uninstall first (data is lost)
|
||||
- Vendor installers may reject `adb install`; pushing + `su -c pm install` usually works
|
||||
|
||||
**After reinstalling:** if you restored a data directory, fix ownership, or the app crashes in a DB-init path:
|
||||
```bash
|
||||
su -c "chown -R <uid>:<uid> /data/user/0/<pkg>"
|
||||
su -c "restorecon -R /data/user/0/<pkg>"
|
||||
```
|
||||
`<uid>` from `dumpsys package <pkg> | grep userId=`.
|
||||
|
||||
## Driving the UI from adb
|
||||
|
||||
Automated UI interaction is where verification loops usually break, and most of the breakage looks like a bug in your script. Most of it is not.
|
||||
|
||||
### `input tap` may simply not work on a given control
|
||||
|
||||
On some OEM ROMs `input tap <x> <y>` is silently ignored for certain controls while working fine for others at neighboring coordinates. **Do not conclude that your automation is wrong.** Before doubting the script:
|
||||
|
||||
```bash
|
||||
adb -s <serial> shell "su -c 'uiautomator dump /sdcard/ui.xml'"
|
||||
adb -s <serial> pull /sdcard/ui.xml
|
||||
# bounds="[x1,y1][x2,y2]" -> tap the center: ((x1+x2)/2, (y1+y2)/2)
|
||||
adb -s <serial> shell "su -c 'input tap <cx> <cy>'"
|
||||
```
|
||||
|
||||
If the control still does not react, bypass the UI path entirely — `am start -n <app.package>/<activity>`, or invoke the logic from Frida. A finished task needs the *code path*, not the tap.
|
||||
|
||||
`input` requires `INJECT_EVENTS`, which the plain `shell` user does not have. An unprivileged `input` **fails silently** — no error, no effect. Always wrap it: `su -c 'input tap ...'`.
|
||||
|
||||
`input text` additionally mangles or drops non-ASCII input. For CJK text, either install an ADB-driven helper IME or set the field from a runtime call rather than typing.
|
||||
|
||||
### Look at the screen — do not drive and wait blind
|
||||
|
||||
The most expensive habit in device work is: tap a coordinate, sleep, tap again, sleep, conclude
|
||||
something about the app. A screen that is *looked at* answers in one step what coordinate-guessing
|
||||
cannot answer in five — the layout shifted, a different dialog came up, a countdown is frozen, the
|
||||
button is disabled, the text on screen says exactly why.
|
||||
|
||||
**Treat a look as a routine step, not a debugging last resort.** Capture:
|
||||
|
||||
- **immediately before** anything time-dependent, so you know the starting state;
|
||||
- **during** a wait, at intervals — state changes are the information, and a single sample at the end
|
||||
cannot distinguish "it progressed" from "it never moved";
|
||||
- **at every decision point**, before choosing the next action;
|
||||
- **on any surprise**, before forming a theory about it.
|
||||
|
||||
`scripts/snap.py` does this with sane bounds, and tells you which kind of evidence you actually got:
|
||||
|
||||
```bash
|
||||
python scripts/snap.py --out shots --tag before
|
||||
python scripts/snap.py --out shots --tag waiting --count 6 --interval 3
|
||||
```
|
||||
|
||||
**Use the stall detector.** If consecutive samples are byte-identical, nothing is happening and more
|
||||
waiting cannot help. Stop, and go find out why — that is a different investigation from waiting
|
||||
longer.
|
||||
|
||||
### Two kinds of screen evidence, and which one is trustworthy here
|
||||
|
||||
| Evidence | What it gives you | When it is the right one |
|
||||
|---|---|---|
|
||||
| **the image** | exactly what is rendered: layout, which dialog, disabled states, drawn text | always available; the **only** evidence for runtime-drawn UI |
|
||||
| **the control tree** (`uiautomator dump`) | precise `bounds`, exact text, diffable | only when it actually has content |
|
||||
|
||||
The tree is more convenient *when it exists* — it gives you tap coordinates and text you can diff. But
|
||||
**verify it has content before planning around it**:
|
||||
|
||||
```bash
|
||||
adb -s <serial> shell "su -c 'uiautomator dump /sdcard/ui.xml'"
|
||||
adb -s <serial> pull /sdcard/ui.xml
|
||||
grep -c '<node' ui.xml # 0 nodes -> the tree is useless for this screen
|
||||
```
|
||||
|
||||
**A runtime-rendered UI frequently exposes no real controls at all.** Cross-platform runtimes, web
|
||||
views and canvas-drawn surfaces often produce an empty (or text-free) tree, which is why a plan built
|
||||
on "read the bounds out of the XML" stalls on exactly those apps — and also why tapping a control that
|
||||
"should be there" silently does nothing. When the tree is empty, the image is the **primary** evidence,
|
||||
not a fallback, and you read it directly rather than trying to derive coordinates from a tree that does
|
||||
not exist.
|
||||
|
||||
`screencap` itself returns a 0-byte file on some ROMs. Write on-device and pull instead:
|
||||
|
||||
```bash
|
||||
adb -s <serial> shell "su -c 'screencap -p /sdcard/x.png'" && adb -s <serial> pull /sdcard/x.png
|
||||
```
|
||||
|
||||
If that is also empty, do not fight it — but note that **a 0-byte capture is not evidence the screen is
|
||||
blank** (`pitfalls.md` P20). And if `uiautomator dump` fails with `could not get idle state`, retry once;
|
||||
a paused animation is usually the cause.
|
||||
|
||||
### Verify form input by reading it back
|
||||
|
||||
Filling a form and tapping submit is **not** a verified interaction. Read the actual field contents and lengths back first:
|
||||
|
||||
```bash
|
||||
adb -s <serial> shell "su -c 'input text <value>'" # no literal spaces; %s encodes one
|
||||
adb -s <serial> shell "su -c 'uiautomator dump /sdcard/ui.xml'"
|
||||
adb -s <serial> pull /sdcard/ui.xml
|
||||
grep -o 'text="[^"]*"' ui.xml # every populated field
|
||||
```
|
||||
|
||||
A whole class of "the button does nothing" is really a local validation rejecting the input — two password fields of different length, a required field empty, a format check. The app returns **before** issuing any request, so a network probe stays silent and the tap looks broken. Compare the lengths you read back, fix the input, retry.
|
||||
|
||||
### ROM background freezing kills your hooks
|
||||
|
||||
Aggressive ROMs freeze backgrounded apps; the logcat signature is a process state transition `state: R -> F` (running to frozen). Once frozen, hooks stop firing and network calls stop, which looks exactly like a broken probe.
|
||||
|
||||
```bash
|
||||
adb -s <serial> shell "su -c 'dumpsys deviceidle whitelist +<app.package>'"
|
||||
adb -s <serial> shell "su -c 'cmd appops set <app.package> RUN_IN_BACKGROUND allow'"
|
||||
```
|
||||
|
||||
While debugging, **do not press HOME** — backgrounding the app is what triggers the freeze. Return to it with `am start -n <app.package>/<activity>` and keep it in the foreground for the whole session.
|
||||
|
||||
## Signal extraction (what to actually read)
|
||||
|
||||
```bash
|
||||
adb -s <serial> logcat -c # clear before the run
|
||||
adb -s <serial> shell "am start -n <pkg>/<activity>"
|
||||
adb -s <serial> logcat -d -v brief | grep -E '<pkg>|FATAL|VerifyError|IncompatibleClassChange|uncaughtException'
|
||||
```
|
||||
|
||||
Failure signatures worth memorizing:
|
||||
|
||||
| Log line | Likely cause |
|
||||
|---|---|
|
||||
| `FATAL EXCEPTION` + Java stack | App-level crash — read the stack |
|
||||
| `VerifyError` / `IncompatibleClassChangeError` | Damaged dex (round-trip or bad rewrite) → `pitfalls.md` P3 |
|
||||
| `Failure starting process` (no stack, by `ActivityManager`) | ART refused the dex, or device state is broken → `pitfalls.md` P4, P9 |
|
||||
| `ClassNotFoundException: <App.Application>` | Dex rejected wholesale (ordering / structure) → `pitfalls.md` P2 |
|
||||
| `uncaughtException` with **no stack**, right after launch | A crash-reporter SDK swallowed it. Check whether the process is gone, and look for the SDK's own log file under the app's data dir |
|
||||
| App alive but nothing rendered | A swallowed exception in the UI path; hunt the crash-reporter's log file |
|
||||
|
||||
**Crash-reporter SDKs hide your stack traces.** When an app installs a global uncaught-exception handler (友盟/UCrash/Bugly etc.), the Java stack never reaches `logcat` — only a line like `uncaughtException time: ...`. Three ways to get the stack:
|
||||
1. **Frida**, hooking `Thread.setDefaultUncaughtExceptionHandler` or the handler class (most reliable).
|
||||
2. **Race the reporter's log file**: it writes a file under `<app data>/<sdk>/...` then uploads and deletes it. Poll it every ~0.2 s from the device shell and copy on sight:
|
||||
```bash
|
||||
while [ $i -lt 400 ]; do
|
||||
for f in <dir>/*.log; do [ -f "$f" ] && cp -f "$f" /data/local/tmp/capture.log; done
|
||||
i=$((i+1)); sleep 0.2
|
||||
done
|
||||
```
|
||||
3. Run a build with the reporter disabled (not always possible).
|
||||
|
||||
## Determinism
|
||||
|
||||
Before concluding anything from a failure: **reboot the device and retry**, and **run a control build with zero patches**. A surprising share of "my patch broke it" turns out to be device state (`pitfalls.md` P9).
|
||||
@@ -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.
|
||||
@@ -0,0 +1,120 @@
|
||||
# Cross-Platform Runtimes
|
||||
|
||||
Load this when the app's UI is not native, or when Java-layer hooks produce no hits at all.
|
||||
|
||||
Many apps ship a native shell plus a cross-platform runtime. Their business logic, their UI, and their
|
||||
feature gates live **inside the runtime**, not in the dex. Patching the dex then does nothing — the Java
|
||||
code you can see is mostly glue. Determine the runtime before choosing where to patch.
|
||||
|
||||
## Identify the runtime early
|
||||
|
||||
Signals in the APK:
|
||||
|
||||
| Runtime | Giveaways |
|
||||
|---|---|
|
||||
| Flutter | `libflutter.so`, `libapp.so`, `assets/flutter_assets/`, Dart snapshot |
|
||||
| React Native | `libhermes.so` or `libjsc.so`, `index.android.bundle`, `assets/index.android.bundle` |
|
||||
| Unity | `libunity.so`, `libil2cpp.so`, `assets/bin/Data/`, `globalgamemanagers` |
|
||||
| Cordova / Ionic | `assets/www/`, `cordova.js` |
|
||||
| Xamarin / .NET | `libmonodroid.so`, `assemblies/` in the APK |
|
||||
|
||||
A `classes.dex` that is small relative to the total payload is another strong hint that the real code
|
||||
lives elsewhere.
|
||||
|
||||
## The layer trap
|
||||
|
||||
The expensive mistake: seeing a UI element (dialog, paywall, gate) and assuming it is a native control,
|
||||
then spending a long time looking for it in the dex.
|
||||
|
||||
If it is drawn by the runtime, the dex contains only the bridge. Symptoms of being on the wrong layer:
|
||||
|
||||
- hooking the obvious Java dialog/Activity classes produces **zero** hits while the UI clearly appears;
|
||||
- the classes that do appear in stacks are the runtime's own, with obfuscated names;
|
||||
- hooking a Java method changes nothing about the visible behaviour.
|
||||
|
||||
Establish layer ownership **before** investing in a patch direction.
|
||||
|
||||
### How to tell which layer drew a given UI
|
||||
|
||||
1. Enumerate the Java dialog and presentation classes and hook their show/creation paths.
|
||||
2. Reproduce the UI. If any hook fires, the caller stack names the detection and you are on the Java
|
||||
layer.
|
||||
3. If nothing fires while the UI is on screen, the UI is runtime-drawn or native-drawn. Switch axis.
|
||||
4. Expect runtime-owned windows to appear as ordinary system `Dialog`/`Presentation` objects used as
|
||||
**containers** for the runtime's surface. A hit on such a container is not evidence that the visible
|
||||
element is a native dialog — read the class name carefully before drawing conclusions.
|
||||
|
||||
## Flutter specifics
|
||||
|
||||
- Business logic is compiled ahead-of-time into **`libapp.so`**; UI is rendered by `libflutter.so` with
|
||||
no per-widget Java objects.
|
||||
- **Widgets, dialogs, and paywalls are not Java views.** They will never appear in Java stack traces,
|
||||
and no Java hook can intercept them.
|
||||
- The Java side is thin: one Activity, plus plugin classes. Calls cross the boundary through the
|
||||
platform-channel mechanism, whose class names are frequently obfuscated.
|
||||
- Because channel classes may be renamed, locate them **by method signature and call shape**, not by
|
||||
class name.
|
||||
|
||||
### Where to intervene in a Flutter app
|
||||
|
||||
| Goal | Layer | Notes |
|
||||
|---|---|---|
|
||||
| Stop the app being killed / refused at startup | host native library | earliest point, see `native-and-so.md` |
|
||||
| Observe what the runtime is told | platform channel | shows the messages that drive the UI |
|
||||
| Change a decision permanently | `libapp.so` | most direct, hardest to locate |
|
||||
| Change startup behaviour only | host native library constructor | survives runtime restarts |
|
||||
|
||||
Editing `libapp.so` is the most durable but locating Dart AOT code is genuinely hard. This subject is
|
||||
large enough to have its own reference: **`references/dart-aot.md`** covers pinning the Dart version,
|
||||
building a decompiler for exactly that version, the object-pool model and how to index it, the register
|
||||
and boolean conventions, the three assembly signatures that identify most business logic, the locating
|
||||
workflow, and how to patch this layer safely.
|
||||
|
||||
## Locating logic without symbols
|
||||
|
||||
- **Strings first.** User-visible text (dialog bodies, feature labels) is the cheapest anchor in any
|
||||
runtime. Find it, then find what references it.
|
||||
- **Search them in the encoding the runtime actually uses, or you will conclude they do not exist.**
|
||||
This is the single most common false negative in cross-platform work. A runtime snapshot does not
|
||||
necessarily store text as UTF-8; UTF-16 (little-endian on these targets) is common and entirely
|
||||
valid. A UTF-8 search of such a snapshot returns **zero hits**, which reads as "the strings were
|
||||
stripped / encrypted, this route is dead" — and that conclusion is wrong.
|
||||
|
||||
```python
|
||||
blob = open('libapp.so', 'rb').read() # or whichever artifact holds the snapshot
|
||||
for phrase in ('<feature label>', '<dialog title>'):
|
||||
print(phrase, 'utf-8:', blob.find(phrase.encode('utf-8')),
|
||||
'utf-16le:', blob.find(phrase.encode('utf-16-le')))
|
||||
```
|
||||
|
||||
Try both, and try a short distinctive substring rather than a long phrase. Which encoding applies
|
||||
is predictable rather than random: in a Dart AOT snapshot ASCII literals are stored one-byte (a
|
||||
UTF-8 search finds them) while CJK / non-Latin literals are UTF-16LE (a UTF-8 search returns zero).
|
||||
`dart-aot.md` §7 gives the exact framing, and `scripts/dart_pool_strings.py --find` locates either
|
||||
encoding by file offset.
|
||||
- **A phrase that is absent may simply never exist as one literal.** UI text is often assembled from
|
||||
fragments or templates, so "the whole sentence" can be missing while both halves are present.
|
||||
Search the shortest distinctive token, and expect the interesting anchor to be a *label* rather
|
||||
than a sentence.
|
||||
- **Landing on a string tells you where the text lives, not which code decided to show it.** Treat the
|
||||
hit as an anchor for reference-hunting, not as the answer.
|
||||
- **Compare two builds.** The same feature in a slightly different version often reveals the code path.
|
||||
- **Watch the boundary, not the interior.** For cross-platform apps it is usually far cheaper to observe
|
||||
what crosses between layers than to reverse the interior of the runtime.
|
||||
- **Do not assume the obfuscated names are stable or meaningful.** They are not, and treating them as
|
||||
identities leads to conclusions that break on the next build.
|
||||
- **Obfuscated identifiers can be non-ASCII and invisible.** Renaming passes can replace class and
|
||||
member names with characters outside ASCII (combining marks, variant selectors and similar). If a
|
||||
class exists at runtime but "cannot be found" by the name you read from a decompiler, suspect
|
||||
encoding or normalisation in your tooling rather than a missing class. Resolve such members by
|
||||
**shape** — parameter counts, types, interface implementation — instead of by name, and prefer
|
||||
runtime enumeration of loaded classes over guessing identifiers.
|
||||
|
||||
## What this changes about your plan
|
||||
|
||||
- Budget for the runtime layer **up front**. If the app is Flutter/RN/Unity, the dex is not the main
|
||||
battlefield and a dex-only plan will stall.
|
||||
- Any behavioural claim must be validated **through the runtime's own UI**, because that is what the
|
||||
user sees. A patch that changes internal state without changing the UI is not a fix.
|
||||
- Keep the layer you are working in explicit in your notes. "Patched the paywall" is meaningless without
|
||||
saying which layer owned it.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Hand-off boundaries — where this skill ends and another view begins
|
||||
|
||||
Four boundaries that are easy to walk into without noticing. Each names what the other side owns
|
||||
rather than restating it, because two copies of the same advice drift apart. `SKILL.md` keeps the
|
||||
one-line version of each so a routing decision can be made without loading this file; this file
|
||||
carries the detail.
|
||||
|
||||
## 1. JNI — a Java `native` declaration and its implementation are two different views of one function
|
||||
|
||||
This skill reads the Java side (dex) and the native side (`.so`) with different tools, so the *join*
|
||||
is where analyses go wrong.
|
||||
|
||||
| Form | What you see | How to find it |
|
||||
|---|---|---|
|
||||
| Static linkage | symbol `Java_<pkg>_<Class>_<method>` in `.dynsym` | search the dynamic symbol table. Under R8 the class name is a short name, so the symbol deforms with it and a search for the readable original finds nothing |
|
||||
| Dynamic registration | **nothing** in the symbol table — binding happens at runtime | find `RegisterNatives` call sites, or hook it to read the binding table. Obfuscated targets prefer this, and a symbol search fails **silently** on it |
|
||||
| Native → Java callbacks | native code pulling data back through Java | follow `FindClass` / `GetMethodID` / `CallObjectMethod` |
|
||||
|
||||
`FindClass`/`RegisterNatives` in a `.so` tell you a JNI boundary exists even when no `Java_*` symbol
|
||||
does. **Strength note:** the three rows above are documented behaviour, not results from the first
|
||||
verification pass, which did not trace a JNI boundary end to end; `FlutterJNI.loadLibrary` appearing
|
||||
in a dex is the closest it came. Treat them as a map, not as a measurement.
|
||||
|
||||
**The benchmark pass then measured the failure the middle row predicts:** a real dynamically
|
||||
registering library contains **zero `RegisterNatives` symbols**, because the C++ `jni.h` inlines the
|
||||
call — so a symbol-based search fails structurally, not by bad luck. The combination that did fire
|
||||
on a real library was *"exports `JNI_OnLoad` and zero `Java_*`"*. The authoritative treatment is
|
||||
`references/java2c-and-jni-sinking.md` §The JNI boundary — why a symbol search fails silently; this
|
||||
section is the pointer, not the source.
|
||||
|
||||
## 2. Hardening — a dex-side packer observation is a native-side implementation question
|
||||
|
||||
If the dex turns out to be a shell, the logic is behind a loader and the analysis moves to the `.so`
|
||||
that performs the unpacking. `packers.md` owns the dex-side identification; the native deep dive
|
||||
belongs on the other side of this boundary. **Not exercised by the first verification pass** — that
|
||||
target had no packer, so this pointer carries no measurement from it. The benchmark pass measured the
|
||||
*shape discrimination* on public samples but still did not run a live extraction shell
|
||||
(the benchmark matrix (`references/evidence-summary.md` §The capability matrix) row B3).
|
||||
|
||||
## 3. The existing native boundaries — read native anomalies from the APK side, not from inside
|
||||
|
||||
`native-and-so.md` and `native-tamper-and-suicide.md` are deliberately scoped to what you can
|
||||
conclude *from the APK side*: a repacked build that dies instantly with a null-looking fault, a
|
||||
Java-layer check that reports success while the process dies, a terminate path you made not-return.
|
||||
That judgement belongs here, because it is about deciding whether your *patch* caused the death. Deep
|
||||
native work — restoring a symbol, rebuilding a call graph, reversing an OLLVM function — is a
|
||||
different activity with a different toolchain. Point across rather than duplicating: if you need the
|
||||
latter, say so instead of extending those two files into it.
|
||||
|
||||
## 4. When the deliverable stops being an APK, the verification question changes with it
|
||||
|
||||
`SKILL.md` §What "done" means is written for a rebuilt, installable artifact, and every word of it
|
||||
assumes one. The other three forms in G1 each move the evidence somewhere else, and the failure mode
|
||||
is quiet: **a privileged result gets reported in the language of a finished build.**
|
||||
|
||||
| Form | What "verified" now means | What is *not* evidence |
|
||||
|---|---|---|
|
||||
| **LSPosed / Xposed module** | The module was loaded into the target and its hook produced an observable effect **in the target's own log**, on a named build of the target | The module installed; `pm path` returned a path; the package was enabled in the manager. All three are true of a module whose entry class does not exist in its own dex |
|
||||
| **Local RPC / emulation service** | A call returned the value the app itself would produce, from a named target build and a named device or emulated environment — and the harness survives a reconnect | "The script loaded"; a call that returned *something* without a reference value to compare against |
|
||||
| **Analysis report with a stated boundary** | The evidence chain (commands, outputs, and the layer each conclusion belongs to) plus the boundary — what was **not** determined and why | Any implication that a route was exhausted when it was only abandoned |
|
||||
|
||||
The privileged-form drift R1 warns about lives here. `references/lsposed-and-modules.md`,
|
||||
`references/emulation-and-rpc.md` and `references/verification.md` each carry the specific check; this
|
||||
table is the reminder that changing the deliverable's form is a decision that must be re-stated out
|
||||
loud, not a quiet downgrade of what counts as done.
|
||||
|
||||
**A fifth boundary, added by the benchmark pass:** when the deliverable is a **tool or a document in
|
||||
this repository**, "verified" means an independent check exists — a second implementation, an
|
||||
official disassembler, a byte-exact round trip. A tool's own self-test passing is not that check.
|
||||
the benchmark matrix (`references/evidence-summary.md` §The capability matrix) records which rows have one and which do not.
|
||||
@@ -0,0 +1,149 @@
|
||||
# Java2C and JNI Sinking — the dex is not where the code is
|
||||
|
||||
Load this when the dex shows method bodies missing or replaced by `native` declarations, and
|
||||
you are about to go looking for a decrypted DEX in memory. **Decide which of the two shapes you
|
||||
have before you spend anything**, because one of them has no DEX to find at any point in the
|
||||
process lifetime, and the search for it is unbounded.
|
||||
|
||||
Two neighbouring routes have historically folded this shape into one: `advanced-unpacking.md`'s
|
||||
dump-shape table and the SKILL.md symptom index both point "whole classes are bare `native`
|
||||
declarations" at `code-virtualization-and-custom-linkers.md`. That file is about protection which
|
||||
leaves the code **reachable** — a private container, a loader, a private opcode interpreter, all of
|
||||
which you can dump and measure. **Java2C is not reachable, because it was never bytecode.** This
|
||||
file splits that row and owns both halves of it; the virtualization file stays what it is.
|
||||
|
||||
**Strength note, read this first.** The measurements below are **observed** — every number was
|
||||
produced by `scripts/java2c_probe.py` against a real sample during this pass, recorded in
|
||||
`references/evidence-summary.md` §The capability matrix. But no Java2C library exists in this repository and
|
||||
none could be built here (no NDK, no clang, WSL unavailable — see that record), so **the
|
||||
Java2C-specific identification criteria are inferred, not observed end to end**. What *is*
|
||||
observed is the discriminating measurement that separates Java2C from JNI sinking, and the JNI
|
||||
boundary behaviour. Treat the type table as a map; label your own conclusions the same way.
|
||||
|
||||
## The five shapes — one table, because the wrong row costs days
|
||||
|
||||
| Shape | Static shape in dex | What is in memory at runtime | Route | Cost of the wrong row |
|
||||
|---|---|---|---|---|
|
||||
| **Landing shell** | `Application` is a third-party class; dex is a small stub | A full, decrypted DEX | Dump memory, filter, patch | Cheap if you notice the stub `Application` |
|
||||
| **Extraction shell** | Reads as a normal dex until you measure it; bodies are stubs | A DEX whose bodies are filled in **on invocation** | Dump, measure `stub%`, FART-style active invocation | Medium — you keep dumping, and every dump is a skeleton |
|
||||
| **VMP** | Dex parses; bodies are *present* but decode as nonsense | A DEX containing **private opcodes** interpreted by a native VM | Measure, then usually stop and report | Medium — recovery cost usually exceeds task value |
|
||||
| **Java2C** | Whole classes are `native`; **no code_item at all** for them | **Never a DEX.** The translated methods exist only as compiled C in a `.so` | Read the `.so`; rebuild the JNI call chain | **Highest in this table** — you hunt a decrypted DEX that is never produced, then blame your dump tooling, then your anti-detection assumptions |
|
||||
| **JNI sinking** | A handful of `native` declarations; the rest of the dex is ordinary Java | A normal DEX | Locate the Java call site; reverse one or a few native functions | Low — but you may widen the job to the whole app if you read it as Java2C |
|
||||
|
||||
The one question that decides the table: **does the artifact ever hold dex bytecode for the
|
||||
methods you care about?**
|
||||
|
||||
- Landing shell, extraction shell, VMP: **yes** — the bytecode is there, in some state, and the
|
||||
work is recovering or interpreting it.
|
||||
- Java2C: **no** — a translated method is a `native` declaration plus an entry in a registration
|
||||
table. Nothing is decrypted, so nothing can be dumped. `[inferred]` as a runtime claim: it
|
||||
follows from the translation mechanism, and this pass did not observe a live Java2C app.
|
||||
- JNI sinking: **yes** — the dex was never damaged.
|
||||
|
||||
## Identification
|
||||
|
||||
### The discriminating measurement is native density
|
||||
|
||||
This is the single most useful number and it separates the two shapes by roughly three orders of
|
||||
magnitude. Measured on real samples with `scripts/java2c_probe.py` `[observed]`:
|
||||
|
||||
| Sample | dex methods | `native` methods | native ratio |
|
||||
|---|---|---|---|
|
||||
| JNI-sinking target A (MASTG L2) | 5081 | 2 | **0.04 %** |
|
||||
| JNI-sinking target B (MASTG L3) | 11182 | 3 | **0.03 %** |
|
||||
| App carrying a real JNI library | 23603 | 7 | **0.03 %** |
|
||||
| Java2C-shaped fixture | 29 | 24 | **82.76 %** |
|
||||
|
||||
A JNI *sink* is by definition surgical: a few hot methods are moved out and the rest of the app
|
||||
stays in Java. **A JNI sink is not "the app is native".** When the density is in the tens of
|
||||
percent, and whole classes have zero Java methods left, you are looking at a translation pass.
|
||||
|
||||
`[observed]` in the same runs: two of the fixture's three classes were native-dominated (>= 70 %
|
||||
of >= 3 named methods), against **zero** such classes in any of the three real targets. Note that
|
||||
constructors are excluded from that test — a translation pass leaves `<init>`/`<clinit>` in Java
|
||||
in practice, so "every method native" is an essentially unreachable test and should not be used.
|
||||
|
||||
### Native-layer signatures
|
||||
|
||||
| Evidence | Strength | Why |
|
||||
|---|---|---|
|
||||
| `.so` exports `Java_*` symbols whose count is roughly 1:1 with the dex native-method count | **strong** | Measured 2:2 (sample A) and 3:3 (sample B) `[observed]`. It is the signature of a *static-linkage* translation or sink. Note it does **not** distinguish Java2C from JNI sinking — only the density does |
|
||||
| The `.so` contains one C function per Java method, each beginning `JNIEnv *env, jobject thiz, ...` | **strong** | Observed in generated, uncompiled output: every translated method became exactly one function with those leading parameters |
|
||||
| Toolchain strings: `Dex2C`, `dynamic_register_compile_methods`, `ScopedLocalRef`, `well_known_classes` | **strong** | These are the runtime header and entry-point names a Dex-to-C toolchain emits. `[observed]` in generated sources; `[inferred]` that they survive compilation into the shipped `.so` |
|
||||
| `JNI_OnLoad` present | **weak** | Present in nearly every JNI library. Measured true on a system library with zero `Java_*` symbols `[observed]` — it told us registration was dynamic, not that anything was hardened |
|
||||
| `RegisterNatives` **string** anywhere in the `.so` | **weak** | See the boundary section: NDK's C++ headers do not emit this symbol at all |
|
||||
| `libc++_shared.so` / `c++_static` | **weak** | An ordinary NDK C++ setting. The Dex-to-C toolchain read here does ship `APP_STL := c++_static`, but so do countless unrelated libraries `[observed]` |
|
||||
| `classes.dex` byte-count per class far below the app's own pre-hardening build | **weak, uncalibrated** | Bodies leaving the dex should shrink it. Measured 584 B/class on the fixture versus 1152–1386 B/class on the real Java targets `[observed]` — but there is no population baseline for "normal", so this only compares an app against **itself** before and after |
|
||||
|
||||
`java2c_probe.py` prints every one of these with its strength attached, and prints the weak ones
|
||||
as an explicit warning block. A weak hit is not a verdict and must not be reported as one.
|
||||
|
||||
## The route: read the .so, not memory
|
||||
|
||||
For Java2C the analyst's instinct — dump memory and find the dex — is not merely expensive, it is
|
||||
**unbounded**, because the success condition can never occur.
|
||||
|
||||
1. **Do not dump.** There is no target artifact. If you dump anyway, you will recover a dex that
|
||||
still shows the same `native` declarations you started with, and the natural (wrong) reading is
|
||||
"the dump failed".
|
||||
2. **List the translated functions.** With static linkage, `Java_<PKG>_<Class>_<method>__<proto>`
|
||||
names map the `.so` back onto the dex one for one. Use the dex as the index and the `.so` as
|
||||
the library.
|
||||
3. **Rebuild the chain backwards.** A translated function calls *out* to Java constantly. The
|
||||
strings `FindClass` / `GetMethodID` / `GetStaticMethodID` / `Call*Method` / `NewObject` /
|
||||
`GetFieldID` inside it are the reverse edges. Following them reconstructs what the original
|
||||
Java method did, which is the only way the logic comes back.
|
||||
4. **Expect a register-machine shape.** Generated code declares a slot per dex register up front
|
||||
and assigns them in sequence. Reading it as if a human wrote it wastes hours; read it as a
|
||||
linearizer output, matching each call site against the JNI signature it passes.
|
||||
|
||||
The decompiler choice matters less here than for obfuscated code: the generated functions are
|
||||
long but flat and unoptimized, which is precisely the shape a decompiler handles well.
|
||||
|
||||
## The JNI boundary — why a symbol search fails silently
|
||||
|
||||
Two independent mechanisms erase the symbol that a reader would search for. Both are real; the
|
||||
second was measured this pass.
|
||||
|
||||
**1. Dynamic registration.** Nothing named `Java_*` is exported. Binding is performed at runtime
|
||||
by a registration table built in a JNI entry point. A grep over the symbol table returns nothing,
|
||||
exits zero, and looks like a legitimate answer.
|
||||
|
||||
**2. `-fvisibility=hidden`, and an inline `RegisterNatives`.** The Dex-to-C toolchain read here
|
||||
ships `APP_CPPFLAGS += -fvisibility=hidden` in its `Application.mk` `[observed]`, which hides the
|
||||
generated functions from the dynamic symbol table — so even a statically-named translation can
|
||||
present as "no `Java_*` symbols". Compounding it: NDK's C++ `jni.h` implements `RegisterNatives`
|
||||
as an **inline member that calls through the function table**, so calling it emits *no*
|
||||
`RegisterNatives` symbol either `[observed]`: a real system library exporting `JNI_OnLoad` and
|
||||
zero `Java_*` symbols contains zero literal `RegisterNatives` symbols. A rule that greps for that
|
||||
name is structurally incapable of firing.
|
||||
|
||||
**The check that does work** `[observed]`: a library that **exports a JNI entry point and exports
|
||||
zero `Java_*` symbols** is registering dynamically. That combination is affirmative evidence, and
|
||||
it fired on a real library in this pass.
|
||||
|
||||
**Confirming it at runtime.** Hook the registration entry point (`JNI_OnLoad`) and read the table
|
||||
it builds, or hook the class-load path and enumerate the class's methods' native backing. Do not
|
||||
conclude "no JNI boundary" from an empty symbol table; conclude "the binding is not visible in the
|
||||
symbol table" and go find where it happens.
|
||||
|
||||
## Failure modes
|
||||
|
||||
| What you see | What it is not | What it is |
|
||||
|---|---|---|
|
||||
| Whole classes are `native`, you dump memory and find the same `native` declarations | "The dump failed" / "the tool is broken" | The translation moved the code out of dex permanently. Re-read the dex shape and open the `.so` |
|
||||
| `grep Java_ .dynsym` returns nothing | "There is no native implementation" | Dynamic registration, or hidden visibility — see the boundary section |
|
||||
| A library exports `JNI_OnLoad` and nothing else you recognise | "It is a loader" | It may be an ordinary dynamically-registering JNI library. A loader normally also has an `init_array` entry and a payload to map |
|
||||
| Native density is high but every class still has Java methods | Java2C | Could be a JNI sink applied widely, or an R8-stripped app. Check whether the `native` methods have matching `Java_*` symbols before concluding |
|
||||
| The dex looks completely unremarkable on a sample you were told is hardened | "Nothing is hardened" | A VMP whose payload is decrypted at runtime has an ordinary static dex. Measured this pass: a VMP-labelled sample had 7 native methods out of 23603 and a 4 % stub ratio `[observed]`. **Static dex metrics cannot see runtime-only hardening** — that needs a runtime check, not more reading |
|
||||
|
||||
## Where this file stops
|
||||
|
||||
- **It does not decompile the generated C.** That is ordinary native reversing; `native-and-so.md`
|
||||
and `native-dbi-and-deobfuscation.md` own it.
|
||||
- **It does not recover a VMP.** Private-opcode interpretation is a different mechanism with a
|
||||
different cost model (`advanced-unpacking.md`).
|
||||
- **The Java2C runtime claim is inferred.** That no dex bytecode exists in memory for a translated
|
||||
method follows from the mechanism, not from a measurement made here. If you get a live Java2C
|
||||
sample, that is the first thing to verify, and it is cheap: attach, force the class to load, and
|
||||
ask whether the method resolves to Java bytecode or to a registered native entry.
|
||||
@@ -0,0 +1,299 @@
|
||||
# Kernel-level and environment hardening — where to go when userspace hooking is not enough
|
||||
|
||||
Load this when you have already concluded, via `detection-and-anti-analysis.md`, that the target
|
||||
genuinely detects your instrumentation — and that the check cannot be neutralised from userspace
|
||||
because it runs before your hook, or bypasses the layers a userspace hook can reach. This file is
|
||||
the **escalation map**: what the next layer down actually is, what it costs, and when it is the
|
||||
wrong answer.
|
||||
|
||||
Division of labour, so the two files never duplicate:
|
||||
|
||||
- `detection-and-anti-analysis.md` owns **identifying** detection and the **cost decision**
|
||||
(work around / change route / accept and report). Read it first; its Step 0 still applies.
|
||||
- This file owns **the routes below userspace** — root-implementation hiding properties, kernel
|
||||
interception options, and the syscall-level realities that decide whether "just hook it" was
|
||||
ever going to work.
|
||||
|
||||
**This repository does not do kernel development.** Everything in the kernel section is a map of
|
||||
externally documented mechanisms with sources, labelled `inferred` — none of it was executed here.
|
||||
That is deliberate: the purpose is to stop you from spending a day rediscovering the version
|
||||
gate () or the conflict (), not to teach kernel hacking.
|
||||
|
||||
**Extension pass, read the next paragraph before relying on it.** This file now has a companion
|
||||
generator — `scripts/kernelsu_syscall_mask.py` — which emits a loadable userspace module skeleton
|
||||
plus kernel-side **templates** for the three routes in. The generator, its userspace output and
|
||||
its own consistency check are `measured`; **the kernel-side code has never been compiled or loaded
|
||||
anywhere**, and every generated kernel file says so in its own header. A template is a starting
|
||||
point that removes the blank page, not a weapon that has been fired.
|
||||
|
||||
## 1. The attack-surface timeline — why "just hook it" fails
|
||||
|
||||
An app process initializes in a fixed order, and every anti-analysis check you will meet lives at
|
||||
one of these stations:
|
||||
|
||||
```
|
||||
linker (ld-android/inker) -> relocations resolved, DT_INIT run
|
||||
.init_array of each loaded .so -> constructors: earliest native code that runs
|
||||
JNI_OnLoad -> packers and RASP SDKs hook here
|
||||
Application.onCreate -> first Java-level check
|
||||
first frame -> UI-visible checks (and the end of "early")
|
||||
```
|
||||
|
||||
The later your tool attaches, the more of this timeline has already executed and taken its
|
||||
decision. Frida in attach mode arrives after all of it; spawn mode arrives at zygote fork, which
|
||||
is why `dynamic-frida.md` prefers it — but a constructor in `.init_array` can still run before
|
||||
the Frida agent finishes injecting, and a check placed there fires once and stores its verdict.
|
||||
|
||||
What those checks actually look for, and why each one is out of reach of a naive userspace hook
|
||||
(the identification side belongs to `detection-and-anti-analysis.md`; this is the *mechanics* of
|
||||
why the hook does not land):
|
||||
|
||||
| Detection mechanism | Why a userspace hook misses it |
|
||||
|---|---|
|
||||
| Inline `svc #0` syscalls (bypassing libc) | PLT/GOT hooks only intercept calls that go through the PLT. Hand-written `svc` instructions in the target's own `.so` never touch libc, so there is no PLT entry to redirect. |
|
||||
| `/proc/self/maps` scan for foreign mappings | The read can be done with raw `openat`+`read` syscalls (same svc trick), and the thing being detected — your injected agent's mapping — exists in the process regardless of what you hook. |
|
||||
| Named pipes / thread-name probes for known agents | Frida's runtime creates identifiable threads (`gum-js-loop` etc.) and (older versions) a linjector pipe. Renaming helps only if you control the runtime's identity, not the probe. |
|
||||
| Code-page checksums of the target's own `.so` | An inline hook *is* a modification of the pages being checksummed. The check catches the hook by construction; the only userspace answer is to also neutralise the checksum, which recursively has the same problem if *that* code is checksummed too. |
|
||||
| `/proc/self/status` `TracerPid` read | The field is written by the kernel. A userspace hook on the reader works only until the reader uses raw syscalls (row 1). See. |
|
||||
|
||||
The structural lesson: **userspace hooking and userspace anti-hooking are the same layer.** A
|
||||
detection routine that lives in the same process as your hook, and that is willing to use raw
|
||||
syscalls and self-verification, can always in principle race you. The routes below win by moving
|
||||
to a layer the check cannot see from userspace — or by not being in the process at all.
|
||||
|
||||
## 2. Root implementations and what each one exposes
|
||||
|
||||
The root method you run decides which artefacts a detector can find *outside* the target process
|
||||
(manager app, daemon, mount layout, SELinux contexts). Facts below are from the projects' own
|
||||
documentation (sources at the end of the section); none were re-measured here — `inferred`.
|
||||
|
||||
| | **Magisk** | **KernelSU** | **APatch** |
|
||||
|---|---|---|---|
|
||||
| How su works | Patches the ramdisk/init; a `magiskd` runs in userspace; the manager app requests su through it | `su` handled **inside the kernel**; a manager app talks to the kernel via a driver/interface. Kernel modification. | Kernel modification via **KernelPatch**: patches the existing `boot.img` kernel — no kernel source needed (the difference from KernelSU) |
|
||||
| Kernel version gate | None (works on old kernels — this is why the 4.14 test device runs it) | Official support = GKI 2.0, kernel **5.10+** (in practice: shipped with Android 12). Backported to 4.14 but you must build the kernel yourself | Same GKI-era expectation; needs only the stock `boot.img` to patch |
|
||||
| Access control | Manager grants per-app; denylist for hiding | Manager + **App Profile** (constrain what a rooted app can do) | **SuperKey**: KPatch installs a new syscall (SuperCall); callers must present the SuperKey credential |
|
||||
| SELinux | Modifies contexts (magiskpolicy) | Kernel-side handling | Hooks/bypasses SELinux rather than rewriting contexts — threads can be rooted in app context without `libsu`/IPC |
|
||||
| Module format | Magisk modules (magic mount) | Magisk-compatible modules; `/system` modification needs a metamodule (overlayfs) | APM (Magisk-compatible) plus **KPM** — kernel-space modules with inline-hook and syscall-table-hook capability |
|
||||
| Zygisk | Built-in | None built-in; add ZygiskNext | None built-in; add ZygiskNext / ReZygisk / NeoZygisk |
|
||||
|
||||
**What this means for hiding** (`inferred`):
|
||||
|
||||
- **Manager identity is the shared weak point.** All three ship a manager app the detector can
|
||||
enumerate by package or by scanning for its UI. All three support "randomise/repackage the
|
||||
manager" — use it, and do not assume the default package name is safe.
|
||||
- **KernelSU/APatch remove the userspace su daemon** the detector could otherwise find as a
|
||||
process or a socket. But they add a kernel-level surface of their own; a detector cannot see it
|
||||
from an unprivileged app — which is the point.
|
||||
- **Magisk on an old kernel is not a disadvantage for hiding**; it is a constraint of the device.
|
||||
Do not switch root schemes to win a detection race — that is §7's stop signal.
|
||||
- **Module ecosystems interoperate but do not merge**: KernelSU's module system conflicts with
|
||||
Magisk magic mount (per KernelSU FAQ, running both means one stops working); su-only coexistence
|
||||
is possible because KernelSU patches the kernel while Magisk patches the ramdisk.
|
||||
|
||||
Sources: KernelSU README + FAQ (github.com/tiann/KernelSU), APatch FAQ (apatch.dev, project
|
||||
originally at github.com/bmax121/APatch), fetched 2026-09. Device facts (Magisk alpha on kernel
|
||||
4.14.186) are `observed` on the reference device used for this pass.
|
||||
|
||||
## 3. Environment-hiding modules: Zygisk-Assistant vs Shamiko
|
||||
|
||||
Both are Zygisk modules that hide root + Zygisk from target apps. **Do not install both** — they
|
||||
overlap in function (mount-namespace handling, trace wiping) and interfere; this is documented in
|
||||
the module ecosystems and was the deciding fact on the reference device (`observed`: Shamiko kept as a backup zip and uninstalled there).
|
||||
|
||||
The practical difference is their **denylist dependency** (from each project's own README,
|
||||
`inferred`):
|
||||
|
||||
| | **Shamiko** | **Zygisk-Assistant** |
|
||||
|---|---|---|
|
||||
| Reads the Magisk denylist | Yes — but **requires DenyList enforcement to be OFF** (it takes over the handling itself) | Magisk: add the target to the denylist, **Enforce DenyList OFF**. KernelSU/APatch: enable the manager's "Umount modules/Exclude modifications" for the target app |
|
||||
| Extra modes | Whitelist mode via `/data/adb/shamiko/whitelist` (empty file); docs warn of performance/memory cost, test-only | — |
|
||||
| On APatch | Officially unsupported ("Shamiko is proprietary software, we cannot adapt it" — APatch FAQ) | Works via ZygiskNext |
|
||||
| Version gates | Newer Shamiko requires recent Magisk/KernelSU bases | Current |
|
||||
|
||||
Selection rule (`inferred`): on Magisk, either works alone — pick one, configure its denylist
|
||||
dependency correctly, and verify on the actual target. On KernelSU/APatch, Zygisk-Assistant (+
|
||||
ZygiskNext) is the supported path. The denylist/enforcement settings are the part people get
|
||||
wrong: **enforcement off, list populated** is the working combination for both.
|
||||
|
||||
Verification status: neither module's hiding effect was tested against a real detector on the
|
||||
reference device — treat every
|
||||
"this hides X" claim above as documented-but-unproven here.
|
||||
|
||||
## 4. Kernel-level interception — the map, and the version gate
|
||||
|
||||
When you need to observe or alter what a target does *at syscall level* — e.g. it reads
|
||||
`/proc/self/status` via raw `svc`, and you want that read to return a spoofed page — userspace is
|
||||
structurally the wrong layer. The options below exist; **none of them were executed in this
|
||||
repository** (`unverified`), and each has a hard prerequisite worth checking before you read any
|
||||
further:
|
||||
|
||||
```
|
||||
uname -r # on the device. The gate for most of this section is 5.10+
|
||||
```
|
||||
|
||||
- **Why 5.10**: Android's GKI (Generic Kernel Image) programme starts at android12-5.10; the
|
||||
kernel-BPF ecosystem for tracing (bpftrace-class tools, kprobe/uprobe attachment as the tooling
|
||||
expects it) targets GKI kernels. Source: the Android kernel architecture documentation on
|
||||
source.android.com (GKI and eBPF pages). On the reference
|
||||
device (kernel **4.14.186**) this entire row is **closed** — `observed` (the version), with the
|
||||
consequence that eBPF-based tooling
|
||||
(stackplz, mcp-termux's tracing half) is unavailable there. Write the one-line environment fact
|
||||
per `detection-and-anti-analysis.md` Step 5 and do not revisit it.
|
||||
|
||||
| Route | What it gives you | Prereqs | Strength |
|
||||
|---|---|---|---|
|
||||
| **eBPF kprobes/uprobes** | Fire a BPF program on kernel or userspace function entry; observe syscall arguments/results system-wide, invisible to the target process | GKI 5.10+ kernel with the BTF/tracing config; root; toolchain (bpftrace or a custom loader) | `inferred` — documented upstream, not run here |
|
||||
| **seccomp-BPF filter** | Per-process syscall allow/deny — can make a syscall **fail**, and seccomp can be installed by the app on itself (no root needed for self-filtering) | Any modern kernel; but a filter you install into the target requires ptrace/zygisk injection first | `inferred`. **Key limitation**: seccomp can reject or error a syscall (SECCOMP_RET_ERRNO/TRAP); it cannot rewrite the *content* of what a successful read returns. It closes doors; it does not paint them. |
|
||||
| **Kernel module hooking** (KPM on APatch; out-of-tree LKM elsewhere) | Inline hooks and syscall-table hooks in kernel space — the layer that *can* rewrite what a `/proc` read returns | APatch (KPM) or a self-built kernel/LKM load path; kernel-dev skills | `inferred`. This is genuine kernel development — out of scope for this skill () |
|
||||
| **Zygisk injection** (not kernel, but below the target's defences) | Run code in the target's process from zygote fork, before `.init_array` of the app's own libs | Magisk/KernelSU+ZygiskNext + a Zygisk module; no ptrace involved | `measured` as a framework (LSPosed runs this way on the reference device); a purpose-built module for a given target is `inferred` here |
|
||||
|
||||
The row that matters most in practice: **Zygisk is the cheap "below userspace" route** — it is in
|
||||
the process earlier than any userspace tool can be, needs no ptrace (so it does not trip
|
||||
`TracerPid`), and its ecosystem is maintained by other people. A custom kernel module that
|
||||
rewrites one `/proc` read is a research project; a Zygisk module that hooks the target's
|
||||
constructor is a build task.
|
||||
|
||||
### 4a. From "no weapon" to "template + gate": what the extension pass added
|
||||
|
||||
The gap this closes is specific. §4 above was a map with no artefact behind it: a reader told
|
||||
"Kernel module hooking — inline hooks and syscall-table hooks in kernel space — the layer that *can*
|
||||
rewrite what a `/proc` read returns" had nowhere to go next. `scripts/kernelsu_syscall_mask.py`
|
||||
produces the artifact; this subsection states its boundary, because the boundary is the part that
|
||||
matters.
|
||||
|
||||
**The correction worth internalising first: a KernelSU module cannot do any of this.** A KernelSU
|
||||
(or Magisk, or APatch-userspace) module is a *userspace* module whose scripts run as root in the
|
||||
normal world. `module.prop`, `post-fs-data.sh` and `service.sh` cannot change what `openat`
|
||||
returns — not because of a version gate, but because nothing in that format is ever in the kernel's
|
||||
return path. Detectors of the kind §2 and §3 discuss are not defeated by a module of that shape.
|
||||
The generator emits that skeleton anyway, because it is the right carrier for the configuration and
|
||||
the metadata, and it says in its own README what the skeleton cannot do.
|
||||
|
||||
What *can* reach the return path is one of three artifacts, and each carries a gate that has to be
|
||||
checked on the actual device:
|
||||
|
||||
| Artifact | What it is | Gate | Status of the shipped template |
|
||||
|---|---|---|---|
|
||||
| **KPM** (KernelPatch / APatch) | A relocatable `.kpm` loaded by kpimg injected into the kernel image; replaces syscall-table pointers (`fp_hook_syscalln`) or rewrites prologues (`hook_wrapN`) | A KernelPatch-patched boot image, plus a **bare-metal** ARM64 toolchain (`aarch64-none-elf-gcc`) — not the NDK | `unverified` — template only, never compiled |
|
||||
| **Out-of-tree LKM** | An ordinary kernel module that reaches `sys_call_table` and swaps a pointer | Kernel source matching the device's exact vermagic, and on ≥5.7 a way around `kallsyms_lookup_name` no longer being exported | `unverified` — template only; rated last of the three on cost |
|
||||
| **eBPF probe** | A BPF program on a syscall tracepoint or kprobe | GKI 5.10+ with the tracing/BTF machinery | `unverified` — template only; the version gate alone closes it on most older devices |
|
||||
|
||||
The eBPF row carries a capability ceiling that is easy to miss and is stated in the generated file
|
||||
as well: **a tracepoint can observe a syscall, not rewrite its result.** `bpf_override_return()`
|
||||
only applies to functions flagged `ALLOW_ERROR_INJECTION`, which raw syscall entries are not —
|
||||
so "eBPF to spoof a `/proc` read" is, on most kernels, an observation plan wearing a rewrite
|
||||
plan's clothes. Observation is genuinely useful (it tells you which syscall the check uses, which
|
||||
`§5` requires you to know anyway); it is not spoofing.
|
||||
|
||||
**Field notes on the KPM route, from a public KPM development write-up** (`inferred` here — the
|
||||
author's measurements, not this repository's; source: blackr0ck, *APatch KPM 开发*,
|
||||
[bbs.kanxue.com/thread-291665.htm](https://bbs.kanxue.com/thread-291665.htm), 2026-06). These are
|
||||
recorded because each one is a day of somebody's time:
|
||||
|
||||
- **An inline hook can install cleanly and never fire.** On a GKI kernel with LTO, the exported
|
||||
symbol is frequently not the call site — the callee was inlined into its only caller, so
|
||||
replacing instructions at the symbol's address intercepts nothing. The reported symptom is
|
||||
"module loaded, callback never ran". Pointer replacement in the syscall table does not have this
|
||||
failure mode, because the syscall entry path must go through the table.
|
||||
- **The instruction-sequence length in a module header is a trap of its own.** The reported cause
|
||||
of a hard-to-diagnose load failure: declaring a resolved kernel function with `extern` makes the
|
||||
compiler emit an undefined reference (`*UND*`) instead of allocating the slot, and the loader
|
||||
refuses the module with `unknown symbol`. Letting it be a tentative definition allocates `.bss`
|
||||
and the loader fills it in. The generated template states this rule in the code, and
|
||||
`verify` checks for it.
|
||||
- **Per-syscall callbacks run on every syscall of that number, system-wide.** The reported
|
||||
incident: a `write` hook that did not first reject `fd <= 2` intercepted the framework's own log
|
||||
output tens of thousands of times and the device rebooted. **Performance is correctness here** —
|
||||
the first statement in every callback is a filter that rejects the common case.
|
||||
- **Which hook point survives is kernel-specific, and the cheap way to find out is to start at the
|
||||
syscall layer.** The same write-up reports that modifying the SELinux internal function they
|
||||
first targeted crashed the kernel under every combination tried (before/after, argument,
|
||||
return value, skip-origin), while a syscall-table hook on `write` proved stable and sufficient.
|
||||
Treat the syscall layer as the default and move inward only with evidence.
|
||||
|
||||
**Measured on the reference device, and why every template here is labelled the way it is.**
|
||||
`<DEVICE>` runs kernel **4.14.186+** (`adb shell 'uname -r; cat /proc/version'` →
|
||||
`4.14.186+`, `Linux version 4.14.186+ (nobody@android-build) (Android (6443078 based on r383902)
|
||||
clang version 11.0.1 ...) #1 SMP PREEMPT Wed Mar 30 23:32:42 CST 2022`). On that device the two
|
||||
remaining gates answer themselves:
|
||||
|
||||
- **eBPF**: 4.14 is far below the 5.10 gate. Closed, as §4 already recorded.
|
||||
- **KPM**: no KernelPatch-patched image is present, and the host has no
|
||||
`aarch64-none-elf-gcc` (`kernelsu_syscall_mask.py gates` reports `NOT FOUND`, along with no
|
||||
`ndk-build` and no `make`). Installing APatch means patching the boot image — a bricking risk
|
||||
that this repository does not take on an APK task.
|
||||
- **LKM**: no kernel source for the device, so no matching vermagic is possible.
|
||||
|
||||
So on this machine the honest answer to "do you have a kernel-level weapon" is: **you have a
|
||||
template, a gate table and a measured statement that the gate is closed here.** That is a route
|
||||
decision, not a failure — and it is the same shape of answer §6 gives for the whole escalation
|
||||
ladder. Do not let the existence of the template change what you claim about it: `unverified` is
|
||||
still `unverified`.
|
||||
|
||||
## 5. The syscall-level realities that survive every hook
|
||||
|
||||
Two facts decide a lot of "unexplainable" behaviour, and both belong to the kernel's design
|
||||
rather than to any tool:
|
||||
|
||||
1. **`TracerPid` is written by the kernel, not by a library.** Anything that ptraces the target
|
||||
(classic Frida attach, debuggers) makes `/proc/self/status` show it, and the only readers that
|
||||
miss it are the ones you broke. Ways out, in escalating cost:
|
||||
- attach-free operation: **Zygisk** (injection at fork; no ptrace) or Frida **spawn** (still
|
||||
ptrace-based but only during injection — verify for your version) or a memory patch applied
|
||||
before detach (`scripts/spawn_patch_detach.py`, `measured`);
|
||||
- kernel-level hiding of the field (KPM/eBPF rewrite of the proc read) —, `unverified`;
|
||||
- **self-ptrace**: the target ptraces itself so nothing else can (classic anti-anti-debug);
|
||||
works because only one tracer is allowed — but it constrains your tooling to non-ptrace
|
||||
routes anyway. `inferred`.
|
||||
2. **A raw `svc` cannot be intercepted from userspace at all.** No PLT, no libc, no hookable
|
||||
symbol. Every plan that says "hook open/read and spoof the maps" silently assumes the target
|
||||
calls libc; a hardened target does not. Verify which one you have before building the spoof:
|
||||
disassemble the check's `.so` and look for `svc #0` (AArch64) / `int 0x80`-era equivalents in
|
||||
the check path (`inferred`; `scripts/native_crash.py`-style capstone disassembly applies).
|
||||
|
||||
## 6. What the reference environment chose (and why it generalises)
|
||||
|
||||
On the 4.14-kernel reference device, the escalation ladder terminates early — and that is the
|
||||
honest outcome (`observed` on that device):
|
||||
|
||||
| Layer | Status on a 4.14 Magisk device |
|
||||
|---|---|
|
||||
| LSPosed (Zygisk) module hooking | available — framework activation verified by log (`welcome to LSPosed!` lines, `lspd` daemon process) |
|
||||
| MT Manager on-device editing/repack/sign | available (`on-device-tooling.md`) |
|
||||
| eBPF tracing | closed (kernel gate) |
|
||||
| KPM / kernel module route | closed on this device — no KernelPatch image, no kernel source, no bare-metal ARM64 toolchain; the **template and gate table** are shipped (§4a) |
|
||||
| Frida with disguised server | available (a renamed server binary was present on the device from prior work) |
|
||||
|
||||
The generalisable rule: **enumerate the ladder for *your* device once, write the one-line
|
||||
environment facts, and stop re-deriving them mid-task.** A closed rung is a route decision, not a
|
||||
failure.
|
||||
|
||||
## 7. When to stop escalating — the decision table
|
||||
|
||||
This extends `detection-and-anti-analysis.md` Step 2 with the kernel rung. The repo's stance:
|
||||
R3 (never ship or claim an unverified artifact) outranks winning the arms race; a detector you
|
||||
defeated with a custom kernel module is not a deliverable anyone can install.
|
||||
|
||||
| Situation | Do this | Not this |
|
||||
|---|---|---|
|
||||
| Check is userspace, hookable, single-site | Neutralise at the site (`native-tamper-and-suicide.md` rules) | Building a hide stack |
|
||||
| Check runs before your userspace tool (init_array) | Zygisk-route module, or static patch of the check itself | Earlier userspace attach racing the constructor |
|
||||
| Check uses raw syscalls | Static patch of the check; or LSPosed/Zygisk module hooking the consuming code | "Hook libc open/read" (does nothing) |
|
||||
| Check detects the *environment* (root/emulator), not your patch | `detection-and-anti-analysis.md` A/B/C — usually a different device or route | Kernel work |
|
||||
| You are about to write a kernel module / patch a kernel | Stop. State what is blocked and the evidence; propose the static/module route | Kernel development (out of scope for this skill) |
|
||||
| You have the kernel template and want to load it on a 4.14/Magisk device | Read `§4a`'s gate table, confirm the gate is closed, and say so | Patching a boot image to obtain a kernel route, on an APK task |
|
||||
| A kernel-side template exists, so it feels like a weapon | It is `unverified` until a device with the matching gate compiles and loads it | Presenting a template as a working capability |
|
||||
| Escalation effort exceeds the user's actual ask | `detection-and-anti-analysis.md` stop signal — switch to static | One more layer |
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] `detection-and-anti-analysis.md` read first; A/B/C chosen deliberately
|
||||
- [ ] Check located on the timeline () before choosing a counter-layer
|
||||
- [ ] Root scheme's artefacts enumerated (manager/daemon/mounts) before blaming the target
|
||||
- [ ] Hiding module: exactly one of Shamiko / Zygisk-Assistant; denylist configured to its spec
|
||||
- [ ] Kernel rung checked against `uname -r` — one line recorded if closed
|
||||
- [ ] Kernel-side template treated as `unverified` until a device with the matching gate
|
||||
actually compiles and loads it (`§4a`); the userspace module skeleton is a carrier for
|
||||
configuration, not a kernel capability
|
||||
- [ ] Raw-syscall vs libc-call distinction verified by disassembly before building any spoof
|
||||
- [ ] No kernel development undertaken as part of an APK deliverable
|
||||
@@ -0,0 +1,370 @@
|
||||
# Long-Task Discipline
|
||||
|
||||
Load this when a task is likely to run long: many rounds, many experiments, or a conversation that will
|
||||
exceed what can be held in context. Also load it before resuming a task someone else (or a past you)
|
||||
started.
|
||||
|
||||
The failure mode this file prevents is not tooling — it is **judgement drift**. Over a long task, three
|
||||
things reliably go wrong, and each is expensive in a way that is invisible while it happens:
|
||||
|
||||
1. **You re-derive what you already proved.** Rounds get spent rediscovering a boundary or a mechanism
|
||||
that was established earlier and then lost.
|
||||
2. **You re-walk a route that was already excluded.** The exclusion is real, but the reason has been
|
||||
forgotten, so the route looks promising again.
|
||||
3. **A wrong conclusion keeps steering.** An early mis-attribution is written into your mental model and
|
||||
silently removes good options, or props up bad ones, for a long time afterwards.
|
||||
|
||||
All three come from the same cause: **conclusions living in context instead of on disk**.
|
||||
|
||||
## Keep a live record, not a log
|
||||
|
||||
Maintain one artifact — a single file — that is the task's authoritative state. Update it as things are
|
||||
learned, not at the end. What matters is not size; it is that every entry is **actionable later**.
|
||||
|
||||
Keep these sections:
|
||||
|
||||
| Section | Contents | Why |
|
||||
|---|---|---|
|
||||
| **Operating rules** | things that must be true every run (how to install, how to launch, how to capture) | prevents repeating a mechanical mistake |
|
||||
| **Confirmed facts** | each with the run that proved it | the basis everything else builds on |
|
||||
| **Refuted conclusions** | what you believed, why it was wrong, what replaced it | stops a dead idea from coming back |
|
||||
| **Dead routes** | ruled out, with the evidence | the single biggest time saver |
|
||||
| **Open questions** | what is still unknown | keeps the next step honest |
|
||||
| **Environment** | device, ports, tool paths, credentials locations | avoids re-discovery |
|
||||
|
||||
Two rules make this file worth having:
|
||||
|
||||
- **Every claim carries its evidence.** "The shell rejects this" must include the run that showed it,
|
||||
including the exact failure text. An unattributed claim is how a false conclusion gets adopted later.
|
||||
- **Refutations are first-class entries.** When you find that an earlier conclusion was wrong, record
|
||||
*both* the wrong conclusion and why it was wrong. Deleting the mistake loses the most valuable thing
|
||||
you learned.
|
||||
|
||||
## Grade your own conclusions
|
||||
|
||||
Label every non-obvious statement with its strength, and never let a weaker label inherit the authority
|
||||
of a stronger one:
|
||||
|
||||
| Grade | Meaning | May it justify a decision? |
|
||||
|---|---|---|
|
||||
| **Observed** | reproduced it, with the exact command and output | yes |
|
||||
| **Inferred** | follows from an observation, but the step is reasoned | provisionally |
|
||||
| **Hypothesis** | plausible, untested | only as something to test |
|
||||
| **Refuted** | tested and found false | never — keep it only as a warning |
|
||||
|
||||
Most long-task damage comes from hypotheses drifting upward into "facts" simply by being repeated. If
|
||||
you catch yourself referring to something as established, check the grade in the record. If it is not
|
||||
observed, go observe it or label it again.
|
||||
|
||||
## Every script answers with a token, not with prose
|
||||
|
||||
A long task is a chain of decisions, and each decision reads the *result* of the step before it. When
|
||||
that result is human-readable prose, the reading step becomes an interpretation — and the
|
||||
interpretation is where a wrong conclusion enters with no error message attached. This repository has
|
||||
already paid for it once: a `logcat`-only verdict concluded that a hooking module never ran, and the
|
||||
module had run nine times.
|
||||
|
||||
So a script in this kit ends with a machine-decidable answer, and the exit code carries the same
|
||||
meaning:
|
||||
|
||||
- **A final `RESULT=<token>` line**, from a small closed vocabulary (`clean`, `patched`, `success`,
|
||||
`crash`, `timeout`, `unavailable`, …). The token is the answer; everything before it is evidence for
|
||||
a human.
|
||||
- **Exit codes with fixed meaning**: `0` the token names success, `1` a real negative finding, `2` the
|
||||
script could not do its job (usage, unreadable input, missing dependency). **A `2` is never a
|
||||
finding about the target** — that distinction is the whole point, and conflating it turns a broken
|
||||
harness into a property of the app.
|
||||
- **Counts as tokens too** where a count is the measurement (`PROBE_COUNT=`, `VULN_COUNT=`,
|
||||
`events=0`), so "nothing happened" is a value you can branch on rather than an absence you have to
|
||||
notice.
|
||||
|
||||
The rule for the reader is the other half: **decide from the token and the exit code, and only then
|
||||
read the prose.** When the two disagree, the token is what the script's author defined and the prose
|
||||
is what they were thinking that day.
|
||||
|
||||
## Single-variable discipline across the whole task
|
||||
|
||||
The most common source of a wrong *and durable* conclusion is a compound experiment: two changes, one
|
||||
failure, one invented explanation. Because the failure is real, the explanation feels earned.
|
||||
|
||||
Consequences to enforce:
|
||||
|
||||
- When a route is about to be abandoned, **re-read why it was abandoned**. If the evidence was compound,
|
||||
the abandonment is not yet justified — re-run it single-variable before writing it off.
|
||||
- Keep a semantic control in your pipeline: a build with **no** changes, run through the same
|
||||
install-and-launch path. If the control fails, nothing else you measure is meaningful.
|
||||
|
||||
## Guard against drift at natural checkpoints
|
||||
|
||||
Do these cheaply, and only when they can change a decision — not as ceremony.
|
||||
|
||||
- **Before starting a new experiment**: read the refuted-conclusions and dead-routes sections. If your
|
||||
plan appears there, stop and read why.
|
||||
- **Before declaring progress**: ask what the *user-visible* outcome is right now. An internal signal
|
||||
improving is not progress (see P19). Re-state the actual target.
|
||||
- **After any surprise**: write it down immediately with evidence, before you have a theory. Theories
|
||||
written after the fact are hard to distinguish from observations.
|
||||
- **When context feels long**: prefer writing to the record over re-reading conversation. The record is
|
||||
what survives; the conversation is what gets truncated.
|
||||
- **When resuming**: read the record first, and treat anything not in it as unknown, even if it feels
|
||||
familiar.
|
||||
|
||||
## The most expensive drift: solving it in an environment the deliverable will never see
|
||||
|
||||
There is one drift in this domain that costs more than all the others, because it produces a
|
||||
result that looks like success and is not one.
|
||||
|
||||
**A runtime-only result is easy to obtain and easy to mistake for a finished artifact.**
|
||||
|
||||
Editing a data file, hooking a live process, blocking a hostname, holding a proxy open — each of
|
||||
these can make the app behave correctly *on the machine where you did it*, with no repackaging
|
||||
required. It is often the fastest path to a visible win. It is also frequently **not the
|
||||
deliverable at all**, because the requirement was never "make it work here".
|
||||
|
||||
The constraint axes that decide this — check every one against the original request, and write
|
||||
down the answer as a testable sentence before you start:
|
||||
|
||||
| Axis | The question | Why it silently invalidates work |
|
||||
|---|---|---|
|
||||
| **Privilege** | must it run **unrooted**? | a rooted-only result cannot be given to a normal user at all |
|
||||
| **Modification form** | must it be a **rebuilt/installable artifact**, or is a live-instrumentation result acceptable? | hooks and data edits do not ship inside an APK |
|
||||
| **ABI / device class** | which ABI, which device family? | an emulator-only or x86-only result is not evidence for a physical ARM device |
|
||||
| **Network** | must it work **online**? | a fix that depends on being offline or on a host proxy fails the moment it is used normally |
|
||||
| **Persistence** | must it survive restart, upgrade, and a fresh install? | in-memory and session-scoped state evaporates |
|
||||
| **Distribution** | does the *shipped file* have to be self-contained? | anything that needs a helper on the machine is not a shippable artifact |
|
||||
|
||||
**Checkpoint question, asked at the same moments as the drift checks above:**
|
||||
|
||||
> *If I handed over exactly what exists right now, would it satisfy the constraint sentence I wrote
|
||||
> at the start?*
|
||||
|
||||
If the answer is no, you have made real progress on a **component**, and that is worth stating as
|
||||
such — but it is not the task. Do not let "it works" stand in for "it works under the constraint".
|
||||
|
||||
**What to do instead of over-claiming.** Split the result into two explicitly labelled parts:
|
||||
|
||||
1. **What is achievable inside the constraint**, and how far along it is.
|
||||
2. **The unconstrained workaround** (needs root / needs a host / needs a proxy), stated as a
|
||||
deliberate fallback, with its requirements made obvious to the reader.
|
||||
|
||||
A privileged workaround is genuinely useful — it can be the difference between using the app and
|
||||
not. It becomes a problem only when it is presented as the deliverable. Report it as a fallback,
|
||||
keep the constrained goal open, and say plainly which one you have.
|
||||
|
||||
**Also watch the inverse**: if the constraint is "must be a rebuilt artifact", do not let a working
|
||||
runtime result quietly close the investigation. Use it for what it is good for — it proves the
|
||||
mechanism and identifies the exact code or data to change — then port that finding into the
|
||||
artifact. The runtime result is the map, not the destination.
|
||||
|
||||
## Bound every wait
|
||||
|
||||
A long task stalls in ways that produce no information and consume the most valuable resource you
|
||||
have: wall-clock time and the next person's patience. Three habits prevent almost all of it.
|
||||
|
||||
**1. Every command has a timeout, and its absence is the bug.** A helper that shells out without a
|
||||
timeout can hang forever, and the failure presents as "the task stopped making progress" rather
|
||||
than "this call blocked". Pass an explicit timeout on every subprocess, every HTTP request, every
|
||||
device call. When one expires, the result is **unknown**, not failed — record it that way and
|
||||
re-check state before retrying.
|
||||
|
||||
**2. Calibrate the expected duration from measurement, not from a guess.** A timeout only means
|
||||
something if it is set relative to how long the operation *should* take, because that is what makes
|
||||
"slow" and "hung" distinguishable. Guessed budgets are either so short that healthy work gets killed,
|
||||
or so long that a stall looks like patience.
|
||||
|
||||
So: **measure once, write it down, then derive the bound.**
|
||||
|
||||
| Operation class | How to bound it |
|
||||
|---|---|
|
||||
| host-side tool (`baksmali`, `apksigner`, a compiler) | seconds to low minutes; time it once, then set roughly 3–5× observed |
|
||||
| device shell call (`adb shell …`) | seconds — **except** the first `su` after a reboot, which can prompt and block |
|
||||
| install / push of a large artifact | minutes; scales with size, not with the app's complexity |
|
||||
| app cold start to first frame | tens of seconds — **longer with a packer**, which does real work before your code runs |
|
||||
| a step needing a human or an external service | **unbounded in principle** — do not wait at all (see P22) |
|
||||
| waiting for an on-screen state change | bound it *and* sample it (point 3) |
|
||||
|
||||
Record the observed durations in the live record. They are exactly the kind of mechanical fact that
|
||||
gets re-derived painfully after a context loss, and having them turns a later timeout into an
|
||||
interpretable signal instead of a mystery.
|
||||
|
||||
**Exceeding the expected window is itself the finding.** It tells you something about state — the
|
||||
device is wedged, the app never reached that phase, the action never happened — and that is a
|
||||
different investigation from waiting longer. Re-check state instead of extending the deadline.
|
||||
|
||||
**3. Every wait has a deadline, an observable to sample, and a look.** "Wait for the operation to
|
||||
finish" is not a plan. Name the observable, how often you will sample it, how long you will sample
|
||||
before giving up, and what you conclude on expiry:
|
||||
|
||||
```
|
||||
waiting for: <observable> e.g. the overlay is gone / the counter incremented
|
||||
sample: <interval + how> e.g. every 15 s: screenshot + current window focus
|
||||
give up at: <deadline> e.g. 150 s
|
||||
on expiry: <what I conclude> e.g. "did not complete within the window" -- not "cannot work"
|
||||
```
|
||||
|
||||
**Sample with your eyes rather than sleeping blind.** When the step is gated on something visible — a
|
||||
screen, a dialog, a progress indicator — capture it and **look at it** instead of sleeping through the
|
||||
interval. A fixed sleep either wastes time or measures the wrong instant; a look tells you what
|
||||
actually happened, and byte-identical consecutive samples tell you nothing is going to change
|
||||
(`environment.md` §look at the screen). This is the cheapest way to avoid spending rounds on a state
|
||||
that was never going to arrive.
|
||||
|
||||
Three rules that follow:
|
||||
|
||||
- **A deadline that passes is a measurement, not a verdict.** "Still not finished after N seconds"
|
||||
tells you about latency and reliability. It does not tell you the approach is impossible, and
|
||||
writing it down as impossible is how a viable route gets discarded.
|
||||
- **Never busy-poll a long job while you have independent work.** Start it, do the other thing, and
|
||||
collect it when it settles. Polling wastes the same resource the task is already spending.
|
||||
- **Prefer a bounded observable over a fixed sleep.** Poll the state you actually care about, with a
|
||||
cap — and prefer a sample you can inspect over a duration you hope is right.
|
||||
|
||||
## Keep the observation window clean
|
||||
|
||||
Every conclusion in this skill rests on "the build I am looking at is the build I made". That
|
||||
assumption is silently false more often than any other, and a contaminated window does not look
|
||||
contaminated — it looks like a result.
|
||||
|
||||
Three ways it happens, all of which have produced confidently wrong findings:
|
||||
|
||||
- **Something else touched the target while you were observing.** Another process, another agent,
|
||||
another window of your own work installed, reverted, restored or cleared the app. Your screenshots
|
||||
and logs are then a mixture of two states and describe neither.
|
||||
- **You are looking at a stale process.** The app was never actually restarted, so the "before" and
|
||||
"after" captures come from the same run.
|
||||
- **The artifact on the device is not the artifact on disk.** An install that reported success, or was
|
||||
skipped because the version matched, leaves the previous build running.
|
||||
|
||||
Guards, in order of cost:
|
||||
|
||||
1. **Pin the identity, not the filename.** Hash the artifact you built, hash the artifact you intend
|
||||
to test, and hash what is actually installed (pull the installed APK or read its digest) — the
|
||||
filename proves nothing. Do this immediately before the observation window, not hours earlier.
|
||||
2. **Take the window deliberately.** Before a capture sequence that a conclusion will rest on, state
|
||||
(to yourself or in the record) that the next N seconds are for this observation only, and do not
|
||||
run another install/uninstall/clear inside it.
|
||||
3. **Timestamp the window.** Record the wall-clock start and end. It costs one line and it is the only
|
||||
way to later notice that a teammate, a background job or your own earlier command landed inside it.
|
||||
4. **Prefer one continuous capture over several short ones.** A restarted capture invites a restarted
|
||||
state; a single sequence cannot straddle an install it did not perform.
|
||||
|
||||
When you discover a window was contaminated, **the correct action is to discard it and re-capture**,
|
||||
not to salvage it. A re-run costs minutes; a wrong conclusion costs the rest of the task, and it will
|
||||
be re-derived from the same bad evidence because the record says it was observed.
|
||||
|
||||
### Captures you never looked at are not evidence
|
||||
|
||||
This is a distinct failure from a contaminated window, and it survives every other guard here. The
|
||||
frames are real, the timestamps are honest, the hashes are right — and nobody looked at them.
|
||||
|
||||
Two shapes it takes, both of which produce a confident wrong answer:
|
||||
|
||||
- **You took the screenshots and moved on.** The images exist; the conclusion was drawn from logcat
|
||||
or from the patch itself. A burst of uninspected frames reads in a report exactly like a burst of
|
||||
inspected ones.
|
||||
- **You did look, but at the wrong thing.** Every frame shows a window that is not your app — a
|
||||
vendor installer confirmation left over from an earlier install, a system dialog, the launcher —
|
||||
and because the frames are consistent with each other, conviction grows. Consistency is not
|
||||
corroboration when all the frames share the same blind spot.
|
||||
|
||||
Guards:
|
||||
|
||||
1. **Check what is on screen before trusting any capture.** One command settles it:
|
||||
`dumpsys activity activities | grep -m1 ResumedActivity`. If the component is not your package,
|
||||
the frames describe something else. Do this at the start of a capture sequence and again at the
|
||||
end — the foreground can change mid-sequence.
|
||||
2. **Inspect immediately, not "later".** Look at the first frame, the middle one, and any frame at a
|
||||
moment you care about (the second the splash would have shown, the moment a dialog would have
|
||||
appeared). If you cannot describe in one sentence what a frame shows, you have not looked at it.
|
||||
3. **Treat identical consecutive frames as a signal.** Byte-identical frames mean the screen is
|
||||
static. That is a finding — a hang, a dialog waiting for input, an activity that never changed —
|
||||
not a capture artefact to be skipped over.
|
||||
4. **Name what you saw, per frame, in the record.** One line each. "f03 at 2.1 s: target main screen,
|
||||
list populated" is evidence; "captured 12 frames" is a file listing.
|
||||
5. **A screenshot of a clean-looking screen is not proof that a dialog is absent** unless you captured
|
||||
continuously across the moment it would have shown. Sampling is not observation: a modal that
|
||||
appears and is then occluded can fall entirely between samples, and every frame you happened to
|
||||
take shows something else.
|
||||
|
||||
`scripts/coldstart.py` exists to make points 1 and 5 automatic: it takes a timed burst, prints the
|
||||
foreground component, and refuses to present the run as meaningful if the foreground is not your
|
||||
app. It still cannot look at the frames for you — that part is on you.
|
||||
|
||||
## Long-context decay: the same mistake, twice
|
||||
|
||||
A long task has a failure mode that has nothing to do with the target: **as the
|
||||
working context fills, settled conclusions lose their force.** Something that was
|
||||
established and verified two hours ago becomes, by the end, just another plausible
|
||||
belief — and the cheapest way to get from a stuck point to a feeling of progress is
|
||||
to re-try the thing that already failed.
|
||||
|
||||
The symptoms, which are recognisable if you watch for them:
|
||||
|
||||
- You are about to run a command whose result you already recorded earlier.
|
||||
- You are about to re-derive a fact (package name, an offset, a version, which
|
||||
patch landed) that is in your own notes.
|
||||
- You are about to re-attempt an approach that failed — and the failure is not
|
||||
obviously connected in your mind to this attempt.
|
||||
- A conclusion you are treating as solid was actually never verified, only
|
||||
assumed early on and repeated since.
|
||||
- You are rewriting a summary of the task from memory rather than reading the
|
||||
record.
|
||||
|
||||
**The antidote is a record written for the second half of the task, not for a
|
||||
human at the end.** Keep it structured so a weakened-context read still gets the
|
||||
decision-relevant content:
|
||||
|
||||
```markdown
|
||||
## Settled (verified — do not re-derive)
|
||||
- <fact> [how it was verified, one clause]
|
||||
## Refuted (do NOT retry — each cost time)
|
||||
- <approach> — fails because <mechanism>
|
||||
## Unverified assumptions currently in play
|
||||
- <assumption> — becomes a problem if <condition>
|
||||
## Next action, and how it will be judged
|
||||
- <one step> — success looks like <observable>
|
||||
```
|
||||
|
||||
Three rules that make the difference:
|
||||
|
||||
1. **Record the mechanism, not just the outcome.** "Traversal via that endpoint
|
||||
broke the home screen because the child request shares the parent load" survives
|
||||
context decay; "tried the endpoint thing, did not work" does not — and the
|
||||
second version invites a third attempt.
|
||||
2. **Re-read the record at every checkpoint, and before starting any new
|
||||
experiment.** Not at the end. The cost is seconds and it is the only thing
|
||||
standing between you and a loop.
|
||||
3. **Two failures of the same shape means the model is wrong, not the
|
||||
parameters.** Do not run a third variation. That is the same rule as the
|
||||
two-strike rule in `SKILL.md`, and long-context decay is exactly what makes
|
||||
people violate it.
|
||||
|
||||
A useful asymmetry: **re-verifying is cheap, re-deciding is not.** Reading a value
|
||||
back out of the artifact takes a second and is fine. Re-running a whole experiment
|
||||
because you forgot its conclusion is what costs the round.
|
||||
|
||||
## Handover
|
||||
|
||||
A handover is the record plus three things, written for someone with **no** memory of the task:
|
||||
|
||||
1. **Operating rules first** — anything mechanical that silently wastes time if unknown.
|
||||
2. **Honest current state** — including what is *not* achieved. An optimistic summary costs the next
|
||||
person far more than an accurate one, because they will build on it.
|
||||
3. **The next best step, and why** — plus the dead routes, so they do not start there.
|
||||
|
||||
Two things belong in every handover, because they are the hardest to recover:
|
||||
|
||||
- **What you got wrong**, with the evidence that corrected it.
|
||||
- **What you never actually verified**, stated plainly. "Never confirmed" is more useful than silence.
|
||||
|
||||
## Avoiding the opposite failure
|
||||
|
||||
Discipline can itself become a burden. Guard against that too:
|
||||
|
||||
- **Do not log everything.** Record only what would change a future decision or prevent a repeated
|
||||
mistake. A record nobody reads is worse than no record, because it looks like coverage.
|
||||
- **Do not treat the record as authority.** It is a summary; the environment is ground truth. If the
|
||||
record and a fresh observation disagree, re-test — do not defend the record.
|
||||
- **Do not let grades calcify.** A hypothesis that becomes testable should be tested, not archived.
|
||||
- **Do not let the record's structure dictate the work.** If a section is empty because it is not
|
||||
relevant to this task, leave it empty.
|
||||
@@ -0,0 +1,353 @@
|
||||
# LSPosed modules — when to deliver a hook instead of a patched APK
|
||||
|
||||
Load this when the honest answer to "patch the APK" is *no*: the target checks its own bytes, derives
|
||||
keys from its signature, re-downloads what you removed, or is simply cheaper to hook than to rebuild.
|
||||
A module is a different **deliverable**, not a different way to analyse — it changes what you ship, how
|
||||
it survives upgrades, and who has to install it.
|
||||
|
||||
This file covers the module route end to end: choosing it, the smallest project that works, the
|
||||
Gradle-free build chain, deployment and verification, what hides it from the target, and what it
|
||||
cannot reach at all.
|
||||
|
||||
**Route honesty first.** A module does not modify the APK, so it cannot be "the patched build". If the
|
||||
user asked for an installable standalone APK, a module is not that artifact, and this file will not
|
||||
make it one.
|
||||
|
||||
## Step 0 — pick the route deliberately
|
||||
|
||||
Three routes ship three different things. Choose on **who runs it and for how long**, not on which one
|
||||
you enjoy more.
|
||||
|
||||
| Route | Deliverable | Runs when | Survives target update | Cost |
|
||||
|---|---|---|---|---|
|
||||
| **Frida** (attach/spawn) | a script, driven by you | while a server process is alive on the device | it does not; you re-run it | lowest to start, highest per-session |
|
||||
| **LSPosed module** | an installable APK the user keeps | every process start, automatically, after the module is enabled and scoped | usually yes — the hook targets classes/methods, not offsets | mid: one project, no repackaging of the target |
|
||||
| **Repackaged APK** | a modified, re-signed APK | whenever the app runs | no — every update needs rework | highest: integrity, signature, packing, repack |
|
||||
|
||||
Reach for a module when **any** of these is true:
|
||||
|
||||
- the target verifies its own signature or APK digest, so a repackaged build dies on arrival
|
||||
(`references/signature-derived-keys.md`, `references/native-tamper-and-suicide.md`);
|
||||
- the change must persist across restarts without your laptop being attached;
|
||||
- attaching a debugger/injector is itself detected, but a startup-time hook is not
|
||||
(`references/detection-and-anti-analysis.md`);
|
||||
- you need a Java-level behaviour change only, and the target's own code is reachable from Java.
|
||||
|
||||
Do **not** reach for a module when the logic you need lives in native code or in an AOT-compiled
|
||||
runtime: see *What this route cannot reach* below.
|
||||
|
||||
## What the module can and cannot change
|
||||
|
||||
| Layer | Module can reach it? | Where to go instead |
|
||||
|---|---|---|
|
||||
| Java/Kotlin app code (incl. classes loaded late by a packer's ClassLoader) | yes, once you have the right `ClassLoader` | — |
|
||||
| `Application` / `Activity` / framework lifecycle | yes | — |
|
||||
| Java SDK internals (OkHttp, ad SDKs, HTTP clients) | yes | — |
|
||||
| Pure native logic inside a `.so` | no — Java hooks see only the JNI boundary | `references/native-and-so.md`, `references/native-dbi-and-deobfuscation.md` |
|
||||
| Extracted / virtualised dex (instruction-抽取, dex VMP) | no — the original method bodies may not exist as dex at all | `references/advanced-unpacking.md` |
|
||||
| Flutter / Dart AOT business logic | no — Dart is not Java; the Java layer only hosts the shell | `references/dart-aot.md` |
|
||||
| Behaviour that is *decided* server-side | irrelevant — a hook cannot remove a server rule | `references/server-api.md`, `references/server-config-and-updates.md` |
|
||||
|
||||
The Flutter row is the one that most often surprises: hooking every Java entry point in a Flutter app
|
||||
still leaves all business logic behind `libapp.so`. Confirm which layer owns your target before
|
||||
building a module for it (`references/dart-aot.md`).
|
||||
|
||||
## How injection actually works
|
||||
|
||||
The mechanism matters because every failure mode below is a consequence of it.
|
||||
|
||||
- LSPosed ships as a **Zygisk module** (`zygisk_lsposed`). Zygisk loads into `zygote`, so every app
|
||||
process forked from it is a candidate for injection. [inferred: standard Magisk/Zygisk design]
|
||||
- A module is **not** injected everywhere. For each forked process LSPosed asks its daemon (`lspd`)
|
||||
whether this package is in the module's **scope**, and only then loads the module. Scope is therefore
|
||||
the real gate; anything inside the module that filters on package name is a second, weaker gate.
|
||||
- Module code is loaded **from memory**, not from a file mapping — the module's dex is read out of the
|
||||
installed APK and handed to an in-memory ClassLoader. This is why a target scanning `/proc/self/maps`
|
||||
for path names finds nothing (`references` below, *What detects you anyway*).
|
||||
- The entry point is the class named in `assets/xposed_init`, and it receives
|
||||
`handleLoadPackage(XC_LoadPackage.LoadPackageParam)` **before the application's own `Application`
|
||||
object is attached** — the earliest Java-visible moment in the process. [measured: the injection
|
||||
point is why `Application.attach` and `Activity.onCreate` hooks installed from here fire even under a
|
||||
packer]
|
||||
- `LoadPackageParam.classLoader` is the **application's** loader (the packer's stub loader when the APK
|
||||
is packed). It is the handle you use to find classes the app loads later, and it is also the object
|
||||
anti-Xposed code counts — see *What detects you anyway*.
|
||||
|
||||
## Minimal module anatomy
|
||||
|
||||
Four files. Nothing else is required — no `res/`, no launcher Activity, no permissions.
|
||||
|
||||
| File | Why it is required |
|
||||
|---|---|
|
||||
| `AndroidManifest.xml` with `<meta-data android:name="xposedmodule" android:value="true"/>` | without this key the APK installs as an ordinary app and never appears in the module list |
|
||||
| same manifest: `xposeddescription`, `xposedminversion` (`82`) | shown in the manager UI; `82` is the classic XposedBridge API level and is accepted by LSPosed |
|
||||
| `assets/xposed_init` | one line: the fully qualified entry class. A typo here produces a module that loads and silently does nothing |
|
||||
| the entry class implementing `IXposedHookLoadPackage` | the only class LSPosed instantiates for you |
|
||||
|
||||
`scripts/lsposed_scaffold.py` writes exactly this set, plus a README containing the build chain below,
|
||||
so the project is reproducible without Gradle:
|
||||
|
||||
```sh
|
||||
lsposed_scaffold.py --package com.example.probe --name "Example probe" \
|
||||
--hook-target com.example.target --out ./module
|
||||
```
|
||||
|
||||
The generated entry class logs on **every** scoped process load before anything else, and hooks
|
||||
`Application.attach` / `Activity.onCreate`. Both are diagnostics first: the first line proves injection,
|
||||
the second names the real Activity classes (under a packer, these are the *unpacked* classes, not the
|
||||
manifest's stub).
|
||||
|
||||
## Building without Gradle
|
||||
|
||||
There is no resource to compile and no dependency resolution to do, so the whole toolchain is five
|
||||
commands. [measured on a Windows bench, JDK 17, build-tools 34.0.0, Android API 28 android.jar,
|
||||
Xposed api-82 stub jar; end-to-end 5.3 s for a one-class module]
|
||||
|
||||
```sh
|
||||
BT=/path/to/build-tools/34.0.0
|
||||
AJ=/path/to/android.jar # platforms/android-28/android.jar
|
||||
API=/path/to/api-82.jar # de.robv.android.xposed:api:82
|
||||
OUT=build
|
||||
|
||||
javac -encoding UTF-8 -source 8 -target 8 -nowarn -cp "$AJ:$API" \
|
||||
-d "$OUT/classes" src/com/example/probe/MainHook.java # 850 ms
|
||||
jar cf "$OUT/classes.jar" -C "$OUT/classes" . # 259 ms
|
||||
"$BT/d8" --min-api 24 --lib "$AJ" --output "$OUT" "$OUT/classes.jar" # 1143 ms -> classes.dex
|
||||
"$BT/aapt2" link -o "$OUT/base.apk" -I "$AJ" --manifest AndroidManifest.xml \
|
||||
--min-sdk-version 24 --target-sdk-version 28 -A assets # 126 ms
|
||||
cp "$OUT/base.apk" "$OUT/unsigned.apk"
|
||||
cp "$OUT/classes.dex" . # must sit next to the apk
|
||||
(cd "$OUT" && "$BT/aapt" add unsigned.apk classes.dex) # 97 ms
|
||||
"$BT/zipalign" -f -p 4 "$OUT/unsigned.apk" "$OUT/aligned.apk" # 76 ms
|
||||
"$BT/apksigner" sign --ks mod.keystore --ks-pass pass:android --key-pass pass:android \
|
||||
--v2-signing-enabled true --out "$OUT/module.apk" "$OUT/aligned.apk" # 1794 ms
|
||||
"$BT/apksigner" verify --print-certs "$OUT/module.apk" # 725 ms -> v2/v3 OK
|
||||
```
|
||||
|
||||
Four traps, all measured rather than guessed:
|
||||
|
||||
- **`d8` rejects a directory.** Pointing it at `build/classes/` fails with *Unsupported source file
|
||||
type*. Jar the classes first and pass the jar.
|
||||
- **`aapt add` resolves its argument relative to the current directory and stores the same name.** Run
|
||||
it from the output directory with `classes.dex` present there; a path like `dex/classes.dex` lands
|
||||
inside the APK as `dex/classes.dex` and the module never loads.
|
||||
- **`jar` and `keytool` are frequently not on `PATH` even when `javac` is.** Oracle's `javapath` shim
|
||||
exposes only `java`/`javac`; call the JDK's `bin` directory explicitly.
|
||||
- **`-bootclasspath` no longer exists on JDK 9+**, so the Android API comes from `-cp` together with the
|
||||
Xposed stub jar. `-source 8 -target 8` keeps the class-file version low enough for `d8`.
|
||||
|
||||
The generated README carries the same chain in copy-paste form, including the Windows variants
|
||||
(`.bat` wrappers, `;` classpath separator).
|
||||
|
||||
## Deploy, enable, and verify
|
||||
|
||||
**There are two independent switches.** Getting one right and not the other is the single most common
|
||||
"I built a module and nothing happens" report.
|
||||
|
||||
| Switch | Where | What it actually controls |
|
||||
|---|---|---|
|
||||
| package enabled state | `pm enable <module>` / `pm disable <module>` | whether Android allows the package's components to run at all |
|
||||
| module enabled | LSPosed Manager (module list) | whether LSPosed loads the module into scoped processes |
|
||||
| scope | LSPosed Manager (module → scope) | *which* packages the module is injected into |
|
||||
|
||||
[measured: a freshly installed module can be in the module database yet disabled — the package
|
||||
manager reported `enabled=0` for the module and `pm enable` flipped it to `enabled=1`, while the
|
||||
LSPosed-side enable flag was still 0 in its own database]
|
||||
|
||||
A third, device-level gate sits on vendor ROMs: the *install* itself can be intercepted by the ROM's
|
||||
security centre. The shape is a shell-identity `pm install` returning a bare `Failure [-99]` while the
|
||||
vendor security app takes the foreground, and the **same APK installing cleanly as root**
|
||||
(`su -c 'pm install -r <apk>'`). If a module APK "will not install" on a phone that is otherwise
|
||||
healthy, try root before suspecting the APK. [measured; the extension record has the transcript]
|
||||
|
||||
Verification has **two channels**, and on a device with a broken platform log only one of them works:
|
||||
|
||||
```sh
|
||||
# 1. the platform log — where the module's line normally appears
|
||||
adb logcat -s <TAG> # then start the target app; do NOT clear the buffer first
|
||||
# 2. LSPosed's own module log — where it appears when logd is broken
|
||||
adb shell "su -c 'cat /data/adb/lspd/log/modules_<timestamp>.log'"
|
||||
```
|
||||
|
||||
The expected first line in either channel is the one the scaffold writes on entry — it fires before
|
||||
any app code:
|
||||
|
||||
```
|
||||
<TAG> injected: <target.package>/<process> cl=<loader>@<hash>
|
||||
```
|
||||
|
||||
[measured] On a device whose `logd` route is broken, channel 1 is **completely empty** — not merely
|
||||
`logcat -s <TAG>`, but `logcat -s LSPosed-Bridge` and a full-buffer search for the tag as well — while
|
||||
channel 2 shows a complete, successful injection for every process start. A ROM whose LSPosed log
|
||||
opens with `Logd maybe crashed (err=Socket operation on non-socket)` is exactly this case, and it had
|
||||
nine successful injections in it while logcat showed nothing at all. Debugging with `logcat` alone
|
||||
there produces the confident and wrong conclusion "the module never ran".
|
||||
|
||||
Decision order when that line is missing — stop at the first hit:
|
||||
|
||||
1. module enabled in LSPosed Manager?
|
||||
2. target package present in the module's **scope**?
|
||||
3. module APK still installed (`pm path <module>`)?
|
||||
4. **was the target process started after the scope change?** Scope is read for newly forked
|
||||
processes; a process that was already running keeps the decision it got at fork time.
|
||||
[external, matches LSPosed behaviour reported independently: a scope change takes effect on the
|
||||
target's next start, no device reboot required — 看雪 thread-291750]
|
||||
|
||||
The LSPosed-side state is inspectable from a root shell:
|
||||
|
||||
| Path | Contents |
|
||||
|---|---|
|
||||
| `/data/adb/lspd/config/modules_config.db` | SQLite: `modules(mid, module_pkg_name, apk_path, enabled)`, `scope(mid, app_pkg_name, user_id)`, `configs(...)` [measured] |
|
||||
| `/data/adb/lspd/log/` | `modules_<timestamp>.log`, `verbose_<timestamp>.log`, `kmsg.log`, `props.txt` [measured] |
|
||||
| process `lspd` | the daemon that serves scope decisions; it is a separate process from the target [measured] |
|
||||
|
||||
Two measured caveats about that log directory, because both waste time:
|
||||
|
||||
- `modules_*.log` **is** the module log: it is the file LSPosed appends your `XposedBridge.log()` lines
|
||||
to. It may *also* contain `Logd maybe crashed (err=Socket operation on non-socket), retrying in 1s...`,
|
||||
which means the platform log channel is unavailable — not that your hook failed. Check this file
|
||||
**and** `logcat -s <TAG>`; a device can deliver one and not the other. [measured: nine successful
|
||||
injections were in this file while logcat showed nothing]
|
||||
- `verbose_*.log` is a *logcat snapshot* that includes the whole system, so it is useful for
|
||||
correlating a system-side event with your run, and useless as a module log.
|
||||
|
||||
**Do not kill `lspd` to force a configuration reload.** [measured] On the bench it is started by
|
||||
`/data/adb/modules/zygisk_lsposed/service.sh`, which runs `unshare -m sh -c "$MODDIR/daemon --from-service &"`;
|
||||
the `daemon` script ends in `exec /system/bin/app_process ... org.lsposed.lspd.Main`. Magisk runs
|
||||
`service.sh` once per boot, so an externally killed `lspd` is not restarted by anything you can see
|
||||
from a root shell. The consequence is worse than a dead daemon: newly forked processes stop getting
|
||||
scope decisions. Configure scope through the manager, which talks to the running daemon.
|
||||
|
||||
The same reasoning applies to editing `modules_config.db` by hand: the file is the daemon's
|
||||
**persisted** state, while the decisions actually served to `zygote` come from the daemon's live copy.
|
||||
A hand-edited row is not wrong — it is simply not read until the daemon reloads it, and there is no
|
||||
supported way to make it reload. [inferred from the storage layout and the daemon lifecycle; the
|
||||
manager path is the one that updates both]
|
||||
|
||||
## Hiding the framework from the target
|
||||
|
||||
Injecting a Java hook is trivial to detect **if the target is allowed to see the root/Zygisk
|
||||
environment at all**. On a rooted device the hiding layer is a separate module, and the two common
|
||||
choices differ in exactly one operational detail: how they learn which apps to hide from.
|
||||
|
||||
| | Zygisk Assistant | Shamiko |
|
||||
|---|---|---|
|
||||
| source | open | closed-source, LSPosed project |
|
||||
| target selection | Magisk **DenyList** entries (or KernelSU profiles); no UI of its own | also reads Magisk's DenyList, **but requires DenyList enforcement to be OFF** |
|
||||
| extra mode | — | creating `/data/adb/shamiko/whitelist` switches to whitelist mode without a reboot; documented as costly in memory/performance and test-only |
|
||||
| mechanism | unmounts root-related paths and masks Zygisk presence for the selected process | same family of process-level hiding, plus its own mount/`maps` handling |
|
||||
|
||||
[external: Zygisk Assistant module description and Shamiko README]
|
||||
|
||||
**The DenyList/enforcement distinction is where people lose afternoons.** Enforcement is Magisk's own
|
||||
feature that unmounts Magisk-provided files for listed processes; the *list* is shared, the *enforcement
|
||||
toggle* is not the same thing. Shamiko wants the list without enforcement; Zygisk Assistant is
|
||||
documented to work with the list as configured.
|
||||
|
||||
[measured on the bench: the DenyList was **enforced but empty** — `magisk --denylist status` reported
|
||||
`Denylist is enforced`, `magisk --denylist ls` printed nothing, `/data/adb/shamiko` did not exist, and
|
||||
Zygisk Assistant v2.1.4 was installed. So on that device nothing was being hidden from any app, while
|
||||
LSPosed and `lspd` were running normally. If a target seems to know it is on a rooted device, check
|
||||
this before blaming the module.]
|
||||
|
||||
Practical rules:
|
||||
|
||||
- **Keep the scope minimal.** Every extra scoped package is both an extra place your hook can break
|
||||
something and an extra ClassLoader in that process (next section).
|
||||
- Hiding and hooking are separate problems. A module that is perfectly hidden can still be detected by
|
||||
the ClassLoader count; a target that never checks cannot see either.
|
||||
- If the target is the *only* app you care about, scoping to it alone is both the smallest blast radius
|
||||
and the least observable configuration.
|
||||
|
||||
## What detects you anyway
|
||||
|
||||
Traditional Java-level anti-Xposed checks — `Class.forName("de.robv.android.xposed.XposedBridge")`,
|
||||
exception-stack scanning for Xposed frames, reflection on `findAndHookMethod`, enumerating
|
||||
in-memory-ClassLoader instances — are all **query interfaces**, and hiding layers exist precisely to
|
||||
falsify their answers. They are no longer a reliable signal that a module is present or absent.
|
||||
|
||||
The check that does not depend on a query interface is the **ClassLoader count**. [external: 看雪
|
||||
thread-291750, which reports a clean baseline of 3 versus 12 with a module injected, threshold ≥9; the
|
||||
original idea is traced there to thread-289567]
|
||||
|
||||
The reason it works, in one paragraph: ART's `ClassLinker` keeps a `class_loaders_` list of every live
|
||||
ClassLoader, and that list is part of GC reachability. A module's loader must stay on that list or the
|
||||
GC will collect the module's code and the hook dies. So a module cannot both remain alive and be absent
|
||||
from a structure the runtime itself walks. Hiding layers can fake lookups; they cannot remove a node
|
||||
from a GC-traversal list without unloading the module.
|
||||
|
||||
The same source reports two further native-side traces of the same injection, which are worth knowing
|
||||
because they are independent of ClassLoader counting: the module's dex shows up as **anonymous
|
||||
executable memory** (it is loaded from memory, so no file mapping exists), and Zygisk's patching of
|
||||
`libart` leaves **private-dirty pages** in the library's executable segments.
|
||||
|
||||
Engineering consequences for anyone choosing this route:
|
||||
|
||||
- **Scope discipline is detection hygiene.** Fewer scoped packages means fewer injected processes; a
|
||||
multi-module stack inflates the same count. The number is per-process, so a clean device elsewhere
|
||||
does not help this process.
|
||||
- **A count check needs a calibrated baseline.** On complex hosts (WebView, split APKs, plugin
|
||||
frameworks, other modules) the clean count is already high, and the threshold has to be measured per
|
||||
host, never copied. [external: same source]
|
||||
- **Detection of the framework is not the same as detection of your change.** If the target reacts to
|
||||
the count by refusing to run, that is an environment finding to report
|
||||
(`references/detection-and-anti-analysis.md`), not something to escalate against indefinitely.
|
||||
- If the target's check is native and inline-hooks *your* detector's counterpart, you are in DBI
|
||||
territory: `references/native-dbi-and-deobfuscation.md`.
|
||||
|
||||
## LSPosed vs Frida — the division of labour
|
||||
|
||||
They are not competitors; they are the two answers to "how long must this hook exist".
|
||||
|
||||
| Need | Frida | LSPosed module |
|
||||
|---|---|---|
|
||||
| Understand a flow once, now | **yes** — interactive, no build step | no: build + install + restart per iteration |
|
||||
| Ship something that works every launch, unattended | no: needs a server process | **yes** |
|
||||
| Hook a `.so` function directly | **yes** (`Interceptor`, Stalker) | no: Java hooks stop at JNI |
|
||||
| Survive a target that kills external injectors | weak: `ptrace`-based attach is the thing being detected | **stronger: no external process, no attach, no port** |
|
||||
| Replace a method's return value in app code | yes | yes |
|
||||
| Be re-runnable after a target update | yes (re-attach) | usually yes (class/method names auto-update far better than byte offsets) |
|
||||
| Need no root on the target device | no | no |
|
||||
|
||||
The productive pattern is to use **both in sequence**: Frida to discover which class and method matter
|
||||
and what the real call chain is (`references/dynamic-frida.md`), then a module to make that finding
|
||||
permanent without touching the APK. Rebuilding a module for every question is the expensive way to
|
||||
explore; exploring with an interactive script and only then writing the module is the cheap way.
|
||||
|
||||
A third option exists when the goal is *calling* target code rather than changing it — exporting a
|
||||
native function over RPC: `references/emulation-and-rpc.md`.
|
||||
|
||||
## Failure modes
|
||||
|
||||
| Symptom | Most likely cause | Check |
|
||||
|---|---|---|
|
||||
| module never appears in the manager | `xposedmodule` meta-data missing/mistyped after manifest merge | `aapt2 dump xmltree` the built APK, or re-read the manifest |
|
||||
| module listed, no `injected:` line in either channel | module disabled in manager, package not in scope, **target process not restarted** after the scope change, or you looked at only one channel | the four-step order above, then the two-channel check |
|
||||
| the platform log is empty but the module log is not | the ROM's `logd` route is broken; the module is working | the tell is `Logd maybe crashed (err=Socket operation on non-socket)` at the top of the module log |
|
||||
| `injected:` line present, but `TARGETS` gate skips the process | in-module gate, not the framework | read the module's own log line, then its gate |
|
||||
| hook installed, method never fires | wrong class/loader: packers and plugin frameworks hold several ClassLoaders | resolve the loader at runtime; enumerate `/proc/<pid>/maps`-independent evidence via `references/dex-patching.md` |
|
||||
| target dies right after injection | target side: ClassLoader counting or anonymous-memory heuristics (above); device side: ROM behaviour | instrument-free control run first (`references/detection-and-anti-analysis.md`) |
|
||||
| `pm enable` had no effect on injection | `pm` state and LSPosed state are different switches | the two-switch table above |
|
||||
| `d8` fails on a directory / `aapt add` cannot find the dex / `keytool` not found | build-chain traps, all three measured | the four traps above |
|
||||
|
||||
## Verification for the record
|
||||
|
||||
- which module APK, its hash, and what it was built from (scaffold output or hand-written);
|
||||
- the build commands and whether `apksigner verify` passed;
|
||||
- `pm path`/`dumpsys package` output for the module (installed, enabled state);
|
||||
- the scope list actually configured;
|
||||
- the target process start time relative to the scope change;
|
||||
- the module's logcat lines, in order, showing injection and any hook installs;
|
||||
- whether the target misbehaved, and the instrument-free control result for the same build.
|
||||
|
||||
## See also
|
||||
|
||||
- `references/dynamic-frida.md` — the exploration half of the workflow above
|
||||
- `references/detection-and-anti-analysis.md` — when the target fights back and when to stop
|
||||
- `references/native-and-so.md`, `references/native-dbi-and-deobfuscation.md` — the layer a Java hook stops at
|
||||
- `references/dart-aot.md` — the layer that owns a Flutter app's logic
|
||||
- `references/advanced-unpacking.md` — extracted dex and the VMP boundary
|
||||
- `references/repack-and-sign.md`, `references/signature-derived-keys.md` — what you are avoiding, and why it is sometimes unavoidable
|
||||
- `references/on-device-tooling.md` — MT Manager and the rest of the on-device kit
|
||||
- `references/verification.md` — what "done" means, including the control-build rule
|
||||
- `scripts/lsposed_scaffold.py` — generates the project skeleton described here
|
||||
@@ -0,0 +1,90 @@
|
||||
# Membership, paywalls, and the honest limits of client-side patching
|
||||
|
||||
Read this **before** investing hours. Most "unlock VIP / remove the paywall" requests cannot be satisfied by patching the client, and knowing that early is the most valuable output you can produce.
|
||||
|
||||
## First question: who decides?
|
||||
|
||||
| Signal | Owner | Client patch can... |
|
||||
|---|---|---|
|
||||
| A local boolean/int/string field in an app model, computed locally | Client | Fully determine the outcome |
|
||||
| A field that comes from an API response and is merely *read* by the UI | Server | Change display only — not access |
|
||||
| Download/stream/play URLs issued by an API | Server | Nothing. You cannot mint a valid URL. |
|
||||
| Entitlement checked on every protected action with a server round-trip | Server | Nothing |
|
||||
| A local cache of a server value | Server (cached) | Temporarily fool the UI until it refreshes |
|
||||
|
||||
**Decision rule:** if the thing you want (a media URL, a decrypted payload, premium data) is *delivered by the server*, the client is not the gate. The gate is server-side and is not patchable.
|
||||
|
||||
## How to prove it in minutes
|
||||
|
||||
Do not guess. Test the API directly (`references/server-api.md`).
|
||||
|
||||
```
|
||||
1. Call the protected endpoint with no credentials. -> 401/403 means auth-gated
|
||||
2. Call it with an obviously forged token. -> another 401 confirms it is validated
|
||||
3. Call the metadata endpoint. -> often 200, giving you the shape,
|
||||
but deliberately omitting the asset URL
|
||||
4. Compare: is the valuable field simply absent? -> then no client change can invent it
|
||||
```
|
||||
|
||||
A concrete, real result pattern worth remembering:
|
||||
|
||||
- `GET /videos/{id}` → **200**, full metadata, cast list, source list — **but no playback URL at all**
|
||||
- `GET /v2/sections/{id}/play-url` → **401** with no token
|
||||
- same, with `Authorization: Bearer <forged>` → **401**
|
||||
- `GET /user/me` with forged token → **401**
|
||||
|
||||
That combination is conclusive: metadata is public, the asset URL is server-minted, and the token is verified. **Client-side patching cannot unlock it.**
|
||||
|
||||
## What *is* legitimately achievable client-side
|
||||
|
||||
Don't overclaim, and don't underclaim either. Genuinely client-side wins:
|
||||
|
||||
- **Remove ads / SDKs.** Ads are a client behavior (`references/ad-removal.md`).
|
||||
- **Remove a UI gate** where the underlying data is already delivered and only a local check hides it (a "locked" tab whose content is already fetched, a disabled button).
|
||||
- **Skip promotional interstitials and self-promo popups** driven by local flags.
|
||||
- **Unlock client-only features**: debug menus, extra player settings, higher local bitrate selection, gesture options.
|
||||
- **Grant a client-side reward** only if the reward is neither validated nor authoritative server-side (check first — a reward that later fails server validation is worse than no reward).
|
||||
- **Bypass a client-side trial timer** whose enforcement is purely local (but expect the server to re-assert on next sync).
|
||||
|
||||
## What is NOT achievable, and how to say so
|
||||
|
||||
- **Paid content whose URL comes from the server.** State it plainly and show the 401 evidence.
|
||||
- **Server-validated rewards / purchases / credits.**
|
||||
- **Anything the server re-checks on the next request.** The patch may look like it works for one screen and then silently revert.
|
||||
|
||||
Wording that is both honest and useful:
|
||||
|
||||
> The client cannot unlock this. Metadata is public, but the asset URL is issued by the server, and the endpoint returns 401 for both anonymous and forged credentials — so no local change can produce a valid URL. What *is* removable client-side is X, Y, Z. To actually get access you need a real account with the required entitlement.
|
||||
|
||||
## The fake-VIP trap
|
||||
|
||||
Forcing a local `isVip()`-style method to return `true` is the classic mistake. Two failure modes:
|
||||
|
||||
1. **Cosmetic only** — UI shows VIP styling, but protected actions still fail server-side.
|
||||
2. **Actively harmful** — the client now believes it holds entitlements it does not, so it enters code paths that expect server data it never receives. Observed result: **blank screen** (process alive, nothing rendered, only a swallowed uncaught exception in the crash buffer).
|
||||
|
||||
If you patch such a method, patch it in a **conservatively correct** way, and verify all screens, not just the one you cared about:
|
||||
- Do not fabricate entitlements that gate data fetching.
|
||||
- Prefer patching *presentation* consumers of the flag over the flag's computation, if the flag is also used for access decisions.
|
||||
|
||||
## Local state vs code — the deliverable question
|
||||
|
||||
A value can be "fixed" in two places:
|
||||
|
||||
| Where | Survives fresh install? | Fits an APK deliverable? |
|
||||
|---|---|---|
|
||||
| Code (patched dex) | Yes | Yes |
|
||||
| App data (datastore/prefs/db) | **No** | No — unless you also patch the read path |
|
||||
|
||||
If a fix works on your device but a user reports it gone after reinstall, this is why (`references/pitfalls.md` P11). Re-implement the fix by patching the **read path** so it always yields the desired value.
|
||||
|
||||
## Reporting template
|
||||
|
||||
```
|
||||
Goal: <what the user asked for>
|
||||
Gate owner: client | server | mixed (evidence: ...)
|
||||
Achievable: <list, with evidence each>
|
||||
Not achievable: <list, with the 401/absent-field evidence>
|
||||
Residual: <what remains and the exact coupling that prevents removing it>
|
||||
Verified on: <device, build hash, what was exercised>
|
||||
```
|
||||
@@ -0,0 +1,195 @@
|
||||
# Native & SO Layer
|
||||
|
||||
Load this when the Java/dex layer is blocked or unsuitable, when you need code to run **before** the app's
|
||||
own code, or when a native library is the only editable place left.
|
||||
|
||||
If instead the problem is that the app **dies on its own** — hangs, restarts, or crashes natively at a
|
||||
roughly constant time after launch — that is a terminate mechanism, not a hooking problem, and the
|
||||
methods differ: read `native-tamper-and-suicide.md` before this file.
|
||||
|
||||
The Java layer is usually the right choice. Go native when you need one of these:
|
||||
|
||||
- execution **earlier than any app code** (before `Application.onCreate`, before static initialisers),
|
||||
- a change that survives the app re-loading or re-initialising its Java state,
|
||||
- a host the integrity checks do not cover while the dex is covered.
|
||||
|
||||
## Picking a host library
|
||||
|
||||
A library is usable only if all three hold. Verify each; do not assume.
|
||||
|
||||
1. **Loaded in your scenario** — confirm in `/proc/<pid>/maps`, not by inspecting the APK.
|
||||
2. **Editable** — per the boundary map you built in `packers.md`.
|
||||
3. **Has a call the runtime makes for you** — an exported `JNI_OnLoad` is ideal, because the runtime
|
||||
invokes it automatically when the library is loaded by the app, with no trigger of your own.
|
||||
|
||||
Prefer a host whose exported JNI entry point exists *and* which appears in `maps`.
|
||||
|
||||
## The auto-load trap: `DT_NEEDED` does not call `JNI_OnLoad`
|
||||
|
||||
Adding a `DT_NEEDED` entry so one library drags in another is a natural idea. It maps the second library,
|
||||
but **`JNI_OnLoad` is not invoked for it** — that callback only fires when the runtime loads a library
|
||||
through the normal library-load API. A dependency loaded purely by the dynamic linker is mapped, not
|
||||
initialised.
|
||||
|
||||
If you rely on `DT_NEEDED`, your entry point must be an **ELF constructor** or a `DT_INIT` entry, not
|
||||
`JNI_OnLoad`. Conversely, if your host exports `JNI_OnLoad` and you control when the app loads it, that is
|
||||
the simplest reliable trigger.
|
||||
|
||||
Related: a constructor/`DT_INIT` runs so early that the VM may not exist yet. Retry, and treat failure as
|
||||
non-fatal — a library that logs nothing must never take the process down.
|
||||
|
||||
## Relocations and page permissions
|
||||
|
||||
This is where most hand-built native payloads fail, and the failure looks like a crash **inside the
|
||||
dynamic linker**, not inside your code.
|
||||
|
||||
Rules that follow from how Android maps libraries:
|
||||
|
||||
- **A relocation target must be writable at load time.** The linker writes the computed address into the
|
||||
target slot. If the slot lives in a section that is mapped read-only, the write faults
|
||||
(`SEGV_ACCERR` in `plain_relocate_impl`).
|
||||
- **Modern Android refuses W+X segments.** Adding a read/write/execute segment to get both properties is
|
||||
rejected; the loader will not give you one.
|
||||
- Together these mean: relative relocations cannot heal pointer slots that sit in an executable-only page.
|
||||
|
||||
### Bootstrapping when relocations cannot work
|
||||
|
||||
If your payload's internal pointers all live in the same page as the code that reads them, and that page
|
||||
must be executable, you cannot use relocations. Do the fixup at runtime instead:
|
||||
|
||||
1. derive the runtime page base (a PC-relative instruction gives you this without any relocation),
|
||||
2. make the page writable/executable via a direct `mprotect` **syscall** (do not depend on the libc
|
||||
symbol being imported; a syscall needs no linkage),
|
||||
3. write the pointer slots yourself, computed from the runtime base,
|
||||
4. restore the page to read+execute,
|
||||
5. then branch into your entry point.
|
||||
|
||||
Every pointer you need is `runtime_base + fixed_offset`. No relocation is added, so the segment
|
||||
properties never have to change.
|
||||
|
||||
Do **not** try to make a page writable by re-mapping it over itself with an anonymous fixed mapping:
|
||||
that severs the file mapping, and any other thread executing code on that page faults immediately. Use a
|
||||
permission change, not a re-map.
|
||||
|
||||
## Replacing a Java method from native code
|
||||
|
||||
You can redirect a Java method's implementation without touching dex:
|
||||
|
||||
- Look up the method id, walk to the `ArtMethod` structure, and rewrite its **quick entry point**.
|
||||
- Emit a short stub: load a target address from a literal pool and branch to it; return values follow the
|
||||
normal calling convention (arguments are already in place, return in the usual register).
|
||||
- **Always preserve the original code you overwrote.** If the method can be entered through its own
|
||||
entry point, a naive redirect makes it jump into your stub, which jumps back into the same entry —
|
||||
infinite recursion. Copy the overwritten instructions into a trampoline and branch back after them.
|
||||
- **Do not patch a method whose entry still points at a shared interpreter bridge.** Methods that have
|
||||
not been compiled by JIT/AOT share an entry; rewriting it breaks every method that shares it.
|
||||
A practical guard: if two unrelated target methods report the identical entry address, both are still
|
||||
on the bridge — refuse to patch rather than corrupting the process.
|
||||
- Also refuse when the first instruction at the entry is PC-relative (address computed from the current
|
||||
program counter): copying it elsewhere silently changes what it points to.
|
||||
|
||||
Make installation **fail-safe**: if any precondition is not met, skip the patch and let the app run
|
||||
unmodified. A hook that silently does not apply is survivable; a hook that corrupts a shared entry is not.
|
||||
|
||||
## Finding call sites without symbols
|
||||
|
||||
Stripped and obfuscated libraries still expose structure:
|
||||
|
||||
- Scan the code section for the pattern that reaches your target (a PC-relative address computation
|
||||
followed by an indirect branch through a table slot). A method reached through a **cached table slot**
|
||||
will ignore a rewrite of its own entry point, because callers never read that field — another reason
|
||||
the entry-point rewrite must be validated per call site.
|
||||
- Dump the executable range and disassemble around candidate offsets instead of trusting symbol names.
|
||||
- When a payload was produced by a just-in-time compiler on the target, the displacement between its
|
||||
pages is often zero, which is exactly why relocation-free bootstrapping is required (above).
|
||||
|
||||
## Cross-architecture notes
|
||||
|
||||
Architecture is not background information here — it decides **which artifact you must edit** and
|
||||
**whether your hook can observe anything at all**. Get it from the runtime, not from the manifest.
|
||||
|
||||
### What the device claims vs what is executing
|
||||
|
||||
Three different answers, and they disagree more often than people expect:
|
||||
|
||||
| Source | Answers | Command |
|
||||
|---|---|---|
|
||||
| The device | which ABIs the system supports | `getprop ro.product.cpu.abi` and `ro.product.cpu.abilist` |
|
||||
| The package manager | **which ABI it chose for this app on this device** | `dumpsys package <pkg> \| grep primaryCpuAbi` |
|
||||
| The live process | **what is actually mapped right now** | `scripts/lib_map.py --pkg <pkg>` |
|
||||
|
||||
Only the third is ground truth. The first two are predictions, and they are wrong exactly when it
|
||||
matters: an emulator whose primary ABI is `x86_64` can be running an `arm64-v8a`-only app through a
|
||||
translator, and a fat APK can have the package manager pick an ABI you did not assume.
|
||||
|
||||
**Consequence: if the library you patched does not appear in the live mapping, your change cannot
|
||||
matter.** That is a plan problem, and no amount of re-patching will fix it.
|
||||
|
||||
### Translation layers change what you are observing
|
||||
|
||||
When an ARM-only app runs on an x86 host, a translator (Intel Houdini, `libndk_translation`, a
|
||||
`native_bridge` in general) is executing the guest code. Detect it from the maps — look for
|
||||
translator marker libraries and for app libraries whose architecture differs from the host's
|
||||
primary ABI (`lib_map.py` reports both).
|
||||
|
||||
What it changes:
|
||||
|
||||
- **Timing.** Translation is slower and less predictable. Anything you measured about latency is
|
||||
about the translator, not the code.
|
||||
- **Native integrity and anti-tamper checks can behave differently** under translation, in either
|
||||
direction — a check that fails on hardware may pass here, and vice versa. A pass under translation
|
||||
is not evidence of a pass on hardware.
|
||||
- **Hook behaviour.** Intercepting translated code is not the same as intercepting native code:
|
||||
address spaces, calling conventions, and what the host sees at a syscall boundary all differ. If a
|
||||
native hook reports nothing while the feature plainly runs, suspect translation before suspecting
|
||||
your script.
|
||||
- **Syscall-level observation shows the host's view.** A file or network call made from the guest
|
||||
may look different on the host side than it would natively.
|
||||
|
||||
**Rule: verify the artifact on the ABI the user will actually run.** An emulator-only result is a
|
||||
mid-task checkpoint, never the final claim (`verification.md`).
|
||||
|
||||
### Fat APKs: patching the ABI that never loads
|
||||
|
||||
An APK can ship `lib/arm64-v8a/`, `lib/armeabi-v7a/`, `lib/x86_64/`… The package manager extracts
|
||||
**one** of them into the install-time native library directory, and only that one is loaded.
|
||||
|
||||
- Check `primaryCpuAbi` and the live maps before editing a `.so`. Editing `arm64-v8a` while the
|
||||
device loads `armeabi-v7a` produces a build that is byte-different and behaviorally identical.
|
||||
- If the deliverable must work for an unknown user, remember their device may select a different
|
||||
ABI than yours. Either patch every ABI present, or state which ABI your artifact targets.
|
||||
- If the app's own libraries are written at *runtime* rather than extracted from the APK, the live
|
||||
paths will not be inside the APK at all (`lib_map.py` marks these `materialized`). Those belong to
|
||||
whatever produced them, and a repacked APK will not carry them in that location.
|
||||
|
||||
### Payloads and tooling are per-ABI too
|
||||
|
||||
- A native payload, an injected library, or a shellcode blob built for one ABI **will not load** on
|
||||
another. The failure usually surfaces as a *missing library* or a generic linker error rather than
|
||||
a clean "wrong architecture" message — do not read it as "my payload is broken".
|
||||
- **Pointer size differs** between 32- and 64-bit targets. A script or struct layout that assumes
|
||||
8-byte words misbehaves silently on a 32-bit target.
|
||||
- Keep build artifacts per-ABI and label them with the ABI in the filename. Mixing them up is a
|
||||
reliable source of "it crashed for no reason" when the same APK behaves differently on an emulator
|
||||
and on a device.
|
||||
|
||||
### Quick decision list
|
||||
|
||||
1. `scripts/lib_map.py --pkg <pkg> --app-only` → which app libraries are loaded, from where, and at
|
||||
what architecture.
|
||||
2. If a translator is present and the task needs reliable native behaviour, move to a matching
|
||||
device.
|
||||
3. If the library you meant to patch is absent, stop — pick the right library first.
|
||||
4. Only then start editing.
|
||||
|
||||
## Verification
|
||||
|
||||
A native patch earns no credit until the behaviour changes **and** the app stays healthy:
|
||||
|
||||
- log a marker from your entry point so you know it ran at all;
|
||||
- confirm the library is in `maps`;
|
||||
- confirm the app reaches its normal UI afterwards;
|
||||
- keep the original library so you can produce an unmodified control build.
|
||||
|
||||
Never conclude "the hook works" from the absence of a crash. Absence of effect is the normal outcome of a
|
||||
hook that never installed.
|
||||
@@ -0,0 +1,318 @@
|
||||
# Native DBI and deobfuscation
|
||||
|
||||
**Load this when static disassembly of a `.so` has stopped producing information** — an exported
|
||||
function decompiles into a dispatcher loop and arithmetic soup, a symbol you need has no name, or you
|
||||
must answer "what actually ran" instead of "what could run". This file covers instruction-level
|
||||
dynamic tracing: what a trace can and cannot decide, how to record one with the scripts in
|
||||
`scripts/`, and where it hands off to emulation and symbolic execution.
|
||||
|
||||
**Hand-off.** `native-and-so.md` owns the APK-side conclusions about a library — which `.so` is
|
||||
loaded, which ABI is executing, whether a hook can observe anything, how to neutralise a terminate
|
||||
path by returning. This file owns execution-level evidence. If the question is "did my patch cause
|
||||
the death", that is `native-and-so.md` and `native-tamper-and-suicide.md`; if it is "which blocks did
|
||||
this function really execute, and how often", it is here.
|
||||
|
||||
**Claim strength.** `measured` = an exact command and its output exist in
|
||||
`references/evidence-summary.md` §The capability matrix. `inferred` = it follows from measured behaviour or
|
||||
from documented behaviour of the tool. `unverified` = reported by someone else or reasoned from
|
||||
first principles without a run. The distinction matters more here than usual, because the honest
|
||||
result of this pass is that **the trace pipeline is unverified on the test device** — see.
|
||||
|
||||
## 1. Which obfuscation are you looking at
|
||||
|
||||
Obfuscator-LLVM ships three transforms, and one target usually carries all three. Identify by
|
||||
**which one made static reading fail**, because each is undone by a different observation.
|
||||
|
||||
| Transform | Static shape | Dynamic signature | What removes it |
|
||||
|---|---|---|---|
|
||||
| **Control-flow flattening** | One dominator block at the top of the function, reached from everywhere; a state variable (register or stack slot) assigned a constant before each jump back; a `switch`/jump table dispatch; the original block order is gone | The dispatcher block dominates the execution histogram by a wide margin; real blocks appear between dispatcher entries; execution sequence looks like `D R1 D R2 D R3 D` | Trace histogram + the state variable's constants: each dispatcher entry is preceded by a store of the next real block's id |
|
||||
| **Bogus control flow (opaque predicate)** | A branch whose condition is algebraically constant (`(x*x) >= 0`, `x*x % 2 == 0`), with a junk block on the impossible side; junk blocks reference unreachable names | The junk branch **never appears** in the trace at all: not once, at any input | Trace absence, and then dead-code deletion of the never-taken side. Absence in a trace is only evidence if the trace covers the input that would take it |
|
||||
| **Instruction substitution** | Arithmetic that is correct but unrecognisable: add chains with negated constants, `xor`/`and`/`or` rewrites of a comparison, MBA identities | The function's **result** is still correct at its boundary, and the single-step value at a known point (e.g. the return register) matches the un-obfuscated expectation | Symbolic execution or a brute-force input sweep; a trace alone does not simplify arithmetic |
|
||||
|
||||
Worked example of the distinction: a flattened function with substituted instructions produces a
|
||||
trace whose histogram has an obvious top, but the arithmetic between dispatcher entries still tells
|
||||
you nothing. Flattening is a **control**-flow problem and the trace answers it; substitution is a
|
||||
**data**-flow problem and needs.
|
||||
|
||||
**Reported, not verified here:** a community write-up of this exact pipeline — trace, aggregate,
|
||||
solve with a symbolic executor, patch the binary — is
|
||||
[基于动态指令追踪与符号执行对抗OLLVM控制流平坦化的工程化实践](https://cloud.tencent.com/developer/article/2721228)
|
||||
(cloud.tencent.com/developer/article/2721228, published 2026-08-05). It reports, on a 2,800-block
|
||||
x64 fixture: 15 s of tracing ≈ 220 MB of raw log, aggregation to 48 KB of block records, ~12 min of
|
||||
parallel symbolic execution resolving 67 dispatcher targets, and 1.2 s of binary patching over 147
|
||||
sites. **Those numbers are the author's, on a Windows x64 DLL, and were not reproduced here** —
|
||||
treat them as an order-of-magnitude sketch for the data-reduction ratio (raw log to block table is
|
||||
roughly 4000:1 there), not as a budget for an arm64 Android target. The article's code snippets are
|
||||
illustrative rather than runnable: its `onReceive` handler indexes parsed events by field name
|
||||
(`item.kind`, `item.start`), while `Stalker.parse(..., {stringify: false})` returns **positional
|
||||
arrays**. Take the method from that article; take the API shape from this repository and from the
|
||||
script header in `scripts/stalker_trace.js`.
|
||||
|
||||
## 2. Pick the observation before you pick the tool
|
||||
|
||||
| Question | Observation | Tool |
|
||||
|---|---|---|
|
||||
| Which blocks executed, and how often? | Block-level trace with execution counts | `scripts/stalker_trace.js` + `scripts/stalker_report.py` |
|
||||
| Which functions inside the module call each other? | Call edges from the same trace | same pair (`call` events) |
|
||||
| Which imported function does this stub reach? | Relocation table, not trace | `scripts/elf_plt.py` |
|
||||
| Where did the process die, and did it look arranged? | Crash record, signal, fault address, backtrace split by owner | `scripts/native_crash.py` |
|
||||
| What value must the state variable have to reach block X? | Constraint solving over the dispatcher | angr / Triton () |
|
||||
| What is the value at a specific point for a specific input? | Deterministic per-instruction replay with memory operands | QBDI () |
|
||||
| What does this code do with no device at all? | Emulation of the `.so` with a synthetic environment | unidbg / Unicorn / QEMU |
|
||||
|
||||
The first two rows are one run of one script — take both. `elf_plt.py` and `native_crash.py` are not
|
||||
competitors here: they answer a symbol and a death respectively, and both work when no trace can be
|
||||
taken at all.
|
||||
|
||||
## 3. Recording a trace with `scripts/stalker_trace.js`
|
||||
|
||||
Frida Stalker recompiles every basic block a followed thread executes and hands the events back to
|
||||
JavaScript. It needs no symbols, no source and no disassembler on the host, which is exactly why it
|
||||
works on stripped OLLVM output.
|
||||
|
||||
**Three rules, each one a failure mode if broken**
|
||||
|
||||
1. **Follow one trigger, not the whole process.** An unrestricted follow of a busy thread produces
|
||||
gigabytes and slows the target down until it dies.
|
||||
2. **Filter to one module.** `targetModule` is the only module whose blocks are reported; call edges
|
||||
report when either end is inside it. Filtering happens on the device, not in post-processing,
|
||||
which is what keeps the log usable.
|
||||
3. **Cap the volume.** `maxBlocks` stops the trace instead of letting the log eat the disk, and the
|
||||
`DONE` line records whether truncation happened.
|
||||
|
||||
**Configuration** (edit `CONFIG` in the script, or override at runtime through `rpc.exports.config`
|
||||
/ `script.post({type:'cfg', payload:{...}})`):
|
||||
|
||||
| Key | Meaning |
|
||||
|---|---|
|
||||
| `targetModule` | the one `.so` whose blocks are reported |
|
||||
| `trigger` | `{kind:'export', module, export}` — start on a named export (a JNI function is the classic); `{kind:'offset', module, offset}` — start at module-relative offset; `{kind:'java', cls, method}` — start at the Java→native boundary; `{kind:'main'}` — follow the thread right after load |
|
||||
| `followMs` | hard stop, so a forgotten trace cannot kill the process |
|
||||
| `maxBlocks` | event cap; the `DONE` line reports truncation |
|
||||
| `events.compile` | first translation of each block — deduplicated block set in first-visit order |
|
||||
| `events.block` | every executed block — the execution histogram |
|
||||
| `events.call` | call edges |
|
||||
| `events.exec` | **per instruction; enormous.** Enable only around a known point |
|
||||
| `events.ret` | return edges |
|
||||
|
||||
**Customising what gets translated (`transform`).** Event flags shape *what you are told*; the
|
||||
`transform` callback shapes *what runs*. It fires before a block is translated and receives the
|
||||
instruction iterator, so it can rewrite the code Stalker is about to execute — inline a counter at one
|
||||
call site, neutralise a logging call inside a hot loop, or substitute a value at the one address you
|
||||
care about. Reach for it when the question is "is this site reached, and how often" rather than "which
|
||||
blocks ran"; do not reach for it when the answer must be an observation, because a transform changes
|
||||
the process's behaviour and stops being passive measurement. Two properties to respect: it can run
|
||||
once per block per thread, so any rewrite must be idempotent, and the rewritten bytes are the DBI's
|
||||
copy, so a crash inside them will not point at a file offset. `inferred`: `scripts/stalker_trace.js`
|
||||
uses events and `onReceive` only; the transform route is documented here, not exercised by this
|
||||
repository's pass.
|
||||
|
||||
**Running it.** With the bundled injector, which writes every event to a log file *and* stdout:
|
||||
|
||||
```
|
||||
python scripts/run_probe.py scripts/stalker_trace.js 1 --pkg <pkg> --via usb --log trace.log
|
||||
```
|
||||
|
||||
With any driver that speaks Frida's rpc, so no file edit is needed:
|
||||
|
||||
```js
|
||||
script.exports.config({ targetModule: 'libapp.so',
|
||||
trigger: { kind: 'offset', module: 'libapp.so', offset: 0x9f6c0 },
|
||||
followMs: 3000, maxBlocks: 50000 });
|
||||
script.exports.start(<tid>); // follow this thread
|
||||
script.exports.status();
|
||||
script.exports.stop();
|
||||
```
|
||||
|
||||
Pass `start(tid)` explicitly rather than relying on the default. **`Process.getMainThreadId()` is
|
||||
not the Android main thread in practice**: on the measured run below, a process with pid `19938` was
|
||||
followed on tid `25606`, and an idle-looking thread produced zero events. On Android the main thread
|
||||
tid equals the pid, which is the value to start from.
|
||||
|
||||
**Output grammar** (one logical line per event; `scripts/stalker_report.py` parses it, including
|
||||
logs where each line is wrapped by `run_probe.py` as `[host ts] [dev ts] TRACE ...`):
|
||||
|
||||
```
|
||||
READY ... config echo and module status; nothing works before it
|
||||
MOD name base=0x.. size=.. path=..
|
||||
BB <seq> <mod>+0x<offset> block first translated (dedup'd skeleton)
|
||||
BLK <seq> <mod>+0x<offset> [size=n] block executed (histogram input)
|
||||
CALL <depth> <from> -> <to> call edge
|
||||
DONE reason=timeout|trigger-leave|maxBlocks|stop-rpc blocks=n blk=n calls=n truncated=0|1
|
||||
FATAL / TRIG-FAIL what did not install and why; the script keeps running
|
||||
```
|
||||
|
||||
**Reducing the log**
|
||||
|
||||
```
|
||||
python scripts/stalker_report.py trace.log --top 30 --skeleton 80 --edges 15 --json report.json
|
||||
```
|
||||
|
||||
Four reductions, each answering a different question: the **execution histogram** (top of the list =
|
||||
dispatcher candidate), the **first-visit order** (deduplicated block set ≈ CFG skeleton with the
|
||||
flattening state machine stripped out), the **call-edge table** (module-internal communication with
|
||||
no symbols), and the **collapsed execution sequence** (`block*N -> block -> ...`, which is where the
|
||||
`D -> R -> D -> R` flattening rhythm becomes visible).
|
||||
|
||||
Diagnostics the report prints, and what they mean:
|
||||
|
||||
- `DONE ... truncated=1` — the trace hit `maxBlocks`. Ratios remain useful; **absolute counts do
|
||||
not**, and a claim about a dispatcher's multiplicity must be re-measured with a higher cap.
|
||||
- `zero BLK with nonzero BB` — the thread was translated but executed nothing in-window: the window
|
||||
closed, or the trigger returned immediately.
|
||||
- `zero BB and zero BLK with DONE present` — the module never ran on the followed thread. Change the
|
||||
thread or the trigger; do not tune the follow time.
|
||||
- Everything zero **and no `DONE`** — the trace never started. Read the `READY` / `TRIG-FAIL` lines
|
||||
first; that is a plumbing problem, not an analysis result.
|
||||
|
||||
## 4. Stalker, QBDI, or emulation
|
||||
|
||||
| Situation | Choice | Why |
|
||||
|---|---|---|
|
||||
| Need block-level coverage and execution counts on a real device, no per-instruction semantics | **Stalker** | Cheapest to start, no host toolchain, works on code with no symbols |
|
||||
| Need per-instruction values, memory operands, or a deterministic replay that can be re-run at a recorded PC | **QBDI** (QuarkslaB Dynamic Binary Instrumentation), via its Frida binding | An independent DBI engine with its own register/memory API; the trace is a program, not a log. It instruments at a different layer than Stalker, which matters on ART: Stalker translates the code it sees, QBDI exposes the instruction stream and its operands directly |
|
||||
| Need to hurt the target as little as possible while instrumenting a hot path | **QBDI** | Deterministic, no reliance on the app's own JIT/AOT output |
|
||||
| Need a result with **no device and no app process** — a hardened sample refuses to run, or the algorithm must be called thousands of times | **Emulation** (unidbg, Unicorn, QEMU) | Runs the `.so` in a synthetic environment with JNI stubs; completely different trade-off (environment fidelity for scale) |
|
||||
| Need "what input reaches block X" rather than "what happened for input I" | **Symbolic execution** () | A trace is one path; a solver enumerates the condition |
|
||||
|
||||
**Stalker and ART do not compose for free.** The followed thread's code can be recompiled by ART's
|
||||
JIT while Stalker has already translated it, and a block's address is only meaningful inside one
|
||||
process lifetime. Practical consequences: keys in a log are `module+offset` and stay comparable
|
||||
across runs only for code that is mapped from file; do not compare a `libart`-materialized block
|
||||
address between two runs; and never place a follow/unfollow pair on a hot function (measured failure
|
||||
in).
|
||||
|
||||
### The cost is not linear, and on arm64 it decides the tool choice
|
||||
|
||||
Two costs decide whether Stalker is the right tool, and neither is visible in the API:
|
||||
|
||||
- **Translation multiplier.** A followed thread pays a large constant factor on every translated
|
||||
block. Community reporting for arm64 puts it at **20-50x**; the mechanism is not controversial
|
||||
(every block is copied into the DBI's code cache and routed through a dispatcher), while the exact
|
||||
number is workload-dependent. This one is `inferred` — this repository has not measured the
|
||||
multiplier itself, only its consequences below.
|
||||
- **What you let into the trace.** Every block of every library the thread touches is a candidate.
|
||||
Following into `libc` / `libart` / `libhwui` is how a four-second window becomes a device-wide
|
||||
event: measured here, one such run took the test phone's `load average` to `34.11` on 8 cores and
|
||||
destroyed a co-running job's attach ().
|
||||
|
||||
`Stalker.exclude()` is the control for the second cost, and it is not optional.
|
||||
`scripts/stalker_trace.js` applies it before `follow()` from its `excludeModules` list and reports
|
||||
what it actually excluded on one `EXCL` line — read that line before believing any trace, because a
|
||||
module that was not loaded at follow time was **not** excluded. Exclude every system library you are
|
||||
not studying; the target module is never excluded even if its name appears in the list.
|
||||
|
||||
Three failure shapes follow from ignoring this: deadlock, a watchdog `SIGABRT`, and the zero-event
|
||||
trace. The deadlock and the watchdog shape remain community reports (`inferred`); the other two were
|
||||
measured, **and the measurement splits the claim in two** — the split matters more than the summary:
|
||||
|
||||
| Arm — one device, one package, one module, 6 s follow | Outcome |
|
||||
|---|---|
|
||||
| attach + resume, **no follow** | process survives |
|
||||
| follow, `excludeModules: []` | **process dies**, script destroyed |
|
||||
| follow, 20 system modules excluded | process survives, **but `blocks=0 blk=0 calls=0`** |
|
||||
|
||||
Exclusion is what keeps the target alive — now `observed`, with the no-follow arm ruling out "it
|
||||
would have died under frida anyway". But exclusion does **not** restore event delivery: the
|
||||
zero-event trace survived the treatment arm unchanged. Treat those as two problems, and do not offer
|
||||
`exclude` as the fix for a follow that delivers nothing. Commands and outputs:
|
||||
`references/evidence-summary.md` §The capability matrix.
|
||||
|
||||
**arm64 also raises the floor.** PAC/BTI-bearing code gives a translator more ways to mis-handle a
|
||||
block than armv7 did, which is one more reason a trace that works on an emulator is not evidence
|
||||
about a device. Treat any arm64 result as needing a control run (a followed thread executing a known
|
||||
loop, nonzero `BLK` lines) before it is interpreted.
|
||||
|
||||
**Emulation is a legitimate alternative, not a consolation prize.** When the target is a pure
|
||||
computation inside a `.so` and its environment can be faked, a Unidbg/Unicorn trace can be both
|
||||
faster and more stable than Stalker on a real arm64 device: no device load, no watchdog, no PAC, and
|
||||
a deterministic replay. That inversion of the usual intuition is half the reason
|
||||
`emulation-and-rpc.md` exists — decide with its decision table rather than by habit.
|
||||
|
||||
## 5. From a trace to a deobfuscated function (inferred)
|
||||
|
||||
This is the part the community write-up above also describes, and it is **inferred** here — the
|
||||
trace end was not reproducible on the test device, so the steps below are a method, not a record.
|
||||
|
||||
1. **Find the dispatcher.** Rank by execution count. In a flattened function the dispatcher runs
|
||||
once per real block; loops multiply it further. A block that is (a) at the top by a wide margin,
|
||||
(b) entered from many distinct predecessors, and (c) followed by many distinct successors, is the
|
||||
dispatcher. `stalker_report.py` prints exactly the three items needed to check that.
|
||||
2. **Find the state variable.** Disassemble the dispatcher entry window statically (the offset is in
|
||||
the trace, so no symbol is needed). Look for the register or stack slot written immediately
|
||||
before the indirect branch; in flattened output this is the value the dispatch table is indexed
|
||||
by. If more than one candidate exists, correlate: the state variable is the one whose written
|
||||
constants match the distinct block ids seen in the trace.
|
||||
3. **Recover the real CFG.** Convert the flat sequence into edges: `dispatcher → real block →
|
||||
dispatcher`. The real blocks' order and repetition come from the trace; the state constants come
|
||||
from step 2; the mapping `state value → real block` turns the dispatcher into a jump table you
|
||||
can then remove in the binary (the LIEF step in the write-up).
|
||||
4. **Solve what the trace did not cover.** A trace is one input's path. For a state value you never
|
||||
observed, symbolically execute the dispatcher **only** (a few tens of bytes: load the state,
|
||||
bounds-check it, index the table) rather than the whole function. This is where angr or Triton
|
||||
earns its cost: the entry point is the dispatcher offset the trace gave you, and the goal is the
|
||||
table index, not the program's semantics. Reported pitfall from the same write-up, worth repeating
|
||||
because it is structural: OLLVM inserts transitions that land outside the table, so prune states
|
||||
whose index exceeds the table bound instead of letting the solver chase them.
|
||||
5. **Only then patch the binary.** Direct-branch rewriting must respect instruction length: an
|
||||
absolute jump needs more bytes than the `mov; jmp` pair it replaces, so pad rather than move
|
||||
anything. `scripts/elf_plt.py` is the tool that tells you what a rewritten site actually reaches
|
||||
(by relocation, not by position), and a byte-diff of two builds is how you prove your patch set is
|
||||
what you think it is.
|
||||
|
||||
## 6. Failure modes
|
||||
|
||||
**Zero events, follow installed (measured, cause unverified).** With host frida 16.7.19 and a
|
||||
matching on-device frida-server 16.7.19 on Android 11 / arm64, `Stalker.follow` on a main thread for
|
||||
4 s produced `blocks=0 blk=0 calls=0` on two different system processes, and an `export`-triggered
|
||||
follow produced `reason=trigger-leave blocks=0` on every one of hundreds of firings. Every line
|
||||
before that was healthy: `READY`, `TRIG following tid=…`, `MOD libc.so base=0x…`. The conclusion to
|
||||
carry forward is narrow and should not be widened: **on that combination, follow installs but events
|
||||
do not arrive in `onReceive`.** It is not a licence to assume Stalker is broken everywhere, and not
|
||||
evidence about any other ROM, frida version or target. Validate the pipeline before trusting a zero
|
||||
result: follow a thread you control executing a known loop, and confirm nonzero `BLK` lines.
|
||||
|
||||
**High-frequency follow/unfollow crashes the target (measured).** Using a hot libc export as the
|
||||
trigger — every call starts a follow and every return ends it — a system UI process died with
|
||||
`SIGSEGV` after hundreds of cycles; the crash record's frame `#00` sat in an anonymous region (the
|
||||
DBI's code cache) and the return address led into `libart`. The process restarted under a new pid.
|
||||
The lesson is not "Stalker is unsafe"; it is **do not use a hot function as a follow trigger**, and
|
||||
do not build a follow/unfollow cycle per-call in a hot path. A single follow of a chosen thread for a
|
||||
bounded window is the shape that works.
|
||||
|
||||
**The device becomes the experiment (measured).** While this pipeline was running, the test device's
|
||||
`load average` reached `34.11` on an 8-core mid-range phone, and a co-running dynamic job on the same
|
||||
phone (a dex-dumping attach belonging to another task) was destroyed mid-flight. On a shared device
|
||||
these are not independent experiments: one heavy DBI run makes every other dynamic result
|
||||
unattributable. Announce the window, keep `followMs` in the low seconds, cap `maxBlocks`, never spawn
|
||||
when attach is enough, and treat a `script has been destroyed` in a *different* job as a signal that
|
||||
your load — not that job's code — is the variable. This is `long-task-discipline.md` §single-variable
|
||||
discipline applied to hardware.
|
||||
|
||||
**A trace of a VM is a trace of the interpreter.** If the target is a virtualized function (private
|
||||
bytecode executed by a handler loop), Stalker will happily report the interpreter's blocks: a huge,
|
||||
flat, repetitive histogram around one dispatch loop. That is a valid finding — it tells you the code
|
||||
is virtualized — but it is **not** the guest program's control flow. Recovering that needs handler
|
||||
identification and a private opcode table; see
|
||||
`code-virtualization-and-custom-linkers.md`, and do not present an interpreter trace as the
|
||||
deobfuscated function.
|
||||
|
||||
**Instrumentation was detected before you started.** If the process dies when you attach, refuses to
|
||||
run under root, or the module you want is never loaded while attached, the problem is upstream of
|
||||
this file: read the first section of `detection-and-anti-analysis.md` before escalating the trace,
|
||||
because the cheap answer is usually to switch to static analysis with `elf_plt.py` and
|
||||
`native_crash.py`, not to fight the detector.
|
||||
|
||||
## 7. Reporting what a trace proves
|
||||
|
||||
- A trace proves **what executed on the followed thread inside the followed window**. It does not
|
||||
prove absence: `BB` never appearing means it did not run *for that input, in that window* — which
|
||||
is meaningful for an opaque-predicate junk block only when the rest of the analysis says the input
|
||||
would have taken it.
|
||||
- Counts are evidence only when `truncated=0`. State the cap.
|
||||
- A dispatcher identification is a hypothesis about a block's role. Confirm it by disassembling the
|
||||
block and finding the table it indexes; a high count alone is also what a spin loop or a memory
|
||||
allocator's fast path looks like.
|
||||
- Write the product's before/after into the evidence record condensed in `references/evidence-summary.md` §Where the full record lives, with the exact command, the
|
||||
module, the offsets, and the strength label. The next person's first question is "was the pipeline
|
||||
known to work when you got that zero", and the answer belongs in that record, not in a footnote.
|
||||
@@ -0,0 +1,293 @@
|
||||
# Native Tamper Response — When the Library Kills Its Own Process
|
||||
|
||||
Load this when the process dies **without a Java stack trace**, when a build that passed every
|
||||
static check crashes seconds after launch, or when you are about to neutralise a native check.
|
||||
|
||||
This file exists because of one specific, expensive mistake. Read §The rule before you edit a
|
||||
single byte of a shell's native library.
|
||||
|
||||
## The rule
|
||||
|
||||
> **Never neutralise a terminate path by making it not return. Make it return.**
|
||||
|
||||
A terminate routine is reached from ordinary code paths. If you replace it with something that
|
||||
**never returns** — an infinite loop, a self-branch, a stub that spins — the caller never resumes,
|
||||
locks are never released, and every unrelated thread that touches those locks wedges. You have not
|
||||
suppressed the check; you have frozen the process.
|
||||
|
||||
The distinction decides the outcome:
|
||||
|
||||
| Neutralisation | Effect on the caller | Result |
|
||||
|---|---|---|
|
||||
| `ret` / return a benign value | continues normally | check suppressed, process healthy |
|
||||
| spin / self-branch / `while(1)` | never resumes | process freezes, watchdog or user kills it, symptom looks unrelated |
|
||||
|
||||
**Two failure shapes this produces, both of which have been misread as "the patch did not work":**
|
||||
|
||||
- The app hangs with no crash record at all, then disappears. Logs show nothing, because nothing
|
||||
crashed — it stopped.
|
||||
- The watchdog or a system supervisor kills the frozen process, producing
|
||||
`Force stopping … from uid 0` or an app-restart loop. The uid-0 killer is a strong tell: an app
|
||||
cannot spawn a root-owned killer, so the executioner is **outside** the app, which means the app
|
||||
froze rather than failed.
|
||||
|
||||
Both are self-inflicted and both are avoided by returning instead of spinning.
|
||||
|
||||
### The corollary: do not touch normal-path symbols
|
||||
|
||||
A hardening library imports a lot of libc. Some of those symbols are terminate paths; most are not.
|
||||
|
||||
**Symbols that must stay untouched** — they are on ordinary code paths and freezing them breaks
|
||||
everything:
|
||||
|
||||
`pthread_exit` · `exit` · `abort` · `android_set_abort_message` · `snprintf` · `closedir` ·
|
||||
`__cxa_atexit` · `__stack_chk_fail`
|
||||
|
||||
A concrete case: a set of hand-made patches rewired five PLT stubs to self-branches on one
|
||||
architecture and a different five on another. On the first architecture two of the five were
|
||||
`snprintf` and `closedir` — a string formatter and a directory-close — so the app froze on the
|
||||
first log line. On the second the five included `pthread_exit`, so every thread that finished
|
||||
wedged. The patches were meant to suppress a suicide check and instead destroyed the process.
|
||||
|
||||
**Before touching a stub, resolve what symbol it belongs to.** Do not infer it from the stub's
|
||||
position or from a comment. See §Resolving a stub to its symbol.
|
||||
|
||||
## How a hardened library terminates the process
|
||||
|
||||
Enumerate these before you patch anything. Each has a different signature and a different remedy.
|
||||
|
||||
| Mechanism | Log signature | Has tombstone? | How to neutralise |
|
||||
|---|---|---|---|
|
||||
| `kill(getpid(), SIGKILL)` | `Process N exited due to signal 9 (Killed)`, **no** exit code, **no** tombstone | no | patch the call site or make the `kill` stub return 0 |
|
||||
| `exit()` / `_exit()` | process ends with an exit code, no signal | no | find the caller; usually one branch of a check |
|
||||
| `abort()` | `signal 6 (SIGABRT)` + tombstone | yes | usually a genuine assertion — find what tripped it |
|
||||
| **Deliberate crash** (see §Deliberate-crash stubs) | `signal 11 (SIGSEGV)`, tiny `fault addr` such as `0x4`, `Cause: null pointer dereference` | yes | nop the faulting store |
|
||||
| `tgkill` / `raise` | same as kill/abort | varies | often not imported at all — check the import table before assuming |
|
||||
|
||||
**Distinguish these before patching.** "The app crashed" is not a diagnosis. The signal number and
|
||||
the presence or absence of a tombstone split the space in half; getting this wrong sends you to the
|
||||
wrong layer for hours.
|
||||
|
||||
### Deliberate-crash stubs
|
||||
|
||||
The most deceptive mechanism: the library does not call anything. It **arranges a fault** so the
|
||||
death looks like an ordinary bug.
|
||||
|
||||
```asm
|
||||
; arm64 shape observed in the wild
|
||||
mov x0, #4 ; load a small constant…
|
||||
mov w1, #1
|
||||
str w1, [x0] ; …and use it as a pointer. fault addr = 0x4
|
||||
```
|
||||
|
||||
Runtime shape: `SIGSEGV`, `SEGV_MAPERR`, `fault addr 0x4` (or `0x0`/`0x8`), `Cause: null pointer
|
||||
dereference`, and — critically — **the register holding that small constant value**.
|
||||
|
||||
How to recognise it rather than treating it as a real bug:
|
||||
|
||||
1. The faulting address is a **small integer**, not a plausible pointer.
|
||||
2. Disassembling backwards from the faulting `pc` shows the constant loaded **a few instructions
|
||||
earlier, in the same basic block**, with nothing in between that could produce a real pointer.
|
||||
3. The block sits immediately before a function epilogue (canary check + `ret`), i.e. the normal
|
||||
exit is right there and this store is bolted on in front of it.
|
||||
4. There is often a **delay loop just above it** — `sleep`/`usleep` repeated N times — because this
|
||||
is a *watchdog*: wait a while, then die if the condition still holds. That delay is why the crash
|
||||
is always "a little while after launch" rather than immediate.
|
||||
|
||||
**Neutralise it by nop-ing the faulting store**, leaving the surrounding arithmetic alone. The
|
||||
thread then falls through into the epilogue and returns normally. Do not remove the whole block:
|
||||
the two `mov`s are harmless and keeping them makes the diff minimal and reviewable.
|
||||
|
||||
**This is why patching `kill` alone is not enough.** A deliberate crash uses no imported symbol at
|
||||
all, so every PLT-level fix — including a correctly implemented one — leaves it untouched.
|
||||
|
||||
### Find every instance, not the first one
|
||||
|
||||
Search the binary for the **byte pattern**, not with a linear disassembler. The exact encoding is
|
||||
architecture-specific and must be derived from the crash you already have, then searched as bytes:
|
||||
|
||||
- Take the faulting `pc`, read the instruction bytes backwards until you have the full constant-then-
|
||||
store sequence.
|
||||
- Search the whole file for that byte sequence.
|
||||
- If the search returns more than one hit, patch each; if it returns exactly one, you have the
|
||||
complete set — record that fact, it is a real result.
|
||||
|
||||
A linear disassembler is the wrong tool here for a reason covered in §Scanner traps.
|
||||
|
||||
## Resolving a stub to its symbol
|
||||
|
||||
When a stub's target is a PLT entry, the symbol comes from the relocation table, **not** from the
|
||||
stub's bytes or its neighbours. Two architectures encode this differently:
|
||||
|
||||
- **x86_64** — the stub is `jmp qword ptr [rip+disp32]` (6 bytes). The target address is
|
||||
`stub_addr + 6 + disp32`; look that address up in the PLT relocation map.
|
||||
- **aarch64** — the stub is **four instructions / 16 bytes** (`adrp` → `ldr` → `add` → `br`). The
|
||||
GOT address is `page(adrp) + add_imm`. Do not assume a 4-byte stub: a 16-byte stub leaves room
|
||||
for a **two-instruction replacement** (`mov x0, #0` + `ret`), which is exactly what you need, and
|
||||
assuming 4 bytes pushes you into borrowing the neighbouring slot — which belongs to a different
|
||||
symbol and breaks it.
|
||||
|
||||
`scripts/elf_plt.py` does both architectures, including the relocation lookup.
|
||||
|
||||
**Never splice in a neighbouring stub's bytes.** Stubs are laid out back to back; the next one
|
||||
along is a different function, and corrupting it produces a second, unrelated failure that you will
|
||||
then spend hours attributing.
|
||||
|
||||
## Patching a terminate path correctly
|
||||
|
||||
Work in this order.
|
||||
|
||||
1. **Confirm the mechanism first.** Get the tombstone or the signal number. Do not patch on a
|
||||
guess about *how* it dies.
|
||||
2. **Decide between call-site and stub.** Call sites are precise but you must find all of them.
|
||||
A stub covers every caller at once, including ones your scan missed — but it changes behaviour
|
||||
for every symbol user, so it is only safe when that symbol means "terminate" and nothing else.
|
||||
3. **For a stub, return success, not failure.** Callers commonly inspect the return value. Returning
|
||||
**0** (success) lets the caller take its "already handled" branch; returning **-1** pushes it
|
||||
into an error path that may try a different termination method. Make the stub *succeed*.
|
||||
4. **Preserve every byte you did not intend to change.** Record the original bytes so the patch is
|
||||
reversible and auditable.
|
||||
5. **Re-verify the file after patching.** Length unchanged, only the intended offsets differ, and
|
||||
the normal-path symbols are byte-identical to the original. `scripts/elf_plt.py --diff` prints
|
||||
exactly that.
|
||||
|
||||
### What "faithful suppression" looks like
|
||||
|
||||
A library that decides "tampered" and then cannot terminate has an inconsistent state — it believes
|
||||
it has killed the process and continues. That is **acceptable and usually harmless**, because the
|
||||
terminate path is the end of the check; nothing downstream re-reads "did I die". But verify it
|
||||
rather than assuming: after a successful suppression the app must reach its **normal UI and stay
|
||||
there**, not loop through half-initialised states.
|
||||
|
||||
## ELF hardening you will meet in these targets
|
||||
|
||||
Hardening libraries are not normal ELF files. Assume the following until measured otherwise.
|
||||
|
||||
### Forged section headers
|
||||
|
||||
A shell may ship a library whose section header table is deliberately wrong — `.text` sized to a
|
||||
handful of bytes, `.dynsym` truncated, sections overlapping. Tools that walk sections therefore
|
||||
produce **confidently wrong** answers (a symbol list with one entry, an import table that is empty).
|
||||
|
||||
**Work from the program headers instead.** `PT_LOAD` gives you the real mapped segments and their
|
||||
permissions; `PT_DYNAMIC` gives you the pointers you need, walked by hand:
|
||||
|
||||
```
|
||||
DT_STRTAB(5) DT_SYMTAB(6) DT_STRSZ(10) DT_SYMENT(11)
|
||||
DT_JMPREL(23) DT_PLTRELSZ(2) DT_PLTREL(20) DT_PLTGOT(3)
|
||||
```
|
||||
|
||||
Then translate any virtual address to a file offset through the `PT_LOAD` that contains it. A
|
||||
library with forged sections that yields a full import list this way is a good sign; one that
|
||||
still yields almost nothing means the hardening is deeper and you should say so rather than
|
||||
reporting the truncated result as the truth.
|
||||
|
||||
### Function boundaries come from `PT_GNU_EH_FRAME`, not from prologue guessing
|
||||
|
||||
You will need "where does this function start". Two bad answers and one good one:
|
||||
|
||||
- **Reverse-decoding backwards from a call site does not work on aarch64.** Almost any 4-byte
|
||||
window decodes as *some* instruction, so a naive scanner reports hundreds of "function starts"
|
||||
that are all fiction.
|
||||
- **Guessing at prologues** (`sub sp, sp, #imm` + `stp`) produces a plausible set with no guarantee
|
||||
of completeness.
|
||||
- **`PT_GNU_EH_FRAME` is authoritative.** If the segment survives (it usually does, because
|
||||
unwinding is needed at runtime), parsing it yields the real function entry table — every entry,
|
||||
exactly. It is several lines of code and it removes an entire class of wrong conclusions.
|
||||
|
||||
**Check for it first.** If it is present, use it; if it is absent, say your boundary list is
|
||||
heuristic.
|
||||
|
||||
### A function's entry is not where its logic is
|
||||
|
||||
Callers reach a routine through several routes: a direct branch, a cached table slot, or a function
|
||||
pointer in writable data. Rewriting the entry point only covers the first. If a call resolves
|
||||
through a cached pointer, your entry-point change is invisible to it — so before patching an entry,
|
||||
scan for the other routes (`scripts/elf_plt.py`, or a byte-pattern scan for the address).
|
||||
|
||||
## Scanner traps
|
||||
|
||||
**A linear disassembler can stop silently.** Given a buffer whose start is not code, a decoder may
|
||||
return a handful of instructions and then nothing, with no error. Concluding "there are no such
|
||||
call sites in this library" from that output is a false negative with real consequences.
|
||||
|
||||
Guards, in order of preference:
|
||||
|
||||
1. **Byte-pattern search** for a sequence you already know (from a crash or a known call site).
|
||||
Fast, exhaustive, no decoder involved.
|
||||
2. **Fixed-bit-pattern scans** for instruction classes whose encoding is regular — the branch
|
||||
instructions are the useful ones here (`BL`/`B` on aarch64, `call rel32` on x86_64). This finds
|
||||
every call site without depending on linear decoding.
|
||||
3. **Resynchronising scan** — decode, and on failure advance by one instruction unit (4 bytes on
|
||||
fixed-width aarch64, 1 byte on x86_64) and retry. Slower, but it does not stop early.
|
||||
|
||||
Whichever you use, **state which one you used and what it can miss**. "No hits" from a scan whose
|
||||
coverage you cannot describe is not evidence.
|
||||
|
||||
## Deciding whether a dynamic-resolution path matters
|
||||
|
||||
Hardening libraries frequently build a table of function pointers with `dlopen` + `dlsym` — often
|
||||
40+ symbols including terminate calls. This raises an obvious worry: *a call through that table
|
||||
bypasses the PLT stub, so my stub patch does nothing.*
|
||||
|
||||
**Measure it instead of assuming either way.** For each symbol of interest, find whether its table
|
||||
slot is ever **read**:
|
||||
|
||||
- Locate the slot's address (the store from the `dlsym` return, or the table base + index).
|
||||
- Search the code for instructions that load from that address (`adrp`+`add`+`ldr` on aarch64).
|
||||
- **Zero readers means the entry is written and never used.** The table is a redundant fast path,
|
||||
not a bypass, and PLT-level patching is complete.
|
||||
|
||||
Real outcome from a hardened target: the `kill` slot had **zero** readers while the `exit` slot had
|
||||
exactly one, inside a routine that was already suppressed — so no dynamic-resolution bypass existed
|
||||
at all, and the expensive "patch `dlsym` itself" plan would have broken 40 unrelated symbols for
|
||||
nothing.
|
||||
|
||||
**Do not blanket-patch a symbol resolver.** It feeds `open`, `read`, `mmap`, `dlopen` and more.
|
||||
Changing its behaviour to reach one terminate symbol is a large blast radius for a problem you may
|
||||
not have.
|
||||
|
||||
## What these checks actually detect — and why your environment changes the answer
|
||||
|
||||
A terminate path is not reading your patch. It is reading **the world around the process**, so what
|
||||
trips it is environmental, and the same build can die in one setup and live in another.
|
||||
|
||||
| Signal | Typical probe |
|
||||
|---|---|
|
||||
| a debugger is attached | `ptrace`, `TracerPid` in `/proc/self/status`, `prctl` |
|
||||
| the parent process was replaced | a cached `getppid()` compared against a fresh one |
|
||||
| the process was suspended | wait status showing `SIGSTOP` / `SIGTRAP` |
|
||||
| instrumentation is present | hook-framework artefacts: listening ports, thread names, unusual mappings |
|
||||
| a "non-standard" environment | root binaries, emulator fingerprints, writable system paths, build props |
|
||||
|
||||
Two consequences decide whether your experiments mean anything at all:
|
||||
|
||||
1. **A pass under translation is not a pass on hardware.** Under an ARM-on-x86 translator the
|
||||
environment probes return different answers — in *both* directions. A check that fires natively
|
||||
may stay quiet here, and one that is quiet natively may fire. Emulator or translated results are
|
||||
a mid-task checkpoint, never the final verdict.
|
||||
|
||||
2. **Your own tooling can be the thing that trips it.** If the app only dies while you are attached,
|
||||
you are looking at a probe aimed at *you*, not at the app's ordinary startup path. Reproduce with
|
||||
nothing attached before you patch anything.
|
||||
|
||||
**Run the unmodified original through the identical conditions first.** If it dies the same way, the
|
||||
check is firing on the environment rather than on your change, and every conclusion drawn from the
|
||||
patched build's death is void. This is the same control-build rule as everywhere else in this skill,
|
||||
and it is the single cheapest way to avoid chasing a detection that is not about you.
|
||||
|
||||
## Verification
|
||||
|
||||
A native suppression earns no credit until:
|
||||
|
||||
1. The process **survives the window in which it used to die** — and that window must be measured,
|
||||
not guessed. If the observed death was ~40 s after launch, a 30-second test proves nothing.
|
||||
2. It then **keeps surviving** through real interaction, not just idling.
|
||||
3. `crash`/`tombstone` buffers are clean, **and** the process id never changed (a restart with a new
|
||||
pid is a death even when nothing logged a crash).
|
||||
4. The normal-path symbols are byte-identical to the original build.
|
||||
5. A **control build** — same pipeline, no native patch — still dies the same way. Without the
|
||||
control you cannot tell suppression from an unrelated change in timing.
|
||||
|
||||
**Record the observed time-to-death before patching.** It is the only thing that later tells you
|
||||
whether your patch worked or merely moved the window.
|
||||
@@ -0,0 +1,145 @@
|
||||
# On-device tooling — MT Manager, APK MCP, LSPosed, Termux
|
||||
|
||||
Sometimes the PC is the wrong place to work: the device already has the APK installed, the edit is
|
||||
one string, the target is a 38 MB repack you would otherwise push back and forth, or there simply
|
||||
is no laptop in the loop. This file covers the **on-device toolchain** and — more importantly —
|
||||
when each tool beats its PC counterpart and when it does not.
|
||||
|
||||
Evidence basis: the facts below come from a real configured environment (Android 11, Magisk +
|
||||
Zygisk, LSPosed v1.9.2, MT Manager 2.26.9). Framework
|
||||
activation and the module set are `observed` there; per-workflow usability notes are `measured`
|
||||
where marked, otherwise `inferred`. The MT MCP tool surface is from MT's official documentation
|
||||
(`observed` as a list; live invocation requires the service to be running — see).
|
||||
|
||||
|
||||
**Load this when:** the work is better done on the phone than on the PC -- one-string edits, an already-installed target, or no laptop in the loop. It gives MT Manager edit/repack/sign and its APK MCP surface, LSPosed Manager, and Termux.
|
||||
|
||||
## 1. MT Manager as a reverse-engineering workbench
|
||||
|
||||
MT Manager (`bin.mt.plus`) is a file manager plus APK editor. For this skill's purposes it is a
|
||||
**complete repack pipeline that runs on the phone**: view, edit, rebuild, sign.
|
||||
|
||||
What it covers, mapped to this skill's workflow stages:
|
||||
|
||||
| Skill stage | MT capability | Notes |
|
||||
|---|---|---|
|
||||
| Recon (manifest, SDKs) | APK viewer: manifest, entry list, string search across dex/resources | String search is the fast path; no full Java decompile in the editor workflow |
|
||||
| Dex reading/editing | Dex viewer + smali-level editing | Smali is the editing granularity — same discipline as `dex-patching.md` |
|
||||
| Removing signature verification | Built-in "去除签名校验" (signature-check killer) feature | Convenience feature; audit what it actually patched — a Java-layer signature killer can be a decoy while a native check still kills you (`code-virtualization-and-custom-linkers.md` §decoy) |
|
||||
| Extracting artifacts | Pull `classes.dex`, `.so`, `resources.arsc`, axml out of any APK/zip | Useful to move a single `.so` to the PC toolchain without a full unzip |
|
||||
| Repack + sign | Rebuild APK and sign it on device | Output lands in MT's working directory; verify with the same discipline as `repack-and-sign.md` (alignment, `resources.arsc` rules still apply — the platform enforces them regardless of which machine built the file) |
|
||||
|
||||
**When MT beats the PC pipeline:** quick single-file edits, on-the-spot verification on the same
|
||||
device the app is installed on, and sessions where shuffling 40 MB APKs over USB dominates the
|
||||
work. **When the PC wins:** anything scriptable/repeatable, dex-wide cross-referencing
|
||||
(`find_refs.py`), byte-precision patching with header recomputation, and anything needing a real
|
||||
decompiler. The honest split: MT for **breadth on device**, PC for **depth and repeatability**.
|
||||
`inferred`.
|
||||
|
||||
## 2. MT's built-in APK MCP server
|
||||
|
||||
MT 2.26.9+ ships an "APK MCP" service: it exposes MT's analysis and modification capabilities to
|
||||
an MCP (Model Context Protocol) client — which in practice means an AI agent drives MT's tooling
|
||||
over HTTP while the human sees the results on the phone. The official framing: AI understands the
|
||||
request and calls the tools; MT does the local read/modify/repack/sign.
|
||||
|
||||
**Transport and endpoint** (`measured` on the reference environment):
|
||||
|
||||
- Streamable HTTP MCP at `http://127.0.0.1:8787/mcp` from the PC, via `adb forward tcp:8787
|
||||
tcp:8787`; or directly `http://<phone-ip>:8787/mcp` on the LAN.
|
||||
- **The service must be started by hand in the MT UI** (side drawer → Tools → APK MCP → Start).
|
||||
adb cannot start it. Before it is started, the port is simply not listening — probe with
|
||||
`scripts/mt_mcp_probe.py`, which prints waiting instructions in that state.
|
||||
|
||||
**Tool surface** (official documentation; names observed from the published list):
|
||||
|
||||
| Category | Tools |
|
||||
|---|---|
|
||||
| Open/selection | `mt_apk_list_available_apks`, `mt_apk_open` |
|
||||
| Reading/search | `mt_apk_list`, `mt_apk_search`, `mt_apk_read_text`, `mt_apk_read_bytes`, `mt_apk_continue` |
|
||||
| Dex analysis | `mt_apk_dex_outline_class`, `mt_apk_dex_xref` |
|
||||
| Resources | `mt_apk_resource_read`, `mt_apk_resource_xref` |
|
||||
| Native static | `mt_apk_native_inspect`, `mt_apk_native_read_items`, `mt_apk_native_map_address`, `mt_apk_native_xref`, `mt_apk_native_disassemble`, `mt_apk_native_function_cfg` |
|
||||
| Modification | `mt_apk_edit_open`, `mt_apk_edit_text`, `mt_apk_edit_resource`, `mt_apk_patch_bytes`, `mt_apk_native_patch_instructions`, `mt_apk_native_patch_string`, `mt_apk_edit_check`, `mt_apk_build` |
|
||||
| Cleanup | `mt_apk_close` |
|
||||
|
||||
**Official constraints — budget your plans around these, not around hopes:**
|
||||
|
||||
1. **No Java decompilation.** The stated position: AI reads smali directly; decompiled Java of
|
||||
hardened/R8'd code misleads more than it helps. Cross-references (`dex_xref`) and class
|
||||
outlines are the structural view.
|
||||
2. **`resources.arsc`: existing entries only.** No new locales or new entries — edit what ships,
|
||||
do not plan feature-additions through arsc.
|
||||
3. **`.so` support is static analysis only.** No dynamic execution, no emulation, no C/C++
|
||||
pseudocode. Xrefs and CFG are "best-effort within a limited budget" — direct static results,
|
||||
**not** proof of a complete call chain or of runtime behaviour. For depth, the PC side (IDA/
|
||||
Ghidra-class, see `toolchain.md`) remains the answer.
|
||||
|
||||
**Handing an APK to the server** (three supported ways): open an APK in MT's file list and stay
|
||||
on its info dialog ("the current APK file"); open an installed app's info dialog the same way; or
|
||||
drop the APK into MT's configured MCP working directory and refer to it by filename/package. The
|
||||
working directory is also where rebuild output lands.
|
||||
|
||||
**Probing from the PC:** `python scripts/mt_mcp_probe.py` does the full JSON-RPC handshake
|
||||
(initialize → initialized → tools/list) with no MCP SDK dependency and prints a grouped tool
|
||||
inventory. If MT's MCP has not been started in the UI, it exits with waiting instructions and
|
||||
code 2 — usable as a loop check ("is it up yet") while you start it by hand. `measured`.
|
||||
|
||||
## 3. LSPosed Manager — the module route on device
|
||||
|
||||
LSPosed (via Zygisk) gives you a **Java-layer hook platform that needs no PC at runtime**: write/
|
||||
install a module once, and it applies to target apps at every launch.
|
||||
|
||||
Facts from the reference environment (`observed`):
|
||||
|
||||
- Activation is verifiable from logs: the `ZygiskCompanion: welcome to LSPosed!` banner, the
|
||||
version line, the `lspd` daemon process running as system.
|
||||
- Install discipline: `magisk --install-module` unpacks to `/data/adb/modules_update/` and only
|
||||
**merges after a reboot** — a module that "did not install" is usually one that has not merged
|
||||
yet. `ls /data/adb/modules/` is the truth; `modules_update/` non-empty means pending.
|
||||
- Zygisk itself needs one reboot after being enabled before its injection layer exists at all.
|
||||
|
||||
Limits that matter for this skill: LSPosed hooks **Java/ART** — it cannot hook inside a native
|
||||
`.so` (that is Frida/IDA territory, and the reason `framework-runtimes.md`'s runtime check
|
||||
exists), and it does nothing for Flutter/Dart AOT logic below the platform channel. Use it for
|
||||
the layer it owns: Java API interception, intent/telemetry rewriting, feature gates that live in
|
||||
Java. A hook module is also **not a deliverable** for a request that wants an installable APK —
|
||||
it is an environment change on your device (`detection-and-anti-analysis.md` Step 4 keeps these
|
||||
separate).
|
||||
|
||||
## 4. Termux + frida-server — device-side dynamic analysis
|
||||
|
||||
The phone can run the whole dynamic stack alone: Termux (or a plain root shell) hosts
|
||||
`frida-server`, and scripts run through Termux's own Python.
|
||||
|
||||
- **frida-server version must match the PC-side frida tooling** when the PC is also in the loop
|
||||
(`dynamic-frida.md` owns this). Device-only operation still needs a matching `frida` client in
|
||||
Termux.
|
||||
- **Disguise the server when the target hunts it**: a renamed binary and a non-default port are
|
||||
the minimum; a detection-savvy target scans process names and default ports. The reference
|
||||
environment carried renamed server copies from prior work — the technique is standard, and the
|
||||
detection mechanics are `kernel-and-environment-hardening.md`.
|
||||
- **32/64-bit**: a `zygote64_32` device runs both ABIs; make sure the frida-server architecture
|
||||
matches the process you are attaching to (`environment.md` §which architecture is actually
|
||||
executing).
|
||||
- Keep sessions simple: attach-mode on a hardened target inherits every §1 problem of
|
||||
`kernel-and-environment-hardening.md`; prefer spawn, and prefer `spawn_patch_detach.py`-style
|
||||
patch-then-detach when you only need a memory fix to stick.
|
||||
|
||||
## 5. On-device forensics — where the data is
|
||||
|
||||
Rooted-phone data inspection does not need a PC round-trip. The full layout, formats and
|
||||
ownership rules live in `runtime-data.md` (DataStore/MMKV/SQLite paths, `chown`/`restorecon`
|
||||
after restores, the "app rewrites your edit" decision tree) — on device, MT Manager's file
|
||||
browser with root covers the same reads, and Termux covers scripted pulls. The two on-device
|
||||
cautions that most often bite: edit files **only with the app force-stopped** (the in-memory
|
||||
copy wins otherwise), and re-check ownership after any reinstall (the uid increments).
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Chose the workbench deliberately: MT (on-device, fast) vs PC (scriptable, deep) — not by habit
|
||||
- [ ] MCP started in MT's UI and probed (`mt_mcp_probe.py` exits 0) before planning agent-driven edits
|
||||
- [ ] MCP plans respect the three official constraints (no Java decompile, arsc entries-only, `.so` static-only)
|
||||
- [ ] LSPosed modules verified merged (`/data/adb/modules/`, not `modules_update/`) before testing
|
||||
- [ ] frida-server architecture matches the target process; disguised if the target scans
|
||||
- [ ] Data edits done with the app stopped; ownership fixed after reinstalls
|
||||
155
vendor-new/apk-reverse/skills/apk-reverse/references/packers.md
Normal file
155
vendor-new/apk-reverse/skills/apk-reverse/references/packers.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# Hardened / Packed Targets
|
||||
|
||||
Load this when recon says the app's `Application` class is not the app's own, or when edits make the
|
||||
app die before your code ever runs. Packing is the single most common reason a correct patch appears to
|
||||
"do nothing", so treat it as its own phase with its own evidence standard.
|
||||
|
||||
**Not packed, and still dying?** That is a different layer and a different file:
|
||||
`code-virtualization-and-custom-linkers.md`. There `Application` is the app's own, the dex is fully
|
||||
readable, and yet whole classes are `native` declarations and a private loader carries an embedded
|
||||
validation payload. The single most expensive recon error in this domain is reading "no packer" as
|
||||
"the code is editable" — check for that shape explicitly before planning an edit.
|
||||
|
||||
## What a packer actually does
|
||||
|
||||
A shell (jiagu / weapon / leggu / 360 / ijiami / bangcle class) replaces the manifest's
|
||||
`application android:name` with its own stub. At process start the stub:
|
||||
|
||||
1. loads its own native library from a directory it controls (often extracted at first launch),
|
||||
2. decrypts and loads the real dex, usually from `assets/` or a same-named container,
|
||||
3. optionally verifies that nothing about the APK, its own components, or the environment changed,
|
||||
4. only then forwards to the app's real `Application`.
|
||||
|
||||
Consequences that shape every later decision:
|
||||
|
||||
- **Your code in the app's own dex may never run** if the shell aborts first.
|
||||
- **The shell's native library is the gatekeeper**, and it is usually the only thing that decides
|
||||
"tampered vs clean".
|
||||
- **The shell's own entry is native**, so Java-level reflection into it is usually a dead end.
|
||||
|
||||
## Reading the rejection signal
|
||||
|
||||
The most informative failure looks like this:
|
||||
|
||||
```
|
||||
UnsatisfiedLinkError: JNI_ERR returned from JNI_OnLoad in "<app>/.jiagu/lib<shell>.so"
|
||||
```
|
||||
|
||||
Read it precisely: the shell's **own** `JNI_OnLoad` returned `JNI_ERR`. That means the shell
|
||||
**decided** to refuse — it is not a crash, not a missing library, not an ABI problem. Distinguish it from
|
||||
the look-alikes:
|
||||
|
||||
| Message | Meaning |
|
||||
|---|---|
|
||||
| `JNI_ERR returned from JNI_OnLoad in <shell>.so` | shell actively refused (integrity or environment) |
|
||||
| `dlopen failed: library "X" not found` | a dependency you added does not exist — **your** bug |
|
||||
| `CANNOT LINK EXECUTABLE` / relocation errors | ELF you produced is malformed |
|
||||
| `ClassNotFoundException` during `Application` init | a manifest component you renamed no longer resolves |
|
||||
|
||||
This distinction matters more than it looks: several classes of "the shell rejected me" are actually
|
||||
self-inflicted. Always grep the crash for `linker`, `CANNOT LINK`, `dlopen` and `not found` **before**
|
||||
concluding that the shell detected anything.
|
||||
|
||||
## Map the validation boundary with single-variable tests
|
||||
|
||||
Do not guess what the shell checks. Measure it. The only reliable method is one change at a time, each
|
||||
with its own install-and-launch run:
|
||||
|
||||
1. **Baseline control** — repack with no changes at all, re-sign, install, launch. If this fails, stop:
|
||||
your pipeline is the problem, not the shell.
|
||||
2. **Existing file, semantically inert change** — flip one byte in a section that nothing reads
|
||||
(a `.comment` string, a duplicated `DT_NEEDED` entry). Survives ⇒ edits to existing files of that
|
||||
class are allowed.
|
||||
3. **New file added** — add one benign ELF under the native library directory. Very commonly this alone
|
||||
is enough to trip a check, which is a distinct policy from (2).
|
||||
4. **Component rename in the manifest** — equal-length rename of one lazy component
|
||||
(an `activity`). Survives ⇒ you have a cheap way to neutralise features.
|
||||
5. **Structural change** — move a program header table, add a segment, add a relocation.
|
||||
|
||||
Record every result. The boundary is usually **not uniform**: typical real outcomes are
|
||||
"existing native libraries editable, new ones not", "activities renamable, providers not",
|
||||
"this dex fingerprinted, that container ignored".
|
||||
|
||||
### Why one variable at a time is not optional
|
||||
|
||||
The most expensive mistake in this phase is testing two hypotheses in one build. If you both edit a
|
||||
shell component **and** add a library, and the app dies, you have learned nothing — and you will likely
|
||||
blame the wrong one, discard a viable route, and lose hours (or in this skill's history, dozens of
|
||||
rounds) re-deriving what a cleaner experiment would have shown in one run.
|
||||
|
||||
### Component-type rules worth knowing up front
|
||||
|
||||
- **Providers are instantiated by the system during process start.** Renaming one so its class no
|
||||
longer resolves fails inside `Application.onCreate` and kills the process before anything else runs.
|
||||
Providers are therefore a poor rename target; prefer disabling the feature that consumes them, or
|
||||
hooking it.
|
||||
- **Activities and services are resolved lazily**, only when actually used. Renaming them is a cheap,
|
||||
low-risk way to make a feature unable to display, and it survives most integrity checks because it
|
||||
changes nothing about the shell.
|
||||
- **Equal-length renames** keep binary XML chunk structure valid, so no manifest re-encoding is needed:
|
||||
replace the name bytes in `AndroidManifest.xml` in place, size unchanged.
|
||||
|
||||
## Native libraries as a place to live
|
||||
|
||||
When the shell blocks dex-level changes, native libraries often remain open — but only some of them are
|
||||
useful. A library is a valid host for your code **only if all three hold**:
|
||||
|
||||
1. **It is actually loaded** in the scenario you care about.
|
||||
2. **It is editable** (per your boundary map above).
|
||||
3. **Something in it is called early enough** to matter.
|
||||
|
||||
Condition 1 is the one people skip, and it is the one that wastes the most time. A library shipped in
|
||||
the APK is not necessarily loaded. **Check `/proc/<pid>/maps`** after a real launch rather than assuming:
|
||||
|
||||
```
|
||||
grep -o '/[^ ]*\.so' /proc/<pid>/maps | sort -u
|
||||
```
|
||||
|
||||
Libraries belonging to optional SDKs are frequently lazy: they exist, they may even be unpacked to disk,
|
||||
and they are never mapped during the startup path you are trying to influence. Unpacked-but-unmapped is
|
||||
the signature of a lazy SDK, not evidence that your file was rejected.
|
||||
|
||||
Pick a host that is (a) present in `maps`, (b) exports a JNI entry point the runtime must call, and
|
||||
(c) editable. A library that exports `JNI_OnLoad` and appears in `maps` is the strongest candidate,
|
||||
because the runtime calls `JNI_OnLoad` on its own, without any trigger from you.
|
||||
|
||||
## Where integrity evidence actually comes from
|
||||
|
||||
Shells commonly check a mix of: APK signature, `AndroidManifest.xml`, their own native libraries, the
|
||||
dex they load, and environment signals (root, emulator, hooking frameworks, debugging).
|
||||
|
||||
Practical consequences:
|
||||
|
||||
- **Your own re-signing is itself a detected change** in most signature-based schemes. Assume any
|
||||
signature check will see your build as tampered and plan around it rather than hoping otherwise.
|
||||
- **The environment matters.** Hooking frameworks, debugging, and non-standard filesystems are commonly
|
||||
probed. A patch that works on a clean device may be refused on a rooted one, and vice versa.
|
||||
- **Absence of a log line is not absence of a check.** A shell that fails silently looks identical to a
|
||||
shell that never checked.
|
||||
|
||||
## Locating what the dialog/detection is attached to
|
||||
|
||||
Blocking dialogs ("tampered", "third-party environment", "abnormal") are the usual user-visible symptom.
|
||||
Establish **which layer draws it** before trying to suppress it:
|
||||
|
||||
- If it is a Java dialog, hooking the dialog class shows a caller stack that names the detection.
|
||||
- If hooking the dialog classes produces **no** hits, the UI is likely drawn by a cross-platform runtime
|
||||
(see `framework-runtimes.md`) or by native code, and Java-level hunting is the wrong axis entirely.
|
||||
|
||||
The cheapest high-value experiment, and one that is routinely skipped: run the **unmodified official
|
||||
build** and the **repackaged build** through the *same* probe and diff what differs. The difference is
|
||||
your detection signal. Doing this early is far cheaper than enumerating candidate checks one by one.
|
||||
|
||||
**When the symptom is a death rather than a dialog** — the process disappears, hangs, or dies at a
|
||||
roughly constant time after launch — the check is not drawing anything, it is terminating. The
|
||||
question then becomes *which mechanism*, and the answer decides the whole approach:
|
||||
`native-tamper-and-suicide.md` covers how to tell an imported terminate call from an **arranged
|
||||
fault** (which calls nothing and therefore defeats every PLT-level fix), how to find the site, and
|
||||
why making a routine "not return" freezes the process instead of suppressing the check.
|
||||
|
||||
## Reporting
|
||||
|
||||
State the boundary you measured, the evidence for each entry, and what remains untested. "The shell
|
||||
rejects X" must always carry the run that proves it, including the exact failure string. An unverified
|
||||
claim about integrity behaviour is worse than no claim, because it silently removes routes from
|
||||
consideration.
|
||||
@@ -0,0 +1,154 @@
|
||||
# Auditing your own patch — proving it landed, and proving it is legal
|
||||
|
||||
Two independent questions, and they fail independently:
|
||||
|
||||
1. **Did the edit land?** Did the bytes you intended actually reach the artifact?
|
||||
2. **Is the result legal?** Will the runtime's verifier accept it?
|
||||
|
||||
A patch can land and be illegal (the class fails to load). It can also fail to land while every
|
||||
build step reports success. Both are silent, and each needs its own check.
|
||||
|
||||
|
||||
**Load this when:** you must prove the edit landed **and** that it is legal. It gives the length-versus-bytes comparison that catches equal-length blind spots, verifier-level legality, and how to report a patch that did not apply.
|
||||
|
||||
## 1. Did it land? Compare *length*, not bytes
|
||||
|
||||
The naive check is a byte-level diff of the target method against the original.
|
||||
**It produces a flood of false positives.** A disassemble→reassemble round-trip rebuilds the
|
||||
string pool — the assembler **de-duplicates identical strings** — which shifts every later
|
||||
string index by a small delta. Hundreds of methods then look "changed" while their behaviour is
|
||||
identical.
|
||||
|
||||
Observed shape of that noise: **~800 methods reported as differing, 84% of them by only 1–4
|
||||
bytes**, all of them constant-pool index shifts, with the method byte-length unchanged.
|
||||
|
||||
**Use instruction-stream length as the first-order predicate.** A real patch that changes control
|
||||
flow or constant structure changes the method's `code_item.insns_size`. Pure index shifts and
|
||||
debug-info reordering do not.
|
||||
|
||||
### The blind spot, and how to close it
|
||||
|
||||
**An equal-length replacement changes nothing measurable by length.**
|
||||
|
||||
Examples that bite: `move-result vX` → `const/4 vX, 0x0` (both one code unit); swapping one
|
||||
`const` for another of the same width; any edit that keeps the instruction count identical.
|
||||
|
||||
Length-only auditing therefore **silently under-reports**: an audit reported 20 changed methods
|
||||
on a build whose patch set actually touched 24 — the four missing ones were all equal-length
|
||||
replacements.
|
||||
|
||||
**Two-tier rule:**
|
||||
|
||||
1. **Tier 1 (length):** scan every method. This yields candidates with essentially zero false
|
||||
positives.
|
||||
2. **Tier 2 (opcode-level):** compare the two versions opcode by opcode **for every method the
|
||||
patch set declares as a target** — not merely the Tier-1 candidates.
|
||||
|
||||
**Tier 2's input cannot come from Tier 1's output.** Equal-length replacements produce no Tier-1
|
||||
candidate at all, so the only reliable index of "what to check at Tier 2" is the patch set's own
|
||||
declarations.
|
||||
|
||||
⇒ **Corollary, which is really a documentation requirement: every patch must declare its target
|
||||
method signature.** A patch that is both equal-length and undeclared is invisible to both tiers;
|
||||
finding it requires opcode-comparing every class, which is possible but expensive.
|
||||
|
||||
### A sharper Tier-2 shape for equal-length replacements
|
||||
|
||||
Compare the **position set of one opcode** inside the method:
|
||||
|
||||
```
|
||||
target method, `move-result` (0x0a) position sets
|
||||
original : [25, 43, 66, 72, 216, ... , 899] (16 entries)
|
||||
patched : [25, 43, 72, 216, ... , 899] (15 entries) <- exactly one removed, pc=66
|
||||
```
|
||||
|
||||
This proves two things at once: the intended site was hit, **and** the other same-shaped call
|
||||
sites in that method were not. Asking "did anything differ" cannot distinguish those.
|
||||
|
||||
## 2. Is it legal? The verifier is a layer static checks cannot see
|
||||
|
||||
An assembler accepting your smali proves the file is **syntactically** valid dex. It does not run
|
||||
the bytecode verifier. A build can pass assembly, pass every class-table check, and still be
|
||||
rejected at class load:
|
||||
|
||||
```
|
||||
java.lang.VerifyError: Verifier rejected class X: void X.<clinit>() failed to verify:
|
||||
[0x1C8] copyRes1 v0<- result0 type=Undefined
|
||||
```
|
||||
|
||||
**Root cause in the observed case:** padding instructions were inserted **between** a producer and
|
||||
its `move-result`. DEX requires `move-result*` to **immediately follow** the instruction that
|
||||
produces the result (`invoke-*`, `filled-new-array*`). The padding broke adjacency, the result
|
||||
type became `Undefined`, and the whole method — and therefore the class — failed to verify.
|
||||
The length arithmetic ("the shorter form plus two padding units preserves the total") was
|
||||
**correct** and still produced an illegal method.
|
||||
|
||||
**Four layers, cheapest first:**
|
||||
|
||||
| Layer | Check | Catches |
|
||||
|---|---|---|
|
||||
| 1 | assembler exit code | syntax, register overflow, bad labels |
|
||||
| 2 | class-table diff (`scripts/dex_classdiff.py`) | class-set / access-flag drift |
|
||||
| 3 | **predecessor legality of `move-result*`** | result consumers detached from their producer |
|
||||
| 4 | device: first execution of that method | everything above, plus real semantics |
|
||||
|
||||
**Layer 3 is a set-difference, not an absolute count.**
|
||||
|
||||
- For both builds, collect every `move-result*` whose immediately preceding instruction is **not**
|
||||
a producer → the "offenders" set.
|
||||
- Compare the sets. **New offenders must be 0.**
|
||||
- **Never use the absolute size as a signal.** A perfectly healthy build can show hundreds of
|
||||
offenders under this definition — payload pseudo-instructions (`packed-switch-payload`,
|
||||
`fill-array-data-payload`, …) are normally read as ordinary instructions by a naive linear
|
||||
scan — so the absolute number is dominated by scanner artifacts. Only the delta means anything.
|
||||
|
||||
Worked consequence: a build whose sole new offender was one `move-result` following padding
|
||||
crashed at class load on device. After moving the padding to **after** the `move-result`, the
|
||||
delta returned to 0 and the same build ran. **The delta predicted the device result before the
|
||||
device was touched.**
|
||||
|
||||
## 3. When patches are applied by text matching
|
||||
|
||||
Some toolchains apply patches by literal match against a text form (smali) rather than through a
|
||||
dex API. The failure modes are textual and unforgiving:
|
||||
|
||||
- **Whitespace is content.** A fragment indented with 5 spaces will not match a file using 4.
|
||||
There is no fuzzy matching; one misplaced space is a zero-hit.
|
||||
- **Line endings.** If the reader normalises CRLF→LF and the writer does not restore it, the
|
||||
patched file becomes LF-only. A later byte-level comparison against the CRLF source then shows
|
||||
the *entire file* as changed. Diff with an ignore-CR-at-EOL option, or compare structure.
|
||||
- **Instruction names must be complete.** `move-result vX` and `move-result-object vX` are
|
||||
different instructions; an anchor missing the suffix matches the wrong thing or nothing.
|
||||
- **Operand punctuation matters.** `instance-of p3, p0, LType` is not the source text when the
|
||||
source reads `LType;`.
|
||||
- **Uniqueness is a hard requirement.** A matcher that finds several hits must fail loudly rather
|
||||
than patch the first. When a one-line anchor is not unique, extend it with a second unique line;
|
||||
**when the *combination* is still not unique, add a third anchor that discriminates the
|
||||
homographs** — usually the branch target label, since identical call shapes often differ in
|
||||
where they jump.
|
||||
- **A re-emitted old fragment is not a failed write.** A patch may legitimately re-emit the old
|
||||
text inside the new one (inserting a guard *before* the lines it matched). A read-back check
|
||||
asserting "the old text is gone" then reports failure on that patch forever. Assert instead
|
||||
that the **new, unique** part of the replacement is present.
|
||||
- **One patch document, one patch block.** A parser that treats every fenced block as a candidate
|
||||
will apply an *example* diff as a real patch — silently cancelling the real one, or editing
|
||||
unrelated code. Keep examples in prose, never in the same fence format.
|
||||
|
||||
## 4. Audit the patch set, not a patch
|
||||
|
||||
For a multi-patch build:
|
||||
|
||||
```
|
||||
expected = every method declared as a target by the patch set
|
||||
tier1 = methods whose insns_size changed
|
||||
tier2 = methods whose opcode stream differs (run over `expected`)
|
||||
missing = expected - (tier1 U tier2) # must be empty
|
||||
```
|
||||
|
||||
**`missing` is the number that matters.** A build can be green on every build step and still be
|
||||
missing a patch. Report `missing` explicitly; do not report the total as if the total were the
|
||||
goal.
|
||||
|
||||
**And re-verify the artifact, not the intent.** If the tool says "applied" but the build on the
|
||||
device is unchanged, you have a stale build, a second copy, or the wrong file
|
||||
(`pitfalls.md` P24). A patch tool's success message describes the tool, not the artifact.
|
||||
1289
vendor-new/apk-reverse/skills/apk-reverse/references/pitfalls.md
Normal file
1289
vendor-new/apk-reverse/skills/apk-reverse/references/pitfalls.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,97 @@
|
||||
# Precedent library — what a pass got right, and how it got there
|
||||
|
||||
`pitfalls.md` is the negative catalogue: what went wrong. This directory is the other half —
|
||||
**cases where the work actually converged**, recorded so the route can be walked again instead
|
||||
of re-derived. The two do not overlap on purpose: a case here points at `pitfalls.md` by entry
|
||||
number rather than restating the failure, and a pitfall that already has a case does not need a
|
||||
second copy in prose.
|
||||
|
||||
Read a case when you are **about to do this kind of work again** — not when you are stuck. A case
|
||||
answers "what sequence produced a defensible result, and what did it cost"; when you are stuck
|
||||
the symptom index in `SKILL.md` is the faster route.
|
||||
|
||||
## Why this exists at all
|
||||
|
||||
This repository's most expensive recurring loss is not ignorance. It is **a conclusion that was
|
||||
correctly retracted and then re-adopted**, because the retraction lived in a conversation while
|
||||
only the original claim was ever written to a file. Three of the cases below are exactly that
|
||||
shape: an attribution that was wrong, corrected by measurement, and corrected again. In each one
|
||||
the *wrong* version is the one that reads as more coherent, which is why it is the one that would
|
||||
be believed by a reader who only saw the summary.
|
||||
|
||||
A case therefore records the execution chain **including the dead ends** — the wrong turn, and
|
||||
the measurement that killed it. Without that part, the case is a success story, and success
|
||||
stories teach nothing that survives the next surprise.
|
||||
|
||||
## The cases
|
||||
|
||||
| Case | Route it establishes | Strongest claim in it | Grade |
|
||||
|---|---|---|---|
|
||||
| `flutter-plus-shell-case-1.md` | classify a hardened Flutter sample before trusting any dynamic result: a pid that keeps moving has to be attributed before it is explained | the same drift was attributed to frida, then to memory pressure, then to the sample's own root-environment check — only the third survived | observed (drift and its cause), unverified (that this generalises) |
|
||||
| `frida-spawn-hangs-zero-events-case-2.md` | a harness can manufacture the very negative result it is looking for: `spawn` leaves the process suspended, and `CONFIG.autoStart` follows before your configuration lands | a zero-event trace was produced by two independent harness defects, not by the target | observed (the mechanism, and the warning that now fires) |
|
||||
| `logd-broken-module-never-ran-case-3.md` | decide whether a hooking module ran at all, on a device whose log daemon is broken | nine successful injections produced **zero** `logcat` lines; the only evidence was a file under `/data/adb/lspd/log/` | observed (both channels), inferred (the framework-bridge explanation) |
|
||||
| `l1-equal-length-patch-case-4.md` | the full chain from a two-byte dex edit to a visible change on screen, with a control build | the dialog disappeared **and** the control build failed the same way first, which is what makes the four bytes responsible | observed end to end |
|
||||
| `manual-grep-finds-what-nobody-grepped-case-5.md` | why a leak scan is a gate and not a document, told from this repository's own near-miss | a shipped pass had to be corrected after the fact for identity a human grep found | observed (the omission and the correction), inferred (that a scanner would have caught it) |
|
||||
|
||||
Each case ends with an action checklist naming the **repository files that should be written
|
||||
back to** if you hit the same thing. A case that does not produce a file edit has not finished.
|
||||
|
||||
## Template
|
||||
|
||||
Copy this skeleton. Keep the headings — the read-back check depends on them, and the grade
|
||||
column is the part that stops a hypothesis from drifting upward into a fact.
|
||||
|
||||
```markdown
|
||||
# <Title> — one line naming the conclusion, not the activity
|
||||
|
||||
## Metadata
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Context | what was being done, in one line |
|
||||
| Cost | what it cost before it converged, measured |
|
||||
| Outcome | what artifact or conclusion exists now |
|
||||
| Evidence | the evidence files, by path |
|
||||
| Related | `pitfalls.md` entries, and the neighbouring reference files |
|
||||
|
||||
## Assertions and grade
|
||||
| # | Assertion | Grade | Evidence |
|
||||
|---|---|---|---|
|
||||
| 1 | ... | observed / inferred / unverified | exact command, file, or line |
|
||||
Reserve `observed` for a claim with a command and its output behind it. An assertion whose
|
||||
evidence is "the mechanism implies it" is `inferred`, and saying so is not hedging.
|
||||
|
||||
## Execution chain (including the dead ends)
|
||||
The wrong turn, the measurement that killed it, and only then the route that held. Numbered,
|
||||
because the order is the information: a reader who starts from the conclusion will pick the
|
||||
wrong sequence.
|
||||
|
||||
## Pits
|
||||
| Pit | Cost | What it was mistaken for |
|
||||
|---|---|---|
|
||||
| ... | measured number, or a count of re-runs | ... |
|
||||
Use a cost you measured. "Two round trips" is a measurement; "a while" is not.
|
||||
|
||||
## Reusable pattern
|
||||
The part that transfers to a different target with no editing. If it needs this target's names,
|
||||
it belongs in the chain above instead.
|
||||
|
||||
## Write back to the repository
|
||||
- [ ] `<file>` — <what to add to it, so the next reader does not pay this again>
|
||||
- [ ] `<file>` — <index line, if a new file was created>
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- **Grade every assertion, and grade it down when in doubt.** `observed` means a command was run
|
||||
and its output is in the evidence record condensed in `references/evidence-summary.md` §Where the full record lives. "I reasoned it out" is `inferred`. This is the
|
||||
same standard `references/long-task-discipline.md` §Grade your own conclusions sets for a live
|
||||
record, and it is the reason a case is worth reading a year later.
|
||||
- **Record the retraction with the claim.** If an earlier conclusion was wrong, both versions
|
||||
belong in the case: the wrong one, why it was wrong, and what replaced it. Deleting the mistake
|
||||
loses the most transferable thing here.
|
||||
- **Do not restate `pitfalls.md`.** Point at the entry. A duplicated failure mode drifts out of
|
||||
sync with its original and then contradicts it.
|
||||
- **One case, one route.** A case that establishes three unrelated things establishes none of
|
||||
them; split it.
|
||||
- **A case with no write-back checklist is a diary entry.** The last section is what makes it
|
||||
knowledge the repository holds rather than knowledge the author had.
|
||||
@@ -0,0 +1,74 @@
|
||||
# A pid that keeps moving must be attributed before it is explained
|
||||
|
||||
## Metadata
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Context | dynamic analysis of a hardened Android sample (Flutter AOT payload behind a third-party application-stub) on a rooted physical device |
|
||||
| Cost | three attribution corrections; two of them were written down as findings before being refuted |
|
||||
| Outcome | the drift is attributed to the sample's own environment check, and the operational rule that falls out of it ("configure root hiding, then re-baseline") |
|
||||
| Evidence | `references/evidence-summary.md` §The capability matrix (the pid-drift section and the frida-dexdump section); `references/evidence-summary.md` §The capability matrix (the root-hiding layer) |
|
||||
| Related | `SKILL.md` §Stop conditions (last bullet); `pitfalls.md` P9, P18; `references/detection-and-anti-analysis.md` |
|
||||
|
||||
## Assertions and grade
|
||||
| # | Assertion | Grade | Evidence |
|
||||
|---|---|---|---|
|
||||
| 1 | The sample starts and stays resident with no instrumentation attached | observed | `ps -A \| grep <PKG>` → `u0_a623 22691 … 642488 SyS_epoll_wait 0 S <PKG>`, 12 s after `am start` |
|
||||
| 2 | During a session its pid changed repeatedly: `22691 → 23452 → 24267 → 27274 → 31284 → 32259 → 1368 → 8161` | observed | the same evidence file's pid-drift transcript |
|
||||
| 3 | The drift reproduces with `frida-server` **stopped** and no attach | observed | a 90-second observation loop, four pid changes, with the frida process absent |
|
||||
| 4 | The process dies as the **foreground top activity**, with no crash, no tombstone and no ANR record, and the platform relaunches it on a 7–18 s cycle | observed | `adb logcat \| grep <PKG>` → `Process <PKG> (pid N) has died: fg TOP` followed by `Start proc <pid> for top-activity <PKG>` |
|
||||
| 5 | No application on the device was hidden from root at the time | observed | `magisk --denylist status` = enforced, `--denylist ls` printed nothing, `/data/adb/shamiko` absent (the root-hiding section of `references/evidence-summary.md` §The capability matrix) |
|
||||
| 6 | The cause is the sample detecting its rooted environment and exiting on purpose | **inferred** — the mechanism fits every observation, but it was not proven by disabling the check |
|
||||
| 7 | Memory pressure was **not** the cause | observed (refuted) | the drift continued at 300–750 MB free of 11.5 GB, and a reclaim kill leaves an `lmkd` line and a low-memory record — neither present |
|
||||
| 8 | Instrumentation was **not** the cause | observed (refuted) | the `frida-server`-stopped control run |
|
||||
| 9 | The device's own hooking framework was already damaged during the window (`lspd` alive but not updating its configuration, `zygote crashed too many times, rolling-back`) | observed | the framework-damage section of `references/evidence-summary.md` §The capability matrix |
|
||||
| 10 | A `start timeout` kill line discriminates between these causes | **observed negative** — it reads identically for a slow init, a reclaiming device and a deliberate delay, so it is not evidence either way | the `ActivityManager` transcript quoted in the same evidence file |
|
||||
|
||||
## Execution chain (including the dead ends)
|
||||
1. The control run looked healthy: start the sample with no instrumentation, wait 12 s, `ps` shows
|
||||
it resident at 642 MB.
|
||||
2. Later the pid had changed eight times. The natural reading — *the sample fights instrumentation*
|
||||
— was written down as the finding. **Dead end 1.**
|
||||
3. The control that killed it: stop `frida-server` entirely, restart the sample, observe for 90
|
||||
seconds. The pid still moved, roughly every 10–20 s. Instrumentation is out.
|
||||
4. The replacement attribution: memory pressure while a Flutter app saturates the CPU during its
|
||||
startup window (439 % CPU, `kswapd0` at two thirds of a core were both measured). **Dead end 2.**
|
||||
5. The measurement that killed it, and the reason it is worth recording: **a different instrument
|
||||
was pointed at the same question.** `logcat` — not pid sampling — showed
|
||||
`has died: fg TOP` with no crash record and an immediate relaunch. A reclaiming device does not
|
||||
do that to a foreground process, and a memory-pressure kill leaves different traces.
|
||||
6. That left "the sample checks its own environment and quits", and the environment check was then
|
||||
findable: nothing on the device was hidden from root. A hardened sample that refuses to run
|
||||
rooted produces exactly this loop, forever.
|
||||
7. Only after the attribution did the *practical* conclusion exist: re-run the baseline with root
|
||||
hiding configured, and treat every earlier dynamic result on that device as measured against a
|
||||
process that was trying to die.
|
||||
|
||||
## Pits
|
||||
| Pit | Cost | What it was mistaken for |
|
||||
|---|---|---|
|
||||
| Sampling the pid instead of reading the platform's lifecycle log | two full attribution rounds | a target-resistance hypothesis, then a host-resource hypothesis — the log settled it in one command |
|
||||
| A 12-second control window | it looked like a stable baseline | the sample's lifetime was 7–18 s, so a single sample can land inside one life and read as stability |
|
||||
| A third candidate mechanism discovered mid-pass (a zygote in a crash-and-rollback cycle restarts app processes by itself) | it invalidated single-cause attribution for the whole window | it did not create the drift, but it means "the pid changed" had at least three independent causes on this device at that moment |
|
||||
| Two refuted attributions already written into an evidence file | deletion cost, not re-measurement cost | each reads as more coherent than the truth, which is why they would have been believed |
|
||||
|
||||
## Reusable pattern
|
||||
- **Attribute before explaining.** A target that dies, drifts, or restarts gets a *mechanism* from
|
||||
the platform's own record first (`logcat`, tombstones, `dumpsys`) — sampling tells you that it
|
||||
happened, never why.
|
||||
- **Refute the cheapest alternative first, with a control that removes it entirely.** Stopping the
|
||||
instrumentation is one command; it eliminated the leading hypothesis in a single run.
|
||||
- **When a measurement disagrees with a recorded conclusion, reopen the conclusion.** That rule
|
||||
(`SKILL.md` §Stop conditions) was applied twice to this pass's own record, and both times the
|
||||
record was the thing in the wrong.
|
||||
- **Record the device's framework state next to any dynamic result.** This device could restart
|
||||
the target without the target being involved.
|
||||
|
||||
## Write back to the repository
|
||||
- [ ] `references/detection-and-anti-analysis.md` — a "the target exits on a rooted device, and the
|
||||
environment is why" subsection: the `fg TOP` + immediate relaunch shape, the two kills that
|
||||
look like it and are not, and the re-baseline step.
|
||||
- [ ] `references/precedents/README.md` — already indexed as case 1; update the grade column if a
|
||||
future pass proves assertion 6 by disabling the check.
|
||||
- [ ] `references/pitfalls.md` — only if the *sampling-instead-of-logcat* mistake is not already
|
||||
covered there; if it is, extend that entry with this measured cycle length rather than adding
|
||||
a second one.
|
||||
@@ -0,0 +1,76 @@
|
||||
# A zero-event trace was manufactured by the harness, not by the target
|
||||
|
||||
## Metadata
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Context | instruction-level tracing with Frida Stalker on Android 11 / arm64-v8a, on system processes, to test whether `Stalker.exclude()` fixes the community-reported zero-event and crash behaviour |
|
||||
| Cost | three arms plus one harness correction; the two defects below both produce a *believable* zero-event trace, so they would have been read as a target finding |
|
||||
| Outcome | the crash half has a clean cause (`exclude` fixes it); the zero-event half does not, and is now an explicit warning in the script rather than a silent empty log |
|
||||
| Evidence | `references/evidence-summary.md` §The capability matrix; `references/evidence-summary.md` §The capability matrix (device attempts A and B) |
|
||||
| Related | `references/native-dbi-and-deobfuscation.md`; `scripts/stalker_trace.js`, `scripts/stalker_report.py`; `pitfalls.md` P26 |
|
||||
|
||||
## Assertions and grade
|
||||
| # | Assertion | Grade | Evidence |
|
||||
|---|---|---|---|
|
||||
| 1 | A follow with an **empty** exclusion list killed the target process; the same process survived an attach + resume with no follow at all | observed | three-arm run: `baseline alive=True`, `control alive=False err=script has been destroyed`, `treatment alive=True` |
|
||||
| 2 | Excluding 20 system modules kept the process alive through the follow window | observed | `treatment … DONE\|reason=timeout` with the process still running, `EXCL\|excluded=20/20 […] not-loaded=3` |
|
||||
| 3 | Exclusion did **not** restore event delivery | observed | `treatment` still reported `blocks=0 blk=0 calls=0 truncated=0` after the full 6 s window |
|
||||
| 4 | The zero-event warning added to the script fires correctly | observed | `treatment` emitted `WARN\|zero events 1500ms after follow … -- the pipeline is NOT proven`; `control` could not, because its process was already gone |
|
||||
| 5 | `CONFIG.autoStart` defaults to `true`, so loading the script starts a follow with default options before any runtime configuration can land | observed | the design section of `references/evidence-summary.md` §The capability matrix; the harness patches it to `false` and asserts the patch count |
|
||||
| 6 | `frida` spawns a process **suspended**: without an explicit `device.resume(pid)` the followed thread executes nothing and the run reports zero events | observed | the same design section, and the `DONE … blocks=0` transcript in `references/evidence-summary.md` §The capability matrix |
|
||||
| 7 | `Process.getMainThreadId()` returned a tid that was **not** the pid on Android (26261 for a pid of 19938), so the script's default thread selection watched the wrong thread | observed | `TRIG following tid=26261 (main-thread follow (pid=19938))` |
|
||||
| 8 | Re-running with the real main thread `start(19938)` produced the same zero | observed | `DONE reason=timeout blocks=0 blk=0 calls=0` |
|
||||
| 9 | A hot-function trigger (`malloc` in `libc.so`) produced 201 complete follow cycles with zero blocks in any of them | observed | 609 log lines, 201 follows, 201 `DONE`s, `nonzero_blk = 0` |
|
||||
| 10 | A per-call follow/unfollow cycle on that hot function crashed a system process | observed once | `systemui` pid `18030 → 26932`, `SIGSEGV` with frame `#00` in an anonymous executable region and the return path into `libart` |
|
||||
| 11 | The crash is the explanation for the zero-event results | **observed negative** — those runs produced their zeros before any death, on two other processes, and one never touched an export trigger | the separation stated in the same native-DBI evidence file |
|
||||
| 12 | Why events do not arrive | **unverified** | the measurement says it is not the exclusion list; it does not identify the cause |
|
||||
|
||||
## Execution chain (including the dead ends)
|
||||
1. The question was framed as a two-claim question, which is why it resolved: does `exclude` stop
|
||||
the crash, and does it restore events? Three arms, one device, one package, one module, one
|
||||
follow window, exactly one variable moving between them.
|
||||
2. The `baseline` arm exists purely to make a death attributable: without it, "following killed it"
|
||||
and "the app dies under frida anyway" are the same observation.
|
||||
3. Two harness defects had to be removed **before** the measurement meant anything, and both were
|
||||
found by noticing that an arm produced the *expected* result for the wrong reason:
|
||||
- `CONFIG.autoStart: true` followed once at `load()` time with default options, which is neither
|
||||
the control nor the treatment, and therefore contaminated both.
|
||||
- spawn leaves the process suspended, so with no explicit `device.resume(pid)` the followed
|
||||
thread ran nothing at all.
|
||||
4. Result: the crash half is answered with a cause, and the zero-event half is answered as a
|
||||
**negative** — exclusion is not its fix, and the community framing that lists "no events" among
|
||||
the symptoms `exclude` cures is contradicted by this measurement.
|
||||
5. Independent corroboration that the pipeline itself was healthy: the export-trigger run produced
|
||||
201 complete follow cycles. A trigger that never fired would look different; a filter that
|
||||
dropped everything would look the same, which is exactly why the claim is limited to "events do
|
||||
not arrive" rather than "Stalker is broken".
|
||||
|
||||
## Pits
|
||||
| Pit | Cost | What it was mistaken for |
|
||||
|---|---|---|
|
||||
| `autoStart` following at `load()` | it invalidated two arms at once | a legitimate treatment result |
|
||||
| No `device.resume()` after spawn | a whole arm's worth of zero events | the target refusing instrumentation |
|
||||
| Reading a zero-event log as "the code did not run" | the reason the `WARN\|zero events` line was added to the script this pass | a finding about the target |
|
||||
| `Process.getMainThreadId()` trusted as "the main thread" | one wasted run | a mis-selected thread |
|
||||
| A system process died mid-run from the trigger pattern | one attribution round | an unrelated crash that could have been folded into the wrong conclusion |
|
||||
|
||||
## Reusable pattern
|
||||
- **A negative result needs its own control arm.** `baseline` (attach, resume, never follow) is
|
||||
what converts "it died" into "the follow killed it".
|
||||
- **Engineer the harness's own failure modes out before measuring.** Both defects here produced the
|
||||
exact shape under investigation; a run that reports the expected symptom is not yet a run that
|
||||
measured anything.
|
||||
- **Make a dead pipeline announce itself.** A warning line the moment the follow window ends with
|
||||
zero events turns an ambiguous empty log into a labelled observation — and it is testable, because
|
||||
the arm whose process died cannot print it.
|
||||
- **Separate "the follow is unsafe" from "the follow observes nothing".** Two arms, two conclusions,
|
||||
never one.
|
||||
|
||||
## Write back to the repository
|
||||
- [ ] `references/native-dbi-and-deobfuscation.md` §6 — keep the two measured boundaries; add the
|
||||
harness-defect paragraph if absent, because a reader who reproduces a zero event will
|
||||
otherwise attribute it to the device.
|
||||
- [ ] `scripts/stalker_trace.js` — already carries the zero-event warning; ensure the `autoStart`
|
||||
comment says *why* the default is dangerous (it is a trap, not a convenience).
|
||||
- [ ] `references/precedents/README.md` — indexed as case 2; move assertion 12 to `observed` only if
|
||||
a future pass identifies the cause.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Four bytes of dex, and a control build that still failed the old way
|
||||
|
||||
## Metadata
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Context | an equal-length dex patch against a public crackme (`UnCrackable-Level1`), taken all the way to an installed, launched build whose blocking dialog is gone |
|
||||
| Cost | one full pipeline re-run after the repository changed mid-pass; one false verification caused by a reused remote filename |
|
||||
| Outcome | the end-to-end chain is measured, with a zero-change control through the **same** pipeline — which is what makes the four bytes, rather than the pipeline, responsible |
|
||||
| Evidence | `references/evidence-summary.md` §The capability matrix (the B1 sections); the benchmark matrix (`references/evidence-summary.md` §The capability matrix) row B1 |
|
||||
| Related | `references/byte-level-patching.md`, `references/patch-audit.md`, `references/repack-and-sign.md`, `references/verification.md` (the control-build rule) |
|
||||
|
||||
## Assertions and grade
|
||||
| # | Assertion | Grade | Evidence |
|
||||
|---|---|---|---|
|
||||
| 1 | The shipped dex's `signature` field is stale while its `checksum` is correct | observed | `checksum field 7fb7d9fa` / `adler32(d[12:]) 7fb7d9fa` matches, while `sha1(d[32:])` does **not** match the stored signature |
|
||||
| 2 | The edit site is the first arm of a three-way root `OR`, and the *fall-through* is the "continue" side | observed | `0x9d0 39000e00 if-nez v0 -> 0x9ec` preceded by `invoke-static …a()Z` / `move-result`, and `0x9ec` is `const-string "Root detected!"` |
|
||||
| 3 | Branch polarity was pinned by the following instruction, not by the method name | observed | `expect_next` required `invoke-static …b()` (the *next* root check) after the branch — `polarity: expect_next satisfied` |
|
||||
| 4 | The patch owns exactly 4 bytes and the header fields own 24; nothing else in the file moved | observed | whole-file diff offsets `['0x8','0xa','0xb','0xc'…'0x1f', '0x9d0','0x9d2']`, and both header fields recomputed from the written file |
|
||||
| 5 | The signature was computed **before** the checksum | observed | `zlib.adler32(d[12:])` and `hashlib.sha1(d[32:])` recomputed on the output reproduce both stored fields, which is only possible in that order |
|
||||
| 6 | `repack.py` reported `resources.arsc STORED and 4-byte aligned (OK)` for control and patched alike | observed | the alignment gate output, identical for both builds |
|
||||
| 7 | Both builds signed with v1+v2+v3 and the same certificate digest | observed | `apksigner verify --print-certs --verbose --min-sdk-version 21` |
|
||||
| 8 | The installed artifact is the built artifact | observed | device `base.apk` sha256 equals the local sha256, for control (`e5a9f335…`) and patched (`ad6a51ce…`) |
|
||||
| 9 | The control build — same pipeline, byte-identical dex — **still shows** the blocking dialog | observed | four window-focus samples, 2 s apart: `Window{… Root detected!}` for original and control, `Window{… <PKG>/…MainActivity}` for patched |
|
||||
| 10 | The patched build reaches its own UI without a dialog | observed | the screenshots: control/greyed-out input with the modal over it, patched with the input live, caret present, `VERIFY` enabled |
|
||||
| 11 | A second, independently built patched APK from the one-command pipeline produced the **same** sha256 as the manual route | observed | `ad6a51cef4699b73ff052d4e049ca9efd64e9570900d19509f0b7a7c20f87e56` |
|
||||
| 12 | The app's functional check compares the user's input against a decrypted plaintext, and the 16-byte AES key is a `const-string` in the dex | observed | `a.b` hook output: key `8d12…73cc`, data decrypts to `I want to believe`, `[a.a] REAL input="" -> false` |
|
||||
| 13 | The input field cannot be driven by `adb shell input text` on this ROM | observed | 7 of 17 characters survived and every space was dropped, across three independent quoting routes; `uiautomator dump` after each attempt is what distinguished "the app said Nope" from "the input never arrived" |
|
||||
| 14 | The success branch was reached by forcing the boolean, not by typing the secret | observed, and labelled as such in the evidence file | `evidence/09_success_branch/*.png` shows the app's own `Success! …` dialog |
|
||||
|
||||
## Execution chain (including the dead ends)
|
||||
1. **Read the branch structure before editing.** The edit target was chosen because its fall-through
|
||||
continues into the next root check — not because of its name. That is the difference between a
|
||||
patch that removes one arm of an `OR` and a patch that removes the check.
|
||||
2. Dry run first: the tool prints the site with the instructions before and after, and the polarity
|
||||
evidence. Nothing was written until `polarity: expect_next satisfied`.
|
||||
3. Applied, then proved with a byte-level diff of the whole file rather than the tool's own report:
|
||||
four payload bytes and the two header fields, and nothing else.
|
||||
4. Repack. **Dead end:** the documented default signing route failed with
|
||||
`error: signer jar not found: uber-apk-signer.jar`, and its own hint — "any zipalign+apksigner
|
||||
based signer works here" — pointed at a code path that did not exist yet. The working route was
|
||||
the supported `--no-sign` seam plus explicit `zipalign` → `apksigner`, and the repository changed
|
||||
mid-pass (another writer landed an `apksigner` route), so the pass recorded **both** states and
|
||||
re-ran the one-command form, which produced byte-identical output. The lesson kept with it:
|
||||
*a "this tool cannot do X" finding on a shared worktree has an expiry date.*
|
||||
5. Install through the **root** path (`pm install -r -d`), because this ROM intercepts the streaming
|
||||
install (see case 5's sibling record for the `[-99]` shape).
|
||||
6. Verify, in the order that matters: hash the installed `base.apk` against the local build; `am
|
||||
start -W`; then window focus sampled four times; then **look at the screenshots**.
|
||||
7. Build a zero-change control through the identical pipeline and repeat steps 5–6. It still fails
|
||||
the old way, which is what closes the attribution.
|
||||
|
||||
## Pits
|
||||
| Pit | Cost | What it was mistaken for |
|
||||
|---|---|---|
|
||||
| Scanning the method for a plausible name instead of reading the branch structure | would have patched an `OR` arm that changes nothing | a successful patch |
|
||||
| Relying on the tool's own "self-verify: checksum_ok=True signature_ok=True" without an independent diff | the stale-signature starting state could have hidden a wrong header order | a correct header |
|
||||
| Reusing the `--tag` value as the remote filename across runs | the final verification showed the **control** build's dialog, from the control run's leftover remote path | a patch that did not work |
|
||||
| `adb shell input text` on a field that silently drops characters | two attempts read as "wrong secret" | a wrong secret |
|
||||
| Trusting a screenshot alone | it cannot tell which build is installed | a verified patch |
|
||||
|
||||
## Reusable pattern
|
||||
- **Equal-length byte patches are the cheap route; choose the site by branch semantics.** Nothing
|
||||
moves, so no offset, `try` block or debug pointer can be invalidated, and the diff is auditable
|
||||
down to the byte.
|
||||
- **A patch needs a same-pipeline control that fails the old way.** "It installs and runs" and "the
|
||||
behaviour changed" are different claims, and only the second one is the deliverable.
|
||||
- **Hash the artifact on the device, at the moment you observe it.** This is the guard that caught
|
||||
the reused-tag mistake, and it is one command.
|
||||
- **Look at the screen, and look at more than one sample.** A modal that appears and is gone between
|
||||
samples is invisible to a single capture.
|
||||
- **Re-derive the header yourself once.** Recomputing both fields from the written bytes is what
|
||||
proves the *order*, and the tool's own success message cannot.
|
||||
|
||||
## Write back to the repository
|
||||
- [ ] `references/repack-and-sign.md` — if the `signer jar not found` message and its apksigner
|
||||
route are not already reconciled, that is a documented contradiction worth closing.
|
||||
- [ ] `references/verification.md` — the control-build rule is the load-bearing part of this case;
|
||||
add the "hash the installed APK before observing" step if it is only implicit there.
|
||||
- [ ] `references/precedents/README.md` — indexed as case 4; the row's grade stays `observed` end to
|
||||
end.
|
||||
@@ -0,0 +1,70 @@
|
||||
# "The module never ran" was an artefact of a broken log channel
|
||||
|
||||
## Metadata
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Context | verifying that an LSPosed hook module was actually injected into a hardened target, on a rooted Android 11 device |
|
||||
| Cost | the verification criterion itself had to be corrected; `logcat` was empty across nine successful injections |
|
||||
| Outcome | the injection is proven from `/data/adb/lspd/log/modules_<timestamp>.log`, and the read rule is now "both channels, always" |
|
||||
| Evidence | `references/evidence-summary.md` §The capability matrix (the injection-line, log-surfaces and scope sections) |
|
||||
| Related | `references/evidence-summary.md` §The capability matrix (the framework-damage section); `references/lsposed-and-modules.md`; `references/environment.md` |
|
||||
|
||||
## Assertions and grade
|
||||
| # | Assertion | Grade | Evidence |
|
||||
|---|---|---|---|
|
||||
| 1 | The module was injected nine consecutive times, each run reaching the target's `MainActivity.onCreate`, the WebView process and an ad SDK activity | observed | nine full injection blocks; nine pids in ~3 minutes (`8111 → 9392 → 10557 → 11453 → 12345 → 13236 → 14155 → 15061 → 15934`) |
|
||||
| 2 | `logcat -s <TAG>` was **empty**, as was `logcat -s LSPosed-Bridge` and a full-buffer `grep -i <TAG>` | observed | the injection-line section of the LSPosed evidence file, stated as the correction to the pass's own earlier draft |
|
||||
| 3 | The module's output existed only in `/data/adb/lspd/log/modules_<timestamp>.log` | observed | the evidence file for the pass is 201 lines, of which 126 are `<TAG>` |
|
||||
| 4 | The same file earlier appeared to contain nothing but `Logd maybe crashed (err=Socket operation on non-socket), retrying in 1s...` | observed | the log-surfaces section of the same evidence file quotes the entire content of an older `modules_*.log` |
|
||||
| 5 | The ROM's `logd` route is broken, so LSPosed's logcat channel delivers nothing while its file channel works normally | **inferred** — the two observations above are consistent with exactly this, and the mechanism was not probed further |
|
||||
| 6 | A reader debugging a module with `logcat` alone would conclude the module never ran, while looking at nine successful injections | observed (as a statement about the evidence), inferred (as a statement about other readers) |
|
||||
| 7 | The target's double-`Application` packer swap is visible in the hook order, and hooks installed at `handleLoadPackage` time survive it | observed | `Application.attach` fires twice — the real `BaseApplication`, then the stub — and by `Activity.onCreate` the reported classes are the **unpacked** ones |
|
||||
| 8 | Scope is per app, not per process | observed | the module also injected into the target's `com.google.android.webview` process and reported an ad SDK activity |
|
||||
| 9 | The pre-reboot self-restart cycle (10–20 s) can be compared with the post-reboot cycle (22–23 s) | **inferred at best, and not a controlled result** — a reboot moves several variables at once | the injection-line section of the same evidence file |
|
||||
| 10 | `verbose_*.log` is a full logcat snapshot, useful for correlating a run with system state and useless as a module log | observed | the log-surfaces section of the same evidence file |
|
||||
|
||||
## Execution chain (including the dead ends)
|
||||
1. The module was built, installed, PM-enabled, and scoped through LSPosed Manager's UI — read from
|
||||
`uiautomator dump` coordinates, never tapped by guess. The scope change was confirmed in the
|
||||
framework's own SQLite database (`scope` gained `(2, '<PKG>', 0)`, `modules.enabled` flipped to
|
||||
`1`, and the database file grew from 156,376 to 201,696 bytes), which is what proves the
|
||||
framework — not merely its daemon process — came back.
|
||||
2. The pass then read `logcat -s <TAG>` and found nothing. **Dead end:** the criterion in the
|
||||
reference file, in the pass's own earlier draft, and in the scaffold's generated README all said
|
||||
to watch `logcat`.
|
||||
3. Rather than conclude "injection failed", the other channel was read: LSPosed's file log. Nine
|
||||
full injection blocks.
|
||||
4. The correct criterion was written down, and it is the transferable part: read **both** channels.
|
||||
`logcat -s <TAG>` where the platform is healthy; `/data/adb/lspd/log/modules_*.log` always.
|
||||
5. The reason the two disagree on this device is a broken `logd` (`Socket operation on non-socket`),
|
||||
which is also visible in unrelated incident lines from the same window (`system server died`,
|
||||
`am is dead`, `no response from bridge, retry in 1s`, `Magisk: zygote crashed too many times,
|
||||
rolling-back`).
|
||||
|
||||
## Pits
|
||||
| Pit | Cost | What it was mistaken for |
|
||||
|---|---|---|
|
||||
| Trusting one log channel on a ROM whose log daemon is damaged | it would have produced a false negative on a working feature | "the module never loaded" |
|
||||
| `logcat` silence read as absence of evidence *for* the target's behaviour | the verification criterion itself was wrong, not just the reading | a statement about the module |
|
||||
| The same silent-failure shape appears twice in the pass | once for the module channel, once for the framework's own state | two different "not installed"-looking states, one of which was real |
|
||||
|
||||
## Reusable pattern
|
||||
- **Before accepting a silence as a finding, ask whether the instrument could have spoken.** This is
|
||||
`pitfalls.md` P26's rule pointed at a *log channel* rather than at a script: check that the channel
|
||||
has ever delivered for a healthy case.
|
||||
- **Prefer a channel that is a file over a channel that is a daemon.** `logcat` depends on `logd`;
|
||||
a file in the module's own directory depends on the module.
|
||||
- **Verify the verifier.** The scope change was confirmed in the framework's own database rather
|
||||
than in the manager's UI reflecting it back — a UI can render a cached state, and the file is the
|
||||
state.
|
||||
- **On a packed target, a Java-layer hook is reachable without touching the APK**: the double
|
||||
`Application` swap is observable, and hooks installed before it survive it. That is a *capability*
|
||||
result, separate from the log-channel lesson, and it is the reason this route is worth the setup.
|
||||
|
||||
## Write back to the repository
|
||||
- [ ] `references/lsposed-and-modules.md` — the verification section must name both channels and the
|
||||
exact file path pattern; a single-channel instruction is what this case disproves.
|
||||
- [ ] `scripts/lsposed_scaffold.py` — the generated README's verification line should carry the same
|
||||
two-channel instruction, because it is the artifact a user reads first.
|
||||
- [ ] `references/precedents/README.md` — indexed as case 3; assertion 5 stays `inferred` until the
|
||||
logd mechanism is probed directly.
|
||||
@@ -0,0 +1,83 @@
|
||||
# The leak nobody grepped for, found by hand and only after the fact
|
||||
|
||||
## Metadata
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Context | this repository's own history: a complete verification pass was recorded, reviewed and merged, and the target identity in its evidence files was found afterwards |
|
||||
| Cost | a manual sweep, and the omission survived at least one pass that produced evidence files |
|
||||
| Outcome | a desensitization convention (`<PKG>` / `<DEVICE>` placeholders, target identity absent by policy) **and** an automated scan (`scripts/scan_leaks.py`), added in the same pass that recorded this case |
|
||||
| Evidence | `references/evidence-summary.md` §The capability matrix; `references/evidence-summary.md` §The capability matrix (its identifier-convention and injection-line sections); `references/evidence-summary.md` §The capability matrix (its environment section) |
|
||||
| Related | `references/desensitization-and-leak-scans.md`; `references/long-task-discipline.md` §Keep a live record, not a log; `references/verification.md` §Reporting |
|
||||
|
||||
## Assertions and grade
|
||||
| # | Assertion | Grade | Evidence |
|
||||
|---|---|---|---|
|
||||
| 1 | Identity was found by a human grep after the material had already been written, and the files had to be corrected | observed as this repository's stated history; the correction itself is visible in the convention every evidence file now opens with | the environment section of `references/evidence-summary.md` §The capability matrix: *"Target identity is deliberately absent. The sample is referred to as the sample, and its package identifier appears as `<PKG>`"* |
|
||||
| 2 | The convention was enforced per file, by hand, at write time | observed | the identifier-convention section of `references/evidence-summary.md` §The capability matrix normalises every occurrence of the package identifier and its sub-package prefixes, and states that the transcripts are otherwise verbatim |
|
||||
| 3 | Verbatim transcripts were kept, and that is deliberate | observed | the same section keeps the hook-reported class names, "those are what make the evidence checkable" |
|
||||
| 4 | No structural gate could have caught it | observed | `check_repo.py` validates layout/frontmatter/`--help`; `check_refs.py` validates section anchors; neither reads content for identity. Both passed on the material that had to be corrected |
|
||||
| 5 | An automated scan would have caught it | **inferred** — the scanner was built and measured against a planted corpus, but it was never run against the historical, pre-correction tree | `references/evidence-summary.md` §The capability matrix, the planted-corpus and repository-scan sections |
|
||||
| 6 | The scan finds the four classes that actually occur here — bundle ids in `pm`/`ps`/`manifest` contexts, 16-character serial-shaped tokens, inline key assignments, host user paths | observed | planted corpus: 30 findings across 6 categories, every rule firing |
|
||||
| 7 | The scan's first run against the real tree was overwhelmingly false positives, and each one was a shape that legitimately appears in this documentation | observed | the repository-scan section of `references/evidence-summary.md` §The capability matrix: 40 findings when the file above was still being written, of which 26 were `strong`/`certain` — and every one of those 26 was inside that pass's own evidence file. After the correction: 0 strong, 4 weak (RFC 5737 usage examples) across 129 files |
|
||||
| 8 | Over-redaction is the failure on the other side, and it is not hypothetical here | observed | the do-not-anonymize list in the reference file is derived from identifiers that a shape-only scanner flagged in this tree: tool names, SDK packages, dex constant identifiers, public crackme names |
|
||||
| 9 | The ratio, not the count, decides whether a gate survives | observed from this case | the pass that produced 27-of-28 false positives also had to fix 26 real ones in its own report on the same day. Both numbers moved the design: exemptions for the shapes that legitimately appear, and a split exit code so the remaining documentation-range hits do not make the gate permanently red |
|
||||
|
||||
## Execution chain (including the dead ends)
|
||||
1. The convention existed and was written down per evidence file: identity absent, placeholders for
|
||||
the package, the device serial and the host paths, verbatim transcripts otherwise.
|
||||
2. It was enforced by hand, at write time, by whoever wrote the file. **This is the dead end**: a
|
||||
convention enforced by attention has no failure signal. The omission was invisible until someone
|
||||
went looking, and by then the material was already in the tree.
|
||||
3. The correction happened after the fact, which is why this case exists at all. A search for the
|
||||
shape — not for the value — is the only route that scales, because the value is exactly what you
|
||||
do not yet know.
|
||||
4. The scan was then built, and the interesting result was **not** "it finds leaks" (that was the
|
||||
easy part). It was that a first run over the real tree produced 28 findings of which 27 were
|
||||
legitimate documentation: a JDK version that looks like an IPv4 literal, tombstone hex tokens
|
||||
that look like serials, a public crackme's package name, a `probe.synthetic.*` fixture, a
|
||||
property lookup. A scanner that shipped in that state would have been ignored within a week, and
|
||||
the leak it exists to catch would have gone with it.
|
||||
5. The final state separates the two decisions: the scanner reports shapes and admits known-benign
|
||||
explanations **with a printed reason** (`--show-exempt`), and a human makes the identity call
|
||||
from the context window the report already contains.
|
||||
|
||||
## Pits
|
||||
| Pit | Cost | What it was mistaken for |
|
||||
|---|---|---|
|
||||
| A convention with no automated check | one manual sweep after the fact | adequate coverage |
|
||||
| Searching for the *shape* after the fact, rather than during the write | it only works if you already suspect | a finished pass |
|
||||
| A shape-only scanner | 27 false positives in 28 findings on this tree alone | a working gate |
|
||||
| Suppressing the false positives by blocking whole address ranges | it would also have hidden the one real weak hit (`scripts/tls_check.py`'s RFC 5737 example) | noise reduction |
|
||||
|
||||
## Reusable pattern
|
||||
- **Desensitize at write time, and verify by shape, not by value.** The value you have to remove is
|
||||
the one you cannot enumerate in advance; the shape is the thing you can write a rule for.
|
||||
- **Decide in code what is exempt, and let the code print why.** An exemption list that cannot be
|
||||
audited is indistinguishable from an exemption list that is too broad, and only one of those is
|
||||
visible in review.
|
||||
- **Keep the transcripts verbatim and replace the identity with a placeholder.** Redacting the
|
||||
*evidence* destroys the thing that makes the record checkable; redacting the *identifier* costs
|
||||
nothing.
|
||||
- **Measure the scanner against your own tree before trusting it.** The false-positive ratio, not
|
||||
the true-positive count, decides whether anyone will keep running it.
|
||||
|
||||
## Write back to the repository
|
||||
- [ ] `references/desensitization-and-leak-scans.md` — the reasoning, the split rule, and the
|
||||
failure-mode table; this case is its motivating evidence.
|
||||
- [ ] The evidence record's own index — `docs/tool-verification/README.md` at the **repository root,
|
||||
not shipped** — carries the topic entry. The in-skill condensation is
|
||||
`references/evidence-summary.md`.
|
||||
- [ ] `references/precedents/README.md` — indexed as case 5.
|
||||
- [ ] **Closed in this pass**: `scripts/scan_leaks.py` is wired into the repository workflow (run it
|
||||
over the tree before a commit) and the `RESULT=` convention is documented in
|
||||
`references/long-task-discipline.md` §Every script answers with a token, not with prose.
|
||||
- [ ] **The scanner caught its own first pass.** The tool written to prevent this class of leak found
|
||||
26 `strong`/`certain` hits on the day it was written, all of them inside its own evidence file —
|
||||
the fixture's literal match values, quoted to prove the rules fire. The fix was to keep the
|
||||
location, rule, category and grade for all 30 findings and replace every literal with a shape
|
||||
description. Boundary that makes it a rule rather than a scare: **the corpus may contain values;
|
||||
the published surface may not.** Evidence and transcripts: the "self-leak this file carries" and
|
||||
"inherited versus post-fix" sections of `references/evidence-summary.md` §The capability matrix.
|
||||
This is the strongest available argument for the gate — the author of the rule needed it on the
|
||||
same day, and did not see it by hand.
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
# Protocol reverse engineering
|
||||
|
||||
**Load this when the app's traffic is not readable by the usual means**: the body is protobuf with no
|
||||
schema, the transport is gRPC, the connection is QUIC rather than TCP, or the client refuses to trust
|
||||
your proxy because it validates the certificate inside a native library.
|
||||
|
||||
Everything in this file assumes you have already decided the traffic matters and that a proxy is
|
||||
allowed to see it. If a *feature-scoped* TLS failure has appeared while the rest of the app works,
|
||||
that is a different problem with its own file: read `tls-and-cert.md` first — it owns the trust-chain
|
||||
attribution and the permissive-defaults template, and this file deliberately does not repeat them.
|
||||
|
||||
**Claim strength.** `measured` = an exact command and output exist in
|
||||
`references/evidence-summary.md` §The capability matrix. `inferred` = documented behaviour or a step that
|
||||
follows directly from a measured one. `unverified` = reported by others, not reproduced here. The
|
||||
wire-format section is `measured` on a self-built fixture; every tool named below is `unverified`
|
||||
unless stated otherwise.
|
||||
|
||||
## 1. Protobuf on the wire (measured)
|
||||
|
||||
Protobuf has no field names on the wire and no header. A payload is a flat sequence of
|
||||
`tag, value` pairs, and a nested message is just a value of type "bytes whose content is another
|
||||
sequence". That is the whole format; the schema supplies meaning and nothing else.
|
||||
|
||||
| Element | Encoding |
|
||||
|---|---|
|
||||
| Varint | base-128, least-significant group first, high bit set on every byte but the last |
|
||||
| Tag | `(field_number << 3) \| wire_type`, itself a varint |
|
||||
| Wire type 0 | varint value (int32/int64/uint/bool/enum) |
|
||||
| Wire type 1 | fixed 8 bytes (fixed64/sfixed64/double) |
|
||||
| Wire type 2 | length-delimited: length varint, then that many bytes (string, bytes, nested message, packed repeated) |
|
||||
| Wire type 5 | fixed 4 bytes (fixed32/sfixed32/float) |
|
||||
| Wire types 3/4 | group start/end, legacy; treat as unsupported when walking a modern payload |
|
||||
|
||||
A host-side check of the rules, including the canonical `150 -> 96 01`, was run independently of the
|
||||
shipped decoder — `references/evidence-summary.md` §The capability matrix records that cross-check against
|
||||
the official runtime. Measured output on the fixture built for it:
|
||||
|
||||
```
|
||||
message (29 B): 089601120774657374696e671a0508011201782206038e029ea7052800
|
||||
field 1 varint 150
|
||||
field 2 len-delimited(7) 'testing'
|
||||
field 3 len-delimited(5) nested message:
|
||||
field 1 varint 1
|
||||
field 2 len-delimited(1) 'x'
|
||||
field 4 len-delimited(6) hex '038e029ea705'
|
||||
field 5 varint 0
|
||||
rebuild from the walked rows equals original: True
|
||||
```
|
||||
|
||||
Three consequences that decide how you interpret any decoded body:
|
||||
|
||||
1. **Field numbers are local to their message.** In the walk above, field 3 of the outer message is a
|
||||
nested message whose own field 1 exists independently. Nothing on the wire says which message a
|
||||
field number belongs to; only a schema does.
|
||||
2. **A packed repeated field is invisible without a schema.** Field 4 above is three varints
|
||||
(`3, 270, 86942`) packed into one length-delimited value. A schema-free decode can only report it
|
||||
as opaque bytes — the length is known, the element boundaries are not.
|
||||
3. **A decoded `0` and a field that was never set are two different questions.** An implicit
|
||||
(no-presence) proto3 field whose value is the default writes **nothing at all**, so `field = 0`
|
||||
and "never touched" produce identical bytes; a field **with** presence (`proto2 optional`,
|
||||
`proto3 optional`) set to `0` does put its tag and a zero byte on the wire. Both halves were
|
||||
measured against the official runtime; a schema-free decoder can only flag the case, never
|
||||
resolve it. So a decoded `0` was emitted on purpose by some writer, and a field *missing* from a
|
||||
decode is not evidence that 0 was the value in use.
|
||||
|
||||
### Decoding without a schema
|
||||
|
||||
- `protoc --decode_raw < body.bin` — the reference implementation's own raw decode; prints field
|
||||
numbers and values in exactly the shape above. Note it prints strings and nested messages without
|
||||
distinguishing them, which is why the fallback order matters: try a nested walk first, then
|
||||
printable UTF-8, then hex.
|
||||
- `protobuf-inspector` and `blackboxprotobuf` are the pip-installable equivalents, and both add
|
||||
heuristics: guessing whether a length-delimited value is a string or a submessage, and (for
|
||||
`blackboxprotobuf`) inferring a candidate type set per field across many samples. Useful when you
|
||||
have traffic but no `protoc`; still unverified here.
|
||||
- Decode **many samples of the same message**, not one. The number of distinct field numbers in a
|
||||
large sample set is what tells you which fields are optional, which repeat, and which are enums
|
||||
whose value set you can then enumerate.
|
||||
- Anything the app stores locally is a sample source that needs no proxy at all: an OkHttp disk cache
|
||||
(the `journal` file plus its entries), a DataStore file, or a Room blob column. `runtime-data.md`
|
||||
covers reading these, and `datastore_inject.py` can re-encode an edited DataStore value once you
|
||||
know the message.
|
||||
|
||||
### The decoder that ships here (measured)
|
||||
|
||||
`scripts/protobuf_decode_raw.py` walks a payload with no schema and prints a tree. Input is a hex
|
||||
string, a binary file, or stdin (hex text and raw bytes are told apart automatically); `0x` prefixes,
|
||||
spaces, commas, newlines and `\xNN` escapes are all accepted, and `|` splits independent frames.
|
||||
`--json` emits the same tree as data, `--max-depth` bounds the nesting, and `--reencode` writes a
|
||||
decoded (possibly hand-edited) tree back to bytes and can diff the result against the original.
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/protobuf_decode_raw.py --hex "08 96 01 12 07 74657374696e67"
|
||||
frame 0 bytes 0..12 (12 B) status=end_of_buffer
|
||||
f1 varint 150
|
||||
f2 len-delimited(7) view=utf8_string
|
||||
candidate utf8_string 0.50 valid UTF-8 with no control characters
|
||||
candidate packed_varint 0.50 the payload is exactly a sequence of 7 varint(s) ...
|
||||
candidate bytes 0.20 opaque bytes: always consistent with the payload ...
|
||||
tie: packed_varint and utf8_string are equally consistent with these bytes ...
|
||||
string: 'testing'
|
||||
```
|
||||
|
||||
The point of the tool is the candidate list. Fed a nested message that `google.protobuf` 6.33.6
|
||||
itself produced (inner payload `0801120178`), it answers:
|
||||
|
||||
```
|
||||
nested_message 0.50 | packed_varint 0.50 | utf8_string 0.35 | bytes 0.20
|
||||
tie: nested_message and packed_varint are equally consistent ... the view is a display default
|
||||
```
|
||||
|
||||
Both readings really are legal protobuf for those six bytes, and no heuristic closes the gap. The
|
||||
confidence numbers are a documented ordering, not probabilities, and the `view` exists only so the
|
||||
tree can be expanded and re-encoded -- it is not a claim about what the field is.
|
||||
|
||||
**Round trip, which is the strongest evidence available without a schema.** Decode, re-encode, and
|
||||
compare bytes: the message serialized by the official runtime re-encodes byte-identically to
|
||||
`SerializeToString()`; a real AndroidX DataStore file (81 B, written by `scripts/datastore_inject.py`)
|
||||
round-trips byte-identically; and editing one value in the JSON tree and re-encoding produced a file
|
||||
that the *other* tool read back as the new value. Two of the built-in fixture families are exceptions
|
||||
by design and are reported as such rather than hidden: a non-canonical varint re-encodes to the
|
||||
minimal form, and a `--split varint-length` run compares frame bodies only, since the length prefixes
|
||||
are framing and not message data.
|
||||
|
||||
**What a schema-free decode cannot conclude -- write none of these down:**
|
||||
|
||||
- that a length-delimited field *is* a string, a submessage or a packed array, rather than that it
|
||||
could be any of them;
|
||||
- the element boundaries of a packed array, or its element type;
|
||||
- that a field's value was 0 because it is absent from the decode, or that a sender "set" a field
|
||||
because a 0 appears (see the two traps above);
|
||||
- which message a field number belongs to, or that the same field number in two payloads means the
|
||||
same thing;
|
||||
- that a payload which parses cleanly is a real message at all -- §6 has that failure mode, and the
|
||||
fixes are a second sample and a round trip, not a longer look at the first one.
|
||||
|
||||
The measured record for this tool -- every command, both input forms, the round trip, and the
|
||||
framing mistake it does *not* protect you from -- is in
|
||||
`references/evidence-summary.md` §The capability matrix.
|
||||
|
||||
## 2. Recovering the schema from the APK
|
||||
|
||||
### What generated protobuf code looks like
|
||||
|
||||
| Marker in the decompiled output | What it tells you |
|
||||
|---|---|
|
||||
| `extends GeneratedMessageLite` / `GeneratedMessageV3` | protobuf class; `Lite` means the reflection/schema runtime, not the descriptor-based one |
|
||||
| `static final int FIELD_NUMBER = 3;` (or `int <name>_ = 3`) | the field number, usually right next to the field's getter |
|
||||
| `switch (tag >>> 3)` with `case` constants | the parse loop's field dispatch — each `case` is a field number, in declaration order |
|
||||
| `input.readInt32()`, `readInt64()`, `readBool()`, `readStringRequireUtf8()`, `readBytes()`, `readMessage(...)`, `readEnum()` | the field's wire type and semantic type, read *after* the tag |
|
||||
| `input.readTag()` returning 0 as the loop exit | the standard `parsePartialFrom` skeleton; the loop body is where all field knowledge lives |
|
||||
| `writeTo(CodedOutputStream)` / `getSerializedSize()` / `dynamicMethod(...)` | the class really is a generated message, not a hand-written DTO |
|
||||
| `newMessageInfo(instance, "<escaped string>", ...)` | protobuf-lite's compact schema blob (see below) |
|
||||
|
||||
The generator keeps those framework calls even after R8, so an obfuscated app usually still carries
|
||||
readable protobuf structure: class and field *names* are gone, field *numbers* and wire types are
|
||||
not.
|
||||
|
||||
### Two routes to a `.proto`
|
||||
|
||||
**Route A — read the field table out of the bytecode (inferred).** Decompile the class, walk
|
||||
`parsePartialFrom`, and record `case <n>` → the `read*` call that follows. Field name unknown? Name
|
||||
it `field_<n>`; a valid `.proto` does not need the original identifiers, and the app's own getter
|
||||
names often survive anyway. Then verify immediately against captured bytes with
|
||||
`protoc --decode=<message> <schema.proto>`: a decode that produces a coherent structure for **every**
|
||||
sample is the proof that your field table is right, and it costs seconds. A field that decodes into
|
||||
nonsense means a wrong wire type, not a wrong field number — check the `read*` call before moving on.
|
||||
|
||||
**Route B — read protobuf-lite's own schema blob (inferred).** protobuf-java-lite 3.x embeds a
|
||||
compact schema string (`rawMessageInfo`) per message class, consumed by `MessageSchema` at runtime.
|
||||
Recovering it means locating that blob and feeding it to a matching runtime build. It is a strictly
|
||||
larger win when it works — it carries field numbers, types, and labels in one place — and it is
|
||||
version-sensitive, so treat Route A as the default and Route B as the acceleration. `unverified`
|
||||
here: no target in this repository was analysed this way.
|
||||
|
||||
**Tooling that automates this (unverified):** `pbtk`/`protodump`-style scanners walk dex/JAR inputs
|
||||
looking for the generated-code shape above and emit `.proto` files, and a protobuf-aware decompiler
|
||||
plugin can do the same from a loaded APK. Use them to produce a first draft, then verify it with
|
||||
`protoc --decode` against real captured bytes — that verification is the step that turns a guess into
|
||||
a schema, and it is the step worth spending time on.
|
||||
|
||||
## 3. gRPC
|
||||
|
||||
gRPC is HTTP/2 with a message framing convention, so the first decision is whether you can see
|
||||
HTTP/2 at all.
|
||||
|
||||
- **Recognise it.** A request carries `:method: POST`, `content-type: application/grpc[+proto]` (also
|
||||
`+json`, `+thrift`), and `:path: /<package>.<Service>/<Method>` — that path shape is the reliable
|
||||
fingerprint; the package in it usually matches a package name visible in the APK, which is how you
|
||||
connect traffic to a client class.
|
||||
- **Message framing.** Each message is: one compression-flag byte, then a 4-byte **big-endian**
|
||||
length, then that many bytes of (possibly compressed) protobuf. Several messages can share one
|
||||
DATA frame and one message can span frames, so reassemble by length, not by frame. The call's
|
||||
outcome is in **trailers** (`grpc-status`, `grpc-message`), which many proxies display separately
|
||||
from the body — a `200` with no body and a nonzero `grpc-status` is a normal gRPC error.
|
||||
- **Read it after capture.** Extract the reassembled message bytes and hand them to
|
||||
`protoc --decode_raw` or your recovered schema. `grpcurl -plaintext -protoset file.protoset`
|
||||
replays against an endpoint once you have a schema, which is also the fastest way to confirm that
|
||||
a recovered `.proto` has the right shape.
|
||||
- **Capture it.** A transparent TCP proxy sees HTTP/2 fine; a tool that only understands HTTP/1.1 may
|
||||
show the tunnel without the frames, which looks like "the app is not making requests". Interception
|
||||
tooling for this comes in two shapes: a proxy add-on that understands the gRPC framing and prints
|
||||
messages (`grpc-dump` and similar mitmproxy add-ons, or a gRPC-Web decoder when the client is a
|
||||
browser-style stack), and a command-line client (`grpcurl`) for replay against a live endpoint. If
|
||||
the app uses TLS (gRPC-Java does by default), the certificate question comes first — see
|
||||
`tls-and-cert.md`, and the native-material trust chain below.
|
||||
|
||||
## 4. QUIC / HTTP/3
|
||||
|
||||
The failure to expect: a working HTTP proxy, a working CA, and **an empty capture**.
|
||||
|
||||
- **Why.** An HTTP proxy is a TCP endpoint, and a client reaches it with `CONNECT`. QUIC is a
|
||||
UDP-based transport with TLS 1.3 built into the handshake; there is no TCP connection for a
|
||||
`CONNECT` to carry and no separate TLS layer for a proxy to terminate.
|
||||
- **Recognise it before debugging anything else.** `Alt-Svc: h3=":443"` in an earlier HTTP response
|
||||
announced the upgrade; on the wire you see UDP traffic to port 443 whose first packets are QUIC
|
||||
Initials (long header, version field, and a TLS ClientHello inside), and the client's ALPN is `h3`.
|
||||
Check for UDP activity when a TCP proxy stays idle while the app plainly works.
|
||||
- **Options, in order of how little they change:**
|
||||
1. **Force a fallback to TCP.** Block or blackhole UDP 443 for the device. A client that cannot
|
||||
reach an origin over QUIC falls back to TCP/HTTP2 if the server also offers it, and then your
|
||||
existing proxy works unchanged. This is the cheapest option and the one to try first, but it is
|
||||
a **network-condition change**: state it in the report, because "the app failed to load over
|
||||
QUIC" and "the app failed" look identical in a screenshot.
|
||||
2. **Decrypt with keys, not with a proxy.** TLS 1.3 key logging (`SSLKEYLOGFILE`-style) works for
|
||||
QUIC exactly as it does for TLS-over-TCP, and a packet capture plus the key log can be
|
||||
decrypted in Wireshark. It requires the client to support a key-log callback — Chromium-based
|
||||
stacks and BoringSSL generally do, Java/OkHttp-based stacks generally do not — so this is a
|
||||
per-client property, not a general solution.
|
||||
3. **Turn HTTP/3 off in the client** by hooking whatever enables it (a library version check, a
|
||||
remote-config flag, or a build-time constant). This is a client-side patch and therefore
|
||||
belongs to the repack decision, not to the capture step.
|
||||
- Everything in this section is `inferred`/`unverified` here: no HTTP/3 target was exercised in this
|
||||
repository. Treat the ordering as a plan whose first step (block UDP 443) needs no tool support to
|
||||
test.
|
||||
|
||||
## 5. SSL pinning that lives inside a native library
|
||||
|
||||
Three different mechanisms are routinely called "pinning", and only the last one survives a system
|
||||
certificate module.
|
||||
|
||||
| Mechanism | Where it runs | What defeats it |
|
||||
|---|---|---|
|
||||
| OkHttp `CertificatePinner` (or a custom `TrustManager`) in Java/Kotlin | app's own client | Client-level, **not** the system path: `tls-and-cert.md` §Step 4 and §"If OkHttp is the failing path" own this case — do not apply the permissive-defaults template to it |
|
||||
| System trust decisions via `HttpsURLConnection` / platform stack | platform | System store policy — see `tls-and-cert.md` §Step 5 |
|
||||
| Verification performed by a **library the app ships**, e.g. a Flutter app's own BoringSSL, or a bundled `libcurl`/`mbedtls` | `libflutter.so`, `libapp.so`, another `.so` | Only an in-memory change to that library's verification path (below), because the library does not consult the platform trust manager you can influence from Java |
|
||||
|
||||
**Flutter, specifically.** A Flutter app's `dart:io` HTTP client runs on the engine's BoringSSL
|
||||
inside `libflutter.so`; the app's own Dart code can additionally supply a custom trusted-certificate
|
||||
context or a bad-certificate callback. Two consequences that decide which direction to work in:
|
||||
|
||||
- If the app is **not** pinning and simply fails to trust your CA, getting your CA into the **system**
|
||||
store is the normal fix. `MoveCertificate`-style Magisk modules exist precisely because Android 7+
|
||||
ignores user-store CAs for app traffic; moving the CA into the system store is a **device change**,
|
||||
not an artifact, and it must be labelled as a fallback rather than the deliverable. Its boundary is
|
||||
exactly this: it changes what the platform trust store contains, so it can only help a client that
|
||||
reads that store.
|
||||
- If the app **is** pinning, or supplies its own verification context, the system store is irrelevant
|
||||
and the change has to land on the library's verification function.
|
||||
|
||||
**Locating the verifier inside `libflutter.so` (unverified — reported method).** The published
|
||||
approach in
|
||||
[universal-flutter-ssl-pinning](https://github.com/vichhka-git/universal-flutter-ssl-pinning)
|
||||
(`github.com/vichhka-git/universal-flutter-ssl-pinning`) automates the anchor search with PyGhidra:
|
||||
|
||||
1. load `libflutter.so` headlessly in Ghidra and scan its defined strings for `ssl_client`;
|
||||
2. resolve cross-references from that string to the **containing functions**;
|
||||
3. decompile each candidate to count its parameters;
|
||||
4. take the **3-parameter** function — that is BoringSSL's
|
||||
`ssl_crypto_x509_session_verify_cert_chain`, the certificate-chain verification entry point;
|
||||
5. emit the discovered RVA into two ready-to-run artifacts: a Frida script that attaches to that
|
||||
address and replaces the return value with `1` (success), and a Renef script that patches the
|
||||
function entry with the ARM64 sequence `MOV X0, #1 ; RET`.
|
||||
|
||||
The repository reports it working on Google Flutter arm64-v8a and Shorebird-patched builds with
|
||||
Frida 16.x/17.x, and notes the RVA is identical across build types for the same engine version. The
|
||||
same project also ships an HTTP monitor that auto-locates BoringSSL's `SSL_write`/`SSL_read` in
|
||||
`libflutter.so` and prints reassembled, de-chunked plaintext — which is the route to take when the
|
||||
traffic cannot be proxied at all, because it never needs a certificate the app will accept.
|
||||
|
||||
**Two things to verify before trusting that recipe on your target.** First, the string anchor is a
|
||||
build artifact: a stripped or repacked engine can lose `ssl_client` while keeping the function, in
|
||||
which case fall back to the structural search — find the `X509_verify_cert`-shaped call sequence in
|
||||
the handshake code, or identify the function called with the handshake structure during
|
||||
`certificate_verify`. Second, the failure mode of a wrong anchor is a **silent** success: replacing a
|
||||
return value in a function that merely *looks* like the verifier makes the certificate check pass
|
||||
in a process that then fails somewhere else, which is easy to misread as "the app detected my hook".
|
||||
Always confirm the hook fires on a connection that would otherwise fail, and confirm it is
|
||||
`SSL_VERIFY`-shaped semantics (the same value the library itself uses for success), rather than
|
||||
assuming `1` is right.
|
||||
|
||||
**When the string anchor is gone (the `session_creator` route).** A second family of anchors does not
|
||||
depend on the `ssl_client` literal at all: instead of finding the verifier through a string it
|
||||
references, find it through the call chain that reaches it. BoringSSL's handshake creates the session
|
||||
and then verifies the peer certificate chain, so the target is the function invoked with the
|
||||
handshake/session structure on that path — this is the shape that Flutter-unpinning write-ups call the
|
||||
session-creator route. It survives string and symbol stripping, because the call graph survives, and
|
||||
it costs more: you must identify the handshake structure first, which is why the string anchor is the
|
||||
fast path and this is the fallback when an engine build has dropped the string. `unverified` here — no
|
||||
engine binary was analysed in this repository.
|
||||
|
||||
**When the system store is genuinely enough.** If the client is Java-side and non-pinning, moving the
|
||||
CA into the system store is the least invasive change and needs no per-library work. The decision is
|
||||
therefore: identify the client first (`tls-and-cert.md` §Step 4 attribution), then choose the
|
||||
mechanism. Patching a library that was never consulting the store is work with no effect; installing
|
||||
a system CA when a native verifier ignores it produces the same empty capture you started with.
|
||||
|
||||
## 6. Failure modes
|
||||
|
||||
**A decode that looks valid but is not.** protobuf is self-synchronising enough that random bytes
|
||||
often decode into a plausible-looking field list. Guard against it by (a) decoding several samples of
|
||||
the same message and expecting the same field set, (b) checking that nested lengths exactly consume
|
||||
their parents, and (c) when you have a schema, re-encoding the decoded structure and comparing bytes
|
||||
with the input — the same round-trip check used in, which caught a decoder bug in this
|
||||
repository's own fixture.
|
||||
|
||||
**A framing choice that is wrong without being refused.** `--split varint-length` means protobuf's own
|
||||
`writeDelimitedTo` framing: a varint length, then the message. gRPC frames a message differently — one
|
||||
compression-flag byte, then a **four-byte big-endian** length (). Handed a real gRPC frame, the
|
||||
varint splitter reported **five** frames rather than failing, while the same bytes decoded with no
|
||||
splitting reported `stray_end_group`. Strip the 5-byte gRPC prefix yourself, and check the frame count
|
||||
either way: a wrong split still produces a decode, which is what makes it expensive.
|
||||
|
||||
**Assuming the proxy sees everything.** The proxy sees what the client sends through it. An app that
|
||||
uses QUIC (), a raw socket, a native client bypassing Java's HTTP stack, or a certificate-pinned
|
||||
connection shows up as silence. Silence is evidence about the transport, not about the app: check
|
||||
UDP activity, check whether a native library is doing the I/O (`lib_map.py`, `dynamic-frida.md`),
|
||||
and only then conclude that a feature is client-side.
|
||||
|
||||
**Patching the wrong verifier.** Covered in §5: the failure is silent, and the resulting state is
|
||||
worse than a clean failure because it looks like progress. Verify the hook against a connection
|
||||
known to fail.
|
||||
|
||||
**Treating a recovered schema as ground truth for the server contract.** A schema recovered from the
|
||||
client tells you what the client expects to send and parse — it does not prove the server honours
|
||||
those fields, and it says nothing about authorization. `server-api.md` owns that question. The
|
||||
recurring expensive mistake is to assume that because a field exists and is settable client-side, the
|
||||
server will act on it.
|
||||
@@ -0,0 +1,157 @@
|
||||
# rasc and droidsaw — the Rust ASC, and what it changes about the indexer-first workflow
|
||||
|
||||
Load this when: you are about to reach for `droidasc` (ASC) to locate classes or references, or when
|
||||
the Python indexer is the bottleneck on a real archive. It states what the Rust re-implementation is,
|
||||
how it was measured here, and **the one class shape where it silently loses code** — which is the
|
||||
part that decides whether you use it alone or keep a second tool next to it.
|
||||
|
||||
**When to prefer it, in one line:** `rasc` is the same interface with the same answers, several times
|
||||
faster, and one measured blind spot; use it as the indexer, keep `droidasc` and JADX as the
|
||||
cross-checks the rest of this skill already requires.
|
||||
|
||||
## What it is
|
||||
|
||||
`MG1937/ASC` has two implementations on different branches. `main` is the Python tool this skill
|
||||
already documents (`pip install droidasc`). The **`rust` branch is `rasc`**, a re-implementation that
|
||||
keeps the CLI shape and drops the Python runtime:
|
||||
|
||||
| | Python `droidasc` main | `rasc` (rust branch) |
|
||||
|---|---|---|
|
||||
| Runtime | CPython + androguard | none — one static-ish binary, **2.13 MB** |
|
||||
| Decompiler behind `getclass` | androguard's | **`droidsaw-dex 2.0.0`** (pure Rust; its project advertises byte-identical DEX re-emission on F-Droid) |
|
||||
| Install | `pip install droidasc` | **must be built** — no release asset, no crate (`cargo install rasc` fetches an unrelated maths parser) |
|
||||
| Commands | `listclass` `getmanifest` `getclass` `findrefs` | `classes` `manifest` `getclass` `findrefs` |
|
||||
| Flags | `--prefix` on `listclass` | `--filter` on `classes` (case-insensitive substring, not a prefix) |
|
||||
| Shared flags | `--threads/--thread`, `-o/--output`, `--debug` | same, plus `--debug` prints phase timings on stderr |
|
||||
|
||||
`scripts/rasc_build.py` wraps the build and the verification, because **a build only one machine has
|
||||
performed is a build nobody else can reproduce**: `--check` reports what is present, `--build`
|
||||
clones and compiles, `--verify <apk>` compares the class-definition sets against `droidasc` and fails
|
||||
if they differ.
|
||||
|
||||
## Measured here
|
||||
|
||||
Environment: Windows 11, rustup `stable-x86_64-pc-windows-gnu` (rustc 1.98.1), MinGW-w64 gcc 16.1.0
|
||||
(UCRT, 64-bit) as the linker. Build from a clean checkout: **117 s**, `cargo build --release` with
|
||||
FatLTO. Binary `rasc` 0.1.0, 2,236,416 bytes, sha256 `1becdb86b444fe7910589f8ade5bccf13794de9f2b466a1c07748b5669c0264b`,
|
||||
from `MG1937/ASC@e809c7b4b454ebc8eb4467f56d44a82e6139472b` (rust branch).
|
||||
|
||||
Two archives, fastest of N runs, same machine, `PYTHONUTF8=1` for the Python side:
|
||||
|
||||
| Archive | Scenario | rasc | droidasc | ratio | answers |
|
||||
|---|---|---|---|---|---|
|
||||
| MASTG `UnCrackable-Level3.apk` (1.4 MB) | `classes` | 0.025 s | 0.116 s | 4.7× | **identical set** (1396 / 1396) |
|
||||
| | `findrefs string http` | 0.021 s | 0.299 s | 14.0× | identical (3 / 3) |
|
||||
| | `findrefs type String` | 0.021 s | 0.307 s | 14.7× | rasc superset (390 vs 384) |
|
||||
| | `getclass` ×2 real classes | 0.086 / 0.082 s | 0.297 / 0.267 s | 3.3–3.5× | both emitted Java |
|
||||
| a real 34.8 MB app (30,768 classes) | `classes` | 0.048 s | 0.190 s | **4.0×** | **identical set**, 0 differences both ways |
|
||||
|
||||
The speedup is smaller than the upstream README's geometric mean of 8.0× because that figure was
|
||||
measured on a 343 MiB archive with 567k classes, where startup cost disappears; on a mid-size app the
|
||||
Python runtime's import time keeps the ratio near 4×. Both are real, and the small-archive numbers are
|
||||
the ones a normal task hits.
|
||||
|
||||
`python skills/apk-reverse/scripts/rasc_build.py --verify <apk>` reproduces the class-set comparison
|
||||
and is the check to run after any rebuild.
|
||||
|
||||
## The blind spot: `getclass` on the outer enum does not inline the constant bodies
|
||||
|
||||
`bench/jadx_parity.py` from the `rasc` repository compares **string literals** between JADX and `rasc`
|
||||
per class, counting a literal JADX has and `rasc` does not as a lost-code signal. Run here on a fair
|
||||
sample — app-like classes, no obfuscated identifiers, since the harness itself refuses to judge a
|
||||
class JADX cannot decompile — **20 classes picked, 17 judged, 1 with a difference, 0 reorderings**.
|
||||
|
||||
The one difference is `org.bouncycastle.crypto.PasswordConverter`, an enum whose constants each
|
||||
override an abstract method. JADX inlines each constant's anonymous subclass; `rasc` prints the
|
||||
constant list:
|
||||
|
||||
```java
|
||||
// JADX
|
||||
public enum PasswordConverter implements CharToByteConverter {
|
||||
ASCII { // from class: ...PasswordConverter.1
|
||||
@Override public byte[] convert(char[] cArr) { ... }
|
||||
@Override public String getType() { return "ASCII"; }
|
||||
},
|
||||
...
|
||||
```
|
||||
|
||||
```java
|
||||
// rasc -- getclass on the OUTER class
|
||||
public enum PasswordConverter implements org.bouncycastle.crypto.CharToByteConverter {
|
||||
ASCII, UTF8, PKCS12;
|
||||
public PasswordConverter() { }
|
||||
public PasswordConverter(PasswordConverter$1 v3) { }
|
||||
}
|
||||
```
|
||||
|
||||
**What is actually lost, measured directly on the constants.** The bodies live in their own classes,
|
||||
so the question is whether they survive as their own classes. They do:
|
||||
|
||||
| class | `rasc getclass` | `droidasc getclass` |
|
||||
|---|---|---|
|
||||
| `PasswordConverter$1` | 404 B, contains `convert(`/`getType(` | 460 B, contains both |
|
||||
| `PasswordConverter$2` | 407 B, contains both | 463 B, contains both |
|
||||
| `PasswordConverter$3` | 406 B, contains both | 462 B, contains both |
|
||||
|
||||
So the correct severity is **a missing inlining step in the outer-class view, not unrecoverable code**:
|
||||
the source is one `getclass` away, on the anonymous subclass JADX names in its own comment
|
||||
(`// from class: ...PasswordConverter.1`). This reference said "the method bodies are gone" when this
|
||||
row was first written, and that was wrong — the subclasses were never queried. The two Python-visible
|
||||
symptoms are the same in both tools (neither inlines), so the difference here is JADX's convenience,
|
||||
not a `rasc` regression against the Python implementation.
|
||||
|
||||
`droidasc`'s outer output is nevertheless richer than `rasc`'s for the same class — 2,170 B against
|
||||
314 B, carrying the synthetic `$VALUES`, `$values()` and the constructors explicitly — which matters
|
||||
when the enum's *shape* is the thing you are reading.
|
||||
|
||||
The rule, now proportional to what was measured:
|
||||
|
||||
- **Use `rasc getclass` for locating and reading ordinary classes.** That is what it was measured on.
|
||||
- **When the class is an `enum` with constant-specific bodies, read the constants.** A collapsed enum
|
||||
is visible on sight (bare constant list, no bodies). Either query the anonymous subclasses directly
|
||||
(`Lpkg/Enum$1;`) or use `droidasc`, whose outer-class listing is more complete.
|
||||
- **Never treat a single decompiler's output as the definition of what exists.** The same rule the VMP
|
||||
section states about opcode tables, applied to a class body.
|
||||
|
||||
Frequency for calibration: 1 of 17 judged classes here, and the other 16 agreed literal for literal.
|
||||
Adopt it, and keep the cross-check — but do not describe this as lost code.
|
||||
|
||||
## What it does not change
|
||||
|
||||
- **The workflow.** The indexer is the same *stage* it always was: `rasc` answers *location*
|
||||
questions, and reading a class you have already located is what a decompiler is for. It does not
|
||||
decompile a whole APK into a project, and neither does the Python tool.
|
||||
- **The verification discipline.** Every claim above is a measurement with a command behind it;
|
||||
where `rasc` and `droidasc` disagree in the tables, the disagreement is recorded rather than
|
||||
averaged away.
|
||||
- **Packer behaviour.** On this repository's hardened sample both tools correctly return 4 classes —
|
||||
the payload is encrypted inside the shell and is not in any DEX either tool can read. A small class
|
||||
count on a hardened target is the target, not the tool.
|
||||
|
||||
## Building it yourself
|
||||
|
||||
```bash
|
||||
python skills/apk-reverse/scripts/rasc_build.py --check # what is present
|
||||
python skills/apk-reverse/scripts/rasc_build.py --build # clone + cargo build --release
|
||||
python skills/apk-reverse/scripts/rasc_build.py --verify app.apk # compare against droidasc
|
||||
```
|
||||
|
||||
Requirements and the traps measured here:
|
||||
|
||||
- **Rust 1.93+** (the crate is `edition = "2024"`; this build used 1.98.1) and `git`.
|
||||
- On Windows, the **GNU host toolchain needs a 64-bit MinGW-w64 gcc**. A 32-bit MinGW fails at link
|
||||
with `sorry, unimplemented: 64-bit mode not compiled in` — the fix is a 64-bit distribution (this
|
||||
build used winlibs gcc 16.1.0), not a flag.
|
||||
- The build fetches crates from crates.io; `vendor/` in the repository only carries the two patched
|
||||
crates (`axmldecoder`, `droidsaw-dex`), so `--offline` fails with `no matching package named anyhow`.
|
||||
- `cargo`/`rustc` installed to a non-default `CARGO_HOME` are invisible to a non-interactive PATH;
|
||||
`rasc_build.py` prepends the known locations rather than reporting the toolchain as absent.
|
||||
|
||||
## Provenance
|
||||
|
||||
`https://github.com/MG1937/ASC` (branch `rust`, commit `e809c7b`), retrieved 2026-09-22.
|
||||
`https://github.com/droidsaw/droidsaw` and `https://crates.io/crates/droidsaw-dex` (2.0.0),
|
||||
consulted 2026-09-22. The Python implementation is `pip install droidasc` (0.1.1.post1 seen here;
|
||||
0.1.1.post2 published 2026-09-21). Nothing from either project is vendored into this repository —
|
||||
`rasc_build.py` clones it into the gitignored `tools/_work/` and the measurements above name the
|
||||
commit they came from.
|
||||
204
vendor-new/apk-reverse/skills/apk-reverse/references/recon.md
Normal file
204
vendor-new/apk-reverse/skills/apk-reverse/references/recon.md
Normal file
@@ -0,0 +1,204 @@
|
||||
# Recon — build the picture before touching anything
|
||||
|
||||
Ten minutes here prevents hours of wrong work. Answer the five questions from `SKILL.md` with concrete evidence.
|
||||
|
||||
|
||||
**Load this when:** starting any new sample. It gives identity, packer detection, embedded SDKs, ABI, and where the app's own code lives -- the ten minutes that prevent hours of wrong work.
|
||||
|
||||
## 1. Identity
|
||||
|
||||
```bash
|
||||
# Without a full decompiler (fast, works everywhere):
|
||||
aapt dump badging app.apk | head -20
|
||||
# or, from the extracted manifest:
|
||||
# package name, versionName, versionCode, sdkVersion, targetSdkVersion
|
||||
# or, if you have ddc (no JVM, single binary, also gives the launcher):
|
||||
ddc info app.apk
|
||||
```
|
||||
|
||||
Record: package name, version name/code, min/target SDK, all requested permissions, all declared components.
|
||||
|
||||
**Do not hand-parse the package name out of the binary manifest as your primary
|
||||
source.** Real AXML string tables are full of class-name fragments that look
|
||||
exactly like package names — in one sample, the strings visible without a real
|
||||
parser suggested `com.<app>.offline` (a service class prefix) while the actual
|
||||
package was `com.<vendor>.app`. Every later `pm list packages`,
|
||||
`dumpsys package`, `/data/data/<pkg>` and `pm install` command depends on this
|
||||
value, so getting it wrong sends the whole recon down a path where nothing is
|
||||
found and the natural conclusion is "the tool is broken".
|
||||
|
||||
Cross-check two independent sources and require agreement:
|
||||
|
||||
- `ddc info app.apk` → `package`, `label`, `launcher`
|
||||
- `aapt2 dump badging app.apk | head -1` (or `aapt dump badging`)
|
||||
|
||||
If they disagree, resolve it before proceeding. A single `label` value is also
|
||||
worth having: it is what appears on the launcher, which makes device-side
|
||||
identification unambiguous later.
|
||||
|
||||
**Note:** some `aapt` builds choke on non-ASCII paths. Normalize sample paths to ASCII before running tooling.
|
||||
|
||||
## 1b. Snapshot the config surface early
|
||||
|
||||
If the app has a remote-config endpoint, its DTOs are the cheapest thing in the
|
||||
package to find, because data classes serialise their own field names into strings
|
||||
that survive R8:
|
||||
|
||||
```bash
|
||||
ddc findrefs app.apk string "SplashConfig" # or Config, Popup, Banner, Tabbar...
|
||||
ddc strings app.apk -f "enabled" --with-locations
|
||||
```
|
||||
|
||||
Do this during recon rather than later: it tells you immediately whether the
|
||||
launch screen, popups and tab set are **client decisions** or **server data**,
|
||||
which is the difference between a five-minute patch and a dead end. See
|
||||
`server-config-and-updates.md`.
|
||||
|
||||
## 2. Is it packed?
|
||||
|
||||
Read `AndroidManifest.xml` → `application android:name`.
|
||||
|
||||
| What you see | Meaning |
|
||||
|---|---|
|
||||
| The app's own class (e.g. `com.example.app.App`) | **No packer.** Dex is directly editable. |
|
||||
| A vendor class (`com.stub.StubApp`, `com.secneo...`, `com.tencent.StubShell`, `s.h.e.l.l.*`, `com.nagain.*`, …) | **Packed.** Unpack first. |
|
||||
| A short meaningless name that does not match the app's package (e.g. `com.bc41`), plus `android:appComponentFactory` pointing at another short class | **Dex-level whole-APK packer.** The shell registers its own Application/proxy under names unrelated to the app. |
|
||||
| `classes.dex` is only tens of KB and holds nothing but shell classes | The real code is not on disk. |
|
||||
| A shell library in `lib/<abi>/` (`libDexHelper.so`, `libjiagu*.so`, `libshell*.so`, …) | Native half of the shell: decrypts and loads the payload. |
|
||||
| One or more large high-entropy `assets/` blobs, possibly **disguised as JPEG** (valid magic bytes, no decodable image) | Encrypted payload. |
|
||||
| `assets/` contains a second `.apk` or `.jar` | Wrapped/loader build. |
|
||||
|
||||
**A clean shape is also a result: record it and move on.** An app whose
|
||||
`application android:name` is its own class, with a single plain `classes.dex` and
|
||||
no native libraries, is directly editable — and that finding is what lets you skip
|
||||
the entire packer/unpack branch of this skill. It is worth one explicit line in the
|
||||
record ("no packer: application is the app's own class; dex readable; no lib/") so
|
||||
that later steps cannot re-open a question that was already answered. Most real
|
||||
targets are this shape; the packer material in this repository exists for the rest.
|
||||
|
||||
Decide from sizes and structure, not from names:
|
||||
|
||||
```bash
|
||||
unzip -l app.apk | sort -k1 -n | tail -20 # biggest entries: assets? libs? dex?
|
||||
```
|
||||
|
||||
**Loaded-shell check (highest fidelity):** if the app runs and its own classes are reachable, it is unpacked *at runtime* regardless of what the manifest says. If the app's own code is not among the dex classes, it is packed on disk.
|
||||
|
||||
### Unpacking a dex-level packer
|
||||
|
||||
**1. Map payload → dumped dex by length.** If the shell uses **length-preserving** encryption, each encrypted `assets/` payload has **exactly the same byte count as the plaintext dex** it decrypts to. That equality is the strongest single discriminator for "which dump is a real original dex" — far more reliable than sorting dumps by size. Measure it per file before trusting any dump.
|
||||
|
||||
**2. Check how the real dex is loaded.** A log line like `InMemoryDexFile[cookie=[0, ...]]` means the dex is built in memory from the payload and never written to disk, so there is no dropped file to find. Use a **memory-scanning** dumper (`frida-dexdump`, or your own script walking the class loaders) instead of hunting for artifacts under the app's data directory. See `references/dynamic-frida.md`.
|
||||
|
||||
**3. Two dumper caveats that cost real time.**
|
||||
- `-D <device-id>` does **not** accept a remote device (`Device '<id>' not found`). Register a remote device with `-H <host>:<port>`; keep `-D` for local/USB ids.
|
||||
- The `-o <dir>` output directory **must already exist** — the tool does not create it. Otherwise every dex write fails and the run still ends with `All done`, which reads exactly like a successful dump of nothing. `mkdir -p out` first, and judge the result by the file count, never by the final message.
|
||||
|
||||
**4. Filter the dumps before using any of them.** A memory dump contains three kinds of garbage:
|
||||
- **Duplicate copies of one dex.** Identical byte count does **not** imply identical content — files of the same size have been measured to differ in ~73% of their bytes, carry a different dex version, and fail to parse. Dedupe by **content hash**, never by size.
|
||||
- **SDK plugin dexes downloaded at runtime**, which were never part of the APK.
|
||||
- **Structurally broken dexes** whose header and body disagree.
|
||||
|
||||
Cheap header check that catches the third class, plus the hash used for the first:
|
||||
|
||||
```python
|
||||
import struct, hashlib
|
||||
d = open(p, 'rb').read()
|
||||
assert d[:4] == b'dex\n' # magic
|
||||
assert struct.unpack_from('<I', d, 32)[0] == len(d) # file_size == actual size
|
||||
assert struct.unpack_from('<I', d, 36)[0] == 112 # header_size
|
||||
sha = hashlib.sha256(d).hexdigest()
|
||||
```
|
||||
|
||||
Then confirm survivors actually parse (`baksmali`/dexlib2) or load on device. Header consistency alone is not proof of a usable dex.
|
||||
|
||||
**5. Which dump is the app's own business dex.** Count string-feature hits per dump instead of trusting file order:
|
||||
|
||||
```bash
|
||||
python scripts/dex_strings.py <dump_dir> --find 'Lcom/example/app/' --per-file
|
||||
python scripts/dex_strings.py <dump_dir> --find 'Lcom/example/adsdk/' --per-file
|
||||
```
|
||||
|
||||
Heuristic: the business dex mixes the app's package prefix (hundreds of hits) with an ad-SDK prefix (thousands of hits). A pure SDK plugin dex has one vendor prefix at its maximum count and **zero** app-package hits. Zero app-package hits means it is not the app.
|
||||
|
||||
## 3. Where does the app's own code live?
|
||||
|
||||
| Indicator | Code location |
|
||||
|---|---|
|
||||
| Plain `classes*.dex`, app classes visible in them | **Dex** — patchable with dexlib2/smali |
|
||||
| `libflutter.so` + `libapp.so` | **Flutter (Dart AOT)** — dex contains only a thin shell |
|
||||
| `libil2cpp.so` + `assets/bin/Data/` | **Unity (IL2CPP)** — native |
|
||||
| `libmono*.so` + `assets/bin/Data/Managed/` | **Unity (Mono)** — managed DLLs under `assets/` |
|
||||
| `libreactnativejni.so` + `assets/index.android.bundle` | **React Native** — JS bundle under `assets/` |
|
||||
| `libhermes.so` | **Hermes bytecode** |
|
||||
| Almost everything in `.so` | **Native** — different toolchain entirely |
|
||||
|
||||
For a **Kotlin/Java app**, note the module layout: app code often lives in a small subset of dex files while large SDKs occupy the rest. Finding which dex holds the app's package is a huge time-saver:
|
||||
|
||||
```bash
|
||||
python scripts/dex_strings.py <dex_dir> --find 'Lcom/example/app/' --per-file
|
||||
```
|
||||
|
||||
Consequence for editing: you usually only need to replace **one or two dex files**, which keeps the repack minimal. When the app is packed, run this check against the **dumped** dexes, not the ones on disk ().
|
||||
|
||||
## 4. SDK and library inventory
|
||||
|
||||
Extract strings across all dex and group by vendor markers. This reveals ads, analytics, crash reporting, attribution, and any anti-tamper SDK, in one pass.
|
||||
|
||||
```bash
|
||||
python scripts/dex_strings.py <dex_dir> --urls
|
||||
python scripts/dex_strings.py <dex_dir> --find 'anythink|openadsdk|com.qq.e|umeng|bugly|crashsdk'
|
||||
```
|
||||
|
||||
What to look for:
|
||||
|
||||
- **Ad networks / aggregators**: `openadsdk` `TTAdSdk` `Pangle` `com.qq.e` `GDTAd` `anythink` `ATSDK` `ksad` `mobads` `sigmob` `bdxadsdk`
|
||||
- **Analytics / crash / attribution**: `umeng` `bugly` `crashsdk` `appsflyer` `adjust` `UMCrash`
|
||||
- **Identity / device id**: `oaid` `msa` `SupplementaryDID` `freemme`
|
||||
- **Anti-tamper / root / hook detection**: `libsgcore` `libInno` `libqmcheat`, strings like `frida` `xposed` `magisk` `substrate`, `/system/xbin/su`
|
||||
- **Media stack**: `libmpv` `libavcodec` `libplayer` `libgdx`
|
||||
|
||||
Important distinction: **a vendor marker in the string table does not mean the app uses that feature**. Presence of `xposed`/`frida` strings is often just an SDK's own detection list. Decide based on *behavior*, not on strings (`references/verification.md`).
|
||||
|
||||
## 5. Signature and tamper checks
|
||||
|
||||
Search the app's **own** packages only (ignore third-party SDKs) for:
|
||||
|
||||
- `getPackageInfo`, `PackageInfo`, `signatures`, `GET_SIGNATURES`, `Signature` → app-side signature verification
|
||||
- hardcoded SHA-1/SHA-256 hex constants, base64 license blobs → certificate pinning to the original signer
|
||||
- `checkSignature`, `verifySignature`, `signCheck` → a named check
|
||||
|
||||
**Beware false positives.** `SignatureCheck` and `verifySignature` exist inside `okhttp3` (`SuppressSignatureCheck`, `BasicCertificateChainCleaner.verifySignature`) — TLS plumbing, not app tamper checks. Likewise a native `lib*.so` named like a security library may be an ad SDK's payload decryptor, not a shell.
|
||||
|
||||
Confirm by checking whether **app code** references it. If the only callers are library-internal, it is not your problem.
|
||||
|
||||
## 6. API surface
|
||||
|
||||
```bash
|
||||
python scripts/dex_strings.py <dex_dir> --urls
|
||||
```
|
||||
|
||||
Collect: base URLs, DoH/DoT lookups, path constants, CDN hosts, custom headers.
|
||||
|
||||
Treat **unique** strings as navigation aids — they can often be searched byte-wise in the dex to find the owning class (see `references/dex-patching.md` §finding-the-call-site).
|
||||
|
||||
Two patterns worth recognizing early:
|
||||
- **Dynamic gateway**: the real API host is fetched at runtime (e.g. via a DNS TXT record over DoH). The hardcoded host in dex may be only a fallback. This affects server-side analysis, not patching.
|
||||
- **Minimal headers / no request signing**: if auth is just `Authorization: Bearer` plus a static app-name header, then a repackaged client is not distinguishable to the server by request signature — which matters for `references/server-api.md`.
|
||||
|
||||
## Recon output
|
||||
|
||||
Write a short profile before patching. Minimum:
|
||||
|
||||
```
|
||||
package / version / ABI
|
||||
packed? (evidence)
|
||||
if packed: packer family, unpack method, kept dumps + sha256
|
||||
code location (dex / flutter / native)
|
||||
app code in which dex files
|
||||
ad SDK(s) and the app's wrapper class
|
||||
analytics/crash SDKs
|
||||
anti-tamper: present? where? app-side or library-internal?
|
||||
API base + notable endpoints
|
||||
device plan (rooted real device / emulator / static only)
|
||||
```
|
||||
@@ -0,0 +1,279 @@
|
||||
# Repack and sign
|
||||
|
||||
Rebuilding is the step where otherwise-correct patches die. Two mistakes account for nearly all of it: **stripping `META-INF/`** and **recompressing entries that must stay stored**.
|
||||
|
||||
|
||||
**Load this when:** rebuilding, signing, installing, or a repacked build misbehaves. It gives the two mistakes that account for nearly all failures here, the STORED-and-aligned rule, and every install refusal that looks like a broken build.
|
||||
|
||||
## The rules
|
||||
|
||||
**1. Strip only signature artifacts, never the whole directory.**
|
||||
|
||||
`META-INF/` contains ServiceLoader registrations the app needs at runtime. Details and the exact failure in `references/pitfalls.md` P1.
|
||||
|
||||
```python
|
||||
SIG_EXT = ('.SF', '.RSA', '.DSA', '.EC')
|
||||
def is_signature_entry(name):
|
||||
if not name.upper().startswith('META-INF/'):
|
||||
return False
|
||||
rest = name[len('META-INF/'):]
|
||||
if '/' in rest: # keep services/, androidx/, native-image/ ...
|
||||
return False
|
||||
up = rest.upper()
|
||||
return up == 'MANIFEST.MF' or up.endswith(SIG_EXT)
|
||||
```
|
||||
|
||||
**2. `AndroidManifest.xml` and `resources.arsc` must stay uncompressed (STORED).**
|
||||
|
||||
Recompressing them produces builds that fail to install or misbehave.
|
||||
|
||||
**2a. `resources.arsc` must ALSO be 4-byte aligned, and the failure is an install refusal.**
|
||||
|
||||
On Android 11+ (targetSdk 30+) the package manager rejects the install outright:
|
||||
|
||||
```
|
||||
Failure [-124: Failed parse during installPackageLI: Targeting R+ (version 30 and
|
||||
above) requires the resources.arsc of installed APKs to be stored uncompressed and
|
||||
aligned on a 4-byte boundary]
|
||||
```
|
||||
|
||||
Two independent requirements hide in that one sentence: STORED, and the entry's
|
||||
**data offset** divisible by 4. Uncompressed `lib/*.so` follows the same rule.
|
||||
|
||||
A naive `zipfile.ZipFile(...).writestr(...)` loop satisfies neither reliably:
|
||||
Python's zip writer gives you no control over entry offsets. `scripts/repack.py`
|
||||
therefore emits local headers itself and pads the **local extra field** to reach
|
||||
the boundary. The padding arithmetic has one trap worth knowing:
|
||||
|
||||
> A zip extra area is a sequence of `(id, size, payload)` records, so its minimum
|
||||
> useful length is 4 bytes. A required pad of **1-3 bytes cannot be expressed**.
|
||||
> When the natural pad falls in that range, insert a stored filler entry of
|
||||
> exactly that size instead -- the filler has a computable size, so the following
|
||||
> entry still lands on the boundary.
|
||||
|
||||
Verify by reading the archive, not by trusting the writer: for each entry Android
|
||||
cares about, parse its local header, compute `header_offset + 30 + namelen +
|
||||
extralen`, and check `compress_type == 0` and `offset % 4 == 0`.
|
||||
`repack.py` prints exactly this ("alignment gate") and `check_alignment()`
|
||||
returns the complaint list programmatically.
|
||||
|
||||
**Do not plan on repairing alignment with `zipalign` after the fact when you can
|
||||
produce a correct archive directly.** A post-hoc pass rewrites the file; on a
|
||||
target that fingerprints its own byte layout, that is a much larger change surface
|
||||
than writing it correctly the first time. Keep `zipalign -c -v 4` as an
|
||||
independent check when the tool is available.
|
||||
|
||||
**3. Keep the APK's zip entry metadata.** Preserve `compress_type`, `external_attr`, `date_time` for entries you copy through.
|
||||
|
||||
**4. Changing any dex changes nothing about resources.** If you only edited dex, do not touch `res/`, `assets/`, or `lib/`.
|
||||
|
||||
**4a. A full `apktool b` rebuild rewrites resource paths even when you edited none.** Decoding with
|
||||
`apktool d` (without `-s`) and rebuilding re-encodes resources, and obfuscated short names are
|
||||
expanded back to readable ones — an entry originally shipped as `res/-B.png` comes back as
|
||||
`res/drawable-hdpi/<real-name>.png`. Expect an entry-level diff against the original to show on the
|
||||
order of a thousand "removed + added" pairs that are **pure renames**, not content changes.
|
||||
|
||||
This is expected and usually harmless, but two consequences matter:
|
||||
|
||||
- **Never read that diff as "I broke something".** Compare by identity (rename-aware, or by content
|
||||
hash grouped by size) before drawing a conclusion. A toy diff that reports 1100 changes when you
|
||||
edited one method is a **tool** artifact and will send you hunting a bug that does not exist.
|
||||
- **It changes the byte layout of the whole archive.** On a target that fingerprints its own file
|
||||
against a stored hash, or whose protection binds offsets into a container, a full rebuild is a much
|
||||
larger change surface than a dex-only swap. If your only edit is dex, prefer replacing the
|
||||
`classes*.dex` entries inside the original zip (`compress_type` preserved) over a full rebuild.
|
||||
Keep that dex-only path as a fallback for exactly this reason.
|
||||
|
||||
If you must avoid resource churn entirely, decode with `-s` (do not decode resources) and only
|
||||
rebuild what you changed.
|
||||
|
||||
**5. Signing creates new `MANIFEST.MF`/`*.SF`/`*.RSA`.** That is expected — the check is that **no signature artifact from the ORIGINAL** survives, and **no non-signature entry was lost**.
|
||||
|
||||
**6. Any dex you edited must have its header integrity fields recomputed.**
|
||||
Every dex carries two fields that cover the rest of the file:
|
||||
|
||||
```
|
||||
bytes 12..32 = sha1(data[32:]) # signature — must be computed FIRST
|
||||
bytes 8..12 = adler32(data[12:]) # checksum — covers the signature, so it is LAST
|
||||
```
|
||||
|
||||
Skipping this does not always stop the app from starting, which is what makes it
|
||||
dangerous. Android logs
|
||||
`Failure to verify dex file ...: Bad checksum (computed, expected)` — the real
|
||||
value appears as "expected", which reads backwards — and then falls back to
|
||||
interpreting the dex instead of using a verified image. The visible symptom is an
|
||||
unrelated startup failure such as `ClassNotFoundException` for an ordinary class
|
||||
(the Application class, or a small AndroidX component), so the trail points at the
|
||||
APK structure rather than at two stale header words.
|
||||
|
||||
`scripts/dexutil.py` exposes `fix_dex_header()` (correct order, plus
|
||||
`verify_dex_header()` so you can assert the result), and
|
||||
`scripts/dex_patch_bytes.py` runs both automatically before writing.
|
||||
|
||||
**7. `jarsigner` rewrites the archive; `apksigner` does not.**
|
||||
|
||||
Adding a v1 JAR signature with `jarsigner` recompresses entries as a side effect,
|
||||
which **destroys the alignment from rule 2a**. The signature still verifies, so the
|
||||
build looks correct until the install is refused with `[-124]`. Sign with
|
||||
`apksigner` (v1+v2+v3) unless you have a specific reason not to.
|
||||
|
||||
## Repacking an unpacked (de-shelled) app
|
||||
|
||||
When the sample was packed, the dex you are about to patch came out of a memory dump while the APK still carries the shell. Build a **de-shelled base APK** first, then treat it as an ordinary APK for the rest of this document.
|
||||
|
||||
1. **Assemble the base.** Start from the original APK: replace `classes*.dex` with the dumped real dexes, and delete the shell library under `lib/<abi>/` plus the encrypted `assets/` payloads. Strip **only** signature artifacts — `META-INF/*.SF|*.RSA|*.DSA|*.EC` and the top-level `MANIFEST.MF`. Never delete the whole `META-INF/` (`references/pitfalls.md` P1).
|
||||
2. **Decode with `apktool`.** `apktool d base.apk` gives the smali tree and a **readable text manifest**.
|
||||
3. **Fix the manifest.** Point `application android:name` at the app's real Application class and **remove** `android:appComponentFactory`. Grep the decoded tree for leftover shell class references before building.
|
||||
4. **Patch, then rebuild.** `apktool b` → `zipalign -p -f 4` → `apksigner` (v1+v2+v3) → install and verify against the checklist at the end of this file.
|
||||
|
||||
Why `apktool` rather than editing binary AXML in place: changing `android:name` in binary AXML means hand-editing the string pool and the attribute/chunk sizes around it, and a mis-sized chunk produces an APK that installs but throws far from the edit, typically in component lookup at startup. The text round-trip moves the risk to "did the rebuild preserve everything else", which item 5 of the checklist verifies directly.
|
||||
|
||||
## Pipeline
|
||||
|
||||
`scripts/repack.py` implements all of the above. Conceptually:
|
||||
|
||||
```
|
||||
open original (zip)
|
||||
for each entry:
|
||||
skip entries that are signature artifacts (per rule 1)
|
||||
skip entries being replaced
|
||||
copy through, preserving compression + attributes
|
||||
write replacement entries
|
||||
close
|
||||
sign (v1 + v2 + v3)
|
||||
verify
|
||||
```
|
||||
|
||||
Usage:
|
||||
|
||||
```bash
|
||||
python scripts/repack.py --apk original.apk --dexdir extracted_dex_dir --out out.apk
|
||||
# or replace specific dex files:
|
||||
python scripts/repack.py --apk original.apk --dexdir extracted_dex_dir \
|
||||
--dex "classes7.dex=patched/classes7.dex" \
|
||||
--dex "classes8.dex=patched/classes8.dex" \
|
||||
--out out.apk
|
||||
```
|
||||
|
||||
`--dexdir` supplies the untouched dex files; `--dex name=path` overrides specific ones.
|
||||
|
||||
## Signing
|
||||
|
||||
Two viable routes.
|
||||
|
||||
**Route A — `uber-apk-signer` (handy, wraps zipalign + apksigner):**
|
||||
```bash
|
||||
java -jar uber-apk-signer.jar --apks in.apk --ks ks.jks \
|
||||
--ksAlias <alias> --ksPass <pass> --ksKeyPass <pass> -o out_dir
|
||||
```
|
||||
Known quirk: `-o` and `--overwrite` are mutually exclusive; passing both errors out. Also, it **silently skips already-signed APKs** (`0 processed`) — so de-sign first.
|
||||
|
||||
**Route B — `zipalign` + `apksigner` directly (most explicit):**
|
||||
```bash
|
||||
zipalign -p -f 4 unsigned.apk aligned.apk
|
||||
apksigner sign --ks ks.jks --ks-pass pass:<pass> --key-pass pass:<pass> \
|
||||
--v1-signing-enabled true --v2-signing-enabled true --v3-signing-enabled true \
|
||||
--out signed.apk aligned.apk
|
||||
apksigner verify --print-certs --verbose \
|
||||
--min-sdk-version 21 --max-sdk-version 34 signed.apk
|
||||
```
|
||||
|
||||
Enable **v1 + v2 + v3**. v1 is needed for older Android; v2/v3 for modern verification.
|
||||
|
||||
Generate a keystore once:
|
||||
```bash
|
||||
keytool -genkeypair -v -keystore ks.jks -alias <alias> \
|
||||
-keyalg RSA -keysize 2048 -validity 36500 \
|
||||
-storepass <pass> -keypass <pass> \
|
||||
-dname "CN=<name>, OU=dev, O=dev, L=NA, ST=NA, C=NA"
|
||||
```
|
||||
|
||||
## `apksigner verify` picks schemes from the APK's own minSdk
|
||||
|
||||
`apksigner verify` decides which signature schemes to check from the APK's own `minSdkVersion`. With `minSdk >= 24` it prints v1/v2 as `false` **by design**, even though `META-INF/*.SF` is present and the signature is valid. That output is indistinguishable from "signing did not apply" and sends you into a re-signing loop over a file that was never wrong.
|
||||
|
||||
Always pass the explicit range (Route B above) and read the scheme list from that run only. Never judge a build from a default-argument `apksigner verify`.
|
||||
|
||||
## Installing over an existing app
|
||||
|
||||
| Situation | Command |
|
||||
|---|---|
|
||||
| Same signing key as installed version | `pm install -r` — keeps app data (login state, caches) |
|
||||
| Different signing key | `pm uninstall` first, then install. **App data is lost** — say so in the delivery notes |
|
||||
| OEM installer refuses (`INSTALL_FAILED_*`, vendor restrictions, a bare negative code) | push the APK and install via root: `su -c 'pm install -r -d /data/local/tmp/app.apk'` |
|
||||
| Downgrade needed | add `-d` |
|
||||
| Test-only flag needed | add `-t` |
|
||||
|
||||
Useful detail: keeping the same keystore across builds lets you iterate with `-r` and **preserve a logged-in session**, which matters a lot when the feature you are testing needs auth.
|
||||
|
||||
### Vendor install interception (OEM shells)
|
||||
|
||||
Some ROMs route `adb install` through their own "security centre", which can return
|
||||
a bare failure code while the APK itself is fine:
|
||||
|
||||
```
|
||||
Performing Streamed Install
|
||||
adb.exe: failed to install app.apk: Failure [-99]
|
||||
```
|
||||
|
||||
The device log shows the real actor and reason, and it is not your build:
|
||||
|
||||
```
|
||||
ColorPackageInstallInterceptManager: OPPO_ADB_INSTALL_CANCEL ... packageName=<pkg>
|
||||
```
|
||||
|
||||
**The root path bypasses the interception**, which is why the table above lists it:
|
||||
|
||||
```bash
|
||||
adb -s <serial> push app.apk /data/local/tmp/app.apk
|
||||
adb -s <serial> shell "su -c 'pm install -r -g -d /data/local/tmp/app.apk'"
|
||||
```
|
||||
|
||||
Recognise the shape: a numeric-only failure with no `INSTALL_FAILED_*` constant, an
|
||||
APK that installs fine through the device's own UI, and a device process (package
|
||||
installer / security centre) in the log. Attributing that to the patch is a
|
||||
classic wasted hour.
|
||||
|
||||
### The installer may still own the screen afterwards
|
||||
|
||||
After an intercepted or UI-driven install, a vendor **installer confirmation window
|
||||
can remain the foreground activity indefinitely** — sometimes showing a stale
|
||||
package name from an unrelated earlier attempt. Consequences:
|
||||
|
||||
- `am start` on your app "does nothing", because another window owns the display.
|
||||
- Screenshots of your supposed launch actually show the installer.
|
||||
- `am start -W` can block past any reasonable timeout waiting for a first frame
|
||||
that will never come.
|
||||
|
||||
Clear it (`am force-stop <installer pkg>`, or a HOME key event) and re-launch. Before
|
||||
trusting any capture, check the foreground component:
|
||||
|
||||
```bash
|
||||
adb -s <serial> shell "dumpsys activity activities | grep -m1 ResumedActivity"
|
||||
```
|
||||
|
||||
If it is not your package, the frames are evidence about something else.
|
||||
`scripts/coldstart.py --expect-activity` performs this check automatically.
|
||||
|
||||
## Post-install / post-upgrade hazards
|
||||
|
||||
- **Data directory uid mismatch.** After reinstall the app uid increments; a restored `/data/user/0/<pkg>` directory owned by the old uid is unreadable. Symptom: crash in a DB-init path (`Cannot open database`). Fix:
|
||||
```
|
||||
su -c "chown -R <uid>:<uid> /data/user/0/<pkg>"
|
||||
su -c "restorecon -R /data/user/0/<pkg>"
|
||||
```
|
||||
Get `<uid>` from `dumpsys package <pkg> | grep userId=`.
|
||||
- **Restoring a data backup can itself cause this.** Prefer `cp -f` over an existing file (preserves owner) rather than deleting and re-extracting.
|
||||
|
||||
## Verify the build, not just the signature
|
||||
|
||||
Signature verification proves the file is well-formed. It does **not** prove the app works. Always:
|
||||
|
||||
1. `apksigner verify` passes with the explicit SDK range above — a default-args run can report v1/v2 `false` on a perfectly valid signature.
|
||||
2. Install succeeds.
|
||||
3. Launch succeeds; process stays alive.
|
||||
4. `logcat` shows no `FATAL EXCEPTION` / `VerifyError` / `IncompatibleClassChangeError` / `uncaughtException`.
|
||||
5. `META-INF/services/*` count matches the original.
|
||||
6. Unchanged dex files are byte-identical to the original (compare hashes); in a de-shelled build, compare against the de-shelled base instead.
|
||||
|
||||
Build a **control** at least once: same pipeline, zero patches. If the control fails, your pipeline or environment is at fault, not your patch (`references/pitfalls.md` P9).
|
||||
194
vendor-new/apk-reverse/skills/apk-reverse/references/routing.md
Normal file
194
vendor-new/apk-reverse/skills/apk-reverse/references/routing.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# Routing tables — the on-demand inventory behind `SKILL.md`
|
||||
|
||||
This file is the **lookup layer**. `SKILL.md` loads in full when the skill activates, so it carries
|
||||
only what has to be one hop there: the symptom index (recognising a failure must not become a two-hop
|
||||
lookup) and the four gates with their pass criteria.
|
||||
|
||||
Everything you consult *after* deciding to work lives here, and one load gets all of it:
|
||||
|
||||
| You want | Table |
|
||||
|---|---|
|
||||
| every reference file, and when to load it | **Reference index** |
|
||||
| every script this skill ships, and what it does | **Script index** |
|
||||
| the symptom table as part of a whole-inventory read | **Symptom index** (mirror — the live copy is in `SKILL.md`; edit it there) |
|
||||
|
||||
Coherence is enforced: `python check_routing.py` fails when this file and `SKILL.md` disagree, when a
|
||||
reference file is not named here, or when a script is not named here.
|
||||
|
||||
## Symptom index
|
||||
|
||||
|
||||
| What you observe | Load first |
|
||||
|---|---|
|
||||
| A repackaged/re-signed build **dies before your code runs**; `SIGSEGV`, all registers zero, `pc=0`, `fault addr` near `0x0` | `native-tamper-and-suicide.md` (deliberate crash), then `code-virtualization-and-custom-linkers.md` |
|
||||
| **No packer** (Application is the app's own, dex readable) **and it still dies** | `code-virtualization-and-custom-linkers.md` §a loader is still a possibility; but if the same build also dies on a *second, unrelated* device you are looking at an ordinary startup fault, not a hardened one |
|
||||
| The app dies at startup on **every** device, packed or not, with **no tombstone** while `crash_dump` says `already traced` and logcat says `exited cleanly (0)` | a bundled crash reporter has taken the signal handlers, so the platform's own trail is gone. Frida spawn-gating is the recovery route |
|
||||
| A `FORTIFY: pthread_mutex_lock called on a destroyed mutex` abort in a Flutter app, on the **main** thread, before the first frame completes | `dart-aot.md` — check `libapp.so` is actually being loaded; Flutter's engine bootstrap is the usual place a native lifecycle fault surfaces |
|
||||
| Log says a **Java-layer** signature/integrity check **passed**, yet the process dies | `code-virtualization-and-custom-linkers.md` §a Java-layer "signature killer" is a decoy |
|
||||
| Deleting a library fixes validation but yields `UnsatisfiedLinkError: dlopen failed: library "X" not found` | `code-virtualization-and-custom-linkers.md` §the deadlock that eats hours |
|
||||
| Whole classes appear as bare `native` declarations with no body | `java2c-and-jni-sinking.md` — read it **before** dumping memory: if this is Java2C there is no DEX to find, at any point in the process lifetime. A handful of `native` methods in an otherwise ordinary dex is JNI sinking, not this |
|
||||
| A `Java_*` search over a hardened library returns nothing at all | `java2c-and-jni-sinking.md` §The JNI boundary — why a symbol search fails silently — dynamic registration, or `-fvisibility=hidden`. The check that works is "exports `JNI_OnLoad` and zero `Java_*`" |
|
||||
| You are about to publish an evidence file, a transcript or a README that quotes real work | `references/desensitization-and-leak-scans.md` — run `scripts/scan_leaks.py` **before** it is committed; the hit list is a set of lines to look at, and `--show-exempt` is where the wrong suppressions show |
|
||||
| A hooking module appears to have run but its log tag is silent, and you are about to record "it never loaded" | `references/precedents/logd-broken-module-never-ran-case-3.md` — a broken `logd` delivers nothing on `logcat` while the module's whole run sits in LSPosed's file log; read both channels |
|
||||
| A library's **SONAME does not match its filename** | `code-virtualization-and-custom-linkers.md`, `native-and-so.md` |
|
||||
| Your edit had **no effect at all**, with no error | `server-config-and-updates.md` §3 (the value may be server-sent), then `packers.md` §map the validation boundary |
|
||||
| Process **hangs** with no crash record, or dies to a `uid 0` killer | `native-tamper-and-suicide.md` §the rule (you probably made a terminate path *not return*) |
|
||||
| Death looks like an ordinary null dereference in a hardened library | `native-tamper-and-suicide.md` §deliberate-crash stubs |
|
||||
| The app dies **only while you are attached/rooted** | `detection-and-anti-analysis.md`; run the unmodified original under identical conditions first |
|
||||
| **Install fails with `[-124]` and mentions `resources.arsc` / alignment** | `repack-and-sign.md` §2a — STORED **and** 4-byte aligned, both required |
|
||||
| **Install fails with a bare numeric code (e.g. `[-99]`) and no `INSTALL_FAILED_*`** | `repack-and-sign.md` §vendor install interception — a device-side interceptor, not your build. Use the root `pm install` path |
|
||||
| **After an install, `am start` does nothing / screenshots show another app / `am start -W` hangs** | `repack-and-sign.md` §the installer may still own the screen |
|
||||
| Log shows `Failure to verify dex file ...: Bad checksum` and a startup `ClassNotFoundException` for an ordinary class | `byte-level-patching.md` §the dex header has two integrity fields — order matters |
|
||||
| An install "succeeded" but nothing changed, or the version did not move | `long-task-discipline.md` §keep the observation window clean |
|
||||
| Evidence contradicts itself, or a capture looks like two states mixed | `long-task-discipline.md` §keep the observation window clean |
|
||||
| You took screenshots but drew the conclusion from logs or from the patch itself | `long-task-discipline.md` §captures you never looked at are not evidence |
|
||||
| You are about to re-run an experiment whose result you already recorded | `long-task-discipline.md` §long-context decay |
|
||||
| A script will not start, or a tool "is missing" | `scripts/doctor.py`, then `toolchain.md` §"not on PATH" is not "not installed" |
|
||||
| A hook or probe reports **no events at all**, and you are about to call it detection | `scripts/anti_detect_probe.js` for the environment self-report first, then `detection-and-anti-analysis.md` §Step 3: locating the check — the order of search from Stage 0 |
|
||||
| `attach` hangs and then fails **while the process is still in `ps`** | `detection-and-anti-analysis.md` §Step 3 Stage 0 — check for `D` in `/proc/<pid>/stat`, and attach a *different* pid as a one-line control before blaming the target |
|
||||
| The app exits with no tombstone, no crash and no ANR record | `detection-and-anti-analysis.md` §Step 3 — a clean self-exit means the check ran before your hooks existed; the branch conditions there say which Stage |
|
||||
| A dump region validates as the wrong thing, or an `r--s` view of `base.apk` looks like a dex | `advanced-unpacking.md` §What this route cannot do, and how to tell before you spend the window |
|
||||
| Feature-scoped network failure (login/register/pay) while the rest works | `tls-and-cert.md` — do not assume your patch caused it |
|
||||
| Everything works but **every signed request fails** after repack | `signature-derived-keys.md` |
|
||||
| A re-signed build **runs fine, renders its whole UI and logs no error — but one feature silently never loads**, and `dumpsys`/DNS/logcat show **no request for it at all** (not a rejected request: *no request*) | `code-virtualization-and-custom-linkers.md` §what the native check actually reads — refusing **before** the request is built. Not the row above: "sent and rejected" and "never sent" have different owners |
|
||||
| You cannot tell whether a missing feature is **your patch's fault or the target's own behaviour** | `long-task-discipline.md` §single-variable discipline. Run the **zero-change control through the same pipeline**, and the decisive variant: the unmodified original with the patch applied **in memory only**, same device, same network |
|
||||
| Under Frida `spawn`, the UI never appears — `mCurrentFocus` stays `null`, screenshots come back blank, the Activity stack never builds | `dynamic-frida.md` §spawn keeps the Activity stack down: write the patch into memory, **detach**, then start the Activity normally |
|
||||
| `frida-server` keeps disappearing mid-experiment, or the device reboots itself while you are working | `dynamic-frida.md` §when the ROM hunts your instrumentation |
|
||||
| Ads still appear after a patch that should have killed them | `server-config-and-updates.md` §6 (cached config / remote re-enable), then `ad-removal.md` §step 4 (count the SDK's own log lines; n -> 0, not "I did not see it") |
|
||||
| A forced-update or "must update" gate blocks the build | `updates-and-forced-upgrade.md` §step 6 |
|
||||
| The dialog is gone but the feature is still locked | `membership-and-limits.md` / `account-gates.md` — decide server vs client authority before patching again |
|
||||
| You are about to discard a route as "blocked" | `packers.md` — re-read it before writing any route off; mis-attributed failures have removed viable routes for hours |
|
||||
| The task has run long and you are unsure what is already proven | `long-task-discipline.md` §keep a live record |
|
||||
| A dumped dex parses in full, the classes are all there, and most method bodies are `return-void` stubs or nop fills | `references/advanced-unpacking.md` — an extraction shell: measure the `stub%` with `scripts/dex_dump_validate.py` before trusting any of it, and know that recovering the bodies is a different route |
|
||||
| Your `frida` dump dies mid-write (`script has been destroyed`), or the process you are dumping keeps changing pid | `advanced-unpacking.md` §dumping when frida is refused — rule out memory pressure first; a reclaim-and-relaunch needs no instrumentation |
|
||||
| A repack is refused by several independent checks, or the build has to keep working through store updates | `references/lsposed-and-modules.md` — deliver a module instead of an APK; G1's form table says when |
|
||||
| A hook module is installed, enabled and scoped, yet its log tag never appears — and you are about to conclude it never ran | `lsposed-and-modules.md` §Deploy, enable, and verify — **a `logcat`-only verdict has already been wrong here**: on one ROM `logd` is broken and output reaches only `/data/adb/lspd/log/modules_<ts>.log` |
|
||||
| A module's entry class is missing from its own dex (so it can never load), yet the package installs, enables and looks healthy | `references/lsposed-and-modules.md` — check `assets/xposed_init` against the dex's actual classes; installation is not evidence of anything |
|
||||
| You only need to **call** the target's own routine (sign, token, encrypt) rather than change the app | `references/emulation-and-rpc.md` — emulate it, or service-ify the live function over Frida RPC |
|
||||
| A native function is a many-thousand-line `switch` state machine, or the decompiler's output is meaningless | `references/native-dbi-and-deobfuscation.md` — OLLVM shapes, a Stalker trace, and how far a trace actually gets you |
|
||||
| `Stalker.follow` installs but no events arrive, or following a hot libc export crashes the process | `references/native-dbi-and-deobfuscation.md` §6 failure modes — this repository measured both |
|
||||
| The traffic is protobuf/gRPC/QUIC, or a proxy sees TLS but requests still fail on a Flutter app | `references/protocol-reverse.md` — schema-less protobuf, frame capture, and native-side pinning |
|
||||
| Userspace hooks land and the app still dies: the check reads `/proc/self/status` through a raw `svc`, or runs before `JNI_OnLoad` | `references/kernel-and-environment-hardening.md` — what the next layer up and down can actually do, and when to stop |
|
||||
| You must edit, repack, sign or inspect the APK **from the phone itself** | `references/on-device-tooling.md`, `scripts/mt_mcp_probe.py` |
|
||||
| A captured body decodes to nothing readable, or you cannot tell whether a length-delimited field is a string, a nested message or a packed array | `protocol-reverse.md`. Protobuf on the wire (measured) — run `scripts/protobuf_decode_raw.py`; the candidate list and its `tie:` lines are the answer |
|
||||
| Method bodies are present but decode as **private opcodes**, and you need the mapping rather than an explanation of why VMP is hard | `vmp-differential-analysis.md`, then `advanced-unpacking.md` for the shape diagnosis |
|
||||
| A store build arrives as `base.apk` + `split_config.*.apk`, or a rebuilt build is refused **as a set** although every file verifies on its own | `split-apk.md` — one keystore across every member for `pm install-multiple`, and check that a merge is legal before trusting a merged single APK |
|
||||
|
||||
_54 row(s) below the header._
|
||||
|
||||
## Reference index (no longer inline in `SKILL.md`)
|
||||
|
||||
|
||||
| File | Load when |
|
||||
|---|---|
|
||||
| `references/recon.md` | Starting any new sample; identifying packer, SDKs, code location, ABI |
|
||||
| `references/server-config-and-updates.md` | **The launch screen, a popup or the tab set is server-sent**; no ad SDK was found; a removed promo came back; anything controlled by a `*Config`/`*Popup` DTO with an `enabled` flag |
|
||||
| `references/byte-level-patching.md` | You want to change behaviour by editing a few bytes rather than rebuilding a method — equal-length patches, locating an instruction's exact offset, dex header integrity fields, branch polarity, verifier legality |
|
||||
| `references/packers.md` | The app is packed/hardened, or an edit makes it die before your code runs. Also load before discarding any route as "blocked by the shell" |
|
||||
| `references/code-virtualization-and-custom-linkers.md` | **No packer, dex is readable, and a re-signed build still dies** — whole classes turned `native`, a private loader with a mismatched SONAME, a self-decrypting payload, a Java-layer "signature killer", and the keep-it/drop-it deadlock |
|
||||
| `references/java2c-and-jni-sinking.md` | **Whole classes are bare `native` declarations and you are about to hunt for a decrypted DEX** — Java2C has none, ever; the code is in a `.so`. Also the JNI boundary: why a `Java_*` symbol search fails silently |
|
||||
| `references/vmp-differential-analysis.md` | Method bodies are present but decode as **private opcodes** — a real Dex VMP: the known-plaintext differential, which links can and cannot be automated, how to *prove* a derived opcode table, smali generation, and when the route is closed |
|
||||
| `references/framework-runtimes.md` | The UI is not native (Flutter / React Native / Unity / Cordova), or Java-layer hooks fire zero times while the UI clearly works |
|
||||
| `references/dart-aot.md` | The logic lives in a Dart AOT snapshot (`libapp.so`): pinning the Dart version, building a matching decompiler, the object pool and reference indexing, register/boolean conventions, locating and patching Dart code |
|
||||
| `references/native-and-so.md` | Patching in a `.so`, needing code to run before the app's own code, hand-built native payloads that crash inside the linker, or **deciding which library/ABI is actually loaded and executing** |
|
||||
| `references/native-tamper-and-suicide.md` | The process dies on its own (no Java stack, or a native crash that looks like a bug); you are about to neutralise a `kill`/`exit`/`abort` path; or a hardened library's sections/function boundaries look wrong |
|
||||
| `references/detection-and-anti-analysis.md` | The app fights back: it dies after you attach, refuses to run, detects root/hook/debugger. **Cost-first (A/B/C), plus the order of search** (see its Step 3 stage funnel) and when to go static |
|
||||
| `references/toolchain.md` | Choosing or invoking tools, something is not installed (including "not on PATH but present on disk"), a tool's output smells wrong, or you need to know which tools exist only as a GUI |
|
||||
| `references/ad-removal.md` | Task involves ads, trackers, sponsored cards, splash/interstitial/reward |
|
||||
| `references/updates-and-forced-upgrade.md` | The patched build must **keep working over time**; the app has any version check, forced-upgrade dialog, self-update installer, or hot-update/resource channel. Load this for essentially every build you intend to ship. |
|
||||
| `references/account-gates.md` | Task mentions "no login required", "don't force sign-in", "skip phone binding", "guest mode"; or a screen/feature is unreachable signed-out. Also load before promising that an account-scoped screen will show anything |
|
||||
| `references/membership-and-limits.md` | Task involves VIP, subscription, paid content, unlock, "fully cracked" |
|
||||
| `references/server-api.md` | The behavior is decided by a response, or you need to know if a patch can even matter |
|
||||
| `references/dex-patching.md` | Any actual editing of dex/smali, choosing a patch layer, choosing a tool |
|
||||
| `references/patch-audit.md` | Proving a patch **landed**, or that it is **legal**: length-vs-bytes comparison, the equal-length blind spot, verifier-level legality (`move-result*` adjacency), text-matching patch traps, and how to report a missing patch |
|
||||
| `references/repack-and-sign.md` | Rebuilding, signing, installing, or a repacked app misbehaves |
|
||||
| `references/signature-derived-keys.md` | The app reads `signatures[0]`/`toCharsString()`, or a rebuilt APK installs and runs but every signed request fails (`sign`/`_p`/`uth` empty or `-1`) |
|
||||
| `references/runtime-data.md` | Local state matters: DataStore, SharedPreferences, SQLite, protobuf caches, tokens — **or your data edit keeps being reverted, or a stored value looks encrypted** |
|
||||
| `references/dynamic-frida.md` | Frida setup, hooking strategy, tracing caller chains, finding the real call site |
|
||||
| `references/environment.md` | Device/emulator setup, root, ADB, networking, offline devices, emulator console control and recovery, **the preflight check to run before every experiment block** |
|
||||
| `references/verification.md` | Defining what "done" means; building the evidence chain |
|
||||
| `references/tls-and-cert.md` | One feature fails at runtime (login, registration, payment, an API-backed screen) while the rest of the app works |
|
||||
| `references/third-party-builds.md` | The input is a "cracked"/"modded" build you did not produce — audit it before trusting it |
|
||||
| `references/long-task-discipline.md` | The task will run long, or you are resuming one. Live record, conclusion grading, drift checkpoints, **deliverable-form drift (rooted-only vs shippable)**, bound-your-waits, **captures-you-never-looked-at**, **long-context decay**, handover |
|
||||
| `references/pitfalls.md` | Always worth a skim before building. This is the failure catalogue. |
|
||||
| `references/rasc-and-droidsaw.md` | You are about to reach for the ASC indexer, or the Python one is the bottleneck: the Rust re-implementation, its measured speedup and **identical class sets**, the one class shape where it silently drops code, and how to build it |
|
||||
| `references/coverage-and-limits.md` | You need to weigh a claim before trusting it: the evidence behind each covered item, the dependencies this skill does not ship, and the record of what was never exercised |
|
||||
| `references/coverage/route-inventory.md` | You must answer "**can this skill actually do X**" in one pass: the per-route inventory — covered by verified mechanism, covered only by a documented route, the dependencies not shipped, and what is out of scope |
|
||||
| `references/evidence-summary.md` | You need to know **whether a route was verified and how strong the evidence is**, from inside an installed copy: capability → one-line conclusion → strength → where the evidence lives |
|
||||
| `references/routing.md` | You want the **whole inventory in one load**: every reference file with when to load it, every script with what it does, and a mirror of the symptom index |
|
||||
| `references/desensitization-and-leak-scans.md` | You are about to publish anything derived from real work — evidence, a transcript, a README — or a leak scan reports a hit: what must be desensitised versus kept, the do-not-anonymize list, and how the scan gates a commit |
|
||||
| `references/precedents/` | You are about to do a kind of work this repository has already converged on — a hardened Flutter target, a zero-event trace, a module whose log is silent, an equal-length patch taken to the device. Positive cases with graded evidence and dead ends |
|
||||
| `references/handoff-boundaries.md` | A routing decision is about to cross into another discipline: the JNI form table, the packer-versus-loader split, and what "verified" means for each of G1's four deliverable forms |
|
||||
| `references/advanced-unpacking.md` | **The dump landed but the bodies are empty** (an extraction shell), or decode as private opcodes: the stub-ratio measurement, why FART's hooks died on Android 12-16, the root-side dump, its **boundary**, the layered descent, and where recovery stops |
|
||||
| `references/lsposed-and-modules.md` | The client-side logic is reachable but **a rebuilt APK is refused**: delivering a system-level hook module instead, its gradle-free build chain, scope configuration and verification, and the layer a Java module cannot reach |
|
||||
| `references/emulation-and-rpc.md` | You need to **call** a routine rather than change the app — a signing routine, a token, a cipher: emulated execution (Unidbg/Unicorn) with its environment-filling cost, versus service-ifying the live function over Frida RPC |
|
||||
| `references/native-dbi-and-deobfuscation.md` | A native function is an OLLVM state machine, or you need instruction-level execution evidence: Stalker traces, the trace-to-CFG route, the Stalker/QBDI/emulation decision, and the **measured** zero-event and crash boundaries |
|
||||
| `references/protocol-reverse.md` | The traffic is protobuf without a schema, gRPC, or QUIC/HTTP3; or a proxy sees TLS while the app still fails — schema recovery, frame capture, and native-side certificate pinning (Flutter/BoringSSL) with its boundaries |
|
||||
| `references/kernel-and-environment-hardening.md` | Userspace hooking provably cannot reach the check — raw `svc` syscalls, `init_array`-early detection: what each layer can still do, the kernel-route map with its version gate, and when escalating is wrong |
|
||||
| `references/on-device-tooling.md` | Working **from the phone itself**: MT Manager edit/repack/sign and its built-in APK MCP, LSPosed Manager, Termux+frida, and on-device data inspection |
|
||||
| `references/split-apk.md` | The target is a **split APK / App Bundle set** (`base.apk` + `split_config.*.apk`), or `pm path <PKG>` returned several files: reading a set, merge versus unified re-signing, and the install refusal each mistake produces |
|
||||
|
||||
_43 row(s) below the header._
|
||||
|
||||
## Script index (no longer inline in `SKILL.md`)
|
||||
|
||||
|
||||
| Script | Purpose |
|
||||
|---|---|
|
||||
| `scripts/doctor.py` | **Run this first.** Script inventory is scanned from the directory (`registered/checked = N / N`, unjudgeable entries counted as `unknown`, never skipped) and capability is resolved as a real requires-closure — so it reports BLOCKED with the missing piece and an installable next step instead of inferring "can re-sign" from the presence of a JVM |
|
||||
| `scripts/dexutil.py` | **Dependency-free dex reader**: structural walk, exact instruction decode, `fix_dex_header`/`verify_dex_header` in the correct order, branch/operand helpers. Shared by the dex scripts; also dumps one method with offsets standalone |
|
||||
| `scripts/dex_find_insn.py` | Locate an instruction by **decoded semantics** and get its exact byte offset, with context and both sides of any branch. This is how you find a patch site without guessing offsets or scraping listings |
|
||||
| `scripts/dex_patch_bytes.py` | Apply **equal-length byte patches** from a JSON spec: semantic match, polarity pin via `expect_next`, equal-length enforcement, verifier legality, dex header recompute, re-decode to prove the edit landed. `--dry-run` first |
|
||||
| `scripts/dex_check_verifier.py` | Tier-3 check: does any **conditional branch target a `move-result*`** (bypassing its producer, so the class fails to load)? Compares two builds and distinguishes pre-existing findings from regressions your patch introduced |
|
||||
| `scripts/coldstart.py` | Cold-launch and capture a **timed screenshot burst + logcat signals + installed-build facts + launch timing**, and warn when the foreground activity is not your app |
|
||||
| `scripts/smtool.py` | baksmali/smali wrapper with a bundled classpath (assemble/disassemble dex) |
|
||||
| `scripts/patch_smali.py` | Method-body replacement in a smali tree, matched by signature |
|
||||
| `scripts/dex_strpatch.py` | Byte-level string constant patch with **string_ids ordering guard** |
|
||||
| `scripts/dex_classdiff.py` | Compare two dex class tables (set + access flags) to prove a patch was surgical |
|
||||
| `scripts/dex_strings.py` | Dump/extract strings and endpoints from dex without a decompiler |
|
||||
| `scripts/dart_pprefs.py` | Build/query the object-pool offset -> code-site index for a Dart AOT snapshot (arithmetic decode; seconds, not minutes) |
|
||||
| `scripts/dart_pool_strings.py` | Recover string literals from a Dart AOT snapshot: framed entries, the one-byte vs UTF-16 split, file offsets, and a run-length noise filter |
|
||||
| `scripts/dart_disasm.py` | Annotated windowed disassembly of Dart AOT code (pool + boolean annotations) plus a B/BL caller index |
|
||||
| `scripts/find_refs.py` | Count and list callers of a method/field (blast-radius check). Takes a smali tree, a `.dex`, a directory of either, or an `.apk`. Always prints what it scanned, so "the input was unreadable" cannot be mistaken for "nothing references it" |
|
||||
| `scripts/repack.py` | Rebuild an APK: replace dex, strip only signatures, keep `META-INF/services/`, write a **4-byte-aligned** archive, sign, verify. Also **split sets**: `--split-dir`, `--split-mode resign`/`merge`, `--signer auto/jar/apksigner` |
|
||||
| `scripts/dexpatch/` | dexlib2 method-level rewriter (for changes that genuinely need new instructions) + build notes |
|
||||
| `scripts/devsh.py` | Quoting-safe ADB shell helper for rooted devices |
|
||||
| `scripts/usb_net_proxy.py` | Give an offline device network over USB (adb reverse + local proxy) |
|
||||
| `scripts/datastore_inject.py` | Encode/inject AndroidX DataStore preferences (protobuf) safely |
|
||||
| `scripts/probe_api.py` | Probe an app's HTTP API with correct headers, report status/shape |
|
||||
| `scripts/install_test.py` | Install a build and run a launch/health check with logcat signal extraction |
|
||||
| `scripts/frida_probe.js` | Four-layer runtime probe: app network layer + OkHttp + java.net + swallowed exception messages |
|
||||
| `scripts/run_probe.py` | Inject a probe, stream it to a timestamped log file, stay resident while you operate the app |
|
||||
| `scripts/tls_check.py` | Strict certificate check for one or more hosts (expired / wrong host / untrusted CA) |
|
||||
| `scripts/preflight.py` | Read-only environment check before every experiment block: device, root, ABI/translation, clock skew, leftover proxy/forwards, dead device server. Run this before blaming a patch. |
|
||||
| `scripts/lib_map.py` | What is **actually mapped** into a live process: per-library path, base, architecture (`ELF e_machine`), and classification (system / from-APK / runtime-materialized). Answers "which library and which ABI is really executing". |
|
||||
| `scripts/elf_plt.py` | Resolve a PLT stub to its imported symbol (x86_64 + aarch64) from the **relocation table**, list a symbol's callers, and **byte-diff two libraries naming the symbol each changed stub belongs to**. Run before patching any stub |
|
||||
| `scripts/so_constpatch.py` | Same-length rewrite of an isolated string constant, for **redirecting a library load instead of defeating a check**. Enforces equal length, refuses substrings of longer identifiers, reports constant-pool hits, patches inside an APK or a bare `.so`. **APK rebuild preserves per-entry zip metadata** (STORE stays STORE, `extra`/`external_attr`/`create_system` untouched, `resources.arsc` STORED and 4-byte aligned) and **refuses by default** when `extractNativeLibs="false"` or the arsc is not STORED+aligned — pointing at `repack.py`; only `--unsafe-rebuild` forces it |
|
||||
| `scripts/apk_diff.py` | Entry-level diff of two APKs by content hash: changed / **added** (injection candidates) / removed. Audits a third-party build and proves your own was surgical |
|
||||
| `scripts/native_crash.py` | Locate a native death from a log or tombstone: signal, fault address, registers, frames split app vs system, the faulting instruction — plus a flag when the fault looks **arranged** rather than accidental |
|
||||
| `scripts/grab_crash.py` | Recover a stack that a crash-reporter SDK swallowed, when the log shows the app died but prints no backtrace of its own. |
|
||||
| `scripts/blob_decode.py` | Decode an opaque stored value by searching the parameter space (base64/base64url/hex × rotation × deflate/zlib/gzip) instead of guessing, then re-encode an edited payload with the same parameters. |
|
||||
| `scripts/snap.py` | Bounded burst screenshot + control-tree capture, with a stall detector and an explicit verdict on whether the accessibility tree is usable at all. Use it so you *look* at the screen instead of driving blind. |
|
||||
| `scripts/sig_probe.py` | Find the exact `signatures[0].toCharsString()` value: offline candidates from an APK (`--apk`), or the authoritative read from a live package (`--live`) |
|
||||
| `scripts/spawn_patch_detach.py` | **Spawn under a probe, detach, then drive the UI.** Under spawn the Activity stack often never comes up; memory writes survive detach while hooks do not. Ordering matters: resume *before* waiting for `PATCHED` |
|
||||
| `scripts/hook_patch_only.js` | The minimal probe for `spawn_patch_detach.py`: neutralise one native death site by offset and report `PATCHED`. `MODULE_NAME`, `FILE_OFFSET`, `PATCH_BYTES`; the replacement must be an equal-length "return" epilogue |
|
||||
| `scripts/dex_dump_validate.py` | Dedupe, validate and rank dumped dex images: sha256 grouping, header integrity, extraction-shell discrimination via the **trivial-body ratio** (bimodal, not a threshold — `advanced-unpacking.md`), ranking, `--json`, `--trim` for page-aligned captures |
|
||||
| `scripts/dex_mem_scan.py` | Find embedded dex images in memory captures and extract each at the size **its own header declares**: chunked scanning, magic validation, sha256 dedupe, `--dump DIR`, `--keep-partial` |
|
||||
| `scripts/lsposed_scaffold.py` | Generate a minimal LSPosed/Xposed **module project skeleton**: manifest with the xposed meta-data, `assets/xposed_init`, the hook class, and build notes for a gradle-free toolchain (javac → d8 → aapt2 → zipalign → apksigner) |
|
||||
| `scripts/frida_rpc_serve.py` | Bridge a Frida script's `rpc.exports` to a local caller with reconnect handling, so a live native function can be **called** instead of reversed |
|
||||
| `scripts/rpc_template.js` | The editable companion to `frida_rpc_serve.py`: an `rpc.exports` skeleton plus a native-function call placeholder |
|
||||
| `scripts/stalker_trace.js` | Instruction-level tracing with Frida Stalker: configurable module/offset targets, trigger selection, the event stream, output-size rules, and `transform` customisation |
|
||||
| `scripts/stalker_report.py` | Reduce a `stalker_trace.js` log into block histograms and call sequences, and print an explicit diagnostic for the measured **zero-event** case |
|
||||
| `scripts/mt_mcp_probe.py` | Probe MT Manager's on-device APK MCP (Streamable HTTP, `127.0.0.1:8787/mcp`): JSON-RPC handshake plus grouped `mt_apk_*` tool inventory; prints start-it-by-hand instructions and exits 2 while the service is down |
|
||||
| `scripts/java2c_probe.py` | The evidence that separates **Java2C** from an extraction shell, a VMP and JNI sinking: per-class `native` density and stub ratio from the dex, plus `Java_*` / `JNI_OnLoad` / registration evidence from the `.so` |
|
||||
| `scripts/protobuf_decode_raw.py` | **Schema-free protobuf decode**: hex / file / stdin to a JSON tree, every length-delimited field as a candidate set with ties labelled; `--reencode --check` for a byte-exact round trip |
|
||||
| `scripts/vmp_diff_harness.py` | Differential hardening for Dex-VMP: build a labelled opcode-coverage fixture (**218 of 224 opcodes, measured**), derive a candidate private-opcode map from an original/hardened dex pair, verify it in a closed loop, render smali |
|
||||
| `scripts/kernelsu_syscall_mask.py` | Generate a KernelSU/APatch syscall-masking scaffold: an installable userspace module plus KPM/LKM/eBPF kernel-side templates, each with its version gate and an explicit unverified label. **A userspace module cannot change a syscall return value** |
|
||||
| `scripts/capabilities.py` | The capability registry `doctor.py` reads: per capability the requires-closure, what is present, what is missing, an install hint and an estimated cost — so "can this machine do X" is computed rather than guessed |
|
||||
| `scripts/device_shell.py` | Shared device-side command construction: POSIX single-quote escaping for `su -c`, argv arrays rather than string concatenation, and Android identifier validation (`--pkg`, components) so a malformed value is rejected here instead of being parsed by the device shell |
|
||||
| `scripts/rasc_build.py` | Build and verify **rasc**, the Rust re-implementation of ASC: `--check` what is present, `--build` clone + cargo (no release asset exists), `--verify <apk>` compares its class-definition set against the Python `droidasc` and fails on any difference |
|
||||
| `scripts/scan_leaks.py` | Scan a repository for target identity before it is published: bundle ids in manifest/`pm`/`ps` contexts, serial-shaped tokens, PATs, inline appkey assignments, literal endpoints, host user paths. Built-in do-not-anonymize exemptions, context-bearing findings, `--show-exempt` prints why a hit was suppressed, `--fail-on strong\|any`, exit 0/1/2 with a `RESULT=` token |
|
||||
| `scripts/svc_scan.py` | Name the syscall behind an inline `svc` and the segment it sits in — which decides whether a libc-level hook can observe the call at all. `--context` shows neighbours, because a byte scan also matches data |
|
||||
| `scripts/anti_detect_probe.js` | Observer-only probe (patches nothing): path/loader/thread/kill hooks with **caller module + offset**, an environment self-report (`TracerPid`, frida-named mappings), and live streaming so a sub-second target still yields evidence |
|
||||
|
||||
_53 row(s) below the header._
|
||||
@@ -0,0 +1,255 @@
|
||||
# Runtime data — DataStore, SharedPreferences, SQLite, protobuf
|
||||
|
||||
Sometimes the cheapest fix is not code at all, but a value in app data. And sometimes that is exactly the wrong fix, because it does not survive a fresh install (`pitfalls.md` P11).
|
||||
|
||||
|
||||
**Load this when:** the cheapest fix looks like a stored value rather than code -- DataStore, SharedPreferences, SQLite, a protobuf cache, a token. It gives how to edit each safely, and how to tell a value the app rewrites from one it keeps.
|
||||
|
||||
## Decide first: data or code?
|
||||
|
||||
| Question | Answer |
|
||||
|---|---|
|
||||
| Does the deliverable need to work after a clean install? | Then it must be **code**, not data |
|
||||
| Are you iterating on your own device? | Data edits are a fast way to test a hypothesis **before** writing the patch |
|
||||
| Is the value written by the app itself (a token, a cache, a counter)? | Data — and the server may re-assert it |
|
||||
|
||||
**Recommended workflow:** use a data edit to *prove the hypothesis* (cheap), then implement the same effect in code (durable), then verify with a clean install.
|
||||
|
||||
## Where app data lives
|
||||
|
||||
```
|
||||
/data/data/<pkg>/ (== /data/user/0/<pkg>)
|
||||
├── files/
|
||||
│ ├── datastore/*.preferences_pb AndroidX DataStore (protobuf)
|
||||
│ ├── mmkv/<name> MMKV (Tencent) — one file per named store
|
||||
│ └── ...
|
||||
├── shared_prefs/*.xml SharedPreferences
|
||||
├── databases/*.db SQLite (Room, etc.)
|
||||
└── <sdk caches>/ crash logs, download state, ad configs
|
||||
```
|
||||
|
||||
Read with root:
|
||||
```bash
|
||||
adb shell "su -c 'ls -la /data/data/<pkg>/files/datastore/'"
|
||||
adb shell "su -c 'xxd /data/data/<pkg>/files/datastore/<name>.preferences_pb | head -20'"
|
||||
```
|
||||
|
||||
## AndroidX DataStore (protobuf)
|
||||
|
||||
Format:
|
||||
```proto
|
||||
PreferenceMap { map<string, Value> preferences = 1; }
|
||||
Value {
|
||||
oneof value {
|
||||
bool boolean = 1; float float = 2; int32 integer = 3; int64 long = 4;
|
||||
string string = 5; StringSet string_set = 6; double double = 7; bytes bytes = 8;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Encoding an entry requires TWO tag levels:**
|
||||
```
|
||||
outer (map field 1, wire type 2) : 0A <len(inner)>
|
||||
inner (one map entry) : 0A <len(key)> <key> 12 <len(value)> <value>
|
||||
value payload (e.g. int64) : 20 <varint> # field 4, wire type 0
|
||||
value payload (e.g. int32) : 18 <varint> # field 3, wire type 0
|
||||
value payload (e.g. bool) : 08 <0x00|0x01> # field 1, wire type 0
|
||||
value payload (e.g. string) : 2A <len> <utf8> # field 5, wire type 2
|
||||
```
|
||||
|
||||
Omitting the **outer** tag produces a file the app cannot deserialize, and the failure is usually a **stack-less crash** (`pitfalls.md` P8). Use `scripts/datastore_inject.py` rather than hand-rolling it.
|
||||
|
||||
Procedure:
|
||||
1. `am force-stop <pkg>` (DataStore caches in memory and writes back)
|
||||
2. Write the file (as root), preserving ownership — `cp -f` over the existing file keeps its owner
|
||||
3. `restorecon` if SELinux is enforcing
|
||||
4. Start the app and observe
|
||||
|
||||
Verify by reading the file back with `xxd` before launching.
|
||||
|
||||
## SharedPreferences (XML)
|
||||
|
||||
Simple XML. Edit with the app stopped. Types are explicit (`<boolean>`, `<int>`, `<long>`, `<string>`). Same ownership rules.
|
||||
|
||||
## MMKV (Tencent)
|
||||
|
||||
Common in apps whose SDK stack is Chinese-market. One file per named store under `files/mmkv/`,
|
||||
with a `.crc` companion. It is **not** a database and **not** XML — it is a small custom binary
|
||||
format:
|
||||
|
||||
```
|
||||
offset 0 uint32 actual_size size of the data region
|
||||
offset 4 uint32 crc32 over the data region
|
||||
offset 8 data region repeated: varint key_len, key, varint val_len, value
|
||||
```
|
||||
|
||||
Files are preallocated, so trailing zeroes are normal; `actual_size` is what matters.
|
||||
|
||||
Parsing is mechanical, and no library is needed:
|
||||
|
||||
```python
|
||||
import struct
|
||||
|
||||
def parse(blob):
|
||||
size = struct.unpack_from('<I', blob, 0)[0]
|
||||
pos, end, out = 8, 8 + size, []
|
||||
|
||||
def varint(p):
|
||||
v = s = 0
|
||||
while True:
|
||||
b = blob[p]; p += 1
|
||||
v |= (b & 0x7F) << s
|
||||
if not b & 0x80:
|
||||
return v, p
|
||||
s += 7
|
||||
|
||||
while pos < end:
|
||||
klen, pos = varint(pos); key = blob[pos:pos + klen]; pos += klen
|
||||
vlen, pos = varint(pos); val = blob[pos:pos + vlen]; pos += vlen
|
||||
out.append((key, val))
|
||||
return out
|
||||
```
|
||||
|
||||
Writing a change back means rebuilding the region and **recomputing both header fields**:
|
||||
|
||||
```python
|
||||
import struct, zlib
|
||||
|
||||
body = new_region_bytes
|
||||
blob = bytearray(capacity) # keep the original file length
|
||||
struct.pack_into('<I', blob, 0, len(body))
|
||||
struct.pack_into('<I', blob, 4, zlib.crc32(body) & 0xFFFFFFFF)
|
||||
blob[8:8 + len(body)] = body
|
||||
```
|
||||
|
||||
Cautions:
|
||||
|
||||
- **A wrong crc32 makes the library throw the whole store away.** The visible symptom is "my
|
||||
edit did nothing" or "the app reset every setting", never an error.
|
||||
- **Values are raw bytes**, not always text. JSON is common; a serialized protobuf or a small
|
||||
encrypted payload is also common (`scripts/blob_decode.py` searches the framing).
|
||||
- **The in-memory copy wins until the process restarts.** `am force-stop` before editing and
|
||||
re-launch after, or the running app overwrites your change on exit.
|
||||
- **Do not delete the `.crc` companion.** Leave it in place when hand-editing the store; it is
|
||||
validated and regenerated by the library.
|
||||
|
||||
## SQLite
|
||||
|
||||
```bash
|
||||
adb shell "su -c 'sqlite3 /data/data/<pkg>/databases/<db>.db \".tables\"'"
|
||||
adb shell "su -c 'sqlite3 ... \"select * from <table> limit 5;\"'"
|
||||
```
|
||||
Useful for: auth sessions (tokens), user profile caches (often a raw JSON blob), and any server state the app persists. A JSON column often contains the exact server payload — the fastest way to learn field names.
|
||||
|
||||
## Ownership — the silent killer
|
||||
|
||||
The app runs as its own uid. A file written by root with the wrong owner is unreadable, and directory permissions are `drwx------`.
|
||||
|
||||
```bash
|
||||
uid=$(adb shell "dumpsys package <pkg> | grep userId=" | tr -dc '0-9')
|
||||
adb shell "su -c 'chown -R $uid:$uid /data/user/0/<pkg>'"
|
||||
adb shell "su -c 'restorecon -R /data/user/0/<pkg>'"
|
||||
```
|
||||
Symptom of getting this wrong: crash in a database-init path right after launch, often `Cannot open database ... Directory ... doesn't exist`.
|
||||
|
||||
Note: after every reinstall the app's uid **increments**, so a restored data directory needs this again.
|
||||
|
||||
## Introducing a value that does not exist yet
|
||||
|
||||
If the key is absent, the app uses a default. Two options:
|
||||
|
||||
1. **Add the key** with the exact protobuf shape above.
|
||||
2. **Confirm the default first** — sometimes removing a key already yields the behavior you want (e.g. clearing an expiry so it reads as "not set").
|
||||
|
||||
## Making the fix durable
|
||||
|
||||
If the effect must ship inside an APK, move it into code. Patterns, in order of preference:
|
||||
|
||||
- **Patch the read path** so the value is always the desired one. Find where the Flow/getter is built and make it emit a constant.
|
||||
- **Patch the decision**, not the data: find the comparison that consumes the value and make it resolve the way you want.
|
||||
- **Do not patch the generic encoder/boxer** used by the whole app (`pitfalls.md` P6).
|
||||
|
||||
Reference case: a promo popup was gated by `<key>_expires_at`. Writing a large value via DataStore suppressed it on the test device, but a fresh install lost it. The durable fix replaced the dedicated map-lambda that produces the value so it always yields a far-future timestamp — a single-purpose class, safe to patch, no effect on the shared serialization helpers.
|
||||
|
||||
## When the app rewrites your edit
|
||||
|
||||
A very common sequence: you change the value, relaunch, and the value is **back** — often byte for
|
||||
byte identical to what it was before. That is not a failed write. It is the app re-establishing the
|
||||
value from a source of truth you have not touched yet.
|
||||
|
||||
Two causes look identical and need different fixes:
|
||||
|
||||
| Cause | How to tell | Fix |
|
||||
|---|---|---|
|
||||
| The app **re-fetches from the server** and re-persists | take the app offline and relaunch: does your value survive? | lock the file, block the refresh request, or move the change into code |
|
||||
| The app **rewrites the file on every start** from its own defaults | your value does not survive even with the network down | the value is not authoritative — patch the read path instead |
|
||||
|
||||
Do the offline test first. It costs one launch and separates the two cases.
|
||||
|
||||
### Making a data edit stick (in order of durability)
|
||||
|
||||
1. **Immutable file attribute** — the reliable, reversible way to stop a rewrite:
|
||||
```bash
|
||||
su -c "chattr +i <path/to/prefs.xml>"
|
||||
su -c "lsattr <path/to/prefs.xml>" # expect the 'i' flag, e.g. -----i-------
|
||||
# to undo:
|
||||
su -c "chattr -i <path/to/prefs.xml>"
|
||||
```
|
||||
Verify with `lsattr`, not by assuming the command worked.
|
||||
|
||||
**Why permissions do not achieve this.** Tightening the mode or changing the owner looks like the
|
||||
obvious move and does not work, because the app does not open-and-write the existing file — it
|
||||
**deletes the file and creates a new one**. The new file is created by the app, with the app's own
|
||||
mode and owner, so a restrictive `chmod` or a `chown root` is simply not inherited. The immutable
|
||||
attribute is enforced by the filesystem against the delete itself, which is why it holds.
|
||||
|
||||
2. **Keep the app offline** so the refresh source is unreachable. Effective for a test, and
|
||||
sometimes acceptable in use — but be explicit about it, because a fix that only works offline
|
||||
fails the "works under normal conditions" constraint. See the deliverable-drift section in
|
||||
`long-task-discipline.md` before presenting this as a result.
|
||||
|
||||
3. **Block the refresh request** at the runtime or network layer — durable while your instrumentation
|
||||
is running, and requires you to have identified the endpoint (`references/server-api.md`).
|
||||
|
||||
4. **Move the change into code** — patch the read path or the decision. This is the only form that
|
||||
ships inside an APK (`§Making the fix durable` above).
|
||||
|
||||
### Two cautions about locking
|
||||
|
||||
- **Verify the feature, not the value.** Blocking a write the app depends on can make it misbehave
|
||||
or fail loudly. After locking, exercise the feature you care about — "the file still has my value"
|
||||
is not the same as "the app still works".
|
||||
- **A lock is device state; it does not travel with an APK.** If the recipe requires it, record it as
|
||||
an environment requirement, so nobody later mistakes it for something baked into the artifact.
|
||||
|
||||
## Encoded values: do not assume "encrypted"
|
||||
|
||||
Preference values that carry server configuration are frequently stored as a single opaque string
|
||||
rather than readable XML. Before concluding the value is encrypted, check whether it is merely
|
||||
**framed**: a common shape is `base64( rotate( deflate( json ) ) )`, where the cyclic rotation exists
|
||||
precisely so a naive decode fails.
|
||||
|
||||
`scripts/blob_decode.py` searches the parameter space instead of guessing it — outer encoding
|
||||
(base64 / base64url / hex), rotation offset, and compression type — and reports every combination
|
||||
that yields a structured document, plus the exact parameters to re-encode your edited payload:
|
||||
|
||||
```bash
|
||||
# pull the value straight out of a preferences XML and decode it
|
||||
python scripts/blob_decode.py --prefs-xml prefs.xml --name <key> # (see --help for exact name)
|
||||
|
||||
# after editing the decoded payload, rebuild the value with the winning parameters
|
||||
python scripts/blob_decode.py --encode --file decoded.bin --outer base64 --inner raw --cut <N>
|
||||
```
|
||||
|
||||
The rotation search is cheap — a few tens of thousands of candidates resolve in well under a second
|
||||
— so there is no reason to guess. **Distinguish framing from real encryption before spending time
|
||||
on a key:** a framed blob becomes structured the moment you remove the framing, whereas a keyed blob
|
||||
stays random-looking. If it stays random, treat it as opaque and move to the code path that consumes
|
||||
it.
|
||||
|
||||
## Cautions
|
||||
|
||||
- Editing data while the app is running is unreliable: in-memory caches win. Force-stop, edit, then launch.
|
||||
- The server may overwrite your value on next sync — see *When the app rewrites your edit* above.
|
||||
- Do not edit a token you do not own and expect it to be accepted; tokens are validated server-side (`references/server-api.md`).
|
||||
- A restored data directory needs ownership fixed again after every reinstall, because the uid increments.
|
||||
@@ -0,0 +1,94 @@
|
||||
# Server-side analysis — deciding whether a patch can even matter
|
||||
|
||||
If the behavior you want to change is produced by a response, patch the client all you like: nothing real changes. Establishing server authority early is the cheapest high-value step in any "crack this" task.
|
||||
|
||||
## Harvest the API surface from the client
|
||||
|
||||
```bash
|
||||
python scripts/dex_strings.py <dex_dir> --urls
|
||||
python scripts/dex_strings.py <dex_dir> --find 'https?://'
|
||||
```
|
||||
|
||||
Collect: base URL(s), path constants, auth headers, and any dynamic-gateway mechanism.
|
||||
|
||||
Two patterns to watch for:
|
||||
|
||||
- **Static base URL** — straightforward.
|
||||
- **Dynamic gateway** — the real host is fetched at runtime (commonly a DNS TXT record over DoH, or a small config endpoint). The hardcoded host may be a fallback. Symptom: requests go to a host that differs from anything in the dex.
|
||||
```bash
|
||||
# TXT lookup, the usual shape:
|
||||
# https://<doh-provider>/resolve?name=<domain>&type=txt
|
||||
# returns something like "https://<gateway-host>"
|
||||
```
|
||||
This is an anti-blocking technique; it does not block repacking, but it means your static host guess may be stale.
|
||||
|
||||
## Reproduce requests faithfully
|
||||
|
||||
```bash
|
||||
python scripts/probe_api.py --base <base_url> --path /some/path --header 'X-App-Name: <value>'
|
||||
```
|
||||
|
||||
Rules that make the difference between 403 and 200:
|
||||
|
||||
- **Send the app's real headers.** A single custom header (e.g. an app-name header) frequently decides whether a WAF lets the request through. The header value is a string constant in the dex.
|
||||
- **`User-Agent` matters** — often `okhttp/x.y.z` or a runtime-specific default (e.g. `ktor-client`).
|
||||
- **Watch for a CDN/WAF in front.** A 403 with an HTML body naming a WAF means your request shape is wrong, not that the endpoint is gone. Compare with what the app sends (capture it from the device).
|
||||
- **Beware your own egress.** If your host goes through a proxy/VPN, a WAF may block by geography while the device succeeds. Do not conclude "the server rejects the app" from a host-side 403.
|
||||
- **`/health`-style endpoints are gold** — a 200 with a small JSON body proves the backend and its response envelope are alive before you debug anything else.
|
||||
|
||||
## Response envelope
|
||||
|
||||
Most apps wrap responses uniformly, e.g. `{"code":0,"msg":"","data":{...}}`. Learn it once, then read endpoints by shape rather than guessing:
|
||||
|
||||
- `code != 0` + a message → business error, and **the message often enumerates valid inputs** (this is how an ad `position` whitelist was discovered).
|
||||
- `data:null` with HTTP 200 → soft failure.
|
||||
- `401` with a null envelope → auth gate.
|
||||
|
||||
## Prove who owns the gate
|
||||
|
||||
Run this matrix and record it. It is the single most decisive artifact for a paywall or entitlement question.
|
||||
|
||||
| Request | Expected if client-owned | Expected if server-owned |
|
||||
|---|---|---|
|
||||
| protected endpoint, no credentials | would still succeed | **401 / 403** |
|
||||
| same, with a forged token | would still succeed | **401 / 403** |
|
||||
| metadata endpoint | — | 200, often **omitting** the protected field |
|
||||
| list/config endpoints | — | 200 with data |
|
||||
|
||||
If the valuable field is simply **absent** from an otherwise-200 metadata response, no client change can invent it. Say so, with the evidence.
|
||||
|
||||
## Also test the *unknown input* case
|
||||
|
||||
When you plan to change a request parameter (a path, a `position`, an id), probe what the server does with an **invalid** value first:
|
||||
|
||||
- A `400` with a validation message tells you the field is whitelisted, and hands you the full list of valid values.
|
||||
- A `404` tells you the path is exact.
|
||||
|
||||
**This matters before you patch**: redirecting a parameter to an invalid value may produce an error status that the client treats as fatal (`pitfalls.md` P5). Knowing the failure mode in advance prevents breaking the app.
|
||||
|
||||
## Behaviors that help you and behaviors that do not
|
||||
|
||||
**Repack-friendly (do not block you):**
|
||||
- Minimal auth: `Authorization: Bearer <token>` and a static app-name header, with **no request signing, no nonce, no HMAC**.
|
||||
- No client-certificate pinning to the app's own signature.
|
||||
|
||||
**Repack-hostile (will break a re-signed client):**
|
||||
- Signature/certificate checked server-side, or pinned in native code.
|
||||
- Request signing derived from the APK signature — the client computes with its own
|
||||
certificate, so re-signing changes the key. Silent and easy to misdiagnose; detection,
|
||||
extraction and the differential test are in `references/signature-derived-keys.md`.
|
||||
- Device attestation (Play Integrity / SafetyNet, OEM attestation).
|
||||
|
||||
Determine which you face **before** shipping a repack. A client that authenticates fine on first launch but silently fails features later is the classic signature-validation symptom — and when computed signing parameters (`sign`, `_p`, `uth`) evaluate to `-1` or empty, it is the signature-derived-key case rather than a server decision.
|
||||
|
||||
## What to record
|
||||
|
||||
```
|
||||
base url / gateway mechanism
|
||||
required headers (exact values)
|
||||
auth mechanism (token? signed? attested?)
|
||||
response envelope shape
|
||||
endpoints actually used, and their auth requirement
|
||||
protected-asset endpoints, with status codes for: none / forged / (real if available)
|
||||
invalid-input behavior for any parameter you plan to change
|
||||
```
|
||||
@@ -0,0 +1,160 @@
|
||||
# Server-driven UI config — promos, popups, tab bars, and remote re-enable
|
||||
|
||||
Many apps ship a **remote configuration endpoint** that decides what the client
|
||||
renders: a launch screen image, a popup, an announcement, which tabs exist. This is
|
||||
the single most common shape of "ad" in a modern app, and the one most often
|
||||
mis-diagnosed, because there is no ad SDK anywhere in the package to find.
|
||||
|
||||
Two consequences that change the whole plan:
|
||||
|
||||
- **Searching for an ad SDK finds nothing, and that is arithmetically correct.** No
|
||||
amount of further SDK hunting will help. Recognise the shape early and stop.
|
||||
- **The behaviour can be re-enabled server-side without shipping a new client.**
|
||||
Any patch that just leaves the config unread is a patch the operator can undo.
|
||||
The durable fix is in the client's decision path, not in the data.
|
||||
|
||||
|
||||
**Load this when:** a launch screen, popup, announcement or tab set must go, and there is no SDK to find. It gives the server-issued-config shape, how to tell it from a client-side flag, and the remote re-enable that undoes your patch.
|
||||
|
||||
## 1. Two-layer fetch: local default, then remote override
|
||||
|
||||
The most useful diagnostic — and it is a runtime observation, not a static one — is
|
||||
that the config object **changes value once during startup**:
|
||||
|
||||
```
|
||||
before the network round-trip: SplashConfig(enabled=true, duration=5, image=<built-in drawable>)
|
||||
~1s later (config response): SplashConfig(enabled=true, duration=5, image=https://<cdn>/<promo>.png)
|
||||
```
|
||||
|
||||
Seeing this pair proves three things at once: the screen is config-driven, the
|
||||
built-in asset is only an offline fallback, and the content actually shown came
|
||||
from the server. **Capture both states before designing any patch** — a
|
||||
hook on the config type's constructor/factory plus its `toString()` is usually
|
||||
enough (data-class `toString()` keeps the real field names even after R8, which is
|
||||
why it is such a convenient anchor).
|
||||
|
||||
If you only ever see the built-in default, you are looking at an offline or
|
||||
pre-config state and have not observed the real behaviour yet.
|
||||
|
||||
## 2. Locate the config surface
|
||||
|
||||
The DTOs are usually the easiest anchor in the entire app, because data classes
|
||||
serialise their own field names.
|
||||
|
||||
```
|
||||
ddc findrefs app.apk string "SplashConfig" # or any of the field names below
|
||||
ddc strings app.apk -f "enabled" --with-locations
|
||||
```
|
||||
|
||||
Field names worth grepping, in the plural because they recur:
|
||||
|
||||
```
|
||||
splash, splashImage, splashSkip, splashLogo, launchScreen, bootAd
|
||||
noticePopup, announcementPopup, updatePopup, forceUpdate, upgradeDialog
|
||||
tabbar, tabs, items, enabled, showMode, startAt, endAt, showOnce
|
||||
promo, promotion, banner, bannerList, featured, sponsored
|
||||
```
|
||||
|
||||
A `*Config` / `*Popup` / `*Banner` suffix with an `enabled` boolean is the signal
|
||||
you are in this layer. **The `enabled` flag's polarity is not guaranteed by its
|
||||
name** — see.
|
||||
|
||||
Then find the **consumption point**, which is where you patch: the method that
|
||||
reads the flag and decides. The DTO itself is usually immutable and shared, so
|
||||
editing it is both fragile and unnecessary.
|
||||
|
||||
## 3. Patch the decision, not the data
|
||||
|
||||
Ordered from most to least durable:
|
||||
|
||||
| Approach | Durable? | Why |
|
||||
|---|---|---|
|
||||
| Neutralise the client-side branch that consumes `enabled` | **yes** | The server can send anything; the client no longer acts on it |
|
||||
| Force the config field to its "off" value at parse time | partly | Works until a second parse path or a cache exists |
|
||||
| Block or 404 the config endpoint | **no** | Usually breaks the screen that shares the request (see `pitfalls.md` P5) |
|
||||
| Delete the local fallback asset | no | Only affects the offline case |
|
||||
|
||||
**How to neutralise a branch correctly** is in `byte-level-patching.md`: replace the
|
||||
conditional branch with `nop`s so execution takes the fall-through path, rather
|
||||
than redirecting the branch. Forcing a specific side by redirection adds a control
|
||||
flow edge and can trip the verifier.
|
||||
|
||||
**Mandatory before editing: decode both sides of the branch and write down what
|
||||
each one does.** Field names lie. In one real case the field was named `enabled`,
|
||||
and the branch structure was:
|
||||
|
||||
```
|
||||
iget-boolean v, obj -> Config.enabled
|
||||
if-nez v, :countdown ; enabled == false jumps AWAY
|
||||
invoke startMainScreen() ; fall-through: straight into the app
|
||||
:countdown ... ; branch target: show the promo for N seconds
|
||||
```
|
||||
|
||||
so `enabled == true` was the value that **skipped** the promo. Taking the name at
|
||||
face value and "enabling the skip" produces a build that shows the ad on every
|
||||
launch — and it starts cleanly, so nothing catches it until someone looks at the
|
||||
screen.
|
||||
|
||||
## 4. Decide the scope of "remove" before you edit
|
||||
|
||||
Serve-side config usually controls several things at once, and they are not all
|
||||
ads. Classify each field the same way you would a UI element:
|
||||
|
||||
| Field shape | Usually is | Usually not |
|
||||
|---|---|---|
|
||||
| Launch screen image + countdown with no dismiss | promotional | a functional splash |
|
||||
| Popup with a channel/group link and `showOnce` | promotional | |
|
||||
| Popup with changelog + `force` + an external URL | update nag → `updates-and-forced-upgrade.md` | |
|
||||
| Tab bar item with `enabled=false` | absent by operator choice | |
|
||||
| Announcement that is off server-side | nothing to remove | |
|
||||
|
||||
If the user asked for "no ads", removing a genuine brand splash is a judgement
|
||||
call, not an automatic win — a self-owned logo screen with no third-party content
|
||||
is arguably not an ad. State which one you removed; if the request was ambiguous,
|
||||
the safer default is to remove only the server-sourced promotional content and
|
||||
leave a purely local brand screen alone. See `long-task-discipline.md` on not
|
||||
substituting your own goal for the stated one.
|
||||
|
||||
## 5. Verify at three levels, not one
|
||||
|
||||
A config-driven behaviour needs more evidence than a log line:
|
||||
|
||||
1. **Screen, before and after, frame by frame** — `scripts/coldstart.py`. The
|
||||
observable is a visible change plus a change in time-to-first-meaningful-screen.
|
||||
2. **The config object's own values at runtime** — if the app logs or you can hook
|
||||
it, show the value it received. This separates "the server stopped sending it"
|
||||
from "the client stopped obeying it", which are different claims.
|
||||
3. **Correct negative**: if the flag arrives `enabled=true` and nothing happens,
|
||||
that is the strong result. If it arrives `enabled=false`, you have verified
|
||||
nothing about your patch.
|
||||
|
||||
## 6. Remote re-enable: the durability question
|
||||
|
||||
Two channels can undo client-side work without a version bump:
|
||||
|
||||
- **The config endpoint itself.** Your patch must be in the decision path so a
|
||||
future `enabled=true` is still ignored (see). A patch that reads the flag and
|
||||
happens to work today is not durable.
|
||||
- **A hot-update / dynamic-resource channel** (a downloaded bundle, a patch dex, a
|
||||
remotely loaded layout). Look for a cache directory that is not the config cache,
|
||||
or a download-then-load path during startup. If one exists, the patch has to
|
||||
survive it — which usually means the patched decision is the right place anyway,
|
||||
because remote resources rarely replace compiled logic.
|
||||
|
||||
Also check whether the config is **cached to disk** (`SharedPreferences`, a JSON
|
||||
snapshot, a DataStore file). A cached copy means the app can exhibit the old
|
||||
behaviour offline for one launch after your change, which looks like a failed
|
||||
patch. Clear the cache as part of the experiment, and say so when reporting.
|
||||
|
||||
## 7. Reporting template
|
||||
|
||||
```
|
||||
Promo/popup surface: <field> (<DTO class>) | source: server config at <endpoint or unknown>
|
||||
Observed before: <exact values, incl. whether enabled was true/false>
|
||||
Observed after: <exact values / absence>
|
||||
Patch: <class#method, byte offset, bytes before -> after>
|
||||
Durability: <why a future server-side change cannot restore it>
|
||||
Not removed: <fields deliberately left, with reason>
|
||||
Side effects: <what shares the same config or code path>
|
||||
Unverified: <anything not directly observed>
|
||||
```
|
||||
@@ -0,0 +1,149 @@
|
||||
# Signature-derived keys — when re-signing silently breaks the app
|
||||
|
||||
A class of app that repack-friendly guidance does not cover: the client feeds **its own APK
|
||||
signing certificate** into a native routine and uses the result as the key for request
|
||||
signing or for SDK payload encryption. Re-sign the APK and the key changes, so every signed
|
||||
request fails — while the app itself launches perfectly.
|
||||
|
||||
This is distinct from "the server checks the signature". Here the *client* uses the signature
|
||||
as key material, and the server was built against the original key. The failure looks like a
|
||||
server problem and is actually a signing problem.
|
||||
|
||||
|
||||
**Load this when:** the app reads `signatures[0]`/`toCharsString()`, or a rebuilt APK installs, launches and logs nothing wrong while every signed request fails. It gives the 15-minute check that prevents the most expensive silent repack failure.
|
||||
|
||||
## How to spot it (four greps, minutes)
|
||||
|
||||
```bash
|
||||
# the usual shape: signature bytes handed to a crypto helper
|
||||
grep -rn "toCharsString()" <decompiled_sources>
|
||||
grep -rn "getPackageInfo(.*, *64)" <decompiled_sources> # 64 = GET_SIGNATURES
|
||||
grep -rn "signatures\[0\]" <decompiled_sources>
|
||||
|
||||
# and the same pattern in SDK-internal helpers
|
||||
grep -rn "signatures\[0\].toByteArray" <decompiled_sources>
|
||||
```
|
||||
|
||||
Signals that confirm it matters:
|
||||
|
||||
- The value is passed into a `native` method (JNI) or a helper that also does HMAC/AES/DES.
|
||||
- The string table of the native library contains algorithm primitives (`hmac_sha256`,
|
||||
`hmac_md5`, `des_crypt`, `sha256_update`, `initKey`, `make_SubKey`, …) but **no hardcoded
|
||||
certificate or key blob** — i.e. the key is supplied from Java.
|
||||
- Request URLs carry computed parameters (`sign`, `_p`, `uth`, `sig`, `nonce`) that are not
|
||||
derivable from the visible app data.
|
||||
- The same certificate value is also fed to a third-party ad/OAID SDK helper.
|
||||
|
||||
If those hold, **any rebuilt APK needs the original certificate value hardcoded**, not the
|
||||
new one. Patch that before you patch anything else, or you cannot distinguish "my patch broke
|
||||
it" from "signing broke it".
|
||||
|
||||
## The trap: what that value actually is
|
||||
|
||||
Do **not** assume `signatures[0].toByteArray()` is the whole `META-INF/*.RSA` file. It is
|
||||
platform-dependent, and the difference is invisible offline:
|
||||
|
||||
| Platform behaviour | What you get |
|
||||
|---|---|
|
||||
| Old JAR-signature path | the full PKCS#7 blob (`META-INF/CERT.RSA` content) |
|
||||
| Modern (v2/v3 present) | a **single certificate from the chain**, DER-encoded — not the container, and not necessarily the first one |
|
||||
|
||||
Measured consequence on one sample: the `.RSA` file was 1199 bytes / 2398 hex chars, but the
|
||||
runtime value was **777 bytes / 1554 hex chars** — the *last* certificate inside the PKCS#7.
|
||||
Hardcoding the file bytes produced correct-looking smali, a clean build, a successful install,
|
||||
and every request signing parameter evaluating to `-1`.
|
||||
|
||||
**Therefore: never derive this value offline and trust it.** Read it from the running app
|
||||
(`scripts/sig_probe.py --live`), and only fall back to offline extraction to cross-check.
|
||||
|
||||
## Extract the real value
|
||||
|
||||
**Route A — from the device (authoritative):**
|
||||
|
||||
```bash
|
||||
python scripts/sig_probe.py --live <pkg> # needs Frida + a rooted device
|
||||
```
|
||||
|
||||
`--live` takes the package name as its own argument; there is no `--pkg` flag. Verify the call
|
||||
against the script's own usage (`python scripts/sig_probe.py --help`) rather than against this page —
|
||||
`check_commands.py` at the repository root validates documented commands for exactly this reason.
|
||||
|
||||
It prints `signatures[0].toCharsString()` and its length, read from the package manager on the
|
||||
device. This is the only value that is guaranteed to match what the app computes.
|
||||
|
||||
**Route B — offline, to cross-check:**
|
||||
|
||||
```bash
|
||||
python scripts/sig_probe.py --apk <original.apk>
|
||||
```
|
||||
|
||||
This enumerates the candidate certificate blobs inside `META-INF/*.RSA` (each top-level
|
||||
SEQUENCE inside the PKCS#7 `certificates` set) and prints each as hex with its length. Pick the
|
||||
length that Route A reported; if Route A is unavailable, generate one build per candidate —
|
||||
there are rarely more than two.
|
||||
|
||||
## Patch it
|
||||
|
||||
Replace every signature read with a string constant holding the original value, rather than
|
||||
trying to make the new signature look like the old one.
|
||||
|
||||
In smali, the pattern to replace is an invoke/move-result pair:
|
||||
|
||||
```smali
|
||||
invoke-virtual {vX}, Landroid/content/pm/Signature;->toCharsString()Ljava/lang/String;
|
||||
|
||||
move-result-object vX
|
||||
```
|
||||
|
||||
Rewrite both instructions into a single load (a regex over the smali tree handles all sites at
|
||||
once):
|
||||
|
||||
```smali
|
||||
const-string vX, "<original-value-hex>"
|
||||
```
|
||||
|
||||
Notes that save a build cycle:
|
||||
|
||||
- **Do not delete the surrounding `getPackageInfo` calls.** They are harmless and removing
|
||||
them perturbs register allocation. Only the two instructions above need to change.
|
||||
- **Miss one site and it still fails.** Count the sites before and after (`grep -c`), and assert
|
||||
the post-patch count is zero. There is usually more than one — one per crypto helper.
|
||||
- Keep the smali file's `.orig` backup so the patch is re-runnable; some build pipelines pick
|
||||
`.orig` up as an unknown file and warn, which is harmless.
|
||||
- The same treatment applies to copies of the pattern inside bundled SDK packages
|
||||
(`…/secure/…Utils`, `…/oaid/…`), not just the app's own util class.
|
||||
|
||||
## Verify with a control, not with a feeling
|
||||
|
||||
A launch that "looks fine" proves nothing here: the app starts, then quietly fails every
|
||||
network call. Use a differential test.
|
||||
|
||||
1. **Measure the signing parameter on both builds.** Hook the request builder and print the
|
||||
computed parameter for a request the app always makes on startup.
|
||||
- original APK → a value with the normal shape (encoded / base64-ish)
|
||||
- rebuilt APK → the same shape ⇒ signing is consistent
|
||||
- rebuilt APK → `-1`, empty, or null ⇒ the key is still wrong; go back to Route A
|
||||
2. **Compare a full request URL**, parameter by parameter, between the two builds. Anything
|
||||
that differs beyond a timestamp/device id is a candidate root cause.
|
||||
3. **Exercise the feature**, not the launch: an API-backed screen must actually render data.
|
||||
"Network error" on a screen that is otherwise intact is the signature of exactly this bug.
|
||||
|
||||
Before blaming your patch: run the **unmodified original** on the same device and network. If
|
||||
it also fails, the problem is environmental (`references/tls-and-cert.md`,
|
||||
`references/pitfalls.md` P9) and not the repack.
|
||||
|
||||
## Related variants of the same root cause
|
||||
|
||||
- **OAID / device-id SDKs** hash the signature (`MessageDigest("SHA1").digest(signatures[0].toByteArray())`)
|
||||
and the result goes into a request header. Same fix, different call site.
|
||||
- **Ad SDKs** use the signature to encrypt their own config payloads; leaving them unpatched
|
||||
usually degrades ads rather than breaking the app, so patch them after the core path works.
|
||||
- **Certificate pinning to the app's own signature** is a different problem: it is verified,
|
||||
not used as a key. See `references/tls-and-cert.md`.
|
||||
|
||||
## Cost of getting this wrong
|
||||
|
||||
Without this step the task stalls in the worst possible way: the artifact is correct, the
|
||||
patch is correct, the build is correct, and the app is unusable — which reads as "the client
|
||||
patch is impossible" and sends you back to static analysis for hours. Budget 15 minutes for
|
||||
this before the first repack of any signed-request app.
|
||||
@@ -0,0 +1,325 @@
|
||||
# Split APKs and App Bundles
|
||||
|
||||
A store build is frequently **not one file**. Play and most OEM stores ship App Bundles, and the
|
||||
device receives a set: `base.apk` plus one `split_config.*.apk` per ABI, screen density and language
|
||||
(and, for a dynamic-feature bundle, the feature modules as well). Every member carries **the same
|
||||
package name and its own signature**, and the package manager treats the set as one package.
|
||||
|
||||
Two failures follow from treating such a set as an APK, and both look like a broken build when they
|
||||
are nothing of the kind:
|
||||
|
||||
- Signing only the base leaves the set disagreeing about its certificate; the installer rejects the
|
||||
whole set and names the package, not the member you forgot.
|
||||
- "Just merge them into one APK" is free *only* for members that carry code or native libraries. A
|
||||
member that carries resources cannot be folded into the base without merging `resources.arsc`,
|
||||
which is a resource-compiler job. A wrongly merged archive installs and then renders the wrong
|
||||
thing, or dies on the first resource lookup.
|
||||
|
||||
`scripts/repack.py` handles both routes and **refuses to guess**: it inventories the set, decides
|
||||
which route is legal from what the members actually carry, prints the reason, and only then acts.
|
||||
|
||||
**Strength labels** (same three this repository uses everywhere): **observed** = reproduced with an
|
||||
exact command and output; **inferred** = follows from observed facts, step not executed;
|
||||
**unverified** = reported or assumed, not independently confirmed. The measurements behind this
|
||||
file are recorded in `references/evidence-summary.md` §The capability matrix.
|
||||
|
||||
|
||||
**Load this when:** the target arrives as `base.apk` plus `split_config.*.apk`, or a rebuild is refused as a set although every member verifies. It gives reading the set, merge versus unified re-signing, and the refusal each mistake produces.
|
||||
|
||||
## 1. What the set is
|
||||
|
||||
| Member | Manifest `split` attribute | Carries | Foldable into the base? |
|
||||
|---|---|---|---|
|
||||
| base | *(absent)* | all `classes*.dex`, the main `resources.arsc`, every resource not moved out, the real `<application>` | — it *is* the base |
|
||||
| ABI split | `config.arm64_v8a`, `config.armeabi_v7a`, `config.x86_64`, … | `lib/<abi>/*.so` only | **yes** — entry names do not collide |
|
||||
| density split | `config.hdpi` … `config.xxxhdpi` | its own `resources.arsc` **and** `res/` entries | **no** — requires a resource merge |
|
||||
| language split | `config.en`, `config.zh`, … | its own `resources.arsc` (strings) | **no** — same reason |
|
||||
| feature split | `feature_x` (+ `isFeatureSplit="true"`, `configForSplit="base"`) | its own `classes*.dex`, possibly resources | **code only**; its resources hit the same wall |
|
||||
|
||||
**Observed** on a real 9-member set pulled from a stock Android 11 device (arm64-v8a target):
|
||||
|
||||
- the base carried `classes.dex` (872 KB) and `resources.arsc` (71 KB);
|
||||
- the ABI member carried two `lib/arm64-v8a/*.so` files and **no `resources.arsc` at all**;
|
||||
- each density member carried its own `resources.arsc` and its own `res/` entries;
|
||||
- a second, 8-member set's density members carried 39-78 `res/` entries each and 9-15 KB tables.
|
||||
|
||||
So "which member owns the resources" is answerable **from the zip entry list alone** — no device, no
|
||||
resource compiler, no decompiler. Copy a set to disk and ask.
|
||||
|
||||
**A member can own a nearly empty table.** A split is *required* to carry a `resources.arsc`, so a
|
||||
placeholder one is normal: on the same real set, eight density members carried a **40-byte** table
|
||||
and no `res/` entries at all — they are placeholders, not resources. Treating "has an arsc" as "owns
|
||||
resources" would block a merge that is perfectly legal. `repack.py` uses *entries under `res/` or a
|
||||
table bigger than 1 KB* as the test.
|
||||
|
||||
## 2. Getting the set off a device
|
||||
|
||||
`pm path` prints **one line per member**; a package with several lines is a split set. This is how
|
||||
you find one to copy, and it is also how you tell, before anything else, that the thing you are about
|
||||
to patch is not a single APK:
|
||||
|
||||
```bash
|
||||
adb shell 'pm path <PKG>'
|
||||
package:/system/priv-app/<DIR>/<APP>.apk
|
||||
package:/system/priv-app/<DIR>/<APP>-arm64_v8a.apk
|
||||
package:/system/priv-app/<DIR>/<APP>-xxhdpi.apk
|
||||
```
|
||||
|
||||
To enumerate every split set on a device (toybox shell — no `--time-style`, no bash arrays):
|
||||
|
||||
```bash
|
||||
adb shell 'for p in $(pm list packages | cut -d: -f2); do c=$(pm path $p | wc -l); \
|
||||
if [ $c -gt 1 ]; then echo "$c $p"; fi; done'
|
||||
```
|
||||
|
||||
Copy the whole directory out; the members live side by side:
|
||||
|
||||
```bash
|
||||
adb shell 'su -c "ls -la /system/priv-app/<DIR>/"'
|
||||
adb pull /system/priv-app/<DIR> <dest>/
|
||||
```
|
||||
|
||||
Two traps, both **observed**:
|
||||
|
||||
- `adb pull <dir> <dest>` creates `<dest>/<dir>/` — the set arrives one level deeper than you expect.
|
||||
`repack.py --split-dir` searches recursively for exactly this reason.
|
||||
- A **third-party** split set is rare on a stock ROM. On the device measured here, `pm list packages -3`
|
||||
returned **zero** split sets among ~40 third-party apps, while four preinstalled Google components
|
||||
were split. If you need a real set and the third-party list is empty, that is the normal
|
||||
distribution, not a mistake on your side.
|
||||
|
||||
**Do not test by reinstalling a pulled set under its own package name.** It is already installed on
|
||||
that device, owned by the vendor certificate you do not have, so the install fails for a reason that
|
||||
has nothing to do with your pipeline (). Either work on a set you own, or rename the pulled one
|
||||
() to get an installable fixture.
|
||||
|
||||
## 3. The decision: merge or resign
|
||||
|
||||
Run this first — it writes nothing and answers with a reason:
|
||||
|
||||
```bash
|
||||
python skills/apk-reverse/scripts/repack.py --split-dir <set>/ --split-mode analyze
|
||||
```
|
||||
|
||||
| Situation | Route | Why |
|
||||
|---|---|---|
|
||||
| Every non-base member carries only code and/or `lib/` (a placeholder `resources.arsc` is fine) | **merge** | nothing that needs a resource table moves |
|
||||
| Any member carries `res/` entries or a real table | **resign** | a resource merge rewrites the table; see §5 |
|
||||
| You must keep the build installable **and** the app is distributed as a bundle | **resign** | this is what the store does; the set stays a set |
|
||||
| You need one file to hand someone, sideload, or feed to a tool that takes an APK | **merge**, if legal | the only way to get a single artifact |
|
||||
| The delivery must survive store updates, or the ROM's own installer refuses multi-APK installs | **resign** (`pm install-multiple`), or merge and accept a fixed build | — |
|
||||
| A member declares `isFeatureSplit="true"` and ships `classes*.dex` **and** resources | **resign** | its code could fold, its resources cannot; a half-merged build is worse than either route |
|
||||
|
||||
Failure modes, one per route:
|
||||
|
||||
- **merge**, when a resource-carrying member is dropped to make it legal: the app starts and shows
|
||||
the base's resources. Missing density variants show up as blurry or missing images; missing
|
||||
language splits show as untranslated strings. This is a **downgraded** build and has to be
|
||||
delivered as one — `repack.py` prints `DROPPED n entry/ies` and refuses unless you pass
|
||||
`--drop-split-resources`, so the loss cannot be silent.
|
||||
- **merge**, when the base still declares `isSplitRequired="true"`: install succeeds and start is
|
||||
refused (`INSTALL_FAILED_MISSING_SPLIT`,). The attribute has to be cleared first, and that is a
|
||||
binary-AXML edit.
|
||||
- **resign**, when one member is missed: the set fails as a set, with a signature error naming the
|
||||
package. Sign the directory, not a list you typed by hand.
|
||||
- **resign**, when the target ROM's installer is not the platform installer: `adb install-multiple`
|
||||
can be intercepted by an OEM security centre, exactly as single-APK installs are
|
||||
(`references/repack-and-sign.md` §Vendor install interception (OEM shells)). Use the root
|
||||
`pm install-multiple` path.
|
||||
|
||||
## 4. Unified signing — the route that always works
|
||||
|
||||
```bash
|
||||
python skills/apk-reverse/scripts/repack.py \
|
||||
--split-dir <set>/ --split-mode resign \
|
||||
--split-out-dir <signed_set>/ \
|
||||
--ks key.jks --apksigner <path/to/apksigner>
|
||||
```
|
||||
|
||||
Each member is de-signed (only the signature entries), rewritten as a 4-byte-aligned archive, and
|
||||
signed with **one** keystore and **v1+v2+v3**; the run then verifies every member and prints the
|
||||
signer fingerprint per file. The acceptance criterion is not "each file verifies" — it is **one
|
||||
certificate across the whole set**:
|
||||
|
||||
```
|
||||
[result] OK: 8 members, one certificate (sha256 1eb31d9c…2c44)
|
||||
```
|
||||
|
||||
Then install the whole set, base included:
|
||||
|
||||
```bash
|
||||
adb install-multiple -r <signed_set>/*.apk
|
||||
adb shell "su -c 'pm install-multiple -r /data/local/tmp/set/*.apk'" # when an OEM installer refuses the multi-APK form
|
||||
```
|
||||
|
||||
**The signature must cover every member**, and `--abi`/density selection happens at install time, not
|
||||
at signing time — sign them all, let the installer pick (see).
|
||||
|
||||
### Toolchain: `uber-apk-signer` is optional, `apksigner` is not
|
||||
|
||||
`repack.py`'s original signing route went through `uber-apk-signer` only, so a machine with a full
|
||||
Android build-tools directory and no uber-apk-signer could not produce a signed build at all. The
|
||||
script now picks the route itself:
|
||||
|
||||
- `--signer jar` — the uber-apk-signer path (used automatically when the jar exists);
|
||||
- `--signer apksigner` — `zipalign -p -f 4` then `apksigner sign --v1 --v2 --v3`, which is the whole
|
||||
of what the jar was doing for us;
|
||||
- `--signer auto` (default) — jar if present, else apksigner.
|
||||
|
||||
`--apksigner` accepts all three shapes a build-tools install ships: `apksigner.bat`, the `apksigner`
|
||||
shell wrapper, or the bare `lib/apksigner.jar`. **On Windows pass the `.jar` or the `.bat`** — the
|
||||
extensionless wrapper is a shell script and will not execute. A `.jar` value is run through
|
||||
`java -jar`, so `--java` (or java on PATH) is required for it.
|
||||
|
||||
Order is load-bearing: **align, then sign**. `apksigner` adds signature entries without reshuffling
|
||||
the archive; the v1 (JAR) path emulates `jarsigner`, whose rewrite of the zip would destroy the
|
||||
alignment Android R+ requires of `resources.arsc`.
|
||||
|
||||
## 5. Merging into a single APK
|
||||
|
||||
```bash
|
||||
python skills/apk-reverse/scripts/repack.py \
|
||||
--split-dir <set>/ --split-mode merge --out merged.apk \
|
||||
--abi arm64-v8a --ks key.jks --apksigner <path/to/apksigner>
|
||||
```
|
||||
|
||||
What folds: `classes*.dex` (renumbered so a member's `classes.dex` becomes `classes2.dex`, keeping
|
||||
the base's own dex intact), `lib/**`, `assets/**`. What does not, and why: `res/**` and
|
||||
`resources.arsc`.
|
||||
|
||||
**Why resource merging is not attempted.** `resources.arsc` is an indexed table: a global string
|
||||
pool plus type-spec and type chunks, with every resource identified by a `(package, type, entry)`
|
||||
triple and every string referenced by pool offset. Folding a member's table into the base's means
|
||||
rewriting pool offsets, type/entry counts and the alignment of every string, in both directions —
|
||||
that is `aapt2 link`, not a byte-level edit. The failure it produces is the worst kind: the archive
|
||||
is *structurally* valid, installs, and then resolves resource ids to the wrong entries or to nothing.
|
||||
An APK that renders garbage is harder to diagnose than one that refuses to install.
|
||||
|
||||
If you genuinely need one standalone file for a bundle whose resources are split, the honest routes are:
|
||||
|
||||
1. **`bundletool build-apks --mode=universal`** (the vendor tool, needs the original `.aab`) — this is
|
||||
what merges resources correctly, and it is the only route that does.
|
||||
2. **Keep the split set** and install it as a set ().
|
||||
3. **Accept the downgrade** with `--drop-split-resources`, and say so in the delivery.
|
||||
|
||||
**Manifest constraints when you do merge — and when you patch any split at all.** These attributes
|
||||
are the difference between "installs and runs" and an install or start refusal, and they are easy to
|
||||
miss because they live in binary AXML:
|
||||
|
||||
| Attribute | Lives on | Effect if you get it wrong |
|
||||
|---|---|---|
|
||||
| `split="config.xxhdpi"` | `<manifest>` of a **split** | Identifies the member. A split whose `package` differs from the base's, or whose `split` name is duplicated, is refused as a set |
|
||||
| `configForSplit="base"` | `<manifest>` of a split | Ties a configuration split to a feature module. Changing package/module names without updating it silently detaches the split |
|
||||
| `isFeatureSplit="true"` | `<manifest>` of a feature split | Marks an install-time/on-demand module. A merge that folds code in but leaves the attribute declared describes a member that no longer exists |
|
||||
| `isSplitRequired="true"` | `<manifest>` of the **base** | The platform refuses to start a base that believes it is incomplete. This is the one that breaks merged builds () |
|
||||
|
||||
Read them without a decompiler through `scripts/repack.py`'s own AXML reader (`--split-mode analyze`
|
||||
prints the `split` name per member; `inspect_apk()` returns `package`, `split`, `configForSplit`,
|
||||
`isSplitRequired`, `isFeatureSplit`). Changing any of them means editing the binary string pool and
|
||||
the attribute chunks around it, which is what `apktool` is for — and note that a full
|
||||
`apktool b` rebuild rewrites the whole archive
|
||||
(`references/repack-and-sign.md` §Repacking an unpacked (de-shelled) app).
|
||||
|
||||
## 6. Install refusals, and what each one means
|
||||
|
||||
| Message | What it actually says | Attribution |
|
||||
|---|---|---|
|
||||
| `Failure [INSTALL_FAILED_INVALID_APK: ... signatures do not match previously installed version]` / `Package <PKG> signatures do not match` | The set you are installing is signed with a different certificate than the one already on the device | **Not** a malformed build. Either re-sign to the original key, or install to a package name that does not exist yet |
|
||||
| `Failure [INSTALL_FAILED_ALREADY_EXISTS]` / `INSTALL_FAILED_UPDATE_INCOMPATIBLE` | Same package, different signer (or a downgrade without `-d`) | A pulled system component cannot be reinstalled over itself; see §7 |
|
||||
| `Failure [INSTALL_FAILED_MISSING_SPLIT: Missing split for <PKG>]` | The base declares `isSplitRequired="true"` and the set is incomplete — or you merged it | The base is telling the truth: it expects members. Clear the attribute, or install the complete set |
|
||||
| `Failure [INSTALL_FAILED_INVALID_APK: Failed to parse ... split ... has different signature]` | **One member** of the set is signed differently from the rest | This is the failure mode of signing only the base. Sign the whole directory |
|
||||
| `Failure [-124: ... resources.arsc ... stored uncompressed and aligned on a 4-byte boundary]` | One member's `resources.arsc` is compressed or unaligned | Per-member, not per-set. `repack.py` reports it per member before signing (`references/repack-and-sign.md` §2a) |
|
||||
| `Failure [-99]`, or a numeric-only code with no `INSTALL_FAILED_*` | An OEM installer interceptor rejected the request | Nothing to do with the set; use the root `pm install-multiple` path |
|
||||
| `adb install-multiple` on a device that has no `install-multiple` support (very old `pm`) | Install each member with `pm install` in order, base last | Use `pm install-create` / `install-write` / `install-commit` explicitly |
|
||||
|
||||
`INSTALL_FAILED_INVALID_APK` in particular is a **family**, not one error: it covers stale
|
||||
signatures, malformed base/split relationships, and alignment violations. Read the text after the
|
||||
colon before attributing anything — the constant alone will send you looking at the wrong file.
|
||||
|
||||
## 7. Making an installable fixture from a pulled set
|
||||
|
||||
**Observed, and the reason this section exists:** a set pulled from the device is installed *on that
|
||||
device*, under a name owned by the vendor certificate. Re-signed copies cannot be installed back
|
||||
over it, so "does our signed set install?" cannot be answered with the pulled set as it stands.
|
||||
|
||||
The cheap, structure-preserving way out is an **equal-length package rename**: replace every
|
||||
occurrence of the old package name in `AndroidManifest.xml`, `resources.arsc` and `classes*.dex`
|
||||
with a new name of exactly the same length. Nothing moves — no length prefix, no string-pool offset,
|
||||
no dex `string_ids` offset, no resource index — so the only field that must be recomputed is the dex
|
||||
header's integrity pair (signature first, checksum last, see
|
||||
`references/repack-and-sign.md` §Repacking an unpacked (de-shelled) app). Then run the ordinary
|
||||
`--split-mode resign` over the renamed directory and install that.
|
||||
|
||||
A detail worth knowing before you touch a manifest: **a member's Java classes need not live under its
|
||||
manifest `package` at all**. On the real set measured here, the manifest package was
|
||||
`com.google.android.<name>` while all 677 class references in the dex were
|
||||
`Lcom/android/<name>/...` — and every `<activity android:name>` was fully qualified. Renaming the
|
||||
manifest package therefore left every component resolvable, with no dex edit needed. Assume the
|
||||
opposite (that the two must match), and you will edit a dex for nothing.
|
||||
|
||||
## 8. ABI and density matching
|
||||
|
||||
The device tells you what it can run:
|
||||
|
||||
```bash
|
||||
adb shell getprop ro.product.cpu.abilist # e.g. arm64-v8a,armeabi-v7a,armeabi
|
||||
adb shell getprop ro.product.cpu.abi # the primary ABI
|
||||
adb shell getprop ro.product.cpu.abilist32 # 32-bit list, on a 64-bit device
|
||||
```
|
||||
|
||||
Match against the **split name**, not the file name, and mind the separator: split qualifiers use
|
||||
**underscores** (`config.arm64_v8a`) while `lib/` directories use **hyphens** (`lib/arm64-v8a/`). The
|
||||
mapping is a substitution, not a lookup — `arm64_v8a` → `arm64-v8a`, `armeabi_v7a` → `armeabi-v7a`;
|
||||
`x86` and `x86_64` are unchanged. That is why `--abi arm64-v8a` matches a split named
|
||||
`config.arm64_v8a`.
|
||||
|
||||
Four rules that decide ABI questions:
|
||||
|
||||
- **A 64-bit device still installs a 32-bit-only set**, and then runs it 32-bit. If the base carries
|
||||
no native code and only the `armeabi_v7a` split does, the app is a 32-bit app on that device.
|
||||
- **The ABI split is selected by the installer, not by `repack.py`.** `--abi` exists for *merging*
|
||||
(where the choice is baked into one file); for `--split-mode resign`, sign every member and let
|
||||
`pm` pick.
|
||||
- **Merging without `--abi` folds in every ABI member**, which makes one APK with two or three copies
|
||||
of the same library set. Legal, larger, and pointless on a single-device delivery.
|
||||
- **`getprop` reports what the device claims**; what runs is a live mapping question
|
||||
(`scripts/lib_map.py`). A device that reports `arm64-v8a` first can still execute a 32-bit process.
|
||||
|
||||
Density is the same shape without the separator trap: `config.xxhdpi` matches
|
||||
`res/drawable-xxhdpi/`, and the installer picks the closest variant the device's `ro.sf.lcd_density`
|
||||
can use. There is nothing to select on your side.
|
||||
|
||||
## 9. Traps worth paying for once
|
||||
|
||||
- **`resources.arsc` belongs to whoever ships it.** In a split set it exists *per member*, and the
|
||||
base's table does not contain the density member's entries. Reading the base's table and concluding
|
||||
"the resource exists" is wrong; it exists in one configuration only.
|
||||
- **`android:extractNativeLibs`.** If the base declares `extractNativeLibs="false"` (the default for
|
||||
modern targetSdk), the `.so` files are mmapped straight out of the archive, and they must be
|
||||
**uncompressed and page-aligned** — this is what `zipalign -p` exists for. Merging native members
|
||||
into a base and then writing the archive without that alignment produces an install that succeeds
|
||||
and a start that dies in the linker. `repack.py` writes uncompressed `lib/**` 4-byte aligned, and
|
||||
`zipalign -p -f 4` adds the page alignment the loader wants for them.
|
||||
- **A placeholder `resources.arsc` is not a resource split** (). Blocking on "has an arsc" refuses
|
||||
legal merges.
|
||||
- **Do not `zipalign` after signing to "fix" a member.** It rewrites the archive and invalidates the
|
||||
signature. Align, then sign.
|
||||
- **`apktool` may not be usable even when a wrapper exists.** Measured on the pass machine: the
|
||||
`apktool.bat` wrapper pointed at a jar that was not on disk
|
||||
(`Error: Unable to access jarfile <missing>.jar`), while `apksigner.jar` and `zipalign.exe` in the
|
||||
same layout were fine. Check the wrapper's target before planning a manifest edit around it.
|
||||
- **The AXML `split` attribute is a string-pool entry**, which is why an equal-length rename of it is
|
||||
safe and why a length-changing edit is not: the pool's own length prefixes and the attribute chunk
|
||||
sizes around it would both need recomputing.
|
||||
|
||||
## 10. What this file does not claim
|
||||
|
||||
- **It does not merge resources.** No route here reproduces `bundletool build-apks --mode=universal`.
|
||||
A bundle whose resources live in splits stays a set, or becomes a downgraded single APK by explicit
|
||||
choice.
|
||||
- **It does not split a monolithic APK back into modules**, and it cannot turn a `.aab` into APKs —
|
||||
that is `bundletool`, and the `.aab` is the input it needs.
|
||||
- **`--split-mode merge` covers code and native libraries only** (). Feature-split *code* folds;
|
||||
feature-split *resources* do not.
|
||||
- The install-failure texts in §6 are the shape the platform produces; **the exact wording varies by
|
||||
Android version and by OEM installer**. Read the text after the constant, not the constant.
|
||||
@@ -0,0 +1,142 @@
|
||||
# Third-party builds — auditing a "cracked" or "modded" APK before trusting it
|
||||
|
||||
|
||||
**Load this when:** the input is a "cracked" or "modded" APK you did not produce. It gives the audit that tells you what was injected, and why such a build is never a patching workbench.
|
||||
|
||||
## Why this exists
|
||||
|
||||
A modded build of the exact app you are targeting is the most tempting shortcut available:
|
||||
it appears to prove the goal is achievable, and it may already contain the patch you were
|
||||
about to write. It is also the single most common source of wasted hours and of real risk,
|
||||
for two reasons.
|
||||
|
||||
1. **It is usually not editable.** Authors who redistribute mods protect them, often with
|
||||
more layers than the original app ever had. You cannot treat someone else's build as a
|
||||
patching workbench.
|
||||
2. **Its target-audience behaviour is unverified.** The binary may carry injected components,
|
||||
extra endpoints, or embedded credentials. "It runs and the ads are gone" tells you nothing
|
||||
about what else it does.
|
||||
|
||||
Treat a third-party build as an **untrusted sample to be audited**, never as a baseline.
|
||||
The useful output of an audit is a decision plus evidence — not a binary you start from.
|
||||
|
||||
## Step 1: Can you even edit it?
|
||||
|
||||
Find the app's own package prefix (the one that appears in its own class definitions, e.g.
|
||||
`com/<vendor>/<app>/`) and count how many times it appears as a **defined class** in each
|
||||
plaintext dex.
|
||||
|
||||
```
|
||||
business-prefix hits per dex:
|
||||
classes.dex 0 <- shell
|
||||
classes2.dex 0
|
||||
classes3.dex 0
|
||||
classes4.dex 0
|
||||
```
|
||||
|
||||
Zero everywhere means the business code has been **moved out of the plaintext dexes** — the
|
||||
build is protected at a level you cannot patch directly, even though it launches and works.
|
||||
Stop here and treat it as a black box.
|
||||
|
||||
Also check the reported dex count against the file count: a manifest-declared app with only
|
||||
library classes present is the same signal.
|
||||
|
||||
## Step 2: How many protection layers does it carry?
|
||||
|
||||
Mods frequently stack protection from multiple vendors, sometimes because the author
|
||||
re-protected an already-protected app. Look for independent, coexisting families:
|
||||
|
||||
| Layer kind | Typical static evidence |
|
||||
|---|---|
|
||||
| Commercial packer | shell `Application`/`appComponentFactory` in the manifest, a `lib*protect*.so`, encrypted assets, `assets/*.jar` with near-maximal entropy |
|
||||
| DEX-to-native (dex2c) | a "stub" `lib*.so` containing the tool's name, `EntryPoint`/`protect`-style symbols, and **business class names as strings** alongside a near-empty dex layer |
|
||||
| Anti-debugging / RASP | strings such as `TracerPid`, `ptrace`, `NoNewPrivs`, `CheckPtraceSelf`, `AntiFrida`, plus a self-protection log path |
|
||||
| Generic hardening marker files | small text files under `assets/` naming a protection vendor and a protection timestamp |
|
||||
|
||||
The practical consequence of an anti-debugging layer is that Frida-based dynamic work will hit
|
||||
a self-check before it hits your hook. Note it, and prefer static conclusions on such a sample.
|
||||
|
||||
## Step 3: What did the author ADD?
|
||||
|
||||
This is where the audit earns its keep. Diff against the **original build of the same version**
|
||||
(referenced as `<original>` below).
|
||||
|
||||
### New components
|
||||
|
||||
Build the set difference of `activity` / `service` / `receiver` / `provider` declarations,
|
||||
then check whether each new class name actually exists in the plaintext dexes.
|
||||
|
||||
- Class exists and is readable → you can judge its purpose.
|
||||
- Class name appears **nowhere** in plaintext but shows up inside a native library's strings →
|
||||
the logic is in the native/dex2c layer, purpose unknown. Say so.
|
||||
- Nothing registered with an implicit-launch intent-filter (`BOOT_COMPLETED`, `MAIN`) → it cannot
|
||||
be started implicitly, which limits (but does not eliminate) the risk.
|
||||
- A provider with an `authorities` value containing a package-name rewrite (a suffix/prefix that
|
||||
is not the current package) is a strong hint the build was produced by **repackaging or app
|
||||
cloning**, not by editing the original.
|
||||
|
||||
### New outbound endpoints
|
||||
|
||||
Extract every URL/domain/IP from the modded build's `lib/*.so`, `assets/*`, and dex strings,
|
||||
and subtract the original's set. Any domain that exists **only** in the modded build is the most
|
||||
important finding in the audit.
|
||||
|
||||
Rank what you find:
|
||||
- A domain that also serves install attribution or update checks is explainable.
|
||||
- A domain paired with an authorization-looking path (e.g. a `GET...`-style endpoint name
|
||||
nearby in the same string neighbourhood) is *not* explainable by de-ad work.
|
||||
|
||||
### Embedded secrets
|
||||
|
||||
Search native libraries and assets for private-key markers:
|
||||
```
|
||||
-----BEGIN ... PRIVATE KEY----- , PRIVATE KEY , PKCS#8 , RSA PRIVATE
|
||||
```
|
||||
A **private key shipped inside a client artifact** means its "authorization" or "integrity"
|
||||
path is not a security boundary — anyone can extract the key. Report it as a red flag on the
|
||||
build's trust claims, regardless of intent.
|
||||
|
||||
## Step 4: Capability picture — read both sides
|
||||
|
||||
A native library's import table is only half the story.
|
||||
|
||||
- **What it does not have** is meaningful: no `socket`/`connect`, no `execve`/`fork`/`system`,
|
||||
no `ptrace`, no installer-session strings → no direct evidence of command execution or
|
||||
silent install.
|
||||
- **What it references** can override that impression: string references to Java-layer
|
||||
`java/net/Socket`, `InetSocketAddress`, reflection entry points, or executor services mean the
|
||||
capability exists **through the Java layer**, off the native import table.
|
||||
|
||||
State findings as "capability present / no evidence of use" rather than "safe" — an import table
|
||||
is evidence of shape, not of intent.
|
||||
|
||||
## Step 5: The verdict
|
||||
|
||||
Do not hand back a binary. Hand back a decision with evidence and explicit unknowns.
|
||||
|
||||
```
|
||||
VERDICT: use / use with caution / do not use
|
||||
EVIDENCE:
|
||||
- business code plaintext? yes/no (+ counts)
|
||||
- protection layers list
|
||||
- injected components list, each with purpose known/unknown
|
||||
- endpoints present only in this build list
|
||||
- embedded credentials yes/no
|
||||
UNCONFIRMED:
|
||||
- <each item you could not establish, stated plainly>
|
||||
```
|
||||
|
||||
"Use with caution" is the honest answer whenever a build is un-analysable **and** carries new
|
||||
endpoints or unknown injected components. Do not upgrade it to "safe" because it launches
|
||||
cleanly, and do not call it malicious without evidence — both are guesses.
|
||||
|
||||
## Using the audit result
|
||||
|
||||
Two legitimate outcomes:
|
||||
|
||||
1. The audit shows the mod is an honest de-ad of the same version → you may use it to **learn the
|
||||
approach** (diff its patch points against the original), but still rebuild from the original
|
||||
yourself so the artifact you ship has a known provenance.
|
||||
2. The audit shows it is re-protected, injected, or un-analysable → discard it and do the work on
|
||||
the original. That is usually faster than reverse-engineering someone else's protection *and*
|
||||
your target's at the same time.
|
||||
@@ -0,0 +1,181 @@
|
||||
# TLS and certificate failures
|
||||
|
||||
**Symptom class:** after a repack, login or registration fails with a TLS error while the rest of the app behaves normally. This is the most common way a *correct* patch gets blamed for a *pre-existing, server-side* condition. Work the five steps below before changing a single byte of the app.
|
||||
|
||||
|
||||
**Load this when:** one feature fails at runtime -- login, registration, payment, an API-backed screen -- while the rest of the app works. It gives the five steps that separate a real TLS/certificate problem from the patch you just built.
|
||||
|
||||
## Fast path
|
||||
|
||||
| # | Action | Evidence |
|
||||
|---|---|---|
|
||||
| 1 | Read the full exception chain, prove it is a date rule | `THROW` events from the probe, or logcat |
|
||||
| 2 | Validate the certificate from a host you trust | `python scripts/tls_check.py <host>` |
|
||||
| 3 | Compare another host from the same app, and the device clock | same script, `adb shell date` |
|
||||
| 4 | Decide which trust chain owns the failing request | `RAW-URL` vs `OKHTTP` probe events |
|
||||
| 5 | Only then patch the system-trust path | fix template below |
|
||||
|
||||
## Symptoms
|
||||
|
||||
- Login or registration reports a generic network failure: `Network request failed: Chain validation failed`.
|
||||
- Everything else works: home, feed, images, playback, guest browsing.
|
||||
- The full chain, visible only if something captures swallowed exceptions:
|
||||
|
||||
```
|
||||
javax.net.ssl.SSLHandshakeException: Chain validation failed
|
||||
caused by: java.security.cert.CertificateException: Chain validation failed
|
||||
caused by: java.security.cert.CertPathValidatorException: timestamp check failed
|
||||
```
|
||||
|
||||
- `timestamp check failed` is the decisive token. The validator rejected the chain on a **date rule**, not on an untrusted issuer or a hostname mismatch. Exactly two conditions produce it: the certificate is expired or not yet valid, or the **clock of whoever performs the check is wrong**.
|
||||
|
||||
## Step 1: get the real host out of the runtime
|
||||
|
||||
Never trust a host recovered from strings alone; a build config can hold dead or staging hosts. Get the host the app actually connects to:
|
||||
|
||||
- Run the four-layer probe: `scripts/frida_probe.js`, launched and streamed to a log by `scripts/run_probe.py` (setup, version alignment, and hooking strategy: `dynamic-frida.md`). Three event labels carry what you need.
|
||||
- `RAW-URL`: the request went through `java.net.URL.openConnection` / `openStream`, the system trust path.
|
||||
- `OKHTTP`: the request went through the app's own OkHttp client, a different trust path.
|
||||
- `THROW`: the original text of an exception an upper layer caught and swallowed. This is where the real TLS message appears when the UI only shows a generic network error.
|
||||
|
||||
Output you need: the exact URL, its host, and which label logged it.
|
||||
|
||||
## Step 2: validate the chain out of band (no app involved)
|
||||
|
||||
This step is independent of the app, the patch, and the device. It runs on a host whose clock you trust:
|
||||
|
||||
```bash
|
||||
python scripts/tls_check.py <host> # strict verification + certificate detail
|
||||
python scripts/tls_check.py <host> <host2> # compare hosts in one run
|
||||
python scripts/tls_check.py <host> --json # machine-readable; exit 2 if any host fails
|
||||
```
|
||||
|
||||
The script validates strictly (system CAs, expiry, hostname) and, when verification fails, still decodes the peer certificate locally, so a failure arrives with a reason instead of a stack trace:
|
||||
|
||||
```
|
||||
EXPIRED certificate has expired
|
||||
NOT-YET-VALID certificate is not valid yet
|
||||
HOSTNAME-MISMATCH chain is fine, the name does not match
|
||||
UNTRUSTED-CA self-signed, or issuer not in the trust store
|
||||
CHAIN-BROKEN bad signature / invalid CA / incomplete chain
|
||||
```
|
||||
|
||||
Interpretation:
|
||||
|
||||
- `EXPIRED` reported from a trustworthy clock is proof that the chain is rejected by the certificate itself, not by the device, the network, or your patch.
|
||||
- A clean pass means the certificate is fine and the failure is a client-side trust decision or a clock problem. Go to Step 3.
|
||||
- Non-443 endpoint, or an address that needs a different SNI name (an IP connect, a CDN edge): `--port <port>` and `--sni <host>`. Getting this wrong produces a `HOSTNAME-MISMATCH` that has nothing to do with the real problem.
|
||||
|
||||
Minimal equivalent, if the script is not at hand:
|
||||
|
||||
```python
|
||||
import socket, ssl
|
||||
ctx = ssl.create_default_context() # strict: system CAs, expiry, hostname
|
||||
with socket.create_connection(("<host>", 443), timeout=10) as raw:
|
||||
with ctx.wrap_socket(raw, server_hostname="<host>") as tls:
|
||||
print("OK", tls.version())
|
||||
# on failure: ssl.SSLCertVerificationError: ... certificate has expired
|
||||
```
|
||||
|
||||
Dates, subject, and issuer in one line, if you prefer no script at all:
|
||||
|
||||
```bash
|
||||
openssl s_client -connect <host>:443 -servername <host> </dev/null 2>/dev/null \
|
||||
| openssl x509 -noout -subject -issuer -dates
|
||||
```
|
||||
|
||||
## Step 3: compare another host, and check the device clock
|
||||
|
||||
Run the same check against every host you extracted from the app, then compare:
|
||||
|
||||
- A very common shape: the business API host is expired while the config, image, or CDN host is valid. Same app, same network, same machine, different certificates. That pattern exonerates the network path and points at one certificate.
|
||||
- Check the device clock; it costs one command:
|
||||
```bash
|
||||
adb shell date # compare against a known-correct time source
|
||||
```
|
||||
If Step 2 passes but the device fails, the cause is the device clock, not the certificate. Correcting the clock and relaxing the trust policy are different decisions; do not do both silently.
|
||||
- Check the **unpatched original** in the same environment. See `verification.md` §the control build rule and `pitfalls.md` P9. If the original fails identically, the patch is not the cause, and no amount of dex work will fix it.
|
||||
|
||||
## Step 4: which trust chain owns the failing request
|
||||
|
||||
One app can carry two independent trust chains, which is exactly why "login is broken but everything else works".
|
||||
|
||||
| Path | Client | Trust configuration | Failure signature |
|
||||
|---|---|---|---|
|
||||
| OkHttp | OkHttp / Retrofit, via `newCall` | app-supplied `SSLSocketFactory` + `HostnameVerifier`, often pinning | pin-related messages (`CertificatePinner`, pin verification failed), or nothing at all if the app relaxed it |
|
||||
| `java.net.URL.openConnection()` | `HttpsURLConnection`, also used by many image loaders, downloaders, and legacy HTTP wrappers | **platform default** trust store and default verifier (`HttpsURLConnection.setDefault*`) | `Chain validation failed` / `timestamp check failed`, the signature above |
|
||||
|
||||
Discriminating, in increasing order of certainty:
|
||||
|
||||
1. **Probe events.** The failing request appears under exactly one label from Step 1: `RAW-URL` (system path) or `OKHTTP` (app client). This is a fast screen, not proof.
|
||||
2. **Trust-manager attribution (definitive).** Hook every `X509TrustManager.checkServerTrusted` implementation and print the instance class name plus a short `new Exception().getStackTrace()`. If the caller frames sit in `okhttp3.*`, or in a custom class the app installs on its own client, the OkHttp path owns the failure. If they sit in a platform trust manager reached through `HttpsURLConnection` / `SSLSocket` internals, the system path owns it. Wording alone is version-dependent, so trust the caller frames, not the message.
|
||||
3. **Cross-check with the fix below.** If OkHttp owns the failing request, the template does nothing for it: that path never reads `HttpsURLConnection` defaults and needs a client-level change (`SSLSocketFactory` / `HostnameVerifier` / pinner; see `dex-patching.md` §where to patch).
|
||||
|
||||
Why the asymmetry exists: the app configured its own trust behavior for OkHttp (a relaxed verifier or a pin), so that path never consults the platform date rule, while the legacy `HttpsURLConnection` path still validates against the system trust store and rejects the expired chain.
|
||||
|
||||
## Step 5: fix template (system path only)
|
||||
|
||||
Install permissive TLS defaults for the `HttpsURLConnection` path at the earliest point of `Application.onCreate()`:
|
||||
|
||||
```java
|
||||
// Call from Application.onCreate() before any other initialization.
|
||||
private static void installPermissiveTlsDefaults() {
|
||||
try {
|
||||
javax.net.ssl.SSLContext ctx = javax.net.ssl.SSLContext.getInstance("TLS");
|
||||
ctx.init(null, new javax.net.ssl.TrustManager[]{
|
||||
new javax.net.ssl.X509TrustManager() {
|
||||
public void checkClientTrusted(java.security.cert.X509Certificate[] c, String a) {}
|
||||
public void checkServerTrusted(java.security.cert.X509Certificate[] c, String a) {}
|
||||
public java.security.cert.X509Certificate[] getAcceptedIssuers() {
|
||||
return new java.security.cert.X509Certificate[0];
|
||||
}
|
||||
}
|
||||
}, new java.security.SecureRandom());
|
||||
javax.net.ssl.HttpsURLConnection.setDefaultSSLSocketFactory(ctx.getSocketFactory());
|
||||
javax.net.ssl.HttpsURLConnection.setDefaultHostnameVerifier(
|
||||
new javax.net.ssl.HostnameVerifier() {
|
||||
public boolean verify(String hostname, javax.net.ssl.SSLSession session) { return true; }
|
||||
});
|
||||
} catch (Exception ignored) {
|
||||
// Leave platform defaults in place: pre-patch behavior, no new failure mode.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Land it as a small utility class plus its inner classes in smali, or as a dexlib2 rewrite that inserts the call at the head of `Application.onCreate` (`dex-patching.md`). Keep it inside the app's own package; do not edit third-party classes.
|
||||
|
||||
Why this shape is correct:
|
||||
|
||||
- It changes **one** trust decision: the default `HttpsURLConnection` factory and verifier. OkHttp derives its trust manager through `TrustManagerFactory` and its own socket factory, so app-side pinning and verifier behavior is untouched. You are not silently weakening a control the app deliberately added.
|
||||
- Call it **before any other initialization**. Globals set after the first `HttpsURLConnection` use still apply, but a library that cached a factory reference earlier keeps the old one, which produces a confusing partial fix.
|
||||
- The scope is still wider than "login". **Every request that goes through `URL.openConnection()`** inherits it, including image loaders, download managers, and older push or analytics SDKs. State that in the report instead of implying you touched one endpoint.
|
||||
- The `catch` that leaves platform defaults alone is deliberate: the worst case is the behavior you started with.
|
||||
- This is a **client-side workaround for an external condition**. The correct fix is renewing the server certificate. Say so, and do not present the patch as the fix.
|
||||
|
||||
Do not: change the device clock, install your own CA into the device trust store (device-side, harder to roll back, and not part of a shippable artifact), rewrite the request host to a different domain, or reach for `System.setProperty` tricks. Each of them either misses the actual cause or widens the change far beyond the failing flow.
|
||||
|
||||
## If OkHttp is the failing path
|
||||
|
||||
Then the failure is about the app's own client configuration, not the platform defaults. Typical causes: a pin that no longer matches, a hostname verifier rejecting a changed host, or a custom `SSLSocketFactory` built from a bundled keystore. Locate it the same way (Step 4, item 2) and patch at the client construction site instead of applying the template above. `dynamic-frida.md` covers finding the real construction site; `dex-patching.md` covers the patch layer.
|
||||
|
||||
## Method notes
|
||||
|
||||
- **Order of investigation is the lesson.** Every piece of evidence above comes from the server, the network, or the platform before anything is attributed to your patch: full exception chain, real host, out-of-band validation, second-host comparison, control build. Only when all of them point at a client trust decision is a patch justified. `pitfalls.md` P16 is this mistake written up as a failure.
|
||||
- **This is a control-build case, not a patch case.** `verification.md` §the control build rule applies directly: same pipeline, zero patches. If the control fails the same way, stop debugging the patch.
|
||||
- **Report the residual honestly.** "The server certificate is expired; the client patch relaxes validation on the `HttpsURLConnection` path only, OkHttp is unchanged, the proper fix is on the server" is the useful statement. "I fixed login" is not.
|
||||
- **Claim strength.** This evidence chain reaches rung 6 on the `verification.md` claim ladder: the mechanism is proven, not merely observed. Say which rung you reached.
|
||||
|
||||
## Triage checklist
|
||||
|
||||
```
|
||||
[ ] Full exception chain captured, contains "timestamp check failed"
|
||||
[ ] Exact host and URL obtained from a runtime probe (not from strings)
|
||||
[ ] Standalone strict validation run: EXPIRED / clean pass
|
||||
[ ] A second host from the same app compared (isolates the certificate)
|
||||
[ ] Device clock checked against a known-correct source
|
||||
[ ] Original unpatched build reproduces the same failure (control build)
|
||||
[ ] Failing request attributed to a trust manager implementation, not to a message
|
||||
[ ] If system path: permissive defaults installed at the top of Application.onCreate
|
||||
[ ] If OkHttp path: client-level config is the patch target instead
|
||||
[ ] Delivery notes state the client-side scope and the server-side root cause
|
||||
```
|
||||
@@ -0,0 +1,598 @@
|
||||
# Toolchain — What to Use, How to Invoke It, and Where It Lies
|
||||
|
||||
Load this when you are choosing tools, when a tool produces an answer that smells wrong, or when
|
||||
something is not installed. It is a map, not a tutorial: each entry says what the tool is *for*, how
|
||||
to drive it non-interactively, and the failure mode that wastes time.
|
||||
|
||||
**Prefer tools you can drive from a command line.** An agent cannot click. A tool that ships only a
|
||||
GUI is not automatically out of reach — check for a headless or MCP path first, which is an
|
||||
install-and-set-up step rather than a reason to substitute the tool. When no such route exists, ask
|
||||
for a human instead of stalling or silently downgrading to a weaker method.
|
||||
|
||||
## Tier 0 — present on almost any machine, no install
|
||||
|
||||
Use these before reaching for anything heavier. They answer structure questions in seconds and
|
||||
cannot be broken by a missing dependency.
|
||||
|
||||
| Tool | Use it for |
|
||||
|---|---|
|
||||
| `file`, `readelf -h/-l/-d/-r`, `objdump -d`, `nm -D`, `strings` | ELF structure, program headers, dynamic section, relocations, dynamic symbols |
|
||||
| `unzip -l`, `zipinfo` | what is actually inside an APK, entry sizes and compression |
|
||||
| `sha256sum` / `Get-FileHash` | identity. **Always hash before and after; never trust "it should be the same file"** |
|
||||
| `xxd` / `hexdump` | byte-level ground truth when a tool disagrees with another tool |
|
||||
| `python3` | all the scripts here; the standard library alone covers most parsing |
|
||||
|
||||
`readelf`/`objdump` walk **section** headers. If a target's section table is forged (see
|
||||
`native-tamper-and-suicide.md` §Forged section headers) they will print confidently wrong output —
|
||||
cross-check with the program headers.
|
||||
|
||||
## Tier 1 — dex and Java
|
||||
|
||||
| Tool | Invoke | Why this one |
|
||||
|---|---|---|
|
||||
| `baksmali` / `smali` (jars) | `java -cp <jars> org.jf.baksmali.Main d <dex> -o <dir>` | round-tripping, reading a method precisely |
|
||||
| `dexlib2` | small Java program | method-level rewrite, leaves everything else untouched (`dex-patching.md`) |
|
||||
| **`rasc`** (Rust ASC) | `rasc findrefs app.apk string <S>` | **the same index, 4–15× faster**, no Python runtime, a Rust DEX decompiler behind `getclass`. Reach for this FIRST when it is built — its one blind spot is documented in `rasc-and-droidsaw.md` |
|
||||
| **`droidasc`** (ASC) | `droidasc findrefs app.apk string <S>` | the original Python index, one `pip install` away, and the cross-check for `rasc`. Reach for it FIRST when `rasc` is not built |
|
||||
| **`ddc`** | `ddc app.apk -c <Class>` | **fastest read of dex as Java, plus query subcommands** — reach for this SECOND, to *read*. See below |
|
||||
| `apktool` | `java -jar apktool.jar d/b` | whole-app decode including resources |
|
||||
| `jadx` | `jadx --no-res -d <out> <apk>` | readable Java for orientation; **not** a source of truth, and **not** a recon entry point (see below) |
|
||||
| `aapt2` | `aapt2 dump badging <apk>` | manifest facts, package name, versions |
|
||||
| `zipalign`, `apksigner` | from build-tools | alignment and signing |
|
||||
|
||||
### rasc — the same index in Rust, when you have built it
|
||||
|
||||
`rasc` is the `rust` branch of the same project (`MG1937/ASC`). Same CLI shape — `classes`,
|
||||
`manifest`, `getclass`, `findrefs {string,type,method,field}` — no Python at all, and a 2.13 MB
|
||||
binary. **Measured on this repository's archives: identical class-definition sets on both a 1.4 MB
|
||||
MASTG challenge and a 34.8 MB app (30,768 classes, 0 differences either way), at 4.0–4.7× for
|
||||
`classes` and up to 14.7× for `findrefs`.**
|
||||
|
||||
It must be built — there is no release asset and no crate — so the kit wraps that:
|
||||
|
||||
```bash
|
||||
python scripts/rasc_build.py --check # what is present
|
||||
python scripts/rasc_build.py --build # needs git + rustup; ~2 min
|
||||
python scripts/rasc_build.py --verify app.apk # compares class sets against droidasc, fails on a difference
|
||||
```
|
||||
|
||||
**It is a registered capability, so G2 does not have to guess:** `doctor.py` reports `dex_index_rust`
|
||||
as `OK` when a binary is present (PATH, `APKREV_TOOLS`, or the work-area path `rasc_build.py` builds
|
||||
into) and `BLOCKED` with that build command as the next action when it is not — alongside
|
||||
`dex_index_python` for the `pip install` route. A machine with neither is not stuck: the Python
|
||||
indexer is one command away, and the two are cross-checked against each other by
|
||||
`rasc_build.py --verify`.
|
||||
|
||||
**The one thing to know before trusting its `getclass`:** on an `enum` whose constants override an
|
||||
abstract method, the outer class is printed as a bare constant list and the per-constant bodies are
|
||||
**not inlined** — no warning, and none in the Python tool either, because both leave those bodies in
|
||||
their own subclasses. They are one query away (`Lpkg/Enum$1;`, and both tools decompile those fully);
|
||||
`droidasc`'s outer-class listing is simply richer (2,170 B vs 314 B on the measured class, carrying
|
||||
`$VALUES`, `$values()` and the constructors). Sampling 20 app-like classes from a real app, 17 were
|
||||
judged by the JADX-parity harness and 16 agreed literal for literal; the one that differed was exactly
|
||||
this shape. Use it to *locate* and to read ordinary classes, and read the constants when an enum shows
|
||||
no bodies. The measurements and the failing class are in `references/rasc-and-droidsaw.md`.
|
||||
|
||||
### droidasc (ASC) — ask an APK "who references this?", in one query
|
||||
|
||||
Install it in one line. No JVM, no Android SDK, no GUI, no first-run indexing:
|
||||
|
||||
```bash
|
||||
pip install droidasc # provides the `droidasc` CLI
|
||||
```
|
||||
|
||||
It treats the artifact as a **read-only database** instead of exporting a source tree: it probes the
|
||||
deflate stream in place and rebuilds only the minimal dex it needs, in memory, for the one class you
|
||||
asked about. Nothing is inflated to disk; there is no index-building phase to wait through. (Design
|
||||
and measurements are the author's; the BlackHat EU 2026 Arsenal abstract is the reference.)
|
||||
|
||||
```bash
|
||||
droidasc findrefs app.apk string "/data/app/" # every site that mentions a literal
|
||||
droidasc findrefs app.apk string com.example/sdk # a channel name, a URL fragment, a field name
|
||||
droidasc findrefs app.apk type com/foo/Bar # who references a type
|
||||
droidasc findrefs app.apk method notify --class MainActivity --fuzzy-class
|
||||
droidasc listclass app.apk --prefix com/poc # what classes exist (28210 classes: seconds)
|
||||
droidasc listclass app.apk -o classes.txt
|
||||
droidasc getclass app.apk Lcom/poc/Main; -o Main.java
|
||||
droidasc getmanifest app.apk -o AndroidManifest.xml
|
||||
```
|
||||
|
||||
Each hit names the **class and method** it sits in. That is the whole value: it turns "which of 28,000
|
||||
classes mentions this string" from a crawl over an exported tree into one sub-second query, and it works
|
||||
straight off the APK you were handed.
|
||||
|
||||
**What it is for, and where it stops.** ASC answers *location* questions. It is not where you read a
|
||||
method body, and it does not replace the kit's dex tooling for patching. Its place is the first ten
|
||||
minutes of recon plus every later "where else is this used?" — the questions that otherwise send you
|
||||
grepping a hand-exported smali tree.
|
||||
|
||||
**Three limits, and one of them is a trap.**
|
||||
|
||||
- **A non-ASCII needle is not trustworthy through a shell.** A `findrefs string` query for a non-ASCII
|
||||
literal returned nothing under Windows/PowerShell, and **an empty result is indistinguishable from a
|
||||
broken argument encoding.** Many non-ASCII literals genuinely live in a native library rather than in
|
||||
dex, so "nothing came back" is a plausible answer — which is exactly what makes the unverified
|
||||
negative dangerous. Confirm it another way before recording it: search the dex bytes directly with a
|
||||
`\u`-escaped needle, or run a control query you know must hit (a path fragment, a package name you
|
||||
already read in the manifest) in the same session.
|
||||
- **A mangled class name may return only resources.** `listclass apk --prefix <the plugin's package>`
|
||||
can come back with nothing but `R$anim` / `R$string` / `R$style` entries, which is **not** a dead end:
|
||||
it is evidence the real class was renamed by R8. `findrefs string <the plugin's channel name>` still
|
||||
hands you the obfuscated class implementing it. This is the single situation where ASC is not an
|
||||
alternative to `ddc` but the only route — `ddc -c <FQCN>` needs the post-R8 name to start with.
|
||||
- **A hit is a *reference*, not a *call*.** `findrefs` proves a literal or type is mentioned in a method;
|
||||
whether that method runs on the path you care about is a different question, and the answer comes from
|
||||
the disassembly or from runtime (`dynamic-frida.md`).
|
||||
|
||||
The division of labour is worth stating plainly: **ASC locates, `ddc` reads.** Running a full `ddc`
|
||||
export first, then grepping it, is the slow path this tool exists to replace.
|
||||
|
||||
**Measured in the extension pass** (droidasc `0.1.1.post1`, installed with `python -m pip install
|
||||
droidasc`; dependency is androguard only, no JVM, no SDK):
|
||||
|
||||
| Command | Result on a 38.7 MB hardened APK |
|
||||
|---|---|
|
||||
| `getmanifest` | 0.44 s → 32,345 B of complete XML |
|
||||
| `findrefs … string jiagu` | 0.42 s → hits inside the shell class's `<clinit>` and `attachBaseContext` |
|
||||
| `getclass com/stub/StubApp` | 0.33 s → 22,802 B of Java |
|
||||
| `listclass` (whole APK) | 0.18 s → **4 classes** |
|
||||
|
||||
Two facts from that run are worth carrying into any recon: the CLI name is **`droidasc`**, not `asc`
|
||||
(there is no `asc` binary, and `asc --version` is simply "command not found" — `python -m droidasc`
|
||||
also works), and on a packed APK a full `listclass` returning four classes is not a broken tool: the
|
||||
business dex is not in the file. Reaching for `getclass` on a class that is not there exits **1** with
|
||||
`Class … not found in APK.` — treat ASC as a manifest-and-shell inspector on hardened input, and take
|
||||
the business-logic question to a different layer.
|
||||
|
||||
### ddc — dex-to-Java with query subcommands (worth adopting)
|
||||
|
||||
A single-file Rust binary, no install, no JVM. Two things make it more useful than
|
||||
"a decompiler":
|
||||
|
||||
```bash
|
||||
ddc info app.apk # label, package, version, launcher, sdk, per-dex counts
|
||||
ddc findrefs app.apk string "SomeToken" # every reference site, class#method
|
||||
ddc strings app.apk -f "splash" --with-locations
|
||||
ddc app.apk -c com.example.Foo # one class as Java
|
||||
ddc app.apk -o out/ # full decompile, a few seconds for a normal APK
|
||||
```
|
||||
|
||||
Measured on a 4.7 MB APK (4,070 classes / 30k methods): `info` ~0.13 s, `findrefs`
|
||||
~0.11 s, one class ~0.25 s, full decompile ~2.8 s producing ~4,000 files.
|
||||
|
||||
**Measured in the extension pass** (ddc `0.1.8`, a single Rust binary — on Windows `bin/ddc.exe`,
|
||||
1,028,096 B, **not on PATH** and carrying no PE version resource, so `ddc -V` is the only way to read
|
||||
its version; source: the project's GitHub releases):
|
||||
|
||||
| Command | Result on a 38.7 MB hardened APK |
|
||||
|---|---|
|
||||
| `ddc info` | 0.24 s → label / package / version / application / launcher / sdk / size / md5, plus per-dex class, method, field and string counts |
|
||||
| `ddc findrefs <apk> string jiagu` | 0.13 s → tabulated `dex | kind | class | method | refs` |
|
||||
| `ddc strings -f jiagu --with-locations` | 0.14 s → strings mapped to the methods that hold them |
|
||||
|
||||
**The trap on this one is the exit code.** Asked for a class that is not present, `ddc` exits **2**
|
||||
*and still prints its help text to stdout* — so a wrapper that tests "did anything come back?" reads a
|
||||
usage error as an answer. Branch on the exit code, never on output emptiness. The same run also
|
||||
confirms the layered picture from the other direction: `info`'s per-dex counters report 4 classes and
|
||||
29 methods with code for the whole 8.9 MB shell dex.
|
||||
|
||||
Why the query subcommands matter more than the speed: **string cross-referencing
|
||||
becomes a lookup instead of a crawl.** Asking "which class mentions this config
|
||||
field name" is a sub-second command here and easily an hour of smali grepping by
|
||||
hand. That single property is what turns "find the convergence point" from
|
||||
exploration into a query.
|
||||
|
||||
`info` also **prevents a specific, costly mistake**: reading the package identity
|
||||
out of the binary manifest by hand. Hand-extracted AXML strings are ambiguous —
|
||||
class-name fragments look exactly like package names, and picking the wrong one
|
||||
sends every later `pm`/`dumpsys`/data-dir query to a package that does not exist.
|
||||
Let the tool report `package`, `label` and `launcher`, and cross-check with
|
||||
`aapt2 dump badging` when available.
|
||||
|
||||
Limits to plan around: types are erased (no generics); R8 short names stay short,
|
||||
so you still need string cross-references to infer meaning; output is a **reading
|
||||
aid, not compilable source** (occasional declaration/use ordering is inverted, and
|
||||
numeric resource ids appear as decimal integers); very large methods produce
|
||||
thousands of lines and are better approached via `findrefs`/`getmethod` first.
|
||||
|
||||
Two more, both measured on a 28,210-class R8-flattened Flutter APK:
|
||||
|
||||
- **`-c <FQCN>` cannot reach a renamed class, and `listclasses <pattern>` cannot recover it either.**
|
||||
Asking for the plugin's documented name returned `class not found`; asking for its package prefix
|
||||
returned only `R$anim` / `R$string` / `R$style` resources. The implementing class existed under a
|
||||
short R8 name, reachable only by string cross-reference. **Plan for `droidasc` to close this gap** —
|
||||
it is not a nicety, it is the difference between locating the class and not.
|
||||
- **A decompiled body can be semantically wrong, not merely ugly.** In one 9 KB helper class the
|
||||
compiler emitted a `return 0;` inside a method declared to return `String`, reordered field
|
||||
assignments, and produced empty `if/else` branches. None of that is safe to reason *about* control
|
||||
flow without checking smali or bytecode. Treat a surprising construct as a decompiler artifact until
|
||||
the bytecode agrees.
|
||||
|
||||
**Use `findrefs` to locate, `-c`/`getmethod` to read, and the bytecode to decide.** Reading order
|
||||
matters: a full `ddc <apk> -o out/` export is a reasonable thing to *have*, but grepping that tree is
|
||||
not a substitute for one indexed query, and it is the slower of the two by a wide margin on any APK
|
||||
worth analyzing.
|
||||
|
||||
**Classpath gotcha.** `baksmali`/`smali` need their dependency jars on the classpath together
|
||||
(`smali`, `antlr-runtime`, `stringtemplate`, `baksmali`, `dexlib2`, `util`, `jcommander`, `guava`).
|
||||
Missing one produces `ClassNotFoundException` that reads like a broken target. `scripts/smtool.py`
|
||||
carries the classpath so you pass it once.
|
||||
|
||||
**Entry-point gotcha.** The main class has moved between versions — `org.jf.*` in older builds,
|
||||
`com.android.tools.smali.*` in newer ones. If `ClassNotFoundException` names the **main class**, it
|
||||
is a version mismatch, not a missing jar.
|
||||
|
||||
**jadx is for reading, not for concluding.** Decompiled Java reorders and rewrites control flow;
|
||||
line numbers and even which branch is which can differ from the bytecode. When a decision depends on
|
||||
it, verify in smali or at runtime. `jadx` writing `(unknown)`/empty method bodies means it failed,
|
||||
not that the method is empty.
|
||||
|
||||
**`smali` assembling can abort silently** — the tree produces no dex while the process still exits
|
||||
0. Never treat exit code as proof of a build. Assert the artifact exists, is fresh, and is non-empty
|
||||
(`patch-audit.md`).
|
||||
|
||||
## Tier 2 — native
|
||||
|
||||
| Tool | Invoke | Notes |
|
||||
|---|---|---|
|
||||
| `radare2` / `rizin` | `r2 -q -c '<cmds>' file` | scriptable disassembly/analysis; the practical choice when no GUI is available |
|
||||
| `Ghidra` (headless) | `analyzeHeadless <proj> <name> -import <file> -postScript <s>` | decompiler without a GUI; slower to script but far better output than a raw disassembler |
|
||||
| `capstone` (python) | library | decoding in your own scripts — see the silent-stop trap below |
|
||||
| **`svc_scan.py`** | `python scripts/svc_scan.py libfoo.so [--context 3]` | **names the syscall behind an inline `svc`** and reports which PT_LOAD segment each hit is in. Use it *before* planning a libc-level hook: a module with its own `exit`/`kill` `svc` sites is not observable through libc, and one without them means a missing event is a finding about your hook. Read the neighbours (`--context`) — a byte scan for `svc` also matches inside data. Measured on the test device: device `libc.so` = 4 termination sites (its own exports), a shell library's 21 hits = all data |
|
||||
| `keystone` (python) | library | assembling a short patch when you are editing bytes by hand |
|
||||
| `pyelftools` (python) | library | **section-based — unreliable on hardened targets.** Prefer hand-walking `PT_LOAD`/`PT_DYNAMIC`, as `scripts/elf_plt.py` does |
|
||||
| IDA Pro | GUI, **or headless via `idalib-mcp`** | the strongest decompiler output. Do not assume it needs a human in front of it — a headless MCP server exists (see *MCP tool servers* below). Ask for a human only when that setup is genuinely unavailable |
|
||||
| `x64dbg` | GUI, Windows | human-driven live debugging of a native Windows target |
|
||||
| `gdb` / `lldb` | CLI | live debugging where a device or emulator allows it |
|
||||
|
||||
**capstone can stop silently.** Decoding a buffer that does not begin at an instruction boundary may
|
||||
return a few instructions and then nothing, with no error. A scan built on that reports "no matches"
|
||||
for a library full of matches. Use byte-pattern search or a resynchronising scan for anything where
|
||||
completeness matters (`native-tamper-and-suicide.md` §Scanner traps).
|
||||
|
||||
**Disassembler output is a hypothesis.** Fixed-width architectures (aarch64) decode almost any
|
||||
4-byte window into *some* instruction, so a wrong start offset yields plausible-looking garbage.
|
||||
Bound your window with a known entry point or a known call site.
|
||||
|
||||
**A decoder is a decoder, not an oracle.** Two independent decoders agreeing is the signal worth
|
||||
having (measured: a hand-written word scan for the `svc` encoding and the `capstone`-based scan in
|
||||
`svc_scan.py` returned the identical 214-site set on a device `linker64`, set difference empty); one
|
||||
decoder's disagreement with itself is not. **`dexdump`** (from build-tools, e.g.
|
||||
`E:\tools\android-14\dexdump.exe`) is the cheap independent reader for the dex half of this: use it
|
||||
to count a dump's class and method records rather than trusting a self-written parser, the same way
|
||||
`advanced-unpacking.md` §Establish "the bodies do not decode" with a decoder you did not write
|
||||
requires.
|
||||
|
||||
### Ghidra — the decompiler for a target whose decisive layer is aarch64
|
||||
|
||||
The recurring shape: the app is fine, the dex is readable, and the layer that actually decides
|
||||
behaviour is a stripped aarch64 `.so`. A disassembler gives you instructions; only a decompiler gives
|
||||
you control flow. **Hand-walking an OLLVM control-flow-flattened function with `capstone` is the single
|
||||
most expensive route available**, and it is what you fall into when no decompiler is installed.
|
||||
|
||||
Ghidra is free, runs headless, and decompiles aarch64. Setup is unzip plus a JDK (11+), which is why
|
||||
"there is no decompiler on this box" is normally an install step rather than a finding about the target
|
||||
(§Closing a capability gap).
|
||||
|
||||
```bash
|
||||
# one-time: unzip the release; point Ghidra at a JDK (JAVA_HOME or the launch script)
|
||||
# headless: import, auto-analyse, run a post-script, discard the project
|
||||
analyzeHeadless <proj_dir> <proj_name> -import target.so \
|
||||
-postScript DumpFunction.java <addr_or_symbol> -scriptPath <dir> -deleteProject
|
||||
```
|
||||
|
||||
Three caveats learned the hard way:
|
||||
|
||||
- **`analyzeHeadless` is slow and its auto-analysis is not free.** Import plus analysis of a 6 MB
|
||||
stripped library is minutes, so it is exactly the kind of run that needs an explicit timeout. Budget
|
||||
it once and reuse the project; do not re-import per question.
|
||||
- **Flattened control flow defeats the decompiler as well.** A `br x8` dispatcher with four constant
|
||||
arithmetic operations in every function header is OLLVM flattening, and the output is a state machine
|
||||
that resembles the program without being readable. **Removing the flattening is the work**
|
||||
(`code-virtualization-and-custom-linkers.md`), not a prerequisite you can skip.
|
||||
- **A decompiler cannot go where the data is not.** When a library's strings and action names are
|
||||
**encrypted in the file and exist only once the module is mapped in memory**, no static tool recovers
|
||||
them by reading harder — escalate to runtime (`dynamic-frida.md`) instead of escalating the static
|
||||
toolchain. Telling "install a decompiler" apart from "the decompiler cannot help here" is worth real
|
||||
time: the two look identical from the outside, and only the first has a static answer.
|
||||
|
||||
## Closing a capability gap — installing the tool IS the task
|
||||
|
||||
A missing tool for the layer you must work in is not a constraint to design around. It is the next
|
||||
step. The measured cost of installation is almost always smaller than the cost of the workaround, and
|
||||
the workaround is what produces "we could not determine X" reports on targets where X was decidable.
|
||||
|
||||
**The decision is not "can I do without it" — it is "how long does installing it take".** Three real
|
||||
numbers, from one Windows box working an R8-flattened Flutter target:
|
||||
|
||||
| Gap | Install cost | What the workaround would have cost |
|
||||
|---|---|---|
|
||||
| No whole-APK cross-reference index | `pip install droidasc` — one command, seconds, no JVM, no SDK | hours of `grep` over a hand-exported smali tree, **per question asked** |
|
||||
| No arm64 decompiler | Ghidra — unzip plus the JDK already present; minutes | a manual ELF/`capstone` walk over an OLLVM control-flow-flattened 6 MB library, per function |
|
||||
| No Dart AOT snapshot front end | `aotopsy` — unzip and run (pure Go, no toolchain); or `blutter`, measured **≈78 s** end to end | "pool offsets can never be mapped", which turned out to be false |
|
||||
|
||||
The third row is the instructive one. The documented cost ("tens of minutes for the first build") was
|
||||
about **30× pessimistic**, and the documented toolchain requirement was overstated. **A tool's stated
|
||||
prerequisites are a claim, not a measurement**, and the cost of checking is one attempt.
|
||||
|
||||
Rules that follow:
|
||||
|
||||
1. **Write the capability down before spending against it.** "arm64 decompilation — absent — install
|
||||
Ghidra" is a task item. "arm64 decompilation — absent" with nothing after it is how a route gets
|
||||
written off for the wrong reason.
|
||||
2. **Check off-PATH before declaring anything absent** (§"not on PATH" is not "not installed").
|
||||
`APKREV_TOOLS` exists so a project-local tools directory or an SDK folder resolves.
|
||||
3. **Install, do not substitute.** A weaker tool's output presented as equivalent to the stronger
|
||||
tool's output is a wrong conclusion with a clean audit trail — worse than an admitted gap.
|
||||
4. **Report the gap only when it is real.** "No arm64 decompiler exists here and it cannot be installed
|
||||
because `<reason>`" is a valid finding. "I used `readelf` instead" is a different statement, and it
|
||||
is not a finding about the target.
|
||||
5. **"Paid" and "GUI-only" are questions, not walls.** IDA has a headless MCP path; Ghidra has
|
||||
`analyzeHeadless`; both are set-up steps. Ask a human only after that route is genuinely
|
||||
unavailable.
|
||||
6. **One real disqualifier.** A decompiler cannot go where the data is not: if a target's strings and
|
||||
action names are **encrypted in the file and only exist in memory**, then no static tool recovers
|
||||
them by reading harder. Recognise that shape early and move the effort to runtime
|
||||
(`dynamic-frida.md`) instead of escalating the static tools — that distinction is the difference
|
||||
between "install a decompiler" and "the decompiler cannot help here", and they look identical from
|
||||
the outside.
|
||||
|
||||
## Where to get a tool we do not ship — a sourced gap list, not a link dump
|
||||
|
||||
The list below exists because "install the tool" needs a *source*, and searching for one on a target's
|
||||
clock is how a ten-minute install becomes an hour. **Two grades are used here and they are different
|
||||
claims:** `URL verified` means the repository was resolved on the date given; `tool unverified` means
|
||||
nobody has run it here. Most rows are `URL verified / tool unverified` — treat them as leads with a
|
||||
date on them, not as recommended tools, and check the row's own prerequisites when you install it
|
||||
(§Closing a capability gap — installing the tool IS the task). Rows marked `measured` have been used here.
|
||||
|
||||
| Gap | Source | Grade / what is actually known |
|
||||
|---|---|---|
|
||||
| Decompiler for dex/Java | `https://github.com/skylot/jadx` | URL verified 2026-09-21; tool unverified here (not installed). The kit's route is `droidasc`/`ddc` for queries and jadx only to *read* a class already located |
|
||||
| Repacking beyond `scripts/repack.py` | `https://github.com/iBotPeaches/Apktool` | URL verified 2026-09-21; tool unverified. Not installed on this host, and the `apktool.bat` in `E:\tools\bin` is a dead link — do not trust a `.bat` to mean the tool works |
|
||||
| smali round-trip outside the bundled dexlib2 kit | `https://github.com/JesusFreke/smali` | URL verified 2026-09-21, **last upstream push 2024-01-17** — maintenance status is a fact to check before adopting it for a new dex version |
|
||||
| Anti-detection on the frida side (patched server plus a script surface aimed at RASP) | `https://github.com/CrackerCat/strongR-frida-android` | URL verified 2026-09-21; tool unverified. A patched `frida-server` is an **environment** change: record it in the task record, because results obtained under it are not comparable to a run without it |
|
||||
| A dex dumper that does not use `ptrace` | `https://github.com/index-login/MobileRE-Skill` (`.kilo/skill/rev-dex-dumper/`) — `panda-dex-dumper`, `mem-dex-dumper` + C source | **Partly measured**: both ELF images were inspected here and neither imports a `ptrace` symbol (`panda` = aarch64 `ET_DYN`, 48 symbols; `mem` = aarch64 static, stripped), so the *claim* is consistent at the symbol layer; the **dump behaviour was not run here**. Our own `/proc/<pid>/mem` read is `advanced-unpacking.md`'s root-side route and is measured |
|
||||
| eBPF-based dex extraction | `https://github.com/LLeavesG/eBPFDexDumper` | URL verified 2026-09-21; tool unverified, **and unusable on `<DEVICE>` anyway** — kernel 4.14 against a 5.10+ requirement (`kernel-and-environment-hardening.md`). Listed so the next reader does not re-derive the version gate from scratch |
|
||||
| Decompilation/deobfuscation/unpacking without a JVM | `https://github.com/adam-040/Enigma`, `https://github.com/1-3-7/disrobe` | URLs verified 2026-09-21; tools unverified. Both are interesting precisely because they remove the JVM/IDE dependency a Ghidra or IDA route carries — evaluate prerequisites before adopting |
|
||||
| Java2C generation to *build* a fixture | `https://github.com/amimo/dcc` | **Partly measured here**: `dcc` itself was run in a previous pass, but the C compile needs an NDK this host does not have, so no Java2C artifact was ever produced (`java2c-and-jni-sinking.md`) |
|
||||
| Building an LSPosed module without gradle, or comparing against a maintained template | `https://github.com/Jordan231111/lsposed-universal-template` | URL verified 2026-09-21; tool unverified. Its layout is gradle-based, which this host cannot build — useful as a **manifest/scope reference** only. (`mabbcoll13/xposed-module-kit` no longer resolves: HTTP 404 on 2026-09-21, which is why this row's date is worth writing down) |
|
||||
| Dart AOT snapshot front end | see the §Closing a capability gap table above | measured here: `aotopsy` runs with no toolchain; `blutter` measured ≈78 s end to end on this host |
|
||||
|
||||
Two habits that keep this list from rotting: **write the access date next to a URL** (a dead link with
|
||||
no date reads as a current recommendation — one row above is already 404), and **promote a row only
|
||||
when someone runs the tool**, so `URL verified` never silently becomes "we use this".
|
||||
|
||||
## MCP tool servers — an external dependency, not a tool on the shelf
|
||||
|
||||
An MCP server is neither a CLI tool nor one of the scripts here. It is a **separate installation plus
|
||||
a running process**: something to install, a service to start, usually an extension to load, often an
|
||||
authorization step. Listing one beside `readelf` would imply it is already present. Declare it as a
|
||||
dependency with its prerequisites, and check whether it is actually available *before* planning work
|
||||
that needs it — discovering this mid-task is the "missing tool" failure again.
|
||||
|
||||
### IDA Pro, headless
|
||||
|
||||
IDA does **not** require a human at a GUI. `mrexodia/ida-pro-mcp` ships `idalib-mcp`, a headless MCP
|
||||
server that drives an IDA database with no GUI process at all:
|
||||
|
||||
```sh
|
||||
uv run idalib-mcp --host 127.0.0.1 --port 8745 path/to/executable # open a binary up front
|
||||
uv run idalib-mcp --host 127.0.0.1 --port 8745 # open databases on demand
|
||||
uv run idalib-mcp --stdio # for stdio-based clients
|
||||
```
|
||||
|
||||
Prerequisites, all of which must be arranged before the first call: IDA Pro 8.3+ (9 recommended;
|
||||
**IDA Free is not supported**), a Python 3.11+ that `idapyswitch` can select, `uv`, and an `idalib`
|
||||
activated globally via `py-activate-idalib.py`. Each open database lives in a worker process that
|
||||
outlives the supervisor and is adopted transparently by a later supervisor on the same host, so
|
||||
several sessions can share one analysis. Every tool call carries an explicit `database` argument —
|
||||
there is no implicit "current database" — and `idb_open` returns the session id you must pass.
|
||||
|
||||
The GUI-plugin variant of the same project is deprecated upstream in favour of `idalib-mcp`; do not
|
||||
set that up for agent work.
|
||||
|
||||
### Ghidra
|
||||
|
||||
`LaurieWired/GhidraMCP` is a Ghidra **extension** plus a separate Python bridge, not a headless
|
||||
analysis server: Ghidra must be running with the plugin loaded (its HTTP server defaults to
|
||||
`127.0.0.1:8080`) and the bridge is another process your client starts. Treat it as requiring an
|
||||
interactive GUI session. For unattended work the shape that actually runs without a GUI is
|
||||
`analyzeHeadless` with a post-script.
|
||||
|
||||
### The boundary: deobfuscate before you hand the binary to a model
|
||||
|
||||
Both are LLM-driven, and they inherit the model's weaknesses. The IDA MCP authors say it plainly in
|
||||
their own README: **"LLMs will not perform well on obfuscated code"**, and they advise removing
|
||||
string encryption, import hashing, control flow flattening, code encryption and anti-decompilation
|
||||
tricks *before* asking a model to solve anything.
|
||||
|
||||
That matters more here than in most domains, because it is precisely the shape of the targets this
|
||||
skill deals with. An OLLVM-style or virtualized binary is not a job for a model reading decompiler
|
||||
output; the deobfuscated version is a *different binary*, and producing it is the work already
|
||||
described in `code-virtualization-and-custom-linkers.md` and
|
||||
`native-tamper-and-suicide.md`. The same advice has a second half: resolve library code first
|
||||
(FLIRT/Lumina) so the model is not asked to reason about `memcpy` as if it were the program.
|
||||
|
||||
Two consequences worth carrying into the plan:
|
||||
|
||||
- Ask a model for **navigation and reading** — rename, comment, summarise a function, convert an
|
||||
immediate — and verify anything that decides the patch against the disassembly yourself.
|
||||
- If the server is not installed, that is a **missing dependency to report**, not a licence to present
|
||||
a weaker tool's output as equivalent (`packers.md`).
|
||||
|
||||
## Tier 3 — runtime and network
|
||||
|
||||
| Tool | Invoke | Notes |
|
||||
|---|---|---|
|
||||
| `frida` (host) + `frida-server` (device) | `frida -U -f <pkg> -l script.js` | **host and device versions must match** — skew produces errors that look like a broken target (`pitfalls.md` P15) |
|
||||
| `objection` | `objection -g <pkg> explore` | quick Java-layer poking on top of Frida |
|
||||
| `mitmproxy` | `mitmproxy --mode regular` | request/response inspection; a device proxy is usually faster to set up than a transparent one |
|
||||
| `HaE` (Burp extension) | load into Burp, then read its rule set | **traffic *triage*, not traffic capture.** See below |
|
||||
| `adb` | everything | the primary device interface |
|
||||
|
||||
**HaE is a reader, not a capture tool, and it is not always the right next step.** HaE (Highlighter and
|
||||
Extractor, `overspace-labs/HaENet`) is a Burp Suite extension that tags and extracts fields from HTTP
|
||||
messages — tokens, IDs, secrets, endpoints, fingerprints — so that a large session becomes readable
|
||||
without hand-scanning every request. It is genuinely useful when the bottleneck is *"there is traffic
|
||||
and I cannot see the parts that matter."*
|
||||
|
||||
It is the wrong tool when the bottleneck is one of these three, and reaching for it there costs rounds:
|
||||
|
||||
- **The requests never leave the client.** No capture tool can display a request that was never built
|
||||
(`code-virtualization-and-custom-linkers.md` §what the native check actually reads). **Establish
|
||||
whether traffic exists before buying tooling to read it** — a DNS/`connect`-level probe answers that
|
||||
in a single run, and on the measured target it was the observation that ended the investigation
|
||||
branch, not a capture.
|
||||
- **The body is encrypted by the app**, so the capture is ciphertext (`server-api.md`).
|
||||
- **Certificate pinning defeats the proxy**, in which case the empty capture is itself the finding
|
||||
(`tls-and-cert.md`).
|
||||
|
||||
Ordering that follows: **first prove there is readable traffic, then choose the reader.** `mitmproxy`
|
||||
captures; HaE triages; neither substitutes for the DNS/connect check that decides whether there is
|
||||
anything to triage. Treat "install a Burp plugin" as a step that must be justified by an existing
|
||||
capture, not as recon.
|
||||
|
||||
Version alignment for Frida is a **hard gate**, not a nicety. Check both sides before writing a
|
||||
script, and check the device architecture — the server binary is per-ABI.
|
||||
|
||||
## Tier 4 — cross-platform runtimes
|
||||
|
||||
| Runtime | Identifier | Tool |
|
||||
|---|---|---|
|
||||
| Flutter / Dart | `libflutter.so` + `libapp.so` | `blutter` (needs the **matching Dart version**) — `dart-aot.md` |
|
||||
| Unity / IL2CPP | `libil2cpp.so` + `global-metadata.dat` | Il2CppDumper + a native decompiler |
|
||||
| React Native | `libhermes.so` / `index.android.bundle` | Hermes bytecode tooling, or plain JS if the bundle is unminified |
|
||||
| Cordova / hybrid | WebView + `assets/www` | read the JS directly; Java layer is usually thin |
|
||||
|
||||
**A version mismatch here wastes the most time of any tool in this file.** A Dart decompiler built
|
||||
for a different engine version produces output that is subtly wrong rather than obviously broken.
|
||||
Pin the version first (`dart-aot.md`) and do not "try it and see".
|
||||
|
||||
## Using the kit's scripts instead of writing your own
|
||||
|
||||
The scripts exist so that the *expensive, generic* parts of this work — decoding a dex instruction at an
|
||||
exact offset, recomputing a dex header in the right order, writing a 4-byte-aligned archive, resolving a
|
||||
PLT stub, bursting screenshots with evidence attached — are not re-derived per task.
|
||||
|
||||
Re-deriving them is not neutral. A hand-rolled instruction decode that silently loses sync, or a
|
||||
hand-rolled repack that quietly drops alignment, produces **a confident wrong answer**, which is the
|
||||
exact failure class this skill exists to prevent. The cost shows up later, as a patch that "had no
|
||||
effect" or an install refused with a bare numeric code.
|
||||
|
||||
**The rule: before writing a script, check whether one here already does it.** The index is in
|
||||
`SKILL.md`; the entry points that matter most are `doctor.py` (what can run here),
|
||||
`dex_find_insn.py` (get an exact offset instead of guessing one), `dex_patch_bytes.py` (apply and prove
|
||||
an equal-length patch), `apk_diff.py` (prove your own build was surgical), `repack.py` (aligned rebuild)
|
||||
and `snap.py` / `coldstart.py` (look at the screen with the evidence attached).
|
||||
|
||||
**When a script here is genuinely wrong, that is a finding to fix and report — not a reason to quietly
|
||||
route around it.** Two live examples of the difference:
|
||||
|
||||
- A decoder utility in this kit carried an **opcode name/width table misaligned by one entry across
|
||||
`0x16`–`0x2C`** (23 rows). Everything decoding through that range **silently lost sync** and produced
|
||||
offsets that were plausible and wrong — no exception, no warning. It was found by comparing against an
|
||||
independent decoder and noticing a disagreement. **If two tools disagree, the disagreement is the
|
||||
product**; it is the cheapest bug signal in this domain and the easiest to throw away by picking the
|
||||
answer you preferred.
|
||||
- Conversely, a *tool's* negative result is not a finding about the target (§the general trap below).
|
||||
Establish that the tool could have found the thing before recording that the thing is absent —
|
||||
especially for string searches, where an empty result is exactly what an unfound string looks like.
|
||||
|
||||
**Corollary for reporting.** "The kit's script X reported Y, and I cross-checked it against Z" is a
|
||||
conclusion. "I wrote my own parser because the script was awkward" is a gap you introduced, and it
|
||||
should be reported as one.
|
||||
|
||||
## "Not on PATH" is not "not installed"
|
||||
|
||||
`doctor.py` and `preflight.py` report whether each tool resolves on `PATH`. That
|
||||
is a statement about `PATH`, not about the machine, and the difference has cost
|
||||
real time: a full custom zip writer was built to work around a missing
|
||||
`apksigner` that was already installed in an SDK directory nothing had added to
|
||||
`PATH`.
|
||||
|
||||
**Before treating a tool as absent, search the filesystem once:**
|
||||
|
||||
```bash
|
||||
# POSIX
|
||||
find / -name apksigner -o -name zipalign -o -name baksmali.jar 2>/dev/null | head
|
||||
|
||||
# Windows (PowerShell) -- list every drive letter first, then search each
|
||||
(Get-PSDrive -PSProvider FileSystem).Root |
|
||||
ForEach-Object { Get-ChildItem -Path $_ -Recurse -Depth 5 -ErrorAction SilentlyContinue `
|
||||
-Include apksigner.bat,zipalign.exe,keytool.exe }
|
||||
```
|
||||
|
||||
Common places that are *not* on `PATH`:
|
||||
|
||||
| Artefact | Typical location |
|
||||
|---|---|
|
||||
| `apksigner`, `zipalign`, `aapt2` | any `build-tools/<ver>/` under an Android SDK, and portable tool bundles |
|
||||
| `apksigner.jar` | inside those same directories (runnable as `java -jar`) |
|
||||
| `keytool`, `jarsigner`, `javac` | a JDK install whose `bin` was never added to `PATH`; `java -XshowSettings:properties -version 2>&1 \| grep java.home` reveals the JDK root even when `keytool` does not resolve |
|
||||
| `uber-apk-signer.jar`, `baksmali*.jar` | bundled with portable APK toolkits |
|
||||
|
||||
Record the resolved absolute paths once, in your running notes, and pass them to
|
||||
the scripts that accept `--apksigner` / `--zipalign` / `--ks` style flags. Every
|
||||
script in this repository takes an explicit path for this reason.
|
||||
|
||||
**A useful asymmetry:** missing `apksigner` is survivable (a v2-only signature
|
||||
block can be appended without reordering the archive), but missing `zipalign` is
|
||||
not a reason to hand-roll alignment either -- write the archive aligned in the
|
||||
first place, as `scripts/repack.py` does. Prefer producing a correct archive over
|
||||
repairing one afterwards: post-hoc alignment tools rewrite the file, which
|
||||
changes every offset and can break a target that fingerprints its own layout.
|
||||
|
||||
## Signing: pick the signer deliberately
|
||||
|
||||
Two signers, and the choice changes the output bytes:
|
||||
|
||||
- **`apksigner`** (build-tools) appends the v2/v3 signature block after the zip
|
||||
content. It does **not** reorder entries, so a carefully aligned archive stays
|
||||
aligned. This is the default choice.
|
||||
- **`jarsigner`** (JDK) rewrites the archive as a side effect of adding the v1 JAR
|
||||
signature, which recompresses entries and **destroys the alignment** that
|
||||
Android R+ requires. A build signed this way can fail to install with
|
||||
`Failure [-124] ... resources.arsc ... aligned on a 4-byte boundary` even though
|
||||
the same archive installed fine moments earlier, and `jarsigner -verify` reports
|
||||
success. If you have run `jarsigner` on an archive and a previously-working
|
||||
install starts failing, re-pack and sign with `apksigner` before investigating
|
||||
anything else.
|
||||
|
||||
Enable **v1 + v2 + v3**. v1 keeps very old devices working; v2/v3 are what modern
|
||||
platforms actually verify, and v1-only builds are rejected in more configurations
|
||||
than people expect.
|
||||
|
||||
## The general trap: a tool's failure is not a finding about the target
|
||||
|
||||
A missing jar, a version skew, a forged section header, and a genuinely absent symbol all produce
|
||||
**empty or partial output**. The output looks the same; the conclusions are completely different.
|
||||
|
||||
Before reporting "there is nothing there", confirm the tool was actually capable of finding it:
|
||||
|
||||
- Did it read the file at all? (entry count, segment count, non-zero output somewhere)
|
||||
- Does a **known-present** item show up? (search for something you already know exists — a symbol
|
||||
you saw in the dynamic table, a string you read in the hex dump)
|
||||
- Would a different method see it? (byte pattern vs decoded scan)
|
||||
|
||||
**This is `pitfalls.md` P25 and P26 in tool form.** A self-built analyser that fails quietly reads
|
||||
exactly like a clean target, and it removes routes from consideration for free.
|
||||
|
||||
## Working without a network
|
||||
|
||||
Assume nothing can be downloaded. What still works, in order of usefulness:
|
||||
|
||||
1. Tier 0 in full, plus the Python standard library — enough for structure, strings, hashing, and
|
||||
most ELF/dex parsing.
|
||||
2. Whatever jars and binaries are already present. **Locate them once and reuse the paths** rather
|
||||
than re-searching on every command.
|
||||
3. A local package cache or an offline mirror, if the environment has one.
|
||||
|
||||
If a tool genuinely cannot be obtained, say which step is blocked and **what a substitute would
|
||||
give up** — do not silently substitute a weaker method and report its result as equivalent.
|
||||
|
||||
## Recording the toolchain actually used
|
||||
|
||||
When you report, name the tools and versions that produced each finding. Two agents with different
|
||||
tool versions will reach different conclusions about the same binary, and without the version that
|
||||
difference is unresolvable later.
|
||||
@@ -0,0 +1,243 @@
|
||||
# Updates and forced upgrade
|
||||
|
||||
Load this whenever the deliverable is a patched build that must **keep working over time** — which is
|
||||
almost always. A build that is correct on the day you ship it and dead a week later has failed, and the
|
||||
cause is nearly always an update path you did not neutralise.
|
||||
|
||||
This is the file that decides whether a patch is *durable*, and it is cheap: the whole job is usually
|
||||
one or two edits.
|
||||
|
||||
## Why this is not optional
|
||||
|
||||
Three independent failure modes, all of which present as "the mod stopped working" with no obvious link
|
||||
to updates:
|
||||
|
||||
1. **Forced upgrade.** The app checks a version endpoint, sees a newer build, and blocks its UI behind a
|
||||
non-dismissible dialog until the user installs the official (unpatched) package. Your patch is gone.
|
||||
2. **Self-update install.** The app downloads an APK and hands it to the package installer. Because the
|
||||
downloaded build is signed differently, the install either fails or — worse — succeeds and replaces
|
||||
your work.
|
||||
3. **Silent "compatibility" downgrade.** The server decides the client version is too old and starts
|
||||
omitting fields or returning an error page. No local code changed, so nothing local looks broken.
|
||||
|
||||
A fourth case is a **resource / hot-update channel**: the app fetches a bundle (JS, a Dart/RN patch, a
|
||||
config blob, a plugin APK) that can reintroduce the behaviour you removed. That is a separate code path
|
||||
from version checking — handle it explicitly if the app has one (see §Step 5 below).
|
||||
|
||||
## Step 1: locate the check
|
||||
|
||||
There is usually exactly **one** update service. Find it before patching anything else.
|
||||
|
||||
**From the client's own vocabulary** (works on any runtime):
|
||||
|
||||
```
|
||||
update / upgrade / new version / check version / force update
|
||||
版本更新 / 检查更新 / 发现新版本 / 立即更新 / 强制更新
|
||||
```
|
||||
|
||||
Search those in the app's string table and in class names; on heavy runtimes search the string pool,
|
||||
remembering its encoding (see `framework-runtimes.md` §Locating logic without symbols — searching only one encoding
|
||||
misses half your hits, and "nothing found" is usually the encoding, not an absence).
|
||||
|
||||
**Signals that a version check exists even if you cannot read the strings:**
|
||||
|
||||
- A network call early in startup whose response contains a version number, a download URL, and
|
||||
sometimes a `force` / `must` / `minimum` flag.
|
||||
- A stored "last update check time" preference (throttling a check implies one exists).
|
||||
- The app ships the package installer path or requests `REQUEST_INSTALL_PACKAGES`.
|
||||
- A permissions/manifest entry for an "unknown sources" install intent.
|
||||
|
||||
**Also look for the *coupling*.** Update checks are frequently run from the same startup routine as ad
|
||||
loading and splash dismissal, and a blocking update dialog often **suspends those siblings** — you may
|
||||
find logging that literally says so ("update dialog showing, skipping X"). That tells you both where the
|
||||
check lives and that your patch must not leave the dialog state machine half-finished.
|
||||
|
||||
## Step 2: classify the shape
|
||||
|
||||
| Shape | How it behaves | Where to cut |
|
||||
|---|---|---|
|
||||
| **Optional update** | a dismissible prompt with a download button | suppressing the dialog is enough |
|
||||
| **Forced update** | non-dismissible; blocks the app | must be cut at the decision, not the dialog |
|
||||
| **Silent version gate** | no dialog; features degrade or the server 400s | client-side is *not* fixable — see §the honest answer |
|
||||
| **Self-update installer** | downloads and installs an APK | cut the check, and ideally the install path too |
|
||||
| **Hot-update / resource patch** | fetches a bundle that can restore behaviour | separate route; treat as a data channel |
|
||||
|
||||
Assume **forced** until you have evidence otherwise, because that is the case that costs the user the
|
||||
most when missed.
|
||||
|
||||
## Step 3: patch the decision, not the dialog
|
||||
|
||||
**Prefer the earliest safe exit over every downstream surface.** Supressing the dialog only hides it;
|
||||
the download may still start, and a later code path may still act on the "new version" verdict.
|
||||
|
||||
Two layers, in this order:
|
||||
|
||||
**Layer 1 — make the check itself a no-op.** Replace the entry of the update routine so it returns
|
||||
immediately. Nothing is requested, nothing is compared, no dialog state is created. This is the root fix
|
||||
and it removes the network call entirely, which also makes verification trivial (Step 4).
|
||||
|
||||
**Layer 2 — neutralise the comparison.** Somewhere the client compares the server version against its
|
||||
own and branches on the result. Force that branch to the "already current" side. This catches any path
|
||||
that reaches the comparison without going through the entry you patched (a second caller, a manual
|
||||
"check for updates" button, a settings screen).
|
||||
|
||||
Layer 2 alone is a valid minimal fix if Layer 1 is awkward; Layer 1 alone is valid if you have confirmed
|
||||
a single entry. **Doing both is cheap insurance and is what makes the claim "this build cannot be
|
||||
upgraded into uselessness" defensible.**
|
||||
|
||||
```
|
||||
target routine: <updateChecker>(...) -> return immediately (no request, no state)
|
||||
target branch: <versionCompare> result -> force the "no update" side
|
||||
```
|
||||
|
||||
**Do not** patch the dialog itself as your primary fix, and **do not** patch the comparison to lie in
|
||||
the *other* direction (claiming an update when there is none) — that produces exactly the blocking
|
||||
dialog you are trying to avoid.
|
||||
|
||||
### What not to touch
|
||||
|
||||
- **Do not rewrite the app's own version number** in the manifest or in a version string. It is the
|
||||
cheapest-looking fix and it breaks things you are not looking at: server-side feature negotiation,
|
||||
analytics, cached asset keys, and the app's own self-comparison. Change the *decision*, not the
|
||||
identity.
|
||||
- **Do not strip the installer permission** unless you have checked nothing else uses it.
|
||||
- **Do not block the update hostname at the transport layer.** That is the same class of mistake as
|
||||
blocking ad hosts (`pitfalls.md` P5): it fails offline/online transitions, needs a helper on the
|
||||
machine, and is not a property of the artifact. It is also not required — the check is local code.
|
||||
|
||||
## Step 4: verify
|
||||
|
||||
Update paths are checked once at launch, so verification is a single clean cold start:
|
||||
|
||||
1. **Clear logs, cold start, and grep for the update vocabulary.** Expect **zero** hits. Not "quieter" —
|
||||
absent. If the routine still runs, the string will still appear.
|
||||
2. **Watch the screen through startup.** The failure mode is a modal, so a sample you did not look at is
|
||||
not evidence (`environment.md` §look at the screen). A blocking dialog is unmistakable and it will be
|
||||
on screen for a long time.
|
||||
3. **Check the network.** With the check disabled there should be **no request** to the version/update
|
||||
endpoint at startup. This is the strongest signal: it distinguishes "dialog suppressed" from "check
|
||||
never happened".
|
||||
4. **Exercise the manual path** if the app has a "check for updates" entry in settings — that is the
|
||||
caller Layer 2 exists for.
|
||||
5. **Confirm what you disabled is only updates.** The update routine sometimes shares a bootstrap with
|
||||
other startup work. If you no-op'd a routine rather than its decision, verify that splash dismissal,
|
||||
first-run setup, and the ad/consent flow still complete.
|
||||
|
||||
**Two-sample rule for the durability claim.** If you are asked whether the build *survives* a server-side
|
||||
version bump, you cannot test that directly. What you can honestly claim is: *(a)* no version request is
|
||||
issued at startup, and *(b)* the comparison no longer reaches its "newer" branch. State it in those terms
|
||||
instead of asserting the future.
|
||||
|
||||
## Step 5: hot-update and resource channels
|
||||
|
||||
If the app can fetch a bundle at runtime, version checking is not the only way your change can be undone.
|
||||
|
||||
- **What to look for**: a startup fetch of a signed bundle/zip/config with a version or hash, a local
|
||||
unpack directory, a dynamic plugin loader, or a JS/Dart/RN patch channel.
|
||||
- **Why it matters**: even with updates disabled, a remotely fetched config can re-enable a feature, and
|
||||
a fetched bundle can overwrite patched logic in the parts it owns.
|
||||
- **Handle it like a data channel, not like an update**: identify what is fetched, whether the patched
|
||||
behaviour is inside that payload, and cut the consumption point if it is. If the patched logic lives in
|
||||
the shipped artifact and the fetched payload cannot override it, say so and move on — that is a
|
||||
finding, not a gap.
|
||||
|
||||
Note the flip side: a **server-issued configuration** is the same category. If the thing you removed was
|
||||
driven by remote config, the client patch holds only while the server keeps sending that config — see
|
||||
`references/server-config-and-updates.md` §6 and `references/membership-and-limits.md` for the general
|
||||
rule that *client-side enforcement can be patched, server-side authority cannot*.
|
||||
|
||||
## The honest answer for silent version gates
|
||||
|
||||
If the app degrades because the **server** decides the client is too old — no local dialog, just missing
|
||||
data or errors on some screens — then there is no client-side fix. The only honest report is:
|
||||
|
||||
- the version the client reports to the server (with evidence of where it is read),
|
||||
- the endpoint that makes the decision (with the observed response),
|
||||
- and a clear statement that this is server-side authority.
|
||||
|
||||
Do not paper over it by forcing the version string at random read sites. If you do change a reported
|
||||
version as an experiment, label it experimental, and remember that a version number is used in more
|
||||
places than the one you are looking at.
|
||||
|
||||
## Delivery note: always state what was disabled
|
||||
|
||||
Update suppression changes behaviour a user can observe ("it never tells me about new versions"). Put it
|
||||
in the delivery notes, in one line, together with anything else you neutralised. If the app also has a
|
||||
legitimate reason to update (security fixes), say that the build will not pick them up automatically.
|
||||
|
||||
## Step 6: when the check is not where you expect
|
||||
|
||||
Three shapes that break the "find the Java comparison and flip it" plan. Recognise them early,
|
||||
because each has a different cheapest remedy.
|
||||
|
||||
### The check is native
|
||||
|
||||
Symptom: you grep the whole dex tree for the version-comparison routine, the update endpoint, or the
|
||||
dialog text and find **the method declared, and zero callers**. A `static native` update entry point
|
||||
with no Java caller means the invocation lives in a `.so` (`registerNatives` from a static block is
|
||||
the usual giveaway — see `code-virtualization-and-custom-linkers.md`).
|
||||
|
||||
Options, cheapest first:
|
||||
|
||||
1. **Attack the response, not the check.** Block or rewrite the update endpoint at the network layer,
|
||||
or empty the version/config response. The native code still runs and still decides "no update".
|
||||
This needs no native work at all and survives a rebuild — check this before reaching for a
|
||||
disassembler.
|
||||
2. **Send the check a version it likes.** If it compares against a value the app itself supplies
|
||||
(a config key, a stored preference, a build field), setting that value is a one-line change.
|
||||
3. **Patch the native comparison.** Last, because it is the most expensive and the most fragile.
|
||||
|
||||
Do not conclude "unpatchable" from "no Java caller". The gate usually reads a value you *can* control.
|
||||
|
||||
### The update hands off to a browser or a WebView
|
||||
|
||||
Very common in this category: the dialog's button fires an `ACTION_VIEW` intent at a download URL,
|
||||
or loads it in an in-app WebView. Two consequences:
|
||||
|
||||
- **You will not find an installer call** in the app, so searching for `PackageInstaller`,
|
||||
`REQUEST_INSTALL_PACKAGES` or a download service finds nothing. That is not evidence the dialog is
|
||||
harmless; the browser does the install.
|
||||
- **Logcat truncates the URL.** `ActivityTaskManager` prints `dat=https://host/...` and elides the
|
||||
rest, so a naive grep gives you a host and no path. Do not build a plan on a truncated URL.
|
||||
|
||||
Ways to get the real URL, cheapest first:
|
||||
|
||||
| Approach | Notes |
|
||||
|---|---|
|
||||
| Hook `Intent` construction / `Context.startActivity` and log `intent.getDataString()` | Most reliable. A truncated log line is a *logging* limit, not a limit on what you can read |
|
||||
| `dumpsys activity activities` / recents while the browser is still in the stack | Free; works only if the activity is still alive |
|
||||
| The browser's own history database (root) | Survives the browser being closed; needs the right DB, and modern browsers may encrypt or prune it |
|
||||
| Read the update *response* instead | Often easier: the URL came from a JSON field you can fetch or hook directly |
|
||||
|
||||
**Deliberately tapping the button is a legitimate probe** — but treat it as a measurement, not a
|
||||
side effect: capture the intent (or the screen) in the same window, and know that on some devices a
|
||||
second tap resumes an already-started download rather than re-issuing the intent.
|
||||
|
||||
### The gate is server-issued with a local fallback
|
||||
|
||||
`fetchEnabled()`-style helpers frequently **default to a local value on any failure** (non-200,
|
||||
timeout, malformed body, signature mismatch). Two things follow:
|
||||
|
||||
- **Read the failure branch before you touch anything.** If failure ⇒ "no update" or "no ad", then
|
||||
merely making the request fail is a complete fix, and it is far cheaper and more robust than
|
||||
patching the comparison.
|
||||
- **The fallback also tells you how to test offline.** Disconnect the device and relaunch: if the
|
||||
gate disappears, the fallback is benign and you have a zero-code workaround as well as a
|
||||
verification signal.
|
||||
|
||||
Conversely, if failure ⇒ "must update", failing the request makes things worse, and you must patch the
|
||||
decision instead.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Update vocabulary searched in **both** string encodings, in the right layer for the runtime
|
||||
- [ ] The single update entry point identified (or its absence established, with evidence)
|
||||
- [ ] Shape classified: optional / forced / silent / installer / hot-update
|
||||
- [ ] Decision-level patch applied, not just the dialog
|
||||
- [ ] Comparison branch neutralised as a second layer
|
||||
- [ ] Manifest version and identity left alone
|
||||
- [ ] Cold start: **zero** update log lines, no blocking modal, **no version request on the wire**
|
||||
- [ ] Manual "check for updates" entry exercised
|
||||
- [ ] Startup siblings (splash dismissal, ads, consent, first-run) still complete
|
||||
- [ ] Hot-update / remote-config channel considered explicitly
|
||||
- [ ] Delivery note states what is now disabled
|
||||
@@ -0,0 +1,131 @@
|
||||
# Verification — what "done" means
|
||||
|
||||
The difference between a hobby patch and a usable deliverable is verification. "It assembles" and "the log is quiet" are not verification.
|
||||
|
||||
|
||||
**Load this when:** you are about to claim the work is done, or you need to define done for a reviewer. It gives the claim ladder, the evidence chain, and what each deliverable form has to show before it counts.
|
||||
|
||||
## The claim ladder
|
||||
|
||||
Each rung is stronger evidence. Climb as high as the task requires, and **state clearly which rung you reached**.
|
||||
|
||||
| Rung | Claim | Evidence required |
|
||||
|---|---|---|
|
||||
| 0 | "The dex was edited" | Byte-level diff of the target method |
|
||||
| 1 | "The structure is intact" | `dex_classdiff`: same classes, zero `ACC_INTERFACE` drift, unchanged classes byte-identical |
|
||||
| 2 | "It builds and signs" | Signature verification passes |
|
||||
| 3 | "It installs and launches" | Process alive, `logcat` free of fatal signatures |
|
||||
| 4 | "The target behavior changed" | The specific feature observed changed (screen exercised, endpoint observed, UI confirmed) |
|
||||
| 5 | "Nothing else regressed" | Adjacent features exercised: images, playback, lists, login, settings |
|
||||
| 6 | "The mechanism is proven" | Independent evidence of *why* — e.g. the SDK's domains are never resolved because init never ran, or the SDK never created its working directory under the app's private storage |
|
||||
| 7 | "The *distributed* artifact is the verified one" | Re-download the published file and compare its hash against the local build you tested |
|
||||
|
||||
**Rung 3 is the minimum for any deliverable. Rung 4 for a claim that the patch solved the problem. Rung 5 before handing it to a user. Rung 6 when the claim is "the subsystem is dead", not merely "the ad is hidden". Rung 7 whenever the deliverable leaves your machine — an upload can truncate, a build can be re-signed by a pipeline step, and the person downloading it has no way to know.**
|
||||
|
||||
## The control build rule
|
||||
|
||||
Before blaming a patch, build a control: **the same pipeline with zero patches**.
|
||||
|
||||
```bash
|
||||
python scripts/repack.py --apk original.apk --dexdir <extracted> --out control.apk
|
||||
```
|
||||
|
||||
- Control fails → your pipeline, the environment, or the device is at fault. Stop debugging the patch (`pitfalls.md` P9).
|
||||
- Control passes, patched fails → the patch is implicated. Bisect: revert one dex at a time.
|
||||
|
||||
Do this **whenever something unexpectedly fails**, and at least once per project.
|
||||
|
||||
## Structural verification (automate it)
|
||||
|
||||
After every dex edit:
|
||||
|
||||
```bash
|
||||
python scripts/dex_classdiff.py <original.dex> <patched.dex>
|
||||
```
|
||||
Expect exactly:
|
||||
```
|
||||
only_in_A=0 only_in_B=0
|
||||
ACC_INTERFACE mismatch: 0
|
||||
access_flags diff (same interface-ness): 0
|
||||
```
|
||||
|
||||
Caveat, and it is an important one: **this check cannot detect code-item damage.** A whole-tree smali round-trip can pass every table check and still crash with `IncompatibleClassChangeError` (`pitfalls.md` P3). Passing this check is necessary, not sufficient.
|
||||
|
||||
The checks that *can* see further — instruction-length auditing, the equal-length-replacement blind spot, and verifier-level legality (`move-result*` adjacency) — are in `references/patch-audit.md`. Use them before claiming a patch landed.
|
||||
|
||||
Also confirm you changed what you intended and nothing more:
|
||||
- Reverse the patched dex and inspect the target method.
|
||||
- Compare unmodified classes byte-for-byte against the original where feasible.
|
||||
|
||||
## Runtime verification
|
||||
|
||||
```bash
|
||||
adb -s <serial> logcat -c
|
||||
adb -s <serial> shell "am start -n <pkg>/<activity>"
|
||||
sleep 5; adb -s <serial> shell "pidof <pkg>" # empty == died
|
||||
sleep 20; adb -s <serial> shell "pidof <pkg>" # same pid == stable
|
||||
adb -s <serial> logcat -d -v brief | grep -E 'FATAL|VerifyError|IncompatibleClassChange|uncaughtException|Failure starting process'
|
||||
```
|
||||
|
||||
**Sample the pid twice, spaced apart.** A single reading catches apps that die in a crash loop.
|
||||
|
||||
Failure signature → cause table lives in `references/environment.md`.
|
||||
|
||||
**Logcat is often not enough.** If the app installs a crash handler (友盟/UCrash/Bugly), the Java stack never reaches logcat. A process that dies with only `uncaughtException time: ...` and no stack **has crashed** — treat it as a failure, not as noise. Extraction techniques: `references/environment.md` §signal extraction.
|
||||
|
||||
## Behavioral verification (the part people skip)
|
||||
|
||||
Launching proves nothing about your change. Exercise it.
|
||||
|
||||
- **Ads removed** → open the screens that had ads (splash, home, detail, player, reward button). Confirm absence *and* confirm the screens still function. Use **three independent signals**, not one: the screens look right, the SDK's log tags disappeared, and the SDK never created its working directory under the app's private storage. An SDK that silently failed to fill an ad slot looks exactly like an SDK that was never initialised — only the last two signals tell them apart.
|
||||
- **A gate patched** → walk the gated flow end to end.
|
||||
- **A timeout/limit patched** → trigger it.
|
||||
- **Data patched** → stop the app, clear/re-write the value, cold start, re-check (proves it is not an in-memory artifact).
|
||||
|
||||
**And then test the neighbours.** The most common real-world regression is a patch that fixed its target and quietly broke something unrelated:
|
||||
|
||||
- Images / cover art render?
|
||||
- Video or audio playback works?
|
||||
- Lists scroll and paginate?
|
||||
- Login / session still valid?
|
||||
- Settings screens open?
|
||||
- Downloads still start?
|
||||
|
||||
This is exactly how a "generic image-card composable" patch removed all cover art while the ad still showed (`pitfalls.md` P6).
|
||||
|
||||
## Screenshot discipline
|
||||
|
||||
Capture before/after screenshots for each claim. Text descriptions are not evidence; a screenshot of the screen without the ad, alongside one from the control build with it, is.
|
||||
|
||||
## Reporting
|
||||
|
||||
```
|
||||
Build: <path> <sha256>
|
||||
Base: <original apk name/version>
|
||||
Changes: <dex → what changed>, one line each
|
||||
Structural: dex_classdiff result (per dex)
|
||||
Signature: verified with --min-sdk-version 21 (v1/v2/v3); report the raw command, not just "ok"
|
||||
Device: <model / android / abi / rooted?>
|
||||
Runtime: pid stable over N s; no fatal signatures
|
||||
Behavior: <screens exercised>, <before/after>
|
||||
Regressions: <adjacent features checked>
|
||||
Distribution: <where it was published> re-downloaded, sha256 matches the tested build
|
||||
Rung reached: 0..7
|
||||
Residual: <what is NOT fixed, and the exact reason>
|
||||
```
|
||||
|
||||
**Be explicit about residuals.** "Ad X remains because it is delivered inside the screen's main data payload, and suppressing it at the transport layer takes the whole screen down" is a useful, honest result. Silently omitting it is not.
|
||||
|
||||
**Separate the two audiences.** The artifact goes to an end user; the analysis report goes to whoever asked for the work. User-facing notes stay neutral and practical — package name and version, install prerequisites, the signature-conflict warning (a different signer means the old install must be removed first), the checksum, and a scope/disclaimer line. Keep the mechanism, the patch points, and the reasoning in your own report, not in the file the end user opens.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Declaring success from a successful assembly.
|
||||
- Claiming "no ads" from a quiet logcat when the SDK never printed anything either way.
|
||||
- Concluding from a single launch without exercising the feature.
|
||||
- Quoting emulator results as device results.
|
||||
- Reporting a fixed feature without checking adjacent features.
|
||||
- Hiding a known residual failure.
|
||||
- Blaming your own patch for a failure the **unmodified original** also has — run the control build first.
|
||||
- Accepting a UI automation result as proof when the interaction may never have fired: verify field contents and look for the actual request, not just the screen.
|
||||
- Treating a feature-scoped network error as a patching problem before checking the server's certificate (`references/tls-and-cert.md`).
|
||||
@@ -0,0 +1,237 @@
|
||||
# Differential hardening (the known-plaintext route to a private opcode table)
|
||||
|
||||
Load this when the dump is a **real Dex VMP** — bodies present, decoding as nonsense to a
|
||||
dalvik disassembler, a native interpreter loop behind them — and you have decided the
|
||||
private opcode space is worth attacking rather than walking away from. The neighbouring
|
||||
file owns the diagnosis (`advanced-unpacking.md` §The honest boundary: real Dex VMP) and
|
||||
the sentence that sends you here. This file owns what happens next, and — more of it than
|
||||
is comfortable — what does not.
|
||||
|
||||
**Strength note, read before trusting any number here.** Every mechanism below was
|
||||
exercised on locally built fixtures, and the closed loop is measured: `simulate` relabels
|
||||
a fixture through a known bijection and `compare` re-derives that table **exactly, 218 of
|
||||
218 emitted opcodes, zero wrong entries, zero fabricated entries** (`references/evidence-summary.md` §The capability matrix).
|
||||
What was **not** exercised is the other half of the premise: **no third-party hardening
|
||||
platform was contacted**. The assumption that a real engine performs a per-opcode
|
||||
substitution at stable instruction length is **inferred** from public VMP write-ups and is
|
||||
exactly the assumption this method can fail on. The cost table's platform column is
|
||||
therefore reasoning from how those platforms are distributed, not from a measured upload.
|
||||
|
||||
## 1. The method, and the one thing it needs to be true
|
||||
|
||||
The hardened body cannot tell you where its instruction boundaries are: its opcodes are
|
||||
private, so the format specification's length table does not apply to it. That is the
|
||||
whole obstacle — a disassembler that cannot decide where instruction *n* ends cannot name
|
||||
instruction *n+1*.
|
||||
|
||||
The differential dissolves it with an oracle you control. Compile a fixture whose every
|
||||
instruction you know; get the same platform to harden it; then **take the original's
|
||||
instruction boundaries and project them onto the hardened bytes**. Original offset
|
||||
`0x14` was a `mul-int` occupying 2 code units, so hardened bytes `0x14..0x17` are the same
|
||||
instruction, re-encoded. Read the hardened opcode byte at each projection and you have a
|
||||
`(original opcode → private byte)` sample. Aggregate over thousands of instructions and
|
||||
the substitution table falls out.
|
||||
|
||||
Three preconditions, and they are not negotiable:
|
||||
|
||||
| Precondition | Why it must hold | What it looks like when it fails |
|
||||
|---|---|---|
|
||||
| **The hardened body keeps the same length** | The projection is a stencil; a length change slides every boundary after the first edit | `compare` reports `resized` for the whole method and refuses the run |
|
||||
| **The substitution is per-opcode, not per-method** | One private byte per original opcode is what makes the table a table | Every original opcode claims the same private byte (`compare` → `NOT-USABLE`, stub shape) |
|
||||
| **The body is still in the dex** | Alignment needs two byte streams | Method has `code_off == 0` (`compare` reports `stripped`) |
|
||||
|
||||
The third is the one that kills the method most often in practice: a VMP that *extracts*
|
||||
bodies to a native interpreter leaves nothing to align. The differential route then has no
|
||||
input at all, and no amount of clever fixture design creates one. Check for it before
|
||||
spending any time here.
|
||||
|
||||
## 2. Cost table — which links can be automated, and which cannot
|
||||
|
||||
This is the part the method's public descriptions leave out. The chain is six links; two
|
||||
of them are unavoidably human, and pretending otherwise is how a "harness" ships that has
|
||||
never once been run against a real target.
|
||||
|
||||
| # | Link | Automation | Where a human must stand | Time cost | Failure mode |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | Compile the coverage fixture into a dex + APK | **Full** — `scripts/vmp_diff_harness.py build`, measured | none | ~40 s on this machine (javac + d8 + aapt2), ~1 s of it the script | toolchain absent or version-drifted; `d8` rejects a non-existent `--output` directory, `aapt2 link` rejects `android:` attributes without `android.jar` |
|
||||
| 2 | **Submit to the hardening platform** | **None. Not automatable.** | 100 % — a browser, an account, sometimes a queue and a review | minutes to **days**; the platform decides | **the binding constraint of the whole route.** No public API; per-account rate limits; uploads are frequently gated behind registration; the artifact often comes back as a download page rather than a file path. A synthetic fixture may simply be **refused** (no real app shape, no certificate, no package identity) |
|
||||
| 3 | Retrieve the hardened output and extract its dex | Partial — if the output is an APK, `unzip` + `dexutil.load_dex` handles it | download and hand-off | seconds, once the file exists | output is an APK whose dex is *also* encrypted, or the platform returns only a repacked APK with a native loader and no readable dex |
|
||||
| 4 | Derive the correspondence | **Full** — `compare`, measured end to end | none | **sub-second** on a 3,065-instruction fixture | alignment slips (see); engine is not a pure relabelling |
|
||||
| 5 | Prove the derived table | Partial — the mechanical half is scripted and measured; the judgement is not | decide whether the held-out check was passed or merely not attempted | minutes | a table that is *self-consistent* but describes a stub () |
|
||||
| 6 | Render Smali from the restored stream | Full — `emit-smali`, measured | none | sub-second | table has holes; restored stream decodes short of the method end |
|
||||
|
||||
**Conclusion, stated plainly: do not build a fully automatic pipeline.** Link 2 is
|
||||
irreducibly manual — the platform has no interface a script can drive, and its output
|
||||
cadence is not one a pipeline can wait on. The right shape is what this repository ships:
|
||||
|
||||
- links 1, 4 and 6 as **reliable local tools** (they are deterministic and were measured);
|
||||
- link 5 as **a checklist plus one scripted test** () — the judgement stays human;
|
||||
- link 2 as **a documented manual step**, with its cost stated up front, so nobody writes
|
||||
an orchestrator that waits forever on a web form;
|
||||
- and links 4–5 **self-verifiable without the platform at all**, which is what `simulate`
|
||||
exists for. You can prove your comparison logic against a known table today; you cannot
|
||||
prove the engine's behaviour until a human uploads a fixture.
|
||||
|
||||
The practical consequence: the deliverable of this route is an **analysis report with a
|
||||
stated boundary**, not a pipeline. Say so before starting.
|
||||
|
||||
## 3. The coverage fixture — what a compiled probe can and cannot reach
|
||||
|
||||
A Java-compiled fixture reaches **218 of the 224 named opcodes in this repository's format
|
||||
table (97.3 %)** — that number is measured, not estimated, and the six it misses are
|
||||
structural rather than incidental. The reason to care is direct: **an opcode your fixture
|
||||
never emits leaves no known plaintext, so its substitution is unreadable by construction.**
|
||||
|
||||
| Missed opcode | Why a javac + d8 fixture cannot reach it |
|
||||
|---|---|
|
||||
| `const-string/jumbo` (0x1b) | needs a `string_ids` index > 65535, i.e. a fixture shipping 65,536+ string constants |
|
||||
| `goto/32` (0x2a) | needs a >32767-code-unit backward jump; javac refuses the method first with `error: code too large` — its own 64 KB per-method bytecode cap. **Measured** on a generated 33,000-statement loop |
|
||||
| `const-method-handle` (0xfe), `const-method-type` (0xff) | no Java-language literal exists for these constants; reachable only via hand-written smali or direct dex construction |
|
||||
| `move-object/16` (0x09) | needs **both** registers ≥ 256; the object frame pushes the source past 256 but d8 encodes that as `move-object/from16` |
|
||||
| `invoke-custom/range` (0xfd) | d8 emitted the 35c form for every lambda shape tried, including a six-parameter one |
|
||||
|
||||
What the fixture **does** cover, by construction, and the design decisions that get it
|
||||
there (each of these was a measured gap before it was closed):
|
||||
|
||||
- **Three-register forms.** javac emits the `/2addr` form whenever the destination is one
|
||||
of the operands, so `sub-int`, `div-int`, `rem-int`, `and-int`, `or-int`, `xor-int`,
|
||||
`shl-int`, `shr-int`, `ushr-int` and their long/float/double counterparts are **absent**
|
||||
from ordinary code. Three distinct locals per operation is what forces them into the
|
||||
stream; the `/2addr` forms then need the accumulator spelling. Both are wanted.
|
||||
- **Literal widths.** `div-int/lit16` needs a divisor in 128..32767; `and-int/lit8` needs a
|
||||
mask inside −128..127. Writing `& 255` produces `and-int/lit16` and silently skips the
|
||||
lit8 form.
|
||||
- **Per-type field access.** `iget-wide`, `iget-object`, `iget-boolean`, `iget-byte`,
|
||||
`iget-char`, `iget-short` and the `sget` set all appear only when the load targets a
|
||||
**fresh local**. Accumulating into an existing register is where d8 narrows the access.
|
||||
- **`/range` invokes.** The 35c invoke forms carry at most five argument registers, so a
|
||||
seven-register call (`this` plus six ints) is what makes the `/range` encoding the only
|
||||
legal one.
|
||||
- **Wide registers and long jumps** — `move/16`, `move-wide/16`, `move-object/from16`,
|
||||
`goto/16` — have **no hand-written form at all**. They exist only past a register-number
|
||||
(≥256) or branch-distance (>127 code units) threshold, so the generator emits bulk
|
||||
source: 300 int locals, 140 long locals, 160 object locals, a 200-statement loop body.
|
||||
Measured yield: `move/16` ×46, `move-wide/16` ×20, `move-object/from16` ×161, `goto/16` ×1.
|
||||
- **Payloads and structure**: `packed-switch`/`sparse-switch` with their payload blocks,
|
||||
`fill-array-data`, `filled-new-array/range`, try/catch ranges with `move-exception`,
|
||||
`monitor-enter`/`monitor-exit`, `instance-of`/`check-cast`, and `invoke-custom` /
|
||||
`invoke-polymorphic` (which is why `d8 --min-api 26 --no-desugaring` is the default —
|
||||
desugaring replaces lambdas with synthetic classes and the two opcodes disappear).
|
||||
|
||||
**Report the coverage every time.** `build` prints it, `audit` recomputes it for any dex.
|
||||
A differential run whose coverage is 60 % produces a table that is 40 % missing, and
|
||||
nothing in the output will say so unless you look at this number.
|
||||
|
||||
## 4. Extracting the correspondence
|
||||
|
||||
`compare ORIGINAL HARDENED` does four things, and the first three are diagnostics that
|
||||
decide whether the fourth is meaningful:
|
||||
|
||||
1. **Pair methods** by `(class, name, descriptor)` and classify each pair. `aligned` means
|
||||
both sides have a `code_item` of **identical `insns_size`** — the only state in which
|
||||
projection is legal. `resized` means the length moved. `stripped` means the hardened
|
||||
method has `code_off == 0`. `missing`/`added` mean the method set itself moved.
|
||||
2. **Project and sample.** For each aligned pair, walk the original's decoded instruction
|
||||
list; for instruction at original offset *o*, read the hardened byte at
|
||||
`hardened.insns_off + (o − original.insns_off)`. Record `(original opcode → hardened
|
||||
byte)` with a count.
|
||||
3. **Aggregate and audit the aggregate.** Per original opcode: one candidate byte and the
|
||||
row is `high`; more than one and the row is a `conflict`. Then the **reverse
|
||||
direction** — build `private byte → set of original opcodes` and flag every private byte
|
||||
claimed by more than one original. This reverse pass is not a nicety; it is the only
|
||||
thing that catches the stub shape ().
|
||||
4. **Emit a run-level verdict**, because a table is dangerous without one: `usable`,
|
||||
`partially-usable`, `not-usable`, or `not-applicable` with the reason. A `not-applicable`
|
||||
run exits non-zero.
|
||||
|
||||
Unreadable opcodes are reported as `undetermined` with the reason — either the fixture
|
||||
never emitted them () or the dex slot is unallocated. They are **not** guessed at.
|
||||
|
||||
## 5. Proving the table — the part that decides whether any of this is real
|
||||
|
||||
A candidate table is a hypothesis. Four checks, in increasing order of what they can
|
||||
falsify:
|
||||
|
||||
**a. Closed-loop regression — measured, and the strongest available without a platform.**
|
||||
`simulate` relabels a fixture's opcodes through a bijection whose ground truth it writes
|
||||
out; `compare` must re-derive that table from the two dexes alone. Measured on this
|
||||
repository's fixture: **218 rows derived, 218 of 218 emitted opcodes recovered, zero
|
||||
entries wrong, zero entries fabricated, zero non-injective private bytes, verdict
|
||||
`usable`.** Any change to the comparison logic that breaks this check is a regression, and
|
||||
the check needs no platform, no device and no network.
|
||||
|
||||
**b. Injectivity — the check that catches a confident wrong answer.** Every private byte
|
||||
must be claimed by exactly one original opcode. Measured against the stub shape (bodies
|
||||
replaced by an equal-length `return-void` fill): per-opcode reading looks *perfect* — 218
|
||||
rows, all `high`, zero conflicts — because every original opcode maps to the same byte.
|
||||
Only the reverse direction exposes it: one private byte claimed by **218** originals →
|
||||
`NOT-USABLE`. A table produced without this check would have been silently worthless.
|
||||
|
||||
**c. Held-out consistency.** Derive the table from one subset of methods, then use it to
|
||||
disassemble a **different** subset that contributed nothing to the derivation. A correct
|
||||
table restores the instructions such that the walk lands exactly on
|
||||
`insns_off + insns_size × 2` and every branch target lands on an instruction boundary. A
|
||||
wrong or incomplete table desynchronises and says so. `emit-smali`'s
|
||||
`instructions restored` / `unmapped-opcode stops` counters are the readout: on the measured
|
||||
fixture, 3,065 restored and 0 stops.
|
||||
|
||||
**d. Cross-check against a second, independent recovery.** The same private opcode space
|
||||
observed through a *different* route — an in-memory dump of the original dex alongside the
|
||||
hardened one, or the `/proc/<pid>/mem` route in
|
||||
`advanced-unpacking.md` §Dumping when frida is refused — is the only evidence that the
|
||||
platform's transformation is the one you assumed. **Not done here**; it requires a real
|
||||
VMP sample and a real platform, and is the honest gap in this file.
|
||||
|
||||
Note what none of these checks establish: that the engine's substitution is *stable across
|
||||
inputs*. Fixture-derived tables have been reported in public work to drift per
|
||||
vendor-version and per hardening pass. Re-derive per target; never cache a table.
|
||||
|
||||
## 6. Generating Smali from a restored stream
|
||||
|
||||
`emit-smali HARDENED_DEX --table TABLE.json` restores opcode bytes through the table and
|
||||
renders method bodies as an annotated smali **reading skeleton**:
|
||||
|
||||
- Boundaries come from the table itself — once a byte maps back to a standard opcode the
|
||||
standard length table applies to that instruction, so the walk is self-bootstrapping.
|
||||
A byte with no table entry stops that method's walk, and the count of such events is
|
||||
printed as the honest coverage measure.
|
||||
- `.class` / `.super` / `.source` / `.method` headers are real, with access flags decoded
|
||||
from `access_flags`, so the skeleton is readable as smali rather than as a hex dump.
|
||||
- Payload blocks (`packed-switch-payload` etc.) are stepped over by their own layout, not
|
||||
treated as opcodes.
|
||||
|
||||
**It is explicitly not assembliable, and the output says so in its first ten lines.**
|
||||
Recovered: instruction boundaries and opcode names. Not recovered: register numbers,
|
||||
field/method/string indices as the engine rewrote them, and any register re-allocation —
|
||||
**no map for those exists in this method at all.** Feeding the skeleton to a smali
|
||||
assembler produces an empty method, which looks like success and is not.
|
||||
|
||||
## 7. When this route does not work — the honest retreat
|
||||
|
||||
| What `compare` reports | What it means | Do this |
|
||||
|---|---|---|
|
||||
| `verdict: not-applicable`, `stripped` > 0 | bodies left the dex for a native interpreter | the differential has no input. Stop. Return to `advanced-unpacking.md` §The honest boundary: real Dex VMP and price the island honestly |
|
||||
| `verdict: not-applicable`, `resized` > 0 | the engine's stream has its own length table | the projection premise is false. Stop rather than guessing boundaries |
|
||||
| `verdict: not-usable`, one private byte shared by many originals | bodies replaced by a common stub | the engine did not relabel per-opcode here. Stop |
|
||||
| `verdict: partially-usable`, conflicts ≤ 30 % | partial relabelling, or alignment slipping on some methods | usable only for the `high` rows; say so and stop at the unreadable ones |
|
||||
| `usable`, but coverage was low | a complete table over an incomplete opcode set | usable, with the uncovered opcodes named as unreadable |
|
||||
|
||||
And the retreat that outranks all of them: **the cheaper question is whether the behaviour
|
||||
you need exists outside the virtualized island.** A hook at the method's Java boundary, an
|
||||
emulated call to the routine rather than a reading of it
|
||||
(`emulation-and-rpc.md`), or a server-side answer frequently reaches the goal without ever
|
||||
paying for the island. Weeks of table recovery is the expensive way to ask a question a
|
||||
boundary hook answers in an hour.
|
||||
|
||||
## 8. Decision summary
|
||||
|
||||
| Observation | Action | Where |
|
||||
|---|---|---|
|
||||
| Bodies present, decode as nonsense | Diagnose real VMP before anything else | `advanced-unpacking.md` §The honest boundary: real Dex VMP |
|
||||
| Need the private opcode mapping | Build the coverage fixture, then submit it by hand | `scripts/vmp_diff_harness.py build`; §2 of this file |
|
||||
| Asked to "automate the whole chain" | Say which link cannot be automated and what it costs | §2 — the platform step is manual, by construction |
|
||||
| Fixture coverage below ~90 % | Add the missing opcode classes before submitting |, and the `audit` report |
|
||||
| Two dexes in hand | Derive the candidate table, read the verdict first | `scripts/vmp_diff_harness.py compare` |
|
||||
| About to trust a derived table | Run the closed loop, the injectivity check and a held-out subset | §5 — a/b/c are scripted |
|
||||
| Table complete | Render the skeleton; expect operands, not registers | `scripts/vmp_diff_harness.py emit-smali` |
|
||||
| `resized` / `stripped` / stub verdict | Stop and say so | §7 |
|
||||
@@ -0,0 +1,353 @@
|
||||
/*
|
||||
* anti_detect_probe.js -- an OBSERVER. It changes nothing.
|
||||
*
|
||||
* Purpose: answer "if a detector is running, what is it looking at?" without
|
||||
* answering it with a patch. That division is deliberate and is the point of this
|
||||
* file: an observation module must not also intervene (no exit_blocking, no
|
||||
* Interceptor.replace of a detection function, no NOPing). A probe that patches
|
||||
* while it watches produces a process whose behaviour you can no longer use as
|
||||
* evidence about the original -- the death you prevented is exactly the signal you
|
||||
* were there to date.
|
||||
*
|
||||
* What it reports, as one `RESULT=` block:
|
||||
* - the mapped libraries, so a detection .so can be named rather than guessed
|
||||
* - every open/openat/fopen/access/stat on a path, with the *caller module+offset*
|
||||
* for the ones that look like environment probing (/proc, /sys, magisk, su,
|
||||
* frida, xposed). The caller offset is the deliverable: it is what turns
|
||||
* "something checks root" into "0x1cef8 in libX.so checks root".
|
||||
* - kills: kill/tgkill/exit/exit_group, with caller, so a self-destruct is
|
||||
* attributed to a module instead of to the platform
|
||||
* - thread creation (pthread_create/clone) with the real entry point, because a
|
||||
* polling detector thread is a different shape from an in-line check
|
||||
* - dlopen/dlsym, so a runtime-resolved symbol is visible
|
||||
* - the process's own view of "am I instrumented": TracerPid, frida-named maps,
|
||||
* frida-named threads, and TCP listeners (a detector often reads these first)
|
||||
*
|
||||
* Load it with spawn (-f) to see the early phase, attach (-p) to see the steady
|
||||
* state. On this repository's test ROM attach-by-name fails, so use --pid.
|
||||
*
|
||||
* Usage (from the kit's own driver):
|
||||
* python scripts/run_probe.py scripts/anti_detect_probe.js <pid> --via usb --log probe.log
|
||||
* Usage (raw frida, explicit pid because -n does not resolve on this ROM):
|
||||
* frida -H 127.0.0.1:27099 -p <pid> -l scripts/anti_detect_probe.js
|
||||
*
|
||||
* Config comes from CONFIG below or from RPC:
|
||||
* script.exports.config({ watchMs: 8000, pathFilter: '/proc' })
|
||||
*/
|
||||
|
||||
var CONFIG = {
|
||||
// How long to keep collecting before printing the RESULT block. 0 = print only
|
||||
// on the stop() RPC, for an interactive session.
|
||||
watchMs: 12000,
|
||||
// Stream a heartbeat every N ms. This exists because of a measured failure mode:
|
||||
// a detector that kills the process inside the watch window means a probe that
|
||||
// only reports at the end reports nothing at all -- the one run that mattered
|
||||
// produced no data. The heartbeat says "the script was still alive at T", which is
|
||||
// what separates "the target died" from "my script never armed".
|
||||
heartbeatMs: 1000,
|
||||
// Stream a line the moment a category fires for the first time, so the last
|
||||
// events before a death are on the wire already.
|
||||
streamFirstHit: true,
|
||||
// Substrings that mark a path access as environment probing. Everything else is
|
||||
// still counted, but not individually reported (it would drown the interesting
|
||||
// lines -- a busy app opens thousands of files).
|
||||
pathFilter: ['/proc', '/sys', 'magisk', 'supersu', 'frida', 'xposed', 'gum-js',
|
||||
'/data/local/tmp', 'busybox', 're.frida', 'linjector'],
|
||||
// Report process-wide events for every thread (false) or only the main thread.
|
||||
allThreads: true,
|
||||
// Hard cap on reported lines per category, so a chatty target cannot flood the log.
|
||||
maxLinesPerCategory: 60,
|
||||
};
|
||||
|
||||
if (typeof CONFIG_OVERRIDE !== 'undefined' && CONFIG_OVERRIDE.anti_detect_probe) {
|
||||
Object.assign(CONFIG, CONFIG_OVERRIDE.anti_detect_probe);
|
||||
}
|
||||
|
||||
var counts = {};
|
||||
var lines = {};
|
||||
var started = Date.now();
|
||||
|
||||
function bump(cat, msg) {
|
||||
counts[cat] = (counts[cat] || 0) + 1;
|
||||
if (!lines[cat]) lines[cat] = [];
|
||||
if (lines[cat].length < CONFIG.maxLinesPerCategory) lines[cat].push(msg);
|
||||
if (CONFIG.streamFirstHit && counts[cat] === 1) {
|
||||
// On the wire immediately: if the target dies a moment later, this is the only
|
||||
// trace of the event that existed.
|
||||
send({ type: 'LIVE', cat: cat, msg: msg, t: Date.now() - started });
|
||||
}
|
||||
}
|
||||
|
||||
function backtraceOwners(ctx) {
|
||||
// Module+offset for every return address inside a mapped module. The offset is
|
||||
// what a static tool needs; the module name is what tells a shell library from
|
||||
// libc. The link register is read first because on arm64 it is where the caller
|
||||
// of the hooked export lives, and it survives even when Thread.backtrace cannot
|
||||
// unwind (Frida's unwinder needs the target's frames to be intact, which is not
|
||||
// guaranteed in a constructor or a signal path).
|
||||
try {
|
||||
var owners = [];
|
||||
var lr = null;
|
||||
try { lr = ctx.lr; } catch (e) { lr = null; }
|
||||
if (lr) { owners.push(describe(lr, 'lr')); }
|
||||
var bt = Thread.backtrace(ctx, Backtracer.ACCURATE);
|
||||
for (var i = 0; i < bt.length && owners.length < 5; i++) {
|
||||
owners.push(describe(bt[i], 'bt' + i));
|
||||
}
|
||||
return owners.length ? owners : ['no-unwind'];
|
||||
} catch (e) {
|
||||
return ['bt-failed:' + e.message];
|
||||
}
|
||||
}
|
||||
|
||||
function describe(addr, tag) {
|
||||
try {
|
||||
var d = DebugSymbol.fromAddress(addr);
|
||||
if (d && d.moduleName) {
|
||||
var base = Module.findBaseAddress(d.moduleName);
|
||||
var off = base ? addr.sub(base) : null;
|
||||
return d.moduleName + (off ? '+0x' + off.toString(16) : '+?') + '(' + tag + ')';
|
||||
}
|
||||
if (d && d.name) return d.name + '(' + tag + ')';
|
||||
return 'anon:' + addr + '(' + tag + ')';
|
||||
} catch (e) {
|
||||
return 'addr?' + addr;
|
||||
}
|
||||
}
|
||||
|
||||
function ownerString(ctx) {
|
||||
// Takes the context explicitly. `this` inside a Frida Interceptor callback is not
|
||||
// reliably set when the callback is an anonymous function expression, and the
|
||||
// measured symptom of getting this wrong is a log full of
|
||||
// "cannot read property 'context' of undefined" -- which reads like a hooking
|
||||
// failure and is actually a JavaScript binding mistake. Callers pass `this.context`.
|
||||
if (!ctx) return 'no-ctx';
|
||||
return backtraceOwners(ctx).join(' | ');
|
||||
}
|
||||
|
||||
function looksInteresting(p) {
|
||||
for (var i = 0; i < CONFIG.pathFilter.length; i++) {
|
||||
if (p.indexOf(CONFIG.pathFilter[i]) >= 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hookExport(modName, exp, cat, argIndex) {
|
||||
var addr = Module.findExportByName(modName, exp);
|
||||
if (addr === null) return false;
|
||||
try {
|
||||
Interceptor.attach(addr, {
|
||||
onEnter: function (args) {
|
||||
try {
|
||||
var p = null;
|
||||
if (argIndex >= 0) p = args[argIndex].readUtf8String();
|
||||
var who = ownerString(this.context);
|
||||
if (p === null) { bump(cat, exp + '() <- ' + who); return; }
|
||||
if (looksInteresting(p)) {
|
||||
bump(cat, exp + '("' + p + '") <- ' + who);
|
||||
} else {
|
||||
counts[cat + '_quiet'] = (counts[cat + '_quiet'] || 0) + 1;
|
||||
}
|
||||
} catch (e) { /* unreadable pointer: count it, never throw out of a hook */ }
|
||||
}
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
bump('hook_errors', modName + '!' + exp + ': ' + e.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function hookTerminators() {
|
||||
// A detector's self-destruct is often a direct libc call; the caller offset is
|
||||
// what identifies it. Note: a module that issues `svc #0` itself will NOT show up
|
||||
// here -- the absence of a report plus a death is itself the signal that a libc
|
||||
// hook cannot see it (scripts/svc_scan.py decides that statically).
|
||||
['exit', 'exit_group', '_exit', 'abort', 'kill', 'tgkill', 'raise', 'pthread_kill']
|
||||
.forEach(function (fn) {
|
||||
var a = Module.findExportByName('libc.so', fn);
|
||||
if (a === null) return;
|
||||
try {
|
||||
Interceptor.attach(a, {
|
||||
onEnter: function (args) {
|
||||
bump('termination', fn + '(' + args.join(',') + ') <- ' +
|
||||
ownerString(this.context));
|
||||
}
|
||||
});
|
||||
} catch (e) { bump('hook_errors', fn + ': ' + e.message); }
|
||||
});
|
||||
}
|
||||
|
||||
function hookThreadCreation() {
|
||||
var pc = Module.findExportByName('libc.so', 'pthread_create');
|
||||
if (pc !== null) {
|
||||
try {
|
||||
Interceptor.attach(pc, {
|
||||
onEnter: function (args) {
|
||||
bump('thread_create', 'pthread_create entry=' + args[2] + ' <- ' +
|
||||
ownerString(this.context));
|
||||
}
|
||||
});
|
||||
} catch (e) { bump('hook_errors', 'pthread_create: ' + e.message); }
|
||||
}
|
||||
// clone() is how a detector bypasses the pthread wrapper; args[3] points at the
|
||||
// argument block whose fifth word holds the real entry point on arm64.
|
||||
var cl = Module.findExportByName('libc.so', 'clone');
|
||||
if (cl !== null) {
|
||||
try {
|
||||
Interceptor.attach(cl, {
|
||||
onEnter: function (args) {
|
||||
var extra = '';
|
||||
try { extra = ' realEntry=' + args[3].add(96).readPointer(); } catch (e) {}
|
||||
bump('thread_create', 'clone()' + extra + ' <- ' + ownerString(this.context));
|
||||
}
|
||||
});
|
||||
} catch (e) { bump('hook_errors', 'clone: ' + e.message); }
|
||||
}
|
||||
}
|
||||
|
||||
function hookLoader() {
|
||||
['dlopen', 'android_dlopen_ext', 'dlsym'].forEach(function (fn) {
|
||||
var a = Module.findExportByName('libc.so', fn);
|
||||
if (a === null) return;
|
||||
try {
|
||||
Interceptor.attach(a, {
|
||||
onEnter: function (args) {
|
||||
var s = null;
|
||||
try { s = args[0].readUtf8String(); } catch (e) {}
|
||||
bump('loader', fn + '("' + s + '") <- ' + ownerString(this.context));
|
||||
}
|
||||
});
|
||||
} catch (e) { bump('hook_errors', fn + ': ' + e.message); }
|
||||
});
|
||||
}
|
||||
|
||||
function selfView() {
|
||||
// What the process can see about itself right now. This is evidence about the
|
||||
// *environment*, not about the detector: it says what is available to be found,
|
||||
// which is the other half of attributing a death.
|
||||
var out = {};
|
||||
var files = ['/proc/self/status', '/proc/self/maps', '/proc/self/task',
|
||||
'/proc/net/tcp', '/proc/net/tcp6', '/proc/self/cmdline'];
|
||||
out.files = {};
|
||||
files.forEach(function (f) {
|
||||
try {
|
||||
var content = File.readAllText(f);
|
||||
if (f === '/proc/self/status') {
|
||||
var m = content.match(/TracerPid:\s*(\d+)/);
|
||||
out.tracerpid = m ? m[1] : 'absent';
|
||||
// A detector may match any of these names; report which are present so a
|
||||
// "frida is visible here" claim is not made from memory.
|
||||
out.status_flags = (content.match(/^(Name|State|Seccomp):.*$/gm) || []).join(' ; ');
|
||||
} else if (f === '/proc/net/tcp' || f === '/proc/net/tcp6') {
|
||||
var listening = (content.match(/^[^\n]*\s0A\s/gm) || []).length;
|
||||
out[f] = 'lines=' + content.split('\n').length + ' listening=' + listening;
|
||||
} else if (f === '/proc/self/maps') {
|
||||
var hits = (content.match(/frida|gum|gadget|linjector/gi) || []);
|
||||
out.frida_named_maps = hits.length;
|
||||
out.map_sample = hits.slice(0, 5);
|
||||
} else {
|
||||
out[f] = 'readable (' + content.length + ' B)';
|
||||
}
|
||||
} catch (e) {
|
||||
out[f] = 'unreadable: ' + e.message;
|
||||
}
|
||||
});
|
||||
try {
|
||||
out.current_thread = Process.getCurrentThreadId();
|
||||
out.threads = Process.enumerateThreads().map(function (t) { return t.id; }).slice(0, 24);
|
||||
} catch (e) { out.threads = 'enumerate failed: ' + e.message; }
|
||||
try {
|
||||
out.modules_named = Process.enumerateModules()
|
||||
.filter(function (m) { return /frida|gum|gadget|linjector/i.test(m.name); })
|
||||
.map(function (m) { return m.name + '@' + m.base; });
|
||||
} catch (e) { out.modules_named = []; }
|
||||
return out;
|
||||
}
|
||||
|
||||
function report() {
|
||||
var out = {
|
||||
kind: 'anti_detect_probe',
|
||||
mode: 'observer-only (this script never changes the target)',
|
||||
ranMs: Date.now() - started,
|
||||
counts: counts,
|
||||
details: lines,
|
||||
selfView: selfView(),
|
||||
};
|
||||
send({ type: 'RESULT', payload: out });
|
||||
console.log('\n=== RESULT (probe) ===');
|
||||
console.log('ranMs=' + out.ranMs);
|
||||
Object.keys(counts).sort().forEach(function (k) {
|
||||
console.log(' ' + k + ' = ' + counts[k]);
|
||||
});
|
||||
var sv = out.selfView;
|
||||
console.log(' tracerpid=' + sv.tracerpid + ' frida_named_maps=' + sv.frida_named_maps +
|
||||
' frida_modules=' + JSON.stringify(sv.modules_named));
|
||||
var interesting = ['termination', 'thread_create', 'env_probe', 'loader'];
|
||||
interesting.forEach(function (cat) {
|
||||
if (!lines[cat]) return;
|
||||
console.log('\n=== ' + cat + ' ===');
|
||||
lines[cat].forEach(function (l) { console.log(' ' + l); });
|
||||
});
|
||||
console.log('\n=== how to read this ===');
|
||||
console.log(' a hook that reports nothing is a finding about the hook (is the export');
|
||||
console.log(' really there? did the code run?) -- establish the pipeline on a control');
|
||||
console.log(' target before concluding "no detection".');
|
||||
}
|
||||
|
||||
function start() {
|
||||
hookExport('libc.so', 'open', 'env_probe', 0);
|
||||
hookExport('libc.so', 'openat', 'env_probe', 1);
|
||||
hookExport('libc.so', 'fopen', 'env_probe', 0);
|
||||
hookExport('libc.so', 'fopen64', 'env_probe', 0);
|
||||
hookExport('libc.so', 'access', 'env_probe', 0);
|
||||
hookExport('libc.so', 'stat', 'env_probe', 0);
|
||||
hookExport('libc.so', 'stat64', 'env_probe', 0);
|
||||
hookExport('libc.so', 'lstat', 'env_probe', 0);
|
||||
hookExport('libc.so', 'readlink', 'env_probe', 0);
|
||||
hookExport('libc.so', 'opendir', 'env_probe', 0);
|
||||
hookExport('libc.so', 'scandir', 'env_probe', 0);
|
||||
hookExport('libc.so', 'strstr', 'strstr_probe', 1);
|
||||
hookExport('libc.so', 'strcmp', 'strcmp_probe', 1);
|
||||
hookTerminators();
|
||||
hookThreadCreation();
|
||||
hookLoader();
|
||||
console.log('=== anti_detect_probe armed (observer only) ===');
|
||||
var sv0 = selfView();
|
||||
console.log('selfView at arm time: ' + JSON.stringify(sv0));
|
||||
send({ type: 'LIVE', cat: 'armed', t: 0, msg: 'script armed', selfView: sv0 });
|
||||
if (CONFIG.heartbeatMs > 0) {
|
||||
// The heartbeat is the liveness proof. Its absence at time T means the script
|
||||
// (or the whole process) was gone before T, which is a different finding from
|
||||
// "the target was quiet".
|
||||
setInterval(function () {
|
||||
send({ type: 'LIVE', cat: 'heartbeat', t: Date.now() - started,
|
||||
counts: JSON.parse(JSON.stringify(counts)) });
|
||||
}, CONFIG.heartbeatMs);
|
||||
}
|
||||
if (CONFIG.watchMs > 0) {
|
||||
var t = setTimeout(report, CONFIG.watchMs);
|
||||
setTimeout(function () { clearTimeout(t); }, CONFIG.watchMs + 1);
|
||||
}
|
||||
}
|
||||
|
||||
rpc.exports = {
|
||||
config: function (patch) { Object.assign(CONFIG, patch); return CONFIG; },
|
||||
status: function () { return { counts: counts, ranMs: Date.now() - started }; },
|
||||
report: function () { report(); return true; },
|
||||
// Kept for interface parity with the kit's other probes; nothing is patched.
|
||||
patch: function () { return 'observed-only: nothing patched'; },
|
||||
};
|
||||
|
||||
if (Java.available) {
|
||||
// Only to record what the Java side is, not to hook it: an observer that hooks
|
||||
// Java would change the timing it is there to measure.
|
||||
try {
|
||||
Java.perform(function () {
|
||||
console.log('java classloader ready, app=' +
|
||||
(Java.androidVersion ? 'Android ' + Java.androidVersion : 'unknown'));
|
||||
});
|
||||
} catch (e) { /* Java optional */ }
|
||||
}
|
||||
|
||||
setImmediate(start);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user