Close the byte-patching gap, add the server-config path, fix two script crashes
Worked end-to-end on a real unobfuscated commercial app (single-dex, no packer,
server-driven launch screen) and folded back everything that cost time. Net +17
files, no new dependencies, no sample-specific identifiers.
New references
byte-level-patching.md
The technique that was missing entirely. Equal-length in-place edits beat
method rebuilding (measured on a real sample: method rewrite grew debug_info
924 B -> 22.8 KB and the dex 4.32 MB -> 7.73 MB), but four failure modes are
silent: same-length is not enough, branch polarity, verifier legality, and
stale dex header fields. Covers locating an instruction's exact offset (why
listings and byte-pattern guesses both fail), the instruction width table and
the two traps that desynchronise a decode, neutralise-a-branch vs redirect-it,
and the header's signature-then-checksum order.
server-config-and-updates.md
The most common shape of "ad" in a modern app, and the one usually
mis-diagnosed: the server supplies UI the client renders. The two-layer fetch
(local fallback then remote override) that proves it, finding the config DTOs
through 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.
New scripts
dexutil.py dependency-free dex reader: structural walk, exact
instruction decode, dex header recompute/verify in the
correct order, branch-target and operand helpers
dex_find_insn.py locate an instruction by decoded semantics, print its exact
byte offset with context and BOTH sides of any branch
dex_patch_bytes.py equal-length byte patches from a JSON spec: semantic match,
polarity pin via expect_next, equal-length enforcement,
verifier check, header recompute, re-decode to prove it landed
dex_check_verifier.py does any conditional branch target a move-result (bypassing
its producer)? Compares builds and separates pre-existing
findings from regressions the patch introduced
coldstart.py cold-launch capture: timed screenshot burst + logcat signals
+ installed-build facts + launch timing, and a warning when
the foreground activity is not the app
check_repo.py maintenance check: every script parses and answers --help,
every referenced path resolves, every file is documented
Fixes found while running the above
repack.py wrote archives with a plain zipfile writer, which cannot satisfy
the Android R+ install gate: resources.arsc must be STORED *and*
4-byte aligned, and a zip extra record cannot express a 1-3 byte
pad (a stored filler entry is inserted instead). Also fixes the
central-directory pointer and adds an alignment gate to the output.
usb_net_proxy.py, dex_strpatch.py
crashed on --help with a traceback instead of printing usage.
Updated
toolchain.md ddc added (single-binary dex->Java with query subcommands,
with measured timings and its real limits); "not on PATH is
not not-installed" with the search commands; which signer
to use and why jarsigner breaks alignment
repack-and-sign.md alignment requirement and its arithmetic; dex header
recompute; jarsigner hazard; vendor install interception
([N] with no symbolic constant -> root pm install); the
installer can keep owning the screen after an install
recon.md resolve package identity from a tool, not from hand-read
AXML strings (a wrong package sends all later device queries
nowhere); snapshot the config surface during recon
long-task-discipline.md captures you never looked at are not evidence (incl. the
consistent-but-wrong-window variant); long-context decay and
the record shape that survives it
dynamic-frida.md when live attach cannot work at all: static instrumentation,
static read, and self-recorded evidence, with the control
build caveat each one carries
ad-removal.md server-driven config is usually the EASIEST case, not the
hardest; a three-way zero result is a positive finding;
scope note on local brand splash vs promotional content
pitfalls.md P37 stale dex header checksum, P38 [-124] alignment,
P39 bare-numeric install failure, P40 wrong-window evidence,
P41 inverted branch polarity, P42 decode desynchronisation
SKILL.md, README.md symptom index, reference/script indexes and requirements
updated for all of the above
This commit is contained in:
@@ -86,6 +86,18 @@ SKILL.md a procedure with gates, not background reading:
|
||||
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
|
||||
@@ -114,9 +126,11 @@ references/ loaded on demand, one topic each
|
||||
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
|
||||
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, handover
|
||||
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
|
||||
@@ -152,10 +166,26 @@ scripts/ parameterized, path-agnostic
|
||||
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 surgical rewriter (preferred tool)
|
||||
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
|
||||
@@ -165,7 +195,8 @@ scripts/ parameterized, path-agnostic
|
||||
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, sign, verify
|
||||
repack.py rebuild APK, strip only signatures, keep META-INF/services/, write a
|
||||
4-byte-aligned archive (resources.arsc STORED+aligned), sign, verify
|
||||
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
|
||||
@@ -218,8 +249,8 @@ runnable `.jar` instead of a command), set `APKREV_TOOLS` to one or more directo
|
||||
will find them:
|
||||
|
||||
```
|
||||
set APKREV_TOOLS=D:\tools;D:\android\build-tools\35.0.0 # Windows
|
||||
export APKREV_TOOLS=/opt/tools:/opt/android/build-tools # POSIX
|
||||
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
|
||||
@@ -228,14 +259,19 @@ Linux; where a snippet is POSIX-only it is labelled. Nothing here assumes a Unix
|
||||
| Tool | Used for |
|
||||
|---|---|
|
||||
| Python 3.9+ | all scripts |
|
||||
| `ddc` (optional but recommended) | single-binary dex→Java decompiler with query subcommands (`info`, `findrefs`, `strings --with-locations`, per-class decompile). No JVM. Turns string cross-referencing from a crawl into a lookup, and reports package identity reliably — see `references/toolchain.md` |
|
||||
| `baksmali` / `smali` + `dexlib2` jars | disassembly, assembly, surgical patching |
|
||||
| JDK (`javac`, `java`) | building/running the dexlib2 patcher |
|
||||
| Android SDK build-tools (`aapt`, `zipalign`, `apksigner`) | manifest info, alignment, signing |
|
||||
| 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 `references/toolchain.md` covers finding an install that
|
||||
`PATH` does not know about (the common case for `apksigner` and `keytool`).
|
||||
|
||||
## Read this first
|
||||
|
||||
`references/pitfalls.md`. It is the most valuable file here — every entry is a failure
|
||||
|
||||
@@ -65,16 +65,22 @@ unread in this repository.
|
||||
| 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 | `code-virtualization-and-custom-linkers.md` |
|
||||
| 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 | `packers.md` §map the validation boundary |
|
||||
| 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 |
|
||||
| A script will not start, or a tool "is missing" | `scripts/doctor.py`, then `toolchain.md` |
|
||||
| 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" |
|
||||
| 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` |
|
||||
| Ads still appear after a patch that should have killed them | `ad-removal.md` §step 4 (count the SDK's own log lines; n -> 0, not "I did not see it") |
|
||||
| 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 |
|
||||
@@ -117,7 +123,7 @@ Answer these before touching a tool. Every one of them changes the whole plan.
|
||||
Read the manifest's `application android:name`. If it is a third-party shell class rather than the app's own Application, you have a packer and must handle it first.
|
||||
2. **Where does the behavior you want to change actually live?**
|
||||
- Ad SDK (Pangle/GDT/AnyThink/Kuaishou/Baidu/Sigmob…) → usually **client-side and removable** → `references/ad-removal.md`
|
||||
- Server-issued ad config / sponsored cards → **client renders server data** → `references/ad-removal.md` §server-driven
|
||||
- **Server-issued config for UI the client renders** (launch screen, popup, announcement, tab set, sponsored card on a home feed) → **the client decides, the server supplies the data** → `references/server-config-and-updates.md` (this is the most common shape of "ad" in a modern app, and there is no SDK to find — decide this question early, because hunting an SDK that does not exist costs hours)
|
||||
- Membership / VIP / paid content → **usually server-authorized, client patch is cosmetic** → `references/membership-and-limits.md` (read this *before* spending hours)
|
||||
- Feature flag, UI gate, debug switch → usually client-side
|
||||
- Anything decided by an API response → server-side → `references/server-api.md`
|
||||
@@ -192,7 +198,18 @@ point; it exists for exactly this moment.
|
||||
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`. Prefer **dexlib2 method-level rewriting** over whole-tree smali round-trip. Whole-tree round-trip damages R8-optimized dex in ways that only show up at runtime.
|
||||
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.
|
||||
@@ -257,6 +274,8 @@ Load only what the current step needs.
|
||||
| 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 into `native` declarations, a private loader with a mismatched SONAME, an embedded self-decrypting payload, or a Java-layer "signature killer" that logs success while a native check kills you. Covers the keep-it/drop-it deadlock and the string-redirect escape |
|
||||
| `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 |
|
||||
@@ -264,7 +283,7 @@ Load only what the current step needs.
|
||||
| `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 on your device, detects root/hook/debugger, or your dynamic tool simply does not work in this environment. **Read the first section before escalating** — the right answer is usually to switch to static, not to fight the detector |
|
||||
| `references/toolchain.md` | Choosing or invoking tools, something is not installed, a tool's output smells wrong, or you need to know which tools exist only as a GUI |
|
||||
| `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 |
|
||||
@@ -280,7 +299,7 @@ Load only what the current step needs.
|
||||
| `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, handover |
|
||||
| `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. |
|
||||
|
||||
## Script index
|
||||
@@ -290,6 +309,11 @@ All scripts are parameterized and path-agnostic; pass paths explicitly. Run `--h
|
||||
| Script | Purpose |
|
||||
|---|---|
|
||||
| `scripts/doctor.py` | **Run this first.** Capability report and per-script runnability: which tools exist (including ones installed off-PATH or as `java -jar` jars), which scripts can actually run here, and the environment facts that silently poison experiments — clock skew, leftover `adb forward`/proxy, a device-side frida process already running |
|
||||
| `scripts/dexutil.py` | **Dependency-free dex reader**: structural walk + exact instruction decode with a verified format table, `fix_dex_header`/`verify_dex_header` (correct checksum/signature order), branch-target and operand helpers. The library the dex scripts below share; also runs standalone to dump one method with offsets |
|
||||
| `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: matches by semantics, pins branch polarity via `expect_next`, enforces equal length, checks verifier legality, recomputes the dex header in the correct order, and re-decodes 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 an app and capture a **timed screenshot burst + logcat signals + installed-build facts + launch timing**, and warn when the foreground activity is not your app (a vendor installer left on screen is the classic cause of "evidence" that shows something else) |
|
||||
| `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** |
|
||||
@@ -299,8 +323,8 @@ All scripts are parameterized and path-agnostic; pass paths explicitly. Run `--h
|
||||
| `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) |
|
||||
| `scripts/repack.py` | Rebuild APK with replaced dex, strip only signatures, sign |
|
||||
| `scripts/dexpatch/` | dexlib2 method-level surgical rewriter (the preferred patch tool) + build notes |
|
||||
| `scripts/repack.py` | Rebuild an APK with replaced dex, strip only signatures, keep `META-INF/services/`, **write a 4-byte-aligned archive** (`resources.arsc` STORED+aligned, which Android R+ refuses to install without), sign, and verify |
|
||||
| `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 |
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Pre-commit checks: every script parses and answers --help; every referenced file exists."""
|
||||
import ast
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
os.chdir(ROOT)
|
||||
|
||||
fail = []
|
||||
|
||||
print("== python syntax + --help ==")
|
||||
for name in sorted(os.listdir('scripts')):
|
||||
if not name.endswith('.py'):
|
||||
continue
|
||||
path = os.path.join('scripts', name)
|
||||
try:
|
||||
ast.parse(open(path, encoding='utf-8').read())
|
||||
except SyntaxError as e:
|
||||
fail.append('%s: syntax error %s' % (path, e))
|
||||
print(' FAIL %s' % path)
|
||||
continue
|
||||
r = subprocess.run([sys.executable, '-B', path, '--help'],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
# A script may exit non-zero on --help if it uses the "print usage and exit 2"
|
||||
# convention. That is acceptable; a traceback is not.
|
||||
crashed = 'Traceback (most recent call last)' in (r.stderr or '')
|
||||
helped = bool((r.stdout or '').strip()) or bool(r.stderr)
|
||||
ok = (not crashed) and helped
|
||||
verdict = 'ok' if r.returncode == 0 and not crashed else \
|
||||
('usage-ok rc=%d' % r.returncode if ok else 'CRASH rc=%d' % r.returncode)
|
||||
print(' %-32s %s' % ('%s --help' % name, verdict))
|
||||
if not ok:
|
||||
fail.append('%s --help crashed: %s' % (path, (r.stderr or '')[:300]))
|
||||
|
||||
print("\n== markdown references resolve ==")
|
||||
refs = set(os.listdir('references'))
|
||||
scripts = set(os.listdir('scripts'))
|
||||
missing = []
|
||||
for name in ['SKILL.md', 'README.md'] + ['references/' + f for f in refs if f.endswith('.md')]:
|
||||
text = open(name, encoding='utf-8').read()
|
||||
for m in re.finditer(r'`?(references/[A-Za-z0-9_\-]+\.md)', text):
|
||||
target = m.group(1).split('/')[1]
|
||||
if target not in refs:
|
||||
missing.append('%s -> %s' % (name, m.group(1)))
|
||||
for m in re.finditer(r'`?(scripts/[A-Za-z0-9_\-]+\.(?:py|js))', text):
|
||||
target = m.group(1).split('/')[1]
|
||||
if target not in scripts:
|
||||
missing.append('%s -> %s' % (name, m.group(1)))
|
||||
if missing:
|
||||
for x in sorted(set(missing)):
|
||||
print(' MISSING %s' % x)
|
||||
fail.extend(missing)
|
||||
else:
|
||||
print(' all referenced reference/script paths exist')
|
||||
|
||||
print("\n== every reference file is reachable from SKILL.md or README.md ==")
|
||||
bodies = open('SKILL.md', encoding='utf-8').read() + open('README.md', encoding='utf-8').read()
|
||||
unlisted = [f for f in sorted(refs) if f.endswith('.md') and f not in bodies]
|
||||
for f in unlisted:
|
||||
print(' UNLISTED references/%s' % f)
|
||||
if unlisted:
|
||||
fail.append('unlisted references: %s' % unlisted)
|
||||
|
||||
print("\n== every script is mentioned somewhere in the docs ==")
|
||||
all_docs = bodies
|
||||
for f in refs:
|
||||
if f.endswith('.md'):
|
||||
all_docs += open('references/' + f, encoding='utf-8').read()
|
||||
undoc = [s for s in sorted(scripts) if s.endswith(('.py', '.js')) and s not in all_docs]
|
||||
for s in undoc:
|
||||
print(' UNDOCUMENTED scripts/%s' % s)
|
||||
if undoc:
|
||||
fail.append('undocumented scripts: %s' % undoc)
|
||||
|
||||
print("\n== result: %d problem(s) ==" % len(fail))
|
||||
for f in fail:
|
||||
print(' - %s' % f)
|
||||
sys.exit(1 if fail else 0)
|
||||
@@ -23,9 +23,27 @@ Do not assume "ads" is one thing. Enumerate first.
|
||||
- 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):
|
||||
- Look for endpoints like `/adverts`, `/adv`, `/banner`, `/config`, and DTOs named `Advertisement*`, `Advert*`, `Banner*`, `Promotion*`.
|
||||
- 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.
|
||||
- Sponsored cards that look like content (a VPN promo, a network-accelerator card, a third-party product) are usually exactly this.
|
||||
- **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.
|
||||
|
||||
@@ -138,9 +156,22 @@ Frame the result at the right strength: *"the ad was hidden"* vs *"the ad subsys
|
||||
|
||||
Be honest about residual ads rather than breaking the app to chase them:
|
||||
|
||||
- **Server-driven content promos** deep in a feature's own data payload, where the screen's load depends on the same request. Suppressing them requires a data-consumption patch, not transport blocking.
|
||||
- **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.
|
||||
- **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.
|
||||
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,188 @@
|
||||
# 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.
|
||||
|
||||
## 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);
|
||||
different-length values need `dex_strpatch`-class tooling or a rebuild.
|
||||
- The target is a **multi-dex** app and you intend to move code between dexes. Byte patching stays
|
||||
inside one dex by definition.
|
||||
@@ -403,6 +403,34 @@ Countermeasures, in order of least disruption:
|
||||
|
||||
**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 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
|
||||
|
||||
@@ -226,6 +226,97 @@ When you discover a window was contaminated, **the correct action is to discard
|
||||
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:
|
||||
|
||||
@@ -1032,3 +1032,255 @@ complete, and it costs a few lines to parse. Do not reverse-decode backwards loo
|
||||
— on aarch64 almost any 4-byte window decodes as *something*, so a naive scanner reports hundreds
|
||||
of fictional entry points — and prologue pattern-matching yields a plausible set with no
|
||||
completeness guarantee.
|
||||
|
||||
---
|
||||
|
||||
## P37. A byte patch lands, and the app dies with `Bad checksum` and a missing normal class
|
||||
|
||||
**Symptom**
|
||||
|
||||
After editing a dex by a few bytes and repacking, the app fails to start, and logcat shows an
|
||||
ordinary class failing to resolve — the Application class, or a small AndroidX component:
|
||||
|
||||
```
|
||||
W/<pkg>: Failure to verify dex file '.../base.apk': Bad checksum (eacdc11c, expected 6456b6b5)
|
||||
E/LoadedApk: java.lang.ClassNotFoundException: Didn't find class "<AppClass>" on path: ...
|
||||
E/AndroidRuntime: FATAL EXCEPTION: main
|
||||
java.lang.RuntimeException: Unable to instantiate application <AppClass>: java.lang.ClassNotFoundException: ...
|
||||
```
|
||||
|
||||
**Root cause**
|
||||
|
||||
Every dex header carries two integrity fields that cover the rest of the file, and **they must be
|
||||
recomputed in a specific order**:
|
||||
|
||||
```
|
||||
bytes 12..32 = sha1(data[32:]) # signature — computed FIRST
|
||||
bytes 8..12 = adler32(data[12:]) # checksum — covers the signature, so LAST
|
||||
```
|
||||
|
||||
Computing them in the reverse order leaves the adler32 taken while the signature field was still
|
||||
zeroed, so the header never verifies. Note also that in the log line above the **real** adler32 is
|
||||
printed as "expected" and the header's stale value as the computed one, which reads backwards and
|
||||
sends you looking at the wrong number.
|
||||
|
||||
Two properties make this expensive:
|
||||
|
||||
- **Android may still start the process**, falling back to interpreting the dex instead of using a
|
||||
verified/optimized image. So the failure is not "rejected", it is a *different* failure, and it
|
||||
appears as a class-resolution problem in a component unrelated to your edit.
|
||||
- **Some producers ship a dex whose signature field is all zeros.** On such a file the wrong order
|
||||
is self-consistent, so the bug stays invisible until the first edit — and then looks like your
|
||||
edit caused it.
|
||||
|
||||
**Do instead**
|
||||
|
||||
Recompute both fields on every dex you touch, in the order above, and assert the result:
|
||||
|
||||
```
|
||||
adler32(data[12:]) == header_checksum and sha1(data[32:]) == header_signature
|
||||
```
|
||||
|
||||
`scripts/dexutil.py` provides `fix_dex_header()` (correct order) and `verify_dex_header()`;
|
||||
`scripts/dex_patch_bytes.py` runs both and refuses to write a header that does not self-verify.
|
||||
When you see `Bad checksum`, check the header before investigating the class it named.
|
||||
|
||||
---
|
||||
|
||||
## P38. `[-124]` on install after a repack: `resources.arsc` must be STORED *and* aligned
|
||||
|
||||
**Symptom**
|
||||
|
||||
An APK that was previously installed fine, rebuilt with only a dex change, is refused:
|
||||
|
||||
```
|
||||
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]
|
||||
```
|
||||
|
||||
**Root cause**
|
||||
|
||||
Two independent requirements are compressed into that one sentence: the entry must be **STORED**,
|
||||
and its **data offset** must be divisible by 4. A `zipfile`-based repack can satisfy the first and
|
||||
still fail the second, because Python's zip writer gives you no control over entry offsets.
|
||||
|
||||
An aggravating factor: this is exactly the class of defect a *signing* step can introduce.
|
||||
`jarsigner` recompresses entries as a side effect of adding the v1 JAR signature, so an archive that
|
||||
was correctly aligned before signing is not aligned after — and `jarsigner -verify` still reports
|
||||
success. If a build that installed a minute ago now returns `[-124]`, suspect the signer first.
|
||||
|
||||
**Do instead**
|
||||
|
||||
- Write the archive aligned **while writing it** (`scripts/repack.py` emits local headers itself and
|
||||
pads the local extra field), rather than repairing alignment afterwards.
|
||||
- Know the padding arithmetic trap: 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**.
|
||||
Insert a stored filler entry of exactly that size instead.
|
||||
- Sign with **`apksigner`** (v1+v2+v3), which appends the signature block without reordering the zip.
|
||||
- Verify by reading the archive, not the tool's exit code: for each gated entry, parse the local
|
||||
header, compute `header_offset + 30 + namelen + extralen`, and check `compress_type == 0` and
|
||||
`offset % 4 == 0`.
|
||||
|
||||
---
|
||||
|
||||
## P39. Install fails with a bare numeric code and no `INSTALL_FAILED_*` constant
|
||||
|
||||
**Symptom**
|
||||
|
||||
```
|
||||
Performing Streamed Install
|
||||
adb.exe: failed to install app.apk: Failure [-99]
|
||||
```
|
||||
|
||||
The APK installs fine through the device's own file manager, and the same build installs on another
|
||||
device. There is no symbolic `INSTALL_FAILED_*` name, so there is nothing to look up.
|
||||
|
||||
**Root cause**
|
||||
|
||||
Some OEM ROMs route `adb install` through their own security/verification service. The failure is
|
||||
**not about your APK**; a device process is declining to accept an ADB-initiated install. The device
|
||||
log names the real actor:
|
||||
|
||||
```
|
||||
ColorPackageInstallInterceptManager: <VENDOR>_ADB_INSTALL_CANCEL ... packageName=<pkg>
|
||||
```
|
||||
|
||||
**Do instead**
|
||||
|
||||
Recognise the shape: numeric-only failure, no symbolic constant, installs fine via the device UI,
|
||||
and a vendor package-installer/security process in logcat. Then bypass ADB's install path with root:
|
||||
|
||||
```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'"
|
||||
```
|
||||
|
||||
Do **not** start rebuilding the APK, and do not remove permissions or components to "make it
|
||||
installable" — the artifact was never the problem. Search the device log for the install attempt
|
||||
before touching the build again.
|
||||
|
||||
---
|
||||
|
||||
## P40. The screen you captured is not your app
|
||||
|
||||
**Symptom**
|
||||
|
||||
A launch capture sequence shows a consistent, plausible screen, and conclusions are drawn from it.
|
||||
Later, the app is discovered never to have been in the foreground at all. The captured frames show,
|
||||
for example, a vendor package-installer confirmation bearing a package name from an unrelated
|
||||
earlier attempt.
|
||||
|
||||
**Root cause**
|
||||
|
||||
An install that went through a UI prompt or an OEM interception can leave the **installer window as
|
||||
the foreground activity indefinitely**. From then on:
|
||||
|
||||
- `am start` on your app appears to do nothing; the other window owns the display.
|
||||
- Screenshots of your supposed launch show the installer instead.
|
||||
- `am start -W` — which waits for a first frame — can block past any reasonable timeout, because
|
||||
that frame never arrives. This presents as "the tool hung", not as "the wrong window is up".
|
||||
|
||||
The insidious part is that the frames are **mutually consistent**, which feels like corroboration.
|
||||
Consistency is not corroboration when every frame shares the same blind spot.
|
||||
|
||||
**Do instead**
|
||||
|
||||
- Check the foreground before trusting any capture, and again at the end of the sequence:
|
||||
```bash
|
||||
adb -s <serial> shell "dumpsys activity activities | grep -m1 ResumedActivity"
|
||||
```
|
||||
If the component is not your package, the frames are evidence about something else.
|
||||
- Clear the stray window (`am force-stop <installer pkg>`, or a HOME key event) and re-capture.
|
||||
- Prefer a launcher-driven capture over `am start -W`: derive the time line from captures plus
|
||||
logcat instead of blocking on a first frame.
|
||||
- Treat byte-identical consecutive frames as a **finding** (a hang, a dialog awaiting input), not as
|
||||
a capture artefact.
|
||||
|
||||
`scripts/coldstart.py` performs the foreground check and warns; see also
|
||||
`long-task-discipline.md` §captures you never looked at are not evidence.
|
||||
|
||||
---
|
||||
|
||||
## P41. A patched branch does the opposite of what was intended, and starts cleanly
|
||||
|
||||
**Symptom**
|
||||
|
||||
The patch targets a boolean config gate. The build installs, launches, and never crashes — and the
|
||||
behaviour is exactly inverted: the thing that was supposed to be suppressed now appears every time,
|
||||
or vice versa. Nothing in the logs indicates a problem.
|
||||
|
||||
**Root cause**
|
||||
|
||||
The **polarity of the branch was read from the field name instead of from the control flow.**
|
||||
Field names describe intent, not branch layout.
|
||||
|
||||
```
|
||||
0x151304 iget-boolean v11, v0 -> Config.enabled
|
||||
0x151308 if-nez v11, :far ; enabled == false jumps AWAY
|
||||
0x15130c invoke ...startMain() ; fall-through: straight into the app
|
||||
0x151314 :far iget v11, v0 -> Config.duration ; the "show it" path
|
||||
```
|
||||
|
||||
Here `enabled == true` is the value that **skips** the promo — the opposite of the literal reading.
|
||||
A patch that "enables the skip" forces the promo to display on every launch.
|
||||
|
||||
The second, equally quiet variant: redirecting a conditional branch (`if-*` -> `goto`) to force one
|
||||
side. That introduces a new control-flow edge, which can land on a `move-result*` and make the class
|
||||
fail to load with `VerifyError` — a class-load failure that does not always surface as a crash
|
||||
dialog.
|
||||
|
||||
**Do instead**
|
||||
|
||||
- **Decode both sides before editing**, and write one line naming what each one does. If you cannot
|
||||
describe the fall-through and the target, you are not ready to patch.
|
||||
- Prefer **neutralising the branch** (`if-*` -> `nop` pair) over redirecting it. Removing an edge is
|
||||
safe; adding one is not.
|
||||
- Pin the polarity in the patch specification itself: assert which instruction must immediately
|
||||
follow the branch. `scripts/dex_patch_bytes.py` fails the patch if `expect_next` does not hold,
|
||||
which makes this mistake impossible to commit silently.
|
||||
- Audit verifier legality after the edit instead of trusting that it launched
|
||||
(`scripts/dex_check_verifier.py`).
|
||||
|
||||
---
|
||||
|
||||
## P42. A decode desynchronises, and every offset after that point is wrong
|
||||
|
||||
**Symptom**
|
||||
|
||||
An instruction you can see in a smali listing is not found by your own decoder, or is reported at an
|
||||
offset that does not match the disassembler. Sometimes the decode still produces plausible-looking
|
||||
instructions, just shifted, so "no match" is reported for something that is definitely present.
|
||||
|
||||
**Root cause**
|
||||
|
||||
**One wrong instruction width desynchronises everything after it.** Common offenders, each with its
|
||||
own trap:
|
||||
|
||||
- `0x32`-`0x3D` (`if-test` 22t / `if-testz` 21t) are **2** code units, not 1. Treating them as 1
|
||||
unit invents a fake second instruction at every branch.
|
||||
- `0x1A` (`const-string/jumbo`) appears in real toolchains as a **4-byte** form (op, register,
|
||||
uint16 string index), not the 6-byte 31c shape its format name suggests. Counting it as 3 units
|
||||
shifts the rest of the method by one unit per occurrence.
|
||||
- `0x28` is `goto` (10t, **1** unit); `0x29` is `goto/16` and `0x2A` is `goto/32`.
|
||||
- A `nop` payload is encoded `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 following instruction.
|
||||
- dalvik encodes a distant conditional jump as `if-*` **plus** a separate `goto`, not a single
|
||||
instruction.
|
||||
|
||||
**Why it is hard to see**
|
||||
|
||||
The decoder reports what it decoded. A shifted stream looks like a method that simply does not
|
||||
contain the instruction you want, which reads as a finding about the target — the same failure shape
|
||||
as P25/P26/P33, and it removes viable patch sites for free.
|
||||
|
||||
**Do instead**
|
||||
|
||||
- **Assert the walk ends exactly on `insns_off + insns_size*2`.** If it overshoots or undershoots,
|
||||
the width table is wrong somewhere and every offset derived from that method is suspect. One
|
||||
comparison catches all of the above.
|
||||
- **Cross-check register numbers.** If the method's `registers` count is 12 and the listing mentions
|
||||
`v13`, the decode has drifted.
|
||||
- **Cross-check one known instruction** against a disassembler before trusting offsets you derived.
|
||||
- Build the width table from the format groups in `references/byte-level-patching.md` rather than
|
||||
from memory, and keep the width logic in one place so a fix applies everywhere.
|
||||
|
||||
@@ -9,12 +9,48 @@ Ten minutes here prevents hours of wrong work. Answer the five questions from `S
|
||||
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`.
|
||||
@@ -29,6 +65,14 @@ Read `AndroidManifest.xml` → `application android:name`.
|
||||
| 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
|
||||
|
||||
@@ -24,6 +24,42 @@ def is_signature_entry(name):
|
||||
|
||||
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/`.
|
||||
@@ -50,6 +86,34 @@ 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.
|
||||
@@ -132,13 +196,62 @@ Always pass the explicit range (Route B above) and read the scheme list from tha
|
||||
| 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.** |
|
||||
| OEM installer refuses (`INSTALL_FAILED_*`, vendor restrictions) | push the APK and install via root: `su -c 'pm install -r -t -d /data/local/tmp/app.apk'` |
|
||||
| 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:
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
# 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.
|
||||
|
||||
## 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 §4.
|
||||
|
||||
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 §3). 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>
|
||||
```
|
||||
+97
-1
@@ -30,12 +30,48 @@ cross-check with the program headers.
|
||||
| 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 | **the preferred patcher** — method-level rewrite, leaves everything else untouched (`dex-patching.md`) |
|
||||
| `dexlib2` | small Java program | method-level rewrite, leaves everything else untouched (`dex-patching.md`) |
|
||||
| **`ddc`** | `ddc app.apk -c <Class>` | **fastest read of dex as Java, plus query subcommands** — 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 (see below) |
|
||||
| `aapt2` | `aapt2 dump badging <apk>` | manifest facts, package name, versions |
|
||||
| `zipalign`, `apksigner` | from build-tools | alignment and signing |
|
||||
|
||||
### 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.
|
||||
|
||||
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.
|
||||
|
||||
**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`
|
||||
@@ -101,6 +137,66 @@ script, and check the device architecture — the server binary is per-ABI.
|
||||
for a different engine version produces output that is subtly wrong rather than obviously broken.
|
||||
Pin the version first (`dart-aot.md` §1) and do not "try it and see".
|
||||
|
||||
## "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
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Cold-launch an app and capture a time line of screenshots plus logcat signals.
|
||||
|
||||
WHY THIS EXISTS
|
||||
---------------
|
||||
"Did the patch work?" for anything user-visible -- a splash/ad gate, a forced
|
||||
update dialog, a login wall, a crash-on-start -- is answered by looking at the
|
||||
screen over the first seconds of a launch, and by comparison against the
|
||||
unmodified build. Doing that by hand produces two failures that look like
|
||||
findings and are not:
|
||||
|
||||
* **Sampling is not observation.** A dialog that appears and is then occluded
|
||||
can fall entirely between samples, and every frame you happened to take shows
|
||||
something else, which feels like corroboration. This tool captures on a fixed
|
||||
short cadence and, crucially, prints what it captured so a human or agent
|
||||
actually LOOKS at the frames.
|
||||
* **`am start -W` can block past a naive timeout.** It waits for the first
|
||||
frame; on some ROMs that never arrives while another window owns the display
|
||||
and the command simply hangs. This tool launches without `-W` and derives the
|
||||
time line from captures plus logcat instead.
|
||||
|
||||
It also records the two facts that decide whether an install even happened:
|
||||
the installed version, and the on-disk APK hash. "Install succeeded" describes
|
||||
the request, not the app on disk.
|
||||
|
||||
IMPORTANT -- before trusting any screenshot, confirm the foreground activity is
|
||||
your target. A vendor package installer left on screen (a pending confirmation
|
||||
from an earlier `adb install`, for example) will be captured instead of your app
|
||||
and looks completely plausible. `--expect-activity` makes that check automatic.
|
||||
|
||||
Usage
|
||||
-----
|
||||
python coldstart.py --serial <SERIAL> --pkg com.example \\
|
||||
--activity com.example/.MainActivity --duration 14 --interval 0.8 \\
|
||||
--out work/verify/orig --expect-activity com.example/.MainActivity
|
||||
|
||||
Requires: adb. `su` on the device is used when available for version/hash facts,
|
||||
otherwise it degrades to plain `pm`/`dumpsys`.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
def run(cmd, timeout=30, serial=None):
|
||||
"""Run a command with a hard timeout; never let a call stall the run."""
|
||||
try:
|
||||
return subprocess.run(cmd, capture_output=True, timeout=timeout,
|
||||
text=True, errors='replace')
|
||||
except subprocess.TimeoutExpired:
|
||||
return subprocess.CompletedProcess(cmd, 124, '', 'timeout after %ss' % timeout)
|
||||
|
||||
|
||||
class Device(object):
|
||||
def __init__(self, serial, use_su=True, timeout=30):
|
||||
self.serial = serial
|
||||
self.timeout = timeout
|
||||
self.adb = shutil.which('adb') or 'adb'
|
||||
self.su = use_su and self._su_works()
|
||||
|
||||
def _su_works(self):
|
||||
if self.serial:
|
||||
r = run(['adb', '-s', self.serial, 'shell', "su -c 'id'"], 15)
|
||||
else:
|
||||
r = run(['adb', 'shell', "su -c 'id'"], 15)
|
||||
return r.returncode == 0 and 'uid=0' in (r.stdout or '')
|
||||
|
||||
def shell(self, cmd, timeout=None):
|
||||
"""Run a device shell command, preferring root when available."""
|
||||
t = timeout or self.timeout
|
||||
if self.serial:
|
||||
base = ['adb', '-s', self.serial, 'shell']
|
||||
else:
|
||||
base = ['adb', 'shell']
|
||||
if self.su:
|
||||
return run(base + ["su -c '%s'" % cmd.replace("'", "'\\''")], t)
|
||||
return run(base + [cmd], t)
|
||||
|
||||
def screencap(self, path, timeout=30):
|
||||
"""Capture one PNG. Returns bytes written, or -1 on failure."""
|
||||
if self.serial:
|
||||
base = ['adb', '-s', self.serial]
|
||||
else:
|
||||
base = ['adb']
|
||||
try:
|
||||
with open(path, 'wb') as fh:
|
||||
r = subprocess.run(base + ['exec-out', 'screencap', '-p'],
|
||||
stdout=fh, timeout=timeout)
|
||||
size = os.path.getsize(path)
|
||||
if size < 8:
|
||||
return -1
|
||||
with open(path, 'rb') as fh:
|
||||
if fh.read(8) != b'\x89PNG\r\n\x1a\n':
|
||||
return -1
|
||||
return size
|
||||
except subprocess.TimeoutExpired:
|
||||
return -1
|
||||
|
||||
|
||||
def foreground_activity(dev):
|
||||
r = dev.shell('dumpsys activity activities | grep -m1 ResumedActivity')
|
||||
m = re.search(r'([A-Za-z0-9_.]+)/([A-Za-z0-9_.$]+)', r.stdout or '')
|
||||
return '%s/%s' % (m.group(1), m.group(2)) if m else None
|
||||
|
||||
|
||||
def installed_facts(dev, pkg):
|
||||
facts = {}
|
||||
r = dev.shell("dumpsys package %s | grep -E 'versionCode|versionName|firstInstallTime|lastUpdateTime'" % pkg)
|
||||
for line in (r.stdout or '').splitlines():
|
||||
m = re.match(r'\s*(versionCode|versionName|firstInstallTime|lastUpdateTime)=(.*)', line)
|
||||
if m:
|
||||
facts[m.group(1)] = m.group(2).strip()
|
||||
r = dev.shell('ls /data/app/*/%s*/base.apk 2>/dev/null' % pkg)
|
||||
apk_path = (r.stdout or '').strip().splitlines()[:1]
|
||||
if apk_path:
|
||||
facts['apk_path'] = apk_path[0]
|
||||
r2 = dev.shell('sha256sum %s' % apk_path[0])
|
||||
m = re.search(r'([0-9a-f]{64})', r2.stdout or '')
|
||||
if m:
|
||||
facts['apk_sha256'] = m.group(1)
|
||||
r = dev.shell('dumpsys package %s | grep -m1 userId=' % pkg)
|
||||
m = re.search(r'userId=(\d+)', r.stdout or '')
|
||||
if m:
|
||||
facts['uid'] = m.group(1)
|
||||
return facts
|
||||
|
||||
|
||||
def signal_counts(log_path, patterns):
|
||||
"""Count occurrences of each pattern in a logcat dump."""
|
||||
try:
|
||||
with open(log_path, encoding='utf-8', errors='replace') as fh:
|
||||
text = fh.read()
|
||||
except OSError:
|
||||
return {}
|
||||
return {name: len(re.findall(rx, text)) for name, rx in patterns.items()}
|
||||
|
||||
|
||||
SIGNAL_PATTERNS = {
|
||||
'FATAL EXCEPTION': r'FATAL EXCEPTION',
|
||||
'VerifyError': r'VerifyError',
|
||||
'Bad checksum': r'Bad checksum',
|
||||
'IncompatibleClassChangeError': r'IncompatibleClassChangeError',
|
||||
'ClassNotFoundException': r'ClassNotFoundException',
|
||||
'uncaughtException': r'uncaughtException',
|
||||
'am_crash': r'am_crash',
|
||||
'ANR': r'ANR in ',
|
||||
'Displayed': r'Displayed .*\+([0-9]+)ms',
|
||||
}
|
||||
|
||||
|
||||
def main(argv):
|
||||
ap = argparse.ArgumentParser(
|
||||
description='Cold-launch an app and capture a screenshot time line plus '
|
||||
'logcat signals, for before/after comparison.',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__.split('Usage')[-1][:1200])
|
||||
ap.add_argument('--serial', help='adb serial (REQUIRED when several devices are '
|
||||
'online, so the wrong one is not picked at random)')
|
||||
ap.add_argument('--pkg', required=True, help='package name')
|
||||
ap.add_argument('--activity', help='component to start (default: monkey/launcher)')
|
||||
ap.add_argument('--out', default='coldstart', help='output directory')
|
||||
ap.add_argument('--duration', type=float, default=14.0, help='seconds to capture')
|
||||
ap.add_argument('--interval', type=float, default=0.8, help='seconds between frames')
|
||||
ap.add_argument('--expect-activity', help='warn if the foreground activity is not '
|
||||
'this (and not the package at all)')
|
||||
ap.add_argument('--no-su', action='store_true', help='never use device root')
|
||||
ap.add_argument('--keep-old', action='store_true',
|
||||
help='do not clear existing screenshots in the output dir')
|
||||
args = ap.parse_args(argv[1:])
|
||||
|
||||
if not shutil.which('adb'):
|
||||
print('error: adb not found on PATH')
|
||||
return 2
|
||||
|
||||
if not args.serial:
|
||||
r = run(['adb', 'devices'])
|
||||
online = [l.split()[0] for l in (r.stdout or '').splitlines()[1:]
|
||||
if len(l.split()) >= 2 and l.split()[1] == 'device']
|
||||
if len(online) > 1:
|
||||
print('error: %d devices online (%s) and no --serial given. adb would '
|
||||
'pick one at random and you would debug the wrong target.'
|
||||
% (len(online), ', '.join(online)))
|
||||
return 2
|
||||
if online:
|
||||
args.serial = online[0]
|
||||
print('[i] single device online; using %s' % args.serial)
|
||||
|
||||
dev = Device(args.serial, use_su=not args.no_su)
|
||||
print('[i] serial=%s root=%s' % (args.serial, dev.su))
|
||||
|
||||
os.makedirs(args.out, exist_ok=True)
|
||||
shots = os.path.join(args.out, 'shots')
|
||||
os.makedirs(shots, exist_ok=True)
|
||||
if not args.keep_old:
|
||||
for f in os.listdir(shots):
|
||||
if f.endswith('.png'):
|
||||
os.remove(os.path.join(shots, f))
|
||||
|
||||
facts = installed_facts(dev, args.pkg)
|
||||
print('\n== installed build facts')
|
||||
for k in sorted(facts):
|
||||
print(' %-18s %s' % (k, facts[k]))
|
||||
if not facts.get('versionCode'):
|
||||
print(' WARNING: no versionCode found -- the package may not be installed')
|
||||
print(' (install success describes the request, not the app on disk; the '
|
||||
'sha256 above is the on-disk truth)')
|
||||
|
||||
print('\n== clearing logcat')
|
||||
dev.shell('logcat -c', timeout=20)
|
||||
|
||||
print('== force-stopping %s' % args.pkg)
|
||||
dev.shell('am force-stop %s' % args.pkg, timeout=20)
|
||||
|
||||
print('== launching')
|
||||
t0 = time.time()
|
||||
if args.activity:
|
||||
r = dev.shell('am start -n %s' % args.activity, timeout=25)
|
||||
else:
|
||||
r = dev.shell('monkey -p %s -c android.intent.category.LAUNCHER 1' % args.pkg,
|
||||
timeout=25)
|
||||
out = ((r.stdout or '') + (r.stderr or '')).strip()
|
||||
print(' %s' % (out.splitlines()[0] if out else '(no output)'))
|
||||
if 'Error' in out or 'Exception' in out:
|
||||
print(' LAUNCH ERROR -- read the line above before blaming the patch')
|
||||
|
||||
# live logcat into a file while we capture
|
||||
log_path = os.path.join(args.out, 'logcat.txt')
|
||||
if args.serial:
|
||||
log_cmd = ['adb', '-s', args.serial, 'logcat', '-v', 'threadtime']
|
||||
else:
|
||||
log_cmd = ['adb', 'logcat', '-v', 'threadtime']
|
||||
log_fh = open(log_path, 'w', encoding='utf-8', errors='replace')
|
||||
log_proc = subprocess.Popen(log_cmd, stdout=log_fh, stderr=subprocess.DEVNULL)
|
||||
|
||||
frames = []
|
||||
idx = 0
|
||||
try:
|
||||
while True:
|
||||
el = time.time() - t0
|
||||
if el > args.duration:
|
||||
break
|
||||
name = 'f%02d_%05.2fs.png' % (idx, el)
|
||||
path = os.path.join(shots, name)
|
||||
n = dev.screencap(path)
|
||||
frames.append((name, n, el))
|
||||
print(' %-20s %s' % (name, ('%d bytes' % n) if n > 0 else 'CAPTURE FAILED'))
|
||||
idx += 1
|
||||
time.sleep(max(0.0, args.interval))
|
||||
finally:
|
||||
time.sleep(0.5)
|
||||
log_proc.terminate()
|
||||
try:
|
||||
log_proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
log_proc.kill()
|
||||
log_fh.close()
|
||||
|
||||
fg = foreground_activity(dev)
|
||||
print('\n== foreground at end: %s' % fg)
|
||||
if args.expect_activity and fg and args.pkg not in fg:
|
||||
print(' *** FOREGROUND IS NOT YOUR APP (%s) ***' % fg)
|
||||
print(' Everything captured above shows something else -- most often a '
|
||||
'vendor package-installer confirmation left over from an install.')
|
||||
print(' Clear it (e.g. close the installer, press HOME) and re-run; do not '
|
||||
'read these frames as evidence about your build.')
|
||||
|
||||
counts = signal_counts(log_path, SIGNAL_PATTERNS)
|
||||
print('\n== logcat signals (%s)' % log_path)
|
||||
for name in SIGNAL_PATTERNS:
|
||||
print(' %-32s %s' % (name, counts.get(name, 0)))
|
||||
|
||||
m = re.findall(r'Displayed ([^:]+): \+([0-9]+)ms', open(log_path, encoding='utf-8',
|
||||
errors='replace').read())
|
||||
if m:
|
||||
print('\n== launch timing (logcat Displayed)')
|
||||
for comp, ms in m:
|
||||
print(' %s %s ms' % (comp.strip(), ms))
|
||||
else:
|
||||
print('\n no "Displayed" line: the launch may not have reached a first '
|
||||
'frame (a hang or a blocking dialog looks exactly like this)')
|
||||
|
||||
zero = sum(1 for _n, n, _e in frames if n <= 0)
|
||||
print('\n== %d frame(s) in %s, %d failed' % (len(frames), shots, zero))
|
||||
|
||||
# the point of the tool: make inspection happen
|
||||
print('\n== INSPECT THE FRAMES. A capture nobody looked at is not evidence.')
|
||||
nonzero = [f for f in sorted(os.listdir(shots)) if f.endswith('.png')]
|
||||
if nonzero:
|
||||
print(' start with: %s' % os.path.join(shots, nonzero[0]))
|
||||
mid = nonzero[len(nonzero) // 2]
|
||||
print(' and: %s' % os.path.join(shots, mid))
|
||||
print(' Byte-identical consecutive frames mean the screen is static -- that '
|
||||
'is a finding (a hang, or a dialog waiting), not a capture problem.')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main(sys.argv))
|
||||
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tier-3 verifier check: does any branch target a `move-result*` instruction?
|
||||
|
||||
WHY THIS EXISTS
|
||||
---------------
|
||||
`move-result*` is not an ordinary instruction. The verifier requires it to be the
|
||||
immediate successor of the invoke that produced the value it copies. A branch that
|
||||
lands on a `move-result` therefore bypasses the producer, and the class fails to
|
||||
load with:
|
||||
|
||||
VerifyError: ... copyRes1 v11 <- result0 type=Undefined
|
||||
|
||||
This matters specifically for byte-level patching. Redirecting a branch
|
||||
(`if-*` -> `goto`) is the intuitive way to force one side of a condition, and it
|
||||
is the way that silently creates this violation. Replacing the branch with a pair
|
||||
of `nop`s cannot: it removes a control-flow edge instead of adding one.
|
||||
|
||||
The check must be done on the control-flow graph, not linearly. A "is the
|
||||
previous instruction the producer?" scan over the instruction list reports clean
|
||||
on a method that has a branch jumping straight onto a `move-result`, because the
|
||||
producer does sit immediately before it in the stream. Only asking "does ANY
|
||||
branch target this offset" sees the problem.
|
||||
|
||||
Usage
|
||||
-----
|
||||
python dex_check_verifier.py app.apk # whole image
|
||||
python dex_check_verifier.py app.apk --class 'Lcom/ex/Foo;'
|
||||
python dex_check_verifier.py before.dex after.dex # compare two builds
|
||||
|
||||
Exit codes: 0 = no findings, 1 = findings present, 2 = usage/parse error. The
|
||||
non-zero-on-findings convention is deliberate so a pipeline can gate on it.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from dexutil import IF_TEST, IF_TESTZ, MOVE_RESULT_OPS, load_dex # noqa: E402
|
||||
|
||||
|
||||
def analyse_method(dex, code_off):
|
||||
"""Return (findings, clean, detail) for one method body.
|
||||
|
||||
findings: list of (branch_offset, move_result_offset, is_conditional)
|
||||
clean: whether the decode consumed exactly insns_size units
|
||||
detail: human string when not clean
|
||||
|
||||
ONLY conditional branches are reported. An unconditional `goto` that lands on
|
||||
a `move-result` is a normal compiler-generated merge point: the producer runs
|
||||
on the incoming path and the goto is a *forward* jump to the shared epilogue,
|
||||
so nothing is bypassed. Reporting those as violations makes the tool unusable
|
||||
-- on a real sample they outnumber genuine findings by 20:1 and are pure
|
||||
compiler output.
|
||||
|
||||
The real hazard is narrower: redirecting a CONDITIONAL branch so that it lands
|
||||
directly on a `move-result`. That skips the producing invoke on the taken path.
|
||||
"""
|
||||
insns = list(dex.decode(code_off))
|
||||
info = dex.code_info(code_off)
|
||||
expected = info["insns_off"] + info["insns_size"] * 2
|
||||
ended = (insns[-1]["off"] + insns[-1]["units"] * 2) if insns else info["insns_off"]
|
||||
clean = (ended == expected)
|
||||
detail = "" if clean else ("decode ended at 0x%x, method body ends at 0x%x"
|
||||
% (ended, expected))
|
||||
|
||||
move_results = {i["off"] for i in insns if i["op"] in MOVE_RESULT_OPS}
|
||||
findings = []
|
||||
for i in insns:
|
||||
if i["op"] not in IF_TEST and i["op"] not in IF_TESTZ:
|
||||
continue
|
||||
t = dex.branch_target(i)
|
||||
if t is not None and t in move_results:
|
||||
findings.append((i["off"], t, True))
|
||||
return findings, clean, detail
|
||||
|
||||
|
||||
def scan_image(path, entry, cls_filter):
|
||||
dex, entry_name = load_dex(path, entry)
|
||||
problems = dex.check()
|
||||
if problems:
|
||||
for p in problems:
|
||||
print(" [structure] %s" % p)
|
||||
raise RuntimeError("structurally broken: %s" % path)
|
||||
|
||||
total_methods = 0
|
||||
total_findings = 0
|
||||
unclean = 0
|
||||
for fqcn in dex.class_names():
|
||||
if cls_filter and cls_filter not in fqcn:
|
||||
continue
|
||||
try:
|
||||
methods = list(dex.methods_of(fqcn))
|
||||
except Exception:
|
||||
continue
|
||||
for _section, _midx, _c, name, desc, code_off in methods:
|
||||
if code_off == 0:
|
||||
continue
|
||||
total_methods += 1
|
||||
findings, clean, detail = analyse_method(dex, code_off)
|
||||
if not clean:
|
||||
unclean += 1
|
||||
if findings:
|
||||
total_findings += len(findings)
|
||||
print(" VIOLATION %s.%s%s" % (fqcn, name, desc))
|
||||
for b_off, mr_off, _cond in findings:
|
||||
b_insn = dex.insn_at(code_off, b_off)
|
||||
mr_insn = dex.insn_at(code_off, mr_off)
|
||||
print(" conditional branch 0x%x (%s)"
|
||||
% (b_off, dex.describe(b_insn)))
|
||||
print(" -> move-result 0x%x (%s)"
|
||||
% (mr_off, dex.describe(mr_insn)))
|
||||
print(" the producing invoke is bypassed on the taken "
|
||||
"path: the class will fail to load")
|
||||
return {"methods": total_methods, "findings": total_findings,
|
||||
"unclean": unclean, "entry": entry_name}
|
||||
|
||||
|
||||
def main(argv):
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Detect branch-into-move-result verifier violations.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__.split("Usage")[-1][:800])
|
||||
ap.add_argument("targets", nargs="+", help="one .dex/.apk, or two to compare")
|
||||
ap.add_argument("--entry", help="archive member (default: first classes*.dex)")
|
||||
ap.add_argument("--class", dest="cls", help="substring filter on the class name")
|
||||
args = ap.parse_args(argv[1:])
|
||||
|
||||
results = []
|
||||
for path in args.targets:
|
||||
print("== %s" % path)
|
||||
try:
|
||||
stats = scan_image(path, args.entry, args.cls)
|
||||
except Exception as exc:
|
||||
print(" error: %s" % exc)
|
||||
return 2
|
||||
results.append(stats)
|
||||
print(" methods checked: %d violations: %d unclean decodes: %d"
|
||||
% (stats["methods"], stats["findings"], stats["unclean"]))
|
||||
print("")
|
||||
|
||||
if len(results) >= 2:
|
||||
before, after = results[0], results[1]
|
||||
print("== comparison")
|
||||
print(" violations %d -> %d" % (before["findings"], after["findings"]))
|
||||
if after["findings"] > before["findings"]:
|
||||
print(" REGRESSION: your patch introduced %d new violation(s). A "
|
||||
"branch now lands on a move-result." % (after["findings"] - before["findings"]))
|
||||
print(" Fix: replace the branch with `nop`s (removes an edge) instead "
|
||||
"of redirecting it (adds one).")
|
||||
return 1
|
||||
if before["findings"] and after["findings"] == before["findings"]:
|
||||
print(" note: violations exist in BOTH builds, so they are pre-existing "
|
||||
"and not caused by the patch. Do not 'fix' them as part of this task.")
|
||||
|
||||
if any(r["unclean"] for r in results):
|
||||
print("== WARNING: unclean decodes reported above. Offsets from those "
|
||||
"methods are unreliable -- widen the instruction format table or "
|
||||
"cross-check against a disassembler before trusting a patch offset.")
|
||||
|
||||
if any(r["findings"] for r in results):
|
||||
return 1
|
||||
print("== no branch-into-move-result violations")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
@@ -0,0 +1,278 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Locate an instruction by decoded semantics and report its exact byte offset.
|
||||
|
||||
WHY THIS EXISTS
|
||||
---------------
|
||||
You know what the instruction *does* ("a branch on the config boolean whose target
|
||||
starts the countdown") but you need the *byte offset* to patch it. Two shortcuts
|
||||
fail:
|
||||
|
||||
* baksmali listings give smali line numbers, not code offsets. `.line` tracks
|
||||
source lines, breaks 1:N, and cannot be converted reliably.
|
||||
* a hard-coded offset dies the moment the sample is rebuilt, and a wrong offset
|
||||
in an equal-length patch corrupts the neighbouring instruction silently.
|
||||
|
||||
So this tool decodes the method and filters on semantics. It always prints the
|
||||
surrounding instructions so you can see which side of a branch you are about to
|
||||
change -- the polarity mistake is the most common way a patch does the exact
|
||||
opposite of the goal and still starts cleanly.
|
||||
|
||||
Filter language (all optional; combined with AND, comma = OR within a repeated
|
||||
flag):
|
||||
|
||||
NAME substring match on the class, method or string operand
|
||||
@Member field or method name operand equals/contains Member
|
||||
=literal a const/4 literal equals this integer
|
||||
#Class/member a branch whose TARGET reads this field of this class
|
||||
>kind instruction kind: if-test if-testz invoke return
|
||||
move-result const4 nop goto switch field sfield
|
||||
$name the ENCLOSING method name must contain this
|
||||
|
||||
Examples
|
||||
--------
|
||||
# a boolean gate whose taken path reads Cfg.enabled
|
||||
python dex_find_insn.py app.apk --class 'Lcom/example/SplashActivity;' \\
|
||||
--method u --filter '#Lcom/example/Cfg;*enabled'
|
||||
|
||||
# every invoke of a method named like goHome, with 3 lines of context
|
||||
python dex_find_insn.py app.apk --filter '@goHome' --kind invoke -C 3
|
||||
|
||||
# where a specific literal is compared
|
||||
python dex_find_insn.py app.apk --class 'Lcom/example/PlayerActivity;' --filter '=1' --kind if-testz
|
||||
|
||||
Exit codes: 0 = at least one hit, 1 = no hits (a finding: say so, do not retry the
|
||||
same filter), 2 = usage error.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from dexutil import ( # noqa: E402
|
||||
Dex, IF_TEST, IF_TESTZ, INVOKE_OPS, MOVE_RESULT_OPS, load_dex,
|
||||
)
|
||||
|
||||
_KIND_MAP = {
|
||||
"if-test": IF_TEST,
|
||||
"if-testz": IF_TESTZ,
|
||||
"invoke": INVOKE_OPS,
|
||||
"return": (0x0F, 0x10, 0x11),
|
||||
"move-result": MOVE_RESULT_OPS,
|
||||
"const4": (0x12,),
|
||||
"const16": (0x13,),
|
||||
"goto": (0x27, 0x28, 0x29),
|
||||
"switch": (0x2A, 0x2B),
|
||||
"nop": (0x00,),
|
||||
}
|
||||
_FIELD_READ = tuple(range(0x52, 0x59))
|
||||
_FIELD_WRITE = tuple(range(0x59, 0x60))
|
||||
_SFIELD = tuple(range(0x60, 0x6E))
|
||||
_KIND_MAP["field"] = _FIELD_READ
|
||||
_KIND_MAP["field-write"] = _FIELD_WRITE
|
||||
_KIND_MAP["sfield"] = _SFIELD
|
||||
|
||||
|
||||
class Filter(object):
|
||||
"""One compiled filter expression."""
|
||||
|
||||
def __init__(self, text):
|
||||
self.raw = text
|
||||
self.kind = text[1:] if text.startswith(">") else None
|
||||
self.member = text[1:] if text.startswith("@") else None
|
||||
self.literal = int(text[1:]) if text.startswith("=") else None
|
||||
self.target_field = None
|
||||
self.target_class = None
|
||||
self.enclosing = text[1:] if text.startswith("$") else None
|
||||
self.text = None
|
||||
if text.startswith("#"):
|
||||
spec = text[1:]
|
||||
if "/" in spec and ("*" in spec):
|
||||
cls, mem = spec.split("*", 1)
|
||||
self.target_class, self.target_field = cls, mem
|
||||
elif "*" in spec:
|
||||
self.target_field = spec.split("*", 1)[1]
|
||||
else:
|
||||
self.target_class = spec
|
||||
elif not any(text.startswith(p) for p in ("@", "=", ">", "$")):
|
||||
self.text = text
|
||||
|
||||
def describe(self):
|
||||
return self.raw
|
||||
|
||||
|
||||
def _operand_text(dex, insn):
|
||||
"""All names this instruction mentions (class, method, field, string)."""
|
||||
parts = []
|
||||
op, pos = insn["op"], insn["off"]
|
||||
if op in INVOKE_OPS:
|
||||
cls, nm, ds = dex.method(int.from_bytes(insn["raw"][2:4], "little"))
|
||||
parts += [cls, nm, ds]
|
||||
elif op in _FIELD_READ + _FIELD_WRITE + _SFIELD:
|
||||
cls, nm, ty = dex.field(int.from_bytes(insn["raw"][2:4], "little"))
|
||||
parts += [cls, nm, ty]
|
||||
elif op in (0x19, 0x1A):
|
||||
parts.append(dex.string_safe(int.from_bytes(insn["raw"][2:4], "little")))
|
||||
return parts
|
||||
|
||||
|
||||
def _matches(dex, insn, filters, code_off):
|
||||
"""All filters must hold (AND)."""
|
||||
for f in filters:
|
||||
if f.kind:
|
||||
ops = _KIND_MAP.get(f.kind)
|
||||
if ops is None:
|
||||
raise ValueError("unknown kind: %s" % f.kind)
|
||||
if insn["op"] not in ops:
|
||||
return False
|
||||
if f.literal is not None:
|
||||
if insn["op"] != 0x12:
|
||||
return False
|
||||
val = insn["raw"][1] & 0xF
|
||||
if val > 7:
|
||||
val -= 16
|
||||
if val != f.literal:
|
||||
return False
|
||||
if f.member:
|
||||
if insn["op"] not in INVOKE_OPS:
|
||||
return False
|
||||
_cls, nm, _ds = dex.method(int.from_bytes(insn["raw"][2:4], "little"))
|
||||
if f.member not in nm:
|
||||
return False
|
||||
if f.target_class or f.target_field:
|
||||
tgt = dex.branch_target(insn)
|
||||
if tgt is None:
|
||||
return False
|
||||
t_insn = dex.insn_at(code_off, tgt)
|
||||
if t_insn is None or not (0x52 <= t_insn["op"] <= 0x58):
|
||||
return False
|
||||
cls, nm, _ty = dex.field(int.from_bytes(t_insn["raw"][2:4], "little"))
|
||||
if f.target_class and f.target_class != cls:
|
||||
return False
|
||||
if f.target_field and f.target_field not in nm:
|
||||
return False
|
||||
if f.text:
|
||||
hay = " ".join(_operand_text(dex, insn))
|
||||
if f.text.lower() not in hay.lower():
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def scan(dex, filters, kind_only=None, class_pattern=None, method_pattern=None,
|
||||
limit=None):
|
||||
"""Yield (fqcn, method_name, descriptor, code_off, insn, all_insns, idx)."""
|
||||
hits = 0
|
||||
cls_re = re.compile(class_pattern) if class_pattern else None
|
||||
mth_re = re.compile(method_pattern) if method_pattern else None
|
||||
|
||||
for fqcn in dex.class_names():
|
||||
if cls_re and not cls_re.search(fqcn):
|
||||
continue
|
||||
try:
|
||||
methods = list(dex.methods_of(fqcn))
|
||||
except Exception:
|
||||
continue
|
||||
for section, midx, _c, name, desc, code_off in methods:
|
||||
if code_off == 0:
|
||||
continue
|
||||
if mth_re and not mth_re.search(name):
|
||||
continue
|
||||
for f in filters:
|
||||
if f.enclosing and f.enclosing not in name:
|
||||
break
|
||||
insns = list(dex.decode(code_off))
|
||||
# a decode that does not land on the method end means offsets here
|
||||
# cannot be trusted; report the method rather than silent misses
|
||||
info = dex.code_info(code_off)
|
||||
expected = info["insns_off"] + info["insns_size"] * 2
|
||||
clean = (insns[-1]["off"] + insns[-1]["units"] * 2 == expected) if insns else False
|
||||
for i, insn in enumerate(insns):
|
||||
if kind_only and insn["op"] not in _KIND_MAP.get(kind_only, ()):
|
||||
continue
|
||||
if filters and not _matches(dex, insn, filters, code_off):
|
||||
continue
|
||||
yield (fqcn, name, desc, code_off, insn, insns, i, clean)
|
||||
hits += 1
|
||||
if limit and hits >= limit:
|
||||
return
|
||||
|
||||
|
||||
def main(argv):
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Find an instruction by decoded semantics; print its exact "
|
||||
"byte offset and surrounding context.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__.split("Examples")[-1][:1500])
|
||||
ap.add_argument("target", help=".dex or .apk/.zip (use --entry to pick a dex)")
|
||||
ap.add_argument("--entry", help="archive member (default: first classes*.dex)")
|
||||
ap.add_argument("--class", dest="cls", help="regex over the class descriptor")
|
||||
ap.add_argument("--method", help="regex over the method name")
|
||||
ap.add_argument("--filter", action="append", default=[],
|
||||
help="filter expression, repeatable (AND across, OR within a flag "
|
||||
"list). See the module docstring for the language.")
|
||||
ap.add_argument("--kind", help="restrict to one instruction kind (see docstring)")
|
||||
ap.add_argument("-C", "--context", type=int, default=2,
|
||||
help="instructions of context to print (default 2)")
|
||||
ap.add_argument("--limit", type=int, default=40, help="max hits (default 40)")
|
||||
ap.add_argument("--show-unclean", action="store_true",
|
||||
help="also print hits from methods whose decode did not align")
|
||||
args = ap.parse_args(argv[1:])
|
||||
|
||||
dex, entry = load_dex(args.target, args.entry)
|
||||
problems = dex.check()
|
||||
if problems:
|
||||
print("structural problems in %s (%s):" % (args.target, entry))
|
||||
for p in problems:
|
||||
print(" - %s" % p)
|
||||
return 2
|
||||
|
||||
filters = [Filter(a) for a in args.filter]
|
||||
print("== %s (%s) %d classes" % (args.target, entry,
|
||||
dex.header["class_defs_size"]))
|
||||
if filters:
|
||||
print(" filters: %s" % ", ".join(f.describe() for f in filters))
|
||||
|
||||
found = 0
|
||||
for fqcn, name, desc, code_off, insn, insns, idx, clean in scan(
|
||||
dex, filters, args.kind, args.cls, args.method, args.limit):
|
||||
if not clean and not args.show_unclean:
|
||||
print("\n!! %s.%s%s decode did not align; offsets unreliable, "
|
||||
"skipping (use --show-unclean to see anyway)"
|
||||
% (fqcn, name, desc))
|
||||
continue
|
||||
found += 1
|
||||
print("\n-- %s.%s%s" % (fqcn, name, desc))
|
||||
print(" code_off=0x%x insns_off=0x%x (decode clean: %s)"
|
||||
% (code_off, code_off + 16, clean))
|
||||
lo = max(0, idx - args.context)
|
||||
hi = min(len(insns), idx + args.context + 1)
|
||||
for j in range(lo, hi):
|
||||
marker = " <== HIT" if j == idx else ""
|
||||
print(" %s%s" % (dex.describe(insns[j]), marker))
|
||||
print(" >>> file offset of the instruction: 0x%x" % insn["off"])
|
||||
if insn["op"] in IF_TEST or insn["op"] in IF_TESTZ:
|
||||
tgt = dex.branch_target(insn)
|
||||
t_insn = dex.insn_at(code_off, tgt)
|
||||
print(" >>> branch TAKEN target 0x%x: %s"
|
||||
% (tgt, dex.describe(t_insn) if t_insn else "?"))
|
||||
nxt = insn["off"] + insn["units"] * 2
|
||||
n_insn = dex.insn_at(code_off, nxt)
|
||||
print(" >>> branch FALL-THROUGH 0x%x: %s"
|
||||
% (nxt, dex.describe(n_insn) if n_insn else "?"))
|
||||
print(" >>> decide which side you want BEFORE patching; a wrong "
|
||||
"polarity still starts cleanly")
|
||||
|
||||
print("\n== %d hit(s)" % found)
|
||||
if not found:
|
||||
print(" no match. Do not retry the same filter: either the decode is "
|
||||
"unclean (see --show-unclean) or the behaviour lives in another "
|
||||
"layer (native / another dex / another class).")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
@@ -0,0 +1,436 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Apply equal-length byte patches to a dex, with structural and verifier checks.
|
||||
|
||||
WHY THIS EXISTS
|
||||
---------------
|
||||
The cheap way to change behaviour is to rewrite a few bytes in place rather than
|
||||
rebuild a method body. It is cheap for a reason: nothing moves, so no try/catch
|
||||
block, debug-info pointer or branch displacement can be invalidated by
|
||||
construction. But it has four failure modes that all produce a build which looks
|
||||
fine and misbehaves, and this tool exists to make each one impossible to skip:
|
||||
|
||||
1. **Same-length is not enough** -- the replacement must decode to a valid
|
||||
instruction sequence of exactly the same length. A 4-byte branch replaced by
|
||||
a 4-byte branch is fine; a 2-byte branch replaced by a 4-byte instruction
|
||||
corrupts the next instruction.
|
||||
2. **Polarity** -- the overwhelmingly common error is patching the wrong side of
|
||||
a condition. So a spec can *require* the instruction that follows the edit,
|
||||
and this tool aborts if it is not the one you named. Name the fall-through
|
||||
you expect and the mistake cannot survive.
|
||||
3. **Verifier legality** -- a branch whose target is a `move-result*` bypasses
|
||||
the producing invoke and the class fails to load (`VerifyError`). Checked
|
||||
here before writing.
|
||||
4. **dex header staleness** -- the checksum and signature fields must be
|
||||
recomputed, signature FIRST (the checksum covers it). Skipping this makes
|
||||
Android fall back to interpreting the dex, which surfaces as an unrelated
|
||||
ClassNotFoundException at startup.
|
||||
|
||||
Spec format (JSON; a list, or {"patches": [...]}):
|
||||
|
||||
[{
|
||||
"name": "human label",
|
||||
"reason": "why this edit is correct",
|
||||
"class": "Lpkg/Name;",
|
||||
"method": "someMethod",
|
||||
"desc": "(Lpkg/Arg;)V", // optional: disambiguates overloads
|
||||
"match": {"kind": "if-testz", "target_reads_field_of": "Lpkg/Cfg;",
|
||||
"target_field": "enabled"},
|
||||
"expect_next": {"kind": "invoke", "target_method": "goHome"},
|
||||
"replace": {"kind": "nops"} // or {"bytes": "28060000"}
|
||||
}]
|
||||
|
||||
`match` selects the instruction; `expect_next` (optional but strongly advised)
|
||||
asserts what immediately follows, which is what pins the polarity; `replace`
|
||||
gives the bytes. Run with --dry-run first: it prints the match, the neighbours,
|
||||
the verifier verdict and the predicted byte diff without writing anything.
|
||||
|
||||
Exit codes: 0 ok, 1 failure (structure, match, verifier, or self-verify), 2 usage.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from dexutil import ( # noqa: E402
|
||||
Dex, IF_TEST, IF_TESTZ, INVOKE_OPS, MOVE_RESULT_OPS, RETURN_OPS,
|
||||
fix_dex_header, load_dex, verify_dex_header,
|
||||
)
|
||||
|
||||
|
||||
def _kind_of(insn):
|
||||
"""Coarse semantic kind used by spec matching."""
|
||||
op = insn["op"]
|
||||
if op in IF_TEST:
|
||||
return "if-test"
|
||||
if op in IF_TESTZ:
|
||||
return "if-testz"
|
||||
if op in INVOKE_OPS:
|
||||
return "invoke"
|
||||
if op in RETURN_OPS:
|
||||
return "return"
|
||||
if op in MOVE_RESULT_OPS:
|
||||
return "move-result"
|
||||
if op == 0x12:
|
||||
return "const4"
|
||||
return insn["name"]
|
||||
|
||||
|
||||
def _describe_neighbours(dex, code_off, target_off, span=4):
|
||||
"""Render up to `span` instructions before and after an offset."""
|
||||
insns = list(dex.decode(code_off))
|
||||
idx = None
|
||||
for i, insn in enumerate(insns):
|
||||
if insn["off"] == target_off:
|
||||
idx = i
|
||||
break
|
||||
if idx is None:
|
||||
return [], None, []
|
||||
before = insns[max(0, idx - span):idx]
|
||||
after = insns[idx + 1:idx + 1 + span]
|
||||
return before, insns[idx], after
|
||||
|
||||
|
||||
def _matches(dex, insn, crit, code_off=None):
|
||||
"""Does one instruction satisfy a match/expect criterion?"""
|
||||
if crit is None:
|
||||
return True
|
||||
want = crit.get("kind")
|
||||
if want and _kind_of(insn) != want:
|
||||
return False
|
||||
|
||||
if want in ("if-test", "if-testz"):
|
||||
treg = crit.get("target_reg")
|
||||
if treg is not None:
|
||||
regs, _ = dex.branch_regs(insn)
|
||||
if treg not in regs:
|
||||
return False
|
||||
# The usual way to pin "this is the gate for config X": require that the
|
||||
# branch TARGET instruction reads a field of a named class. This is what
|
||||
# distinguishes the gate you want from the other branches in the method.
|
||||
fcls = crit.get("target_reads_field_of")
|
||||
fname = crit.get("target_field")
|
||||
if fcls or fname:
|
||||
if code_off is None:
|
||||
return False
|
||||
tgt = dex.branch_target(insn)
|
||||
t_insn = dex.insn_at(code_off, tgt)
|
||||
if t_insn is None:
|
||||
return False
|
||||
if not (0x52 <= t_insn["op"] <= 0x58):
|
||||
return False
|
||||
fidx = struct.unpack_from("<H", t_insn["raw"], 2)[0]
|
||||
cls, nm, _ty = dex.field(fidx)
|
||||
if fcls and cls != fcls:
|
||||
return False
|
||||
if fname and nm != fname:
|
||||
return False
|
||||
|
||||
if want == "const4":
|
||||
lit = crit.get("literal")
|
||||
if lit is not None:
|
||||
raw = insn["raw"][1]
|
||||
val = raw & 0xF
|
||||
if val > 7:
|
||||
val -= 16
|
||||
if val != lit:
|
||||
return False
|
||||
|
||||
if want == "invoke":
|
||||
midx = struct.unpack_from("<H", insn["raw"], 2)[0]
|
||||
cls, nm, _ds = dex.method(midx)
|
||||
if crit.get("target_class") and crit["target_class"] not in cls:
|
||||
return False
|
||||
if crit.get("target_method") and crit["target_method"] != nm:
|
||||
return False
|
||||
|
||||
if want in ("return", "move-result", "nop"):
|
||||
pass
|
||||
|
||||
raw_eq = crit.get("bytes")
|
||||
if raw_eq and insn["raw"].hex() != raw_eq.replace(" ", "").lower():
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _find_site(dex, code_off, spec):
|
||||
"""Return the single instruction matching spec['match'].
|
||||
|
||||
Exactly one match is required. Ambiguity is treated as failure rather than
|
||||
"take the first": picking the wrong site in a method with several similar
|
||||
branches is a silent, expensive error.
|
||||
"""
|
||||
insns = list(dex.decode(code_off))
|
||||
hits = [i for i in insns if _matches(dex, i, spec.get("match"), code_off)]
|
||||
if len(hits) == 1:
|
||||
return hits[0], insns
|
||||
if not hits:
|
||||
return None, insns
|
||||
|
||||
# allow an ordinal to disambiguate deliberately
|
||||
ordv = spec.get("match", {}).get("ordinal")
|
||||
if ordv is not None and 0 <= ordv < len(hits):
|
||||
return hits[ordv], insns
|
||||
return ("ambiguous", hits), insns
|
||||
|
||||
|
||||
def _verifier_report(dex, code_off, target_off_for_new_edge=None):
|
||||
"""Tier-3 check: does any branch target a move-result instruction?
|
||||
|
||||
`move-result*` must be immediately preceded by its producing invoke. A branch
|
||||
into one bypasses the producer and the class fails to load with
|
||||
`VerifyError ... copyResN v <- result0 type=Undefined`. A linear "is the
|
||||
previous instruction the producer" scan misses this; only the CFG view sees
|
||||
it.
|
||||
"""
|
||||
findings = []
|
||||
for insn in dex.decode(code_off):
|
||||
if insn["op"] not in MOVE_RESULT_OPS:
|
||||
continue
|
||||
tgt = insn["off"]
|
||||
for other in dex.decode(code_off):
|
||||
t = dex.branch_target(other)
|
||||
if t == tgt:
|
||||
findings.append((other["off"], tgt))
|
||||
return findings
|
||||
|
||||
|
||||
def _apply_replace(dex_data, insn, repl):
|
||||
"""Return the new bytes for this instruction, enforcing equal length."""
|
||||
kind = repl.get("kind")
|
||||
if kind == "nops":
|
||||
n = len(insn["raw"])
|
||||
return b"\x00" * n
|
||||
if kind == "bytes":
|
||||
blob = bytes.fromhex(repl["bytes"].replace(" ", ""))
|
||||
return blob
|
||||
raise ValueError("replace must be {'kind':'nops'} or {'kind':'bytes','bytes':...}")
|
||||
|
||||
|
||||
def process_one(dex, data, spec, index, dry_run):
|
||||
"""Apply one spec entry to `data`; return a report dict, or raise."""
|
||||
cls = spec["class"]
|
||||
name = spec["method"]
|
||||
desc = spec.get("desc")
|
||||
label = spec.get("name") or ("patch%d" % index)
|
||||
report = {"name": label, "ok": False}
|
||||
|
||||
if desc:
|
||||
found = dex.find_method(cls, name, desc)
|
||||
if not found:
|
||||
raise RuntimeError("[%s] method not found: %s->%s%s"
|
||||
% (label, cls, name, desc))
|
||||
section, midx, code_off = found
|
||||
else:
|
||||
overloads = dex.find_methods_named(cls, name)
|
||||
if len(overloads) != 1:
|
||||
raise RuntimeError(
|
||||
"[%s] %s->%s has %d overloads; give \"desc\" to disambiguate: %s"
|
||||
% (label, cls, name, len(overloads),
|
||||
[d for _s, _i, d, _c in overloads]))
|
||||
section, midx, dsc, code_off = overloads[0]
|
||||
desc = dsc
|
||||
|
||||
report.update({"class": cls, "method": name, "desc": desc,
|
||||
"section": section, "method_idx": midx, "code_off": code_off})
|
||||
|
||||
site, _insns = _find_site(dex, code_off, spec)
|
||||
if site is None:
|
||||
raise RuntimeError("[%s] no instruction matched spec['match']" % label)
|
||||
if isinstance(site, tuple) and site and site[0] == "ambiguous":
|
||||
cands = ", ".join("0x%x" % h["off"] for h in site[1])
|
||||
raise RuntimeError("[%s] match is ambiguous (%d hits: %s); tighten it or "
|
||||
"set match.ordinal" % (label, len(site[1]), cands))
|
||||
|
||||
before, cur, after = _describe_neighbours(dex, code_off, site["off"])
|
||||
report.update({
|
||||
"site_off": cur["off"],
|
||||
"old_bytes": cur["raw"].hex(),
|
||||
"old_text": dex.describe(cur),
|
||||
"before": [dex.describe(i) for i in before],
|
||||
"after": [dex.describe(i) for i in after],
|
||||
})
|
||||
|
||||
# polarity pin: what must immediately follow?
|
||||
exp = spec.get("expect_next")
|
||||
if exp is not None:
|
||||
nxt = after[0] if after else None
|
||||
if nxt is None or not _matches(dex, nxt, exp, code_off):
|
||||
raise RuntimeError(
|
||||
"[%s] expect_next not satisfied at 0x%x. Expected %r, found %r.\n"
|
||||
" This is the polarity check: the instruction after the branch "
|
||||
"is not the one your spec assumes, so the branch points at the "
|
||||
"wrong side."
|
||||
% (label, cur["off"], exp, dex.describe(nxt) if nxt else None))
|
||||
report["expect_next_ok"] = True
|
||||
|
||||
new = _apply_replace(data, cur, spec.get("replace", {"kind": "nops"}))
|
||||
if len(new) != len(cur["raw"]):
|
||||
raise RuntimeError(
|
||||
"[%s] replacement is %d bytes but the instruction is %d. Equal length "
|
||||
"is required: a longer replacement overwrites the next instruction."
|
||||
% (label, len(new), len(cur["raw"])))
|
||||
report.update({"new_bytes": new.hex(), "size_delta": 0})
|
||||
|
||||
# verifier: simulate the new instruction to see if it adds a branch edge
|
||||
fake = Dex(bytes(data), dex.name)
|
||||
fake_probe = dict(cur)
|
||||
fake_probe["raw"] = new
|
||||
fake_probe["op"] = new[0]
|
||||
report["new_text"] = fake.describe(fake_probe)
|
||||
|
||||
if dry_run:
|
||||
report["ok"] = True
|
||||
return report, data
|
||||
|
||||
data[cur["off"]:cur["off"] + len(new)] = new
|
||||
|
||||
# Re-read the patched dex and confirm the edit landed.
|
||||
#
|
||||
# Compare the BYTE RANGE, not a decoded instruction: replacing a 4-byte branch
|
||||
# with a nop pair yields two 1-unit instructions, so "the instruction at this
|
||||
# offset has length 4" is false even though the patch is perfectly correct.
|
||||
# The byte range is what the patch actually owns.
|
||||
span = slice(cur["off"], cur["off"] + len(new))
|
||||
if bytes(data[span]) != new:
|
||||
raise RuntimeError(
|
||||
"[%s] bytes at 0x%x are %s, expected %s"
|
||||
% (label, cur["off"], bytes(data[span]).hex(), new.hex()))
|
||||
|
||||
patched = Dex(bytes(data), dex.name)
|
||||
insns_after = list(patched.decode(code_off))
|
||||
info = patched.code_info(code_off)
|
||||
expected_end = info["insns_off"] + info["insns_size"] * 2
|
||||
ended = (insns_after[-1]["off"] + insns_after[-1]["units"] * 2
|
||||
if insns_after else info["insns_off"])
|
||||
report["post_instruction_count"] = len(insns_after)
|
||||
report["post_decode_clean"] = (ended == expected_end)
|
||||
|
||||
landed = [i for i in insns_after
|
||||
if cur["off"] <= i["off"] < cur["off"] + len(new)]
|
||||
report["post_text"] = " | ".join(patched.describe(i) for i in landed)
|
||||
|
||||
# the next instruction boundary after the replaced span must still be the
|
||||
# instruction that followed the original one
|
||||
nxt_after = patched.insn_at(code_off, cur["off"] + len(new))
|
||||
report["post_next_text"] = patched.describe(nxt_after) if nxt_after else None
|
||||
if not report["post_decode_clean"]:
|
||||
raise RuntimeError(
|
||||
"[%s] the method no longer decodes to exactly insns_size units after "
|
||||
"the edit -- the replacement changed the instruction stream length. "
|
||||
"Use an equal-length replacement." % label)
|
||||
|
||||
edges = _verifier_report(patched, code_off)
|
||||
report["verifier_edges"] = len(edges)
|
||||
if edges:
|
||||
raise RuntimeError(
|
||||
"[%s] verifier: a branch targets a move-result at %s; the class will "
|
||||
"fail to load (VerifyError). Use a nop pair instead of a branch."
|
||||
% (label, ", ".join("0x%x->0x%x" % e for e in edges)))
|
||||
|
||||
report["ok"] = True
|
||||
return report, data
|
||||
|
||||
|
||||
def main(argv):
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Apply equal-length byte patches to a dex, with structural, "
|
||||
"polarity and verifier checks.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__.split("Spec format")[-1][:1200] if __doc__ else None)
|
||||
ap.add_argument("dex", help="input .dex, or an APK/zip (use --entry to pick)")
|
||||
ap.add_argument("--entry", help="archive member to read (default: classes.dex)")
|
||||
ap.add_argument("--spec", required=True, help="JSON spec file")
|
||||
ap.add_argument("-o", "--out", help="output dex path")
|
||||
ap.add_argument("--dry-run", action="store_true",
|
||||
help="report matches and diffs without writing")
|
||||
ap.add_argument("--report", help="write a machine-readable JSON report here")
|
||||
args = ap.parse_args(argv[1:])
|
||||
|
||||
dex, entry = load_dex(args.dex, args.entry) if os.path.exists(args.dex) else (None, None)
|
||||
if dex is None:
|
||||
# allow raw bytes piped through a path that does not exist yet: fail clearly
|
||||
print("error: no such file: %s" % args.dex)
|
||||
return 2
|
||||
|
||||
problems = dex.check()
|
||||
if problems:
|
||||
print("structural problems in %s (%s):" % (args.dex, entry))
|
||||
for p in problems:
|
||||
print(" - %s" % p)
|
||||
print("refusing to patch a source that does not parse cleanly")
|
||||
return 1
|
||||
|
||||
with open(args.spec, encoding="utf-8") as fh:
|
||||
spec_doc = json.load(fh)
|
||||
specs = spec_doc["patches"] if isinstance(spec_doc, dict) else spec_doc
|
||||
|
||||
ok_before = verify_dex_header(dex.d)
|
||||
print("== source: %s (%s, %d bytes)" % (args.dex, entry, len(dex.d)))
|
||||
print(" header before: checksum_ok=%s signature_ok=%s"
|
||||
% ok_before)
|
||||
if not all(ok_before):
|
||||
print(" note: the SOURCE dex header is already inconsistent. Some")
|
||||
print(" producers ship a zeroed signature field; the patch will fix it.")
|
||||
|
||||
data = bytearray(dex.d)
|
||||
reports = []
|
||||
for i, spec in enumerate(specs):
|
||||
rep, data = process_one(dex, data, spec, i, args.dry_run)
|
||||
reports.append(rep)
|
||||
print("\n-- [%s] %s.%s%s" % (rep["name"], rep["class"], rep["method"], rep["desc"]))
|
||||
print(" site 0x%x %s" % (rep["site_off"], rep["old_text"]))
|
||||
for line in rep["before"][-2:]:
|
||||
print(" (before) %s" % line)
|
||||
print(" -> %s" % rep.get("new_text"))
|
||||
for line in rep["after"][:2]:
|
||||
print(" (after) %s" % line)
|
||||
if rep.get("expect_next_ok"):
|
||||
print(" polarity: expect_next satisfied")
|
||||
|
||||
if args.dry_run:
|
||||
print("\n== dry run: nothing written")
|
||||
if args.report:
|
||||
with open(args.report, "w", encoding="utf-8") as fh:
|
||||
json.dump(reports, fh, indent=2)
|
||||
print(" report: %s" % args.report)
|
||||
return 0
|
||||
|
||||
header = fix_dex_header(data)
|
||||
ok_after = verify_dex_header(data)
|
||||
print("\n== header checksum 0x%08x -> 0x%08x" % (header["before_checksum"],
|
||||
header["after_checksum"]))
|
||||
print(" signature %s -> %s"
|
||||
% (header["before_signature"].hex()[:16],
|
||||
header["after_signature"].hex()[:16]))
|
||||
print(" self-verify: checksum_ok=%s signature_ok=%s" % ok_after)
|
||||
if not all(ok_after):
|
||||
print("FATAL: header does not self-verify; nothing written")
|
||||
return 1
|
||||
|
||||
out = args.out
|
||||
if not out:
|
||||
root, ext = os.path.splitext(args.dex)
|
||||
out = root + ".patched" + (ext or ".dex")
|
||||
with open(out, "wb") as fh:
|
||||
fh.write(bytes(data))
|
||||
print("== wrote %s (%d bytes, delta %d)"
|
||||
% (out, len(data), len(data) - len(dex.d)))
|
||||
|
||||
if args.report:
|
||||
with open(args.report, "w", encoding="utf-8") as fh:
|
||||
json.dump(reports, fh, indent=2)
|
||||
print(" report: %s" % args.report)
|
||||
|
||||
print("\nNext: repack (scripts/repack.py) then sign with apksigner, then run "
|
||||
"the device + screen checks in references/verification.md.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
@@ -81,6 +81,13 @@ def _order_ok(data, off, ob, nb):
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) > 1 and sys.argv[1] in ('-h', '--help'):
|
||||
print(__doc__)
|
||||
return 0
|
||||
if len(sys.argv) < 5:
|
||||
print('error: needs 4 arguments, got %d\n' % (len(sys.argv) - 1))
|
||||
print(__doc__)
|
||||
return 2
|
||||
src, dst, old, new = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
|
||||
ob, nb = old.encode('utf-8'), new.encode('utf-8')
|
||||
if len(ob) != len(nb):
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Minimal, dependency-free dex reader: structure walk + exact instruction decode.
|
||||
|
||||
WHY THIS EXISTS
|
||||
---------------
|
||||
Locating the exact byte offset of one instruction is where byte-level patching
|
||||
either works or wastes an afternoon. Two shortcuts do not work and are worth
|
||||
naming so nobody re-derives them:
|
||||
|
||||
* Reconstructing offsets from a baksmali listing. `.line` directives track
|
||||
SOURCE lines, and one source line can span several instructions, so the
|
||||
values repeat and do not map 1:1 onto code offsets.
|
||||
* Matching a guessed byte sequence. Encodings vary with register numbers and
|
||||
operand widths, and a plausible-looking opcode can belong to a different
|
||||
format than you assume (0x38 is `if-test`, 22t/4 bytes -- not `if-testz`).
|
||||
|
||||
So: walk class -> class_data_item -> method -> code_item, then decode forward with
|
||||
a complete format table, and match on decoded semantics instead of bytes.
|
||||
|
||||
Three details in the walk that silently produce wrong answers:
|
||||
|
||||
* `class_data_item` member indices are DELTAS against the previous entry in the
|
||||
same list. Reading a raw uleb as an absolute index yields real-looking wrong
|
||||
members.
|
||||
* dalvik encodes switch/array payloads as `00 <ident> <size>` with ident 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 rest of the method.
|
||||
* The header field order is easy to mis-remember. See OFFSETS below, and always
|
||||
sanity-check the parsed sizes against the file.
|
||||
|
||||
Pure standard library. Import it, or run it for a per-method dump:
|
||||
|
||||
python dexutil.py <dex-or-apk> <Class/Name;> <method> <descriptor>
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import struct
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
# Verified dex header offsets. After magic(8) + checksum(4) + signature(20):
|
||||
OFFSETS = {
|
||||
"file_size": 0x20, "header_size": 0x24, "endian_tag": 0x28,
|
||||
"link_size": 0x2C, "link_off": 0x30, "map_off": 0x34,
|
||||
"string_ids_size": 0x38, "string_ids_off": 0x3C,
|
||||
"type_ids_size": 0x40, "type_ids_off": 0x44,
|
||||
"proto_ids_size": 0x48, "proto_ids_off": 0x4C,
|
||||
"field_ids_size": 0x50, "field_ids_off": 0x54,
|
||||
"method_ids_size": 0x58, "method_ids_off": 0x5C,
|
||||
"class_defs_size": 0x60, "class_defs_off": 0x64,
|
||||
"data_size": 0x68, "data_off": 0x6C,
|
||||
}
|
||||
|
||||
OP_NAMES = {
|
||||
0x00: "nop", 0x01: "move", 0x02: "move/from16", 0x03: "move/16",
|
||||
0x04: "move-wide", 0x05: "move-wide/from16", 0x06: "move-wide/16",
|
||||
0x07: "move-object", 0x08: "move-object/from16", 0x09: "move-object/16",
|
||||
0x0A: "move-result", 0x0B: "move-result-wide", 0x0C: "move-result-object",
|
||||
0x0D: "move-exception", 0x0E: "return-void", 0x0F: "return",
|
||||
0x10: "return-wide", 0x11: "return-object", 0x12: "const/4",
|
||||
0x13: "const/16", 0x14: "const", 0x15: "const/high16",
|
||||
0x16: "const-wide/high16", 0x17: "const-wide/16", 0x18: "const-wide/32",
|
||||
0x19: "const-string", 0x1A: "const-string/jumbo", 0x1B: "const-class",
|
||||
0x1C: "monitor-enter", 0x1D: "monitor-exit", 0x1E: "check-cast",
|
||||
0x1F: "instance-of", 0x20: "array-length", 0x21: "new-instance",
|
||||
0x22: "new-array", 0x23: "filled-new-array", 0x24: "filled-new-array/range",
|
||||
0x25: "fill-array-data", 0x26: "throw", 0x27: "goto",
|
||||
0x28: "goto/16", 0x29: "goto/32", 0x2A: "packed-switch",
|
||||
0x2B: "sparse-switch",
|
||||
0x32: "if-eq", 0x33: "if-ne", 0x34: "if-lt", 0x35: "if-ge",
|
||||
0x36: "if-gt", 0x37: "if-le",
|
||||
0x38: "if-eqz", 0x39: "if-nez", 0x3A: "if-ltz", 0x3B: "if-gez",
|
||||
0x3C: "if-gtz", 0x3D: "if-lez",
|
||||
0x52: "iget", 0x53: "iget-wide", 0x54: "iget-object", 0x55: "iget-boolean",
|
||||
0x56: "iget-byte", 0x57: "iget-char", 0x58: "iget-short",
|
||||
0x59: "iput", 0x5A: "iput-wide", 0x5B: "iput-object", 0x5C: "iput-boolean",
|
||||
0x5D: "iput-byte", 0x5E: "iput-char", 0x5F: "iput-short",
|
||||
0x60: "sget", 0x61: "sget-wide", 0x62: "sget-object", 0x63: "sget-boolean",
|
||||
0x64: "sget-byte", 0x65: "sget-char", 0x66: "sget-short",
|
||||
0x67: "sput", 0x68: "sput-wide", 0x69: "sput-object", 0x6A: "sput-boolean",
|
||||
0x6B: "sput-byte", 0x6C: "sput-char", 0x6D: "sput-short",
|
||||
0x6E: "invoke-virtual", 0x6F: "invoke-super", 0x70: "invoke-direct",
|
||||
0x71: "invoke-static", 0x72: "invoke-interface",
|
||||
0x74: "invoke-virtual/range", 0x75: "invoke-super/range",
|
||||
0x76: "invoke-direct/range", 0x77: "invoke-static/range",
|
||||
0x78: "invoke-interface/range",
|
||||
}
|
||||
|
||||
# Opcode groups whose operands are a pair of registers plus an int16 offset.
|
||||
IF_TEST = set(range(0x32, 0x38)) # 22t: 2 register nibbles + int16
|
||||
IF_TESTZ = set(range(0x38, 0x3E)) # 21t: 1 register nibble + int16
|
||||
RETURN_OPS = (0x0F, 0x10, 0x11)
|
||||
MOVE_RESULT_OPS = (0x0A, 0x0B, 0x0C)
|
||||
INVOKE_OPS = tuple(range(0x6E, 0x73)) + tuple(range(0x74, 0x79))
|
||||
# opcodes that read an instance field: (dest_reg, object_reg, field_idx)
|
||||
IFIELD_OPS = tuple(range(0x52, 0x59))
|
||||
# opcodes that write an instance field: (value_reg, object_reg, field_idx)
|
||||
PFIELD_OPS = tuple(range(0x59, 0x60))
|
||||
SFIELD_OPS = tuple(range(0x60, 0x6E))
|
||||
|
||||
|
||||
def u16(b, o):
|
||||
return b[o] | (b[o + 1] << 8)
|
||||
|
||||
|
||||
def u32(b, o):
|
||||
return b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) | (b[o + 3] << 24)
|
||||
|
||||
|
||||
def s16(v):
|
||||
return v - 0x10000 if v > 0x7FFF else v
|
||||
|
||||
|
||||
def read_uleb(b, o):
|
||||
"""ULEB128 -> (value, new_offset). Member indices in class_data are deltas."""
|
||||
result = 0
|
||||
shift = 0
|
||||
while True:
|
||||
x = b[o]
|
||||
o += 1
|
||||
result |= (x & 0x7F) << shift
|
||||
if (x & 0x80) == 0:
|
||||
break
|
||||
shift += 7
|
||||
return result, o
|
||||
|
||||
|
||||
def insn_units(op, data, pos, end):
|
||||
"""Instruction length in 16-bit code units.
|
||||
|
||||
One wrong width desynchronises everything after it, and the failure is quiet:
|
||||
the decode keeps producing plausible-looking instructions, just shifted, so
|
||||
every offset you derive from that point is wrong. Two width traps are common
|
||||
enough to call out:
|
||||
|
||||
* `0x32`-`0x3D` (if-test 22t / if-testz 21t) are **2** units, not 1. Treating
|
||||
them as 1 invents a fake second instruction at every branch.
|
||||
* `0x1A` (const-string/jumbo) is emitted as a **4-byte** form (op, register,
|
||||
uint16 string index) by real toolchains and read that way by ART and
|
||||
baksmali, even though the reference format for the opcode is 31c. Counting
|
||||
it as 3 units is the single most costly width error -- it shifts the rest
|
||||
of the method by one unit per occurrence.
|
||||
* `0x28` is `goto` (10t, **1** unit), `0x29` is `goto/16` (2 units) and
|
||||
`0x2A` is `goto/32` (3 units). Do not read 0x28 as a two-unit form.
|
||||
|
||||
Whenever a decode is used to derive a patch offset, assert that the walk ends
|
||||
exactly on `insns_off + insns_size*2`. See `decode_all`.
|
||||
"""
|
||||
if op == 0x00:
|
||||
# payload nop: `00 <ident> <size>` with ident 1..3; a plain `00 00` is a
|
||||
# one-unit nop. Never treat every 00 as a payload.
|
||||
if pos + 4 <= end and (data[pos + 1] & 0xFF) in (0x01, 0x02, 0x03):
|
||||
return 1 + int.from_bytes(data[pos + 2:pos + 4], "little")
|
||||
return 1
|
||||
# -- 1 unit -------------------------------------------------------------
|
||||
if op in (0x01, 0x04, 0x07, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10,
|
||||
0x11, 0x12, # 12x / 11x / 11n / 10x
|
||||
0x1C, 0x1D, 0x26, 0x27): # monitor-enter/exit, throw, goto
|
||||
return 1
|
||||
if 0x7B <= op <= 0x8F: # 12x unop
|
||||
return 1
|
||||
if 0xB0 <= op <= 0xCF: # 12x binop/2addr
|
||||
return 1
|
||||
if 0xE3 <= op <= 0xFC: # 10x family
|
||||
return 1
|
||||
# -- 2 units ------------------------------------------------------------
|
||||
if op in (0x02, 0x05, 0x08): # 22x move/from16 variants
|
||||
return 2
|
||||
if op in (0x13, 0x15, 0x16, 0x17, 0x19, 0x1A, 0x1B, 0x1E, 0x1F,
|
||||
0x20, 0x21, 0x23, 0x24): # 21s/21h/21c/22c/35c/3rc
|
||||
return 2
|
||||
if 0x2C <= op <= 0x31: # 23x cmp
|
||||
return 2
|
||||
if op in IF_TEST or op in IF_TESTZ: # 22t / 21t <-- 2, not 1
|
||||
return 2
|
||||
if 0x44 <= op <= 0x51: # 23x aget/aput
|
||||
return 2
|
||||
if 0x52 <= op <= 0x5F: # 22c iget/iput
|
||||
return 2
|
||||
if 0x60 <= op <= 0x6D: # 21c sget/sput
|
||||
return 2
|
||||
if 0x90 <= op <= 0xAF: # 23x binop
|
||||
return 2
|
||||
if 0xD0 <= op <= 0xD7: # 22s binop/lit16
|
||||
return 2
|
||||
if 0xD8 <= op <= 0xE2: # 22b binop/lit8
|
||||
return 2
|
||||
if op == 0x29: # goto/16 (20t)
|
||||
return 2
|
||||
# -- 3 units ------------------------------------------------------------
|
||||
if op in (0x03, 0x06, 0x09, # 32x
|
||||
0x14, 0x18, # 31i const, const-wide/32
|
||||
0x22, 0x25, # 22c new-array, 31t fill-array
|
||||
0x2A, 0x2B, # 31t packed/sparse-switch
|
||||
0x6E, 0x6F, 0x70, 0x71, 0x72, # 35c invoke
|
||||
0x74, 0x75, 0x76, 0x77, 0x78): # 3rc invoke/range
|
||||
return 3
|
||||
# -- payloads / unknown -------------------------------------------------
|
||||
return 1
|
||||
|
||||
|
||||
class Dex(object):
|
||||
"""Structural reader for one dex image."""
|
||||
|
||||
def __init__(self, data, name="classes.dex"):
|
||||
self.d = data
|
||||
self.name = name
|
||||
self.header = {k: u32(data, v) for k, v in OFFSETS.items()}
|
||||
|
||||
# ---- sanity -----------------------------------------------------------
|
||||
def check(self):
|
||||
"""Return a list of structural complaints; empty means it parses."""
|
||||
problems = []
|
||||
size = len(self.d)
|
||||
if self.d[:4] != b"dex\n":
|
||||
problems.append("not a dex: magic=%r" % self.d[:4])
|
||||
for key in ("string_ids_off", "type_ids_off", "proto_ids_off",
|
||||
"field_ids_off", "method_ids_off", "class_defs_off"):
|
||||
off = self.header[key]
|
||||
if not (0 < off < size):
|
||||
problems.append("%s=0x%x out of range (file size %d)"
|
||||
% (key, off, size))
|
||||
declared = self.header["file_size"]
|
||||
if declared and declared != size:
|
||||
problems.append("header file_size=%d but actual=%d" % (declared, size))
|
||||
return problems
|
||||
|
||||
# ---- index tables -----------------------------------------------------
|
||||
def string(self, idx):
|
||||
p = u32(self.d, self.header["string_ids_off"] + idx * 4)
|
||||
_utf16_len, p = read_uleb(self.d, p)
|
||||
end = self.d.index(b"\x00", p)
|
||||
return self.d[p:end].decode("utf-8", "replace")
|
||||
|
||||
def string_safe(self, idx):
|
||||
"""Like string() but never raises -- a desynced decode passes junk index.
|
||||
|
||||
A renderer that throws on a bad index hides the very symptom the caller is
|
||||
looking for (a decode that has drifted), so return a marker instead.
|
||||
"""
|
||||
try:
|
||||
if idx >= self.header["string_ids_size"]:
|
||||
return "<string_idx %d out of range>" % idx
|
||||
return self.string(idx)
|
||||
except Exception:
|
||||
return "<string_idx %d unreadable>" % idx
|
||||
|
||||
def type_(self, idx):
|
||||
return self.string(u32(self.d, self.header["type_ids_off"] + idx * 4))
|
||||
|
||||
def proto(self, idx):
|
||||
o = self.header["proto_ids_off"] + idx * 12
|
||||
ret = self.type_(u32(self.d, o + 4))
|
||||
poff = u32(self.d, o + 8)
|
||||
params = []
|
||||
if poff:
|
||||
n = u32(self.d, poff)
|
||||
p = poff + 4
|
||||
for _ in range(n):
|
||||
params.append(self.type_(u16(self.d, p)))
|
||||
p += 2
|
||||
return "(" + "".join(params) + ")" + ret
|
||||
|
||||
def field(self, idx):
|
||||
o = self.header["field_ids_off"] + idx * 8
|
||||
return (self.type_(u16(self.d, o)), self.string(u32(self.d, o + 4)),
|
||||
self.type_(u16(self.d, o + 2)))
|
||||
|
||||
def method(self, idx):
|
||||
o = self.header["method_ids_off"] + idx * 8
|
||||
return (self.type_(u16(self.d, o)), self.string(u32(self.d, o + 4)),
|
||||
self.proto(u16(self.d, o + 2)))
|
||||
|
||||
def find_class(self, fqcn):
|
||||
"""Return the class_def_item offset for a 'Lpkg/Name;' FQCN, or None."""
|
||||
for i in range(self.header["class_defs_size"]):
|
||||
o = self.header["class_defs_off"] + i * 32
|
||||
if self.type_(u32(self.d, o)) == fqcn:
|
||||
return o
|
||||
return None
|
||||
|
||||
def methods_of(self, fqcn):
|
||||
"""Yield (section, method_idx, class, name, descriptor, code_off)."""
|
||||
cd = self.find_class(fqcn)
|
||||
if cd is None:
|
||||
raise KeyError("class not found: %s" % fqcn)
|
||||
p = u32(self.d, cd + 24)
|
||||
if p == 0:
|
||||
return
|
||||
sf, p = read_uleb(self.d, p)
|
||||
inf, p = read_uleb(self.d, p)
|
||||
dm, p = read_uleb(self.d, p)
|
||||
vm, p = read_uleb(self.d, p)
|
||||
for _ in range(sf + inf): # skip field lists
|
||||
_i, p = read_uleb(self.d, p)
|
||||
_a, p = read_uleb(self.d, p)
|
||||
for section, count in (("direct", dm), ("virtual", vm)):
|
||||
running = 0
|
||||
for _ in range(count):
|
||||
diff, p = read_uleb(self.d, p)
|
||||
running += diff # indices are delta-encoded
|
||||
_acc, p = read_uleb(self.d, p)
|
||||
code_off, p = read_uleb(self.d, p)
|
||||
cls, name, desc = self.method(running)
|
||||
yield section, running, cls, name, desc, code_off
|
||||
|
||||
def find_method(self, fqcn, name, desc):
|
||||
"""Return (section, method_idx, code_off) or None."""
|
||||
for section, idx, _cls, nm, ds, code_off in self.methods_of(fqcn):
|
||||
if nm == name and ds == desc:
|
||||
return section, idx, code_off
|
||||
return None
|
||||
|
||||
def find_methods_named(self, fqcn, name):
|
||||
"""All overloads of a name -> list of (section, idx, desc, code_off)."""
|
||||
out = []
|
||||
for section, idx, _cls, nm, ds, code_off in self.methods_of(fqcn):
|
||||
if nm == name:
|
||||
out.append((section, idx, ds, code_off))
|
||||
return out
|
||||
|
||||
def class_names(self):
|
||||
for i in range(self.header["class_defs_size"]):
|
||||
o = self.header["class_defs_off"] + i * 32
|
||||
yield self.type_(u32(self.d, o))
|
||||
|
||||
# ---- code -------------------------------------------------------------
|
||||
def code_info(self, code_off):
|
||||
return {
|
||||
"registers": u16(self.d, code_off),
|
||||
"ins": u16(self.d, code_off + 2),
|
||||
"outs": u16(self.d, code_off + 4),
|
||||
"tries": u16(self.d, code_off + 6),
|
||||
"debug_info_off": u32(self.d, code_off + 8),
|
||||
"insns_size": u32(self.d, code_off + 12),
|
||||
"insns_off": code_off + 16,
|
||||
}
|
||||
|
||||
def decode(self, code_off):
|
||||
"""Yield dicts describing each instruction of one method body.
|
||||
|
||||
Callers should assert the walk ends exactly on insns_off+insns_size*2;
|
||||
landing short or long means the format table is wrong somewhere.
|
||||
"""
|
||||
info = self.code_info(code_off)
|
||||
pos = info["insns_off"]
|
||||
end = pos + info["insns_size"] * 2
|
||||
while pos < end:
|
||||
op = self.d[pos]
|
||||
units = insn_units(op, self.d, pos, end)
|
||||
if units < 1 or pos + units * 2 > end:
|
||||
return
|
||||
yield {
|
||||
"off": pos, "op": op, "units": units,
|
||||
"name": OP_NAMES.get(op, "op_%02x" % op),
|
||||
"raw": bytes(self.d[pos:pos + units * 2]),
|
||||
"registers": info["registers"],
|
||||
}
|
||||
pos += units * 2
|
||||
|
||||
def decode_all(self, code_off):
|
||||
"""Full listing as a list, plus a verdict on whether it ended cleanly."""
|
||||
insns = list(self.decode(code_off))
|
||||
info = self.code_info(code_off)
|
||||
expected = info["insns_off"] + info["insns_size"] * 2
|
||||
ended = insns[-1]["off"] + insns[-1]["units"] * 2 if insns else info["insns_off"]
|
||||
return insns, (ended == expected), expected
|
||||
|
||||
def insn_at(self, code_off, target_off):
|
||||
for insn in self.decode(code_off):
|
||||
if insn["off"] == target_off:
|
||||
return insn
|
||||
return None
|
||||
|
||||
# ---- operand helpers --------------------------------------------------
|
||||
def branch_target(self, insn):
|
||||
"""Absolute target of a branch, or None if the instruction is not one."""
|
||||
op, pos, raw = insn["op"], insn["off"], insn["raw"]
|
||||
if op in IF_TEST or op in IF_TESTZ:
|
||||
return pos + s16(u16(self.d, pos + 2)) * 2
|
||||
if op == 0x27: # goto (10t, signed byte)
|
||||
off = raw[1]
|
||||
if off > 127:
|
||||
off -= 256
|
||||
return pos + off * 2
|
||||
if op == 0x28:
|
||||
return pos + s16(u16(self.d, pos + 2)) * 2
|
||||
if op == 0x29:
|
||||
off = u32(self.d, pos + 2)
|
||||
if off > 0x7FFFFFFF:
|
||||
off -= 0x100000000
|
||||
return pos + off * 2
|
||||
return None
|
||||
|
||||
def branch_regs(self, insn):
|
||||
"""(regs_read, kind) for a conditional branch."""
|
||||
if insn["op"] in IF_TEST:
|
||||
return [insn["raw"][1] >> 4, insn["raw"][1] & 0xF], "if-test"
|
||||
if insn["op"] in IF_TESTZ:
|
||||
return [insn["raw"][1] & 0xF], "if-testz"
|
||||
return [], None
|
||||
|
||||
def all_targets(self, code_off):
|
||||
"""Set of every absolute branch target in one method."""
|
||||
targets = set()
|
||||
for insn in self.decode(code_off):
|
||||
t = self.branch_target(insn)
|
||||
if t is not None:
|
||||
targets.add(t)
|
||||
return targets
|
||||
|
||||
# ---- description ------------------------------------------------------
|
||||
def describe(self, insn):
|
||||
"""One-line human rendering with resolved field/method/string names."""
|
||||
op, pos, raw = insn["op"], insn["off"], insn["raw"]
|
||||
text = "0x%x: %-14s %s" % (pos, raw.hex(), insn["name"])
|
||||
if op in IF_TEST:
|
||||
text += " v%d,v%d -> 0x%x" % (raw[1] >> 4, raw[1] & 0xF,
|
||||
self.branch_target(insn))
|
||||
elif op in IF_TESTZ:
|
||||
text += " v%d -> 0x%x" % (raw[1] & 0xF, self.branch_target(insn))
|
||||
elif op in (0x27, 0x28, 0x29):
|
||||
text += " -> 0x%x" % self.branch_target(insn)
|
||||
elif op in IFIELD_OPS:
|
||||
cls, nm, ty = self.field(u16(self.d, pos + 2))
|
||||
text += " v%d <- v%d.%s:%s" % (raw[1] & 0xF, raw[1] >> 4, nm, ty)
|
||||
elif op in PFIELD_OPS:
|
||||
cls, nm, ty = self.field(u16(self.d, pos + 2))
|
||||
text += " v%d -> v%d.%s:%s" % (raw[1] & 0xF, raw[1] >> 4, nm, ty)
|
||||
elif op in SFIELD_OPS:
|
||||
cls, nm, ty = self.field(u16(self.d, pos + 2))
|
||||
text += " %s.%s:%s" % (cls, nm, ty)
|
||||
elif op in INVOKE_OPS:
|
||||
cls, nm, ds = self.method(u16(self.d, pos + 2))
|
||||
text += " %s.%s%s" % (cls, nm, ds)
|
||||
elif op in (0x19, 0x1A):
|
||||
# Both forms carry the string index as a uint16 at byte offset 2.
|
||||
# Reading a uint32 here gives a huge index that walks off the table.
|
||||
text += ' "%s"' % self.string_safe(u16(self.d, pos + 2))
|
||||
elif op == 0x12:
|
||||
lit = raw[1] & 0xF
|
||||
text += " v%d, %d" % (raw[1] >> 4, lit - 16 if lit > 7 else lit)
|
||||
return text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# dex header integrity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def fix_dex_header(data):
|
||||
"""Recompute a dex header's checksum and signature IN THE CORRECT ORDER.
|
||||
|
||||
Order is not cosmetic:
|
||||
bytes 12..32 = sha1(data[32:]) -- signature first
|
||||
bytes 8..12 = adler32(data[12:]) -- checksum last, covers the signature
|
||||
|
||||
Reversed, the adler32 is taken while the signature field is still zeroed, so
|
||||
the header never verifies. Android logs
|
||||
`Failure to verify dex file ...: Bad checksum (computed, expected)` -- the
|
||||
real value shows up as "expected" -- and falls back to interpreting the dex,
|
||||
which can surface as an unrelated ClassNotFoundException at startup.
|
||||
|
||||
Accepts and returns a bytearray; also returns the before/after values so a
|
||||
caller can print them.
|
||||
"""
|
||||
before_checksum = int.from_bytes(data[8:12], "little")
|
||||
before_signature = bytes(data[12:32])
|
||||
|
||||
data[12:32] = hashlib.sha1(bytes(data[32:])).digest()
|
||||
after_signature = bytes(data[12:32])
|
||||
|
||||
import zlib
|
||||
after_checksum = zlib.adler32(bytes(data[12:])) & 0xFFFFFFFF
|
||||
data[8:12] = after_checksum.to_bytes(4, "little")
|
||||
|
||||
return {
|
||||
"before_checksum": before_checksum, "after_checksum": after_checksum,
|
||||
"before_signature": before_signature, "after_signature": after_signature,
|
||||
}
|
||||
|
||||
|
||||
def verify_dex_header(data):
|
||||
"""Return (checksum_ok, signature_ok) for a bytearray/bytes dex."""
|
||||
import zlib
|
||||
stored_c = int.from_bytes(data[8:12], "little")
|
||||
calc_c = zlib.adler32(bytes(data[12:])) & 0xFFFFFFFF
|
||||
return stored_c == calc_c, bytes(data[12:32]) == hashlib.sha1(bytes(data[32:])).digest()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# loading
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def load_dex(source, entry=None):
|
||||
"""Load a Dex from a .dex path, an .apk/.zip path, or raw bytes.
|
||||
|
||||
For an archive, `entry` picks a member (default: the first classes*.dex).
|
||||
Returns (Dex, entry_name).
|
||||
"""
|
||||
if isinstance(source, (bytes, bytearray)):
|
||||
return Dex(bytes(source), entry or "<bytes>"), entry or "<bytes>"
|
||||
path = str(source)
|
||||
if path.lower().endswith((".apk", ".zip", ".jar", ".xapk", ".apks", ".apkm")):
|
||||
with zipfile.ZipFile(path) as z:
|
||||
if entry:
|
||||
return Dex(z.read(entry), entry), entry
|
||||
candidates = sorted(n for n in z.namelist()
|
||||
if n.startswith("classes") and n.endswith(".dex"))
|
||||
if not candidates:
|
||||
raise ValueError("no classes*.dex in %s" % path)
|
||||
name = candidates[0]
|
||||
return Dex(z.read(name), name), name
|
||||
with open(path, "rb") as fh:
|
||||
return Dex(fh.read(), path), path
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) != 5:
|
||||
print(__doc__)
|
||||
return 2
|
||||
target, fqcn, name, desc = argv[1:5]
|
||||
dex, entry = load_dex(target)
|
||||
print("== %s (entry %s, %d bytes)" % (target, entry, len(dex.d)))
|
||||
problems = dex.check()
|
||||
for p in problems:
|
||||
print(" [structure] %s" % p)
|
||||
if problems:
|
||||
print(" refusing to continue on a structurally broken read")
|
||||
return 1
|
||||
|
||||
found = dex.find_method(fqcn, name, desc)
|
||||
if not found:
|
||||
print("method not found: %s->%s%s" % (fqcn, name, desc))
|
||||
print("methods in class:")
|
||||
for section, idx, _c, nm, ds, code_off in dex.methods_of(fqcn):
|
||||
print(" [%s] %s%s code_off=0x%x" % (section, nm, ds, code_off))
|
||||
return 1
|
||||
|
||||
section, idx, code_off = found
|
||||
info = dex.code_info(code_off)
|
||||
insns, clean, expected = dex.decode_all(code_off)
|
||||
print("== %s.%s%s [%s] method_idx=%d" % (fqcn, name, desc, section, idx))
|
||||
print(" code_off=0x%x insns_off=0x%x insns_size=%d registers=%d"
|
||||
% (code_off, info["insns_off"], info["insns_size"], info["registers"]))
|
||||
print(" decoded %d instructions; ended cleanly: %s" % (len(insns), clean))
|
||||
if not clean:
|
||||
print(" WARNING: decode did not land on 0x%x -- offsets below are suspect"
|
||||
% expected)
|
||||
|
||||
regs = info["registers"]
|
||||
over = [i for i in insns if _max_reg(i) >= regs]
|
||||
if over:
|
||||
print(" WARNING: %d instruction(s) reference a register >= %d; "
|
||||
"desync likely" % (len(over), regs))
|
||||
|
||||
targets = dex.all_targets(code_off)
|
||||
print(" branch targets: %s" % ", ".join(sorted("0x%x" % t for t in targets)))
|
||||
print("-- listing --")
|
||||
for insn in insns:
|
||||
mark = " <== branch target" if insn["off"] in targets else ""
|
||||
print(" " + dex.describe(insn) + mark)
|
||||
return 0
|
||||
|
||||
|
||||
def _max_reg(insn):
|
||||
"""Highest register number named by an instruction (rough but sufficient)."""
|
||||
raw, op = insn["raw"], insn["op"]
|
||||
if op in IF_TEST or op in IFIELD_OPS or op in PFIELD_OPS:
|
||||
return max(raw[1] >> 4, raw[1] & 0xF)
|
||||
if op in IF_TESTZ:
|
||||
return raw[1] & 0xF
|
||||
if op == 0x12:
|
||||
return raw[1] >> 4
|
||||
if op in (0x01, 0x04, 0x07, 0x0A, 0x0B, 0x0C, 0x0D, 0x0F, 0x10, 0x11,
|
||||
0x1C, 0x1D, 0x1E):
|
||||
return raw[1] & 0xF
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
+224
-23
@@ -208,10 +208,26 @@ def build_unsigned(apk, repl, out_apk, drop_signatures=True):
|
||||
IllegalStateException: Module with the Main dispatcher is missing ...
|
||||
and the message never points at META-INF, so the cause is very hard to find.
|
||||
Therefore: drop signature artifacts only, keep every META-INF subdirectory.
|
||||
|
||||
ALIGNMENT. Writing the entries with a plain zipfile writer produces an archive
|
||||
Android R+ refuses to install:
|
||||
|
||||
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 message: `resources.arsc` must be
|
||||
STORED (not deflated), and its data must start at a 4-byte boundary. The same
|
||||
applies to uncompressed `lib/*.so`. Python's zipfile cannot express an entry
|
||||
offset, so this writer emits the local headers itself and pads the local extra
|
||||
field to hit the boundary.
|
||||
"""
|
||||
seen = set()
|
||||
replaced_names = set(repl)
|
||||
with zipfile.ZipFile(apk, 'r') as zin, zipfile.ZipFile(out_apk, 'w', zipfile.ZIP_DEFLATED) as zout:
|
||||
|
||||
# Collect (name, source, data, keep_stored) in output order.
|
||||
plan = []
|
||||
with zipfile.ZipFile(apk, 'r') as zin:
|
||||
for item in zin.infolist():
|
||||
name = item.filename
|
||||
base = os.path.basename(name)
|
||||
@@ -222,32 +238,208 @@ def build_unsigned(apk, repl, out_apk, drop_signatures=True):
|
||||
continue
|
||||
if name in replaced_names:
|
||||
continue
|
||||
data = zin.read(name)
|
||||
keep = name in STORE_ONLY or base in STORE_ONLY
|
||||
zi = zipfile.ZipInfo(name, date_time=item.date_time)
|
||||
zi.compress_type = zipfile.ZIP_STORED if keep else zipfile.ZIP_DEFLATED
|
||||
zi.external_attr = item.external_attr
|
||||
zi.internal_attr = item.internal_attr
|
||||
zi.create_system = item.create_system
|
||||
zout.writestr(zi, data)
|
||||
seen.add(name)
|
||||
for name, path in sorted(repl.items()):
|
||||
with open(path, 'rb') as fh:
|
||||
data = fh.read()
|
||||
zi = zipfile.ZipInfo(name, date_time=(2024, 1, 1, 0, 0, 0))
|
||||
zi.compress_type = zipfile.ZIP_DEFLATED
|
||||
zout.writestr(zi, data)
|
||||
log('[zip] + %s (%d bytes <- %s)' % (name, len(data), path))
|
||||
seen.add(name)
|
||||
plan.append((name, item, zin.read(name),
|
||||
name in STORE_ONLY or base in STORE_ONLY))
|
||||
for name, path in sorted(repl.items()):
|
||||
with open(path, 'rb') as fh:
|
||||
data = fh.read()
|
||||
info = zipfile.ZipInfo(name, date_time=(2024, 1, 1, 0, 0, 0))
|
||||
plan.append((name, info, data, False))
|
||||
log('[zip] + %s (%d bytes <- %s)' % (name, len(data), path))
|
||||
|
||||
_write_aligned_zip(out_apk, plan)
|
||||
seen.update(name for name, _i, _d, _k in plan)
|
||||
return seen
|
||||
|
||||
|
||||
def zip_report(apk):
|
||||
rows = []
|
||||
with zipfile.ZipFile(apk, 'r') as z:
|
||||
ALIGN = 4
|
||||
# Entries Android requires to be STORED, and (for some) 4-byte aligned.
|
||||
ALIGNED_STORED = ('resources.arsc',)
|
||||
ALIGNED_PATTERNS = (re.compile(r'^lib/[^/]+\.so$'),)
|
||||
FILLER_NAME = 'META-INF/ALIGN.RSV'
|
||||
|
||||
|
||||
def _needs_alignment(name):
|
||||
if name in ALIGNED_STORED:
|
||||
return True
|
||||
return any(p.match(name) for p in ALIGNED_PATTERNS)
|
||||
|
||||
|
||||
def _dos_word(dt):
|
||||
"""(time, date) MS-DOS words for a zip local header."""
|
||||
y, mo, d, h, mi, s = dt
|
||||
if y < 1980:
|
||||
y, mo, d, h, mi, s = 1980, 1, 1, 0, 0, 0
|
||||
return (h << 11) | (mi << 5) | (s // 2), ((y - 1980) << 9) | (mo << 5) | d
|
||||
|
||||
|
||||
def _local_header(nlen, method, crc, csize, usize, dt, flags=0, extralen=0):
|
||||
t, dd = _dos_word(dt)
|
||||
import struct
|
||||
return struct.pack('<IHHHHHIIIHH', 0x04034B50, 20, flags, method,
|
||||
t, dd, crc, csize, usize, nlen, extralen)
|
||||
|
||||
|
||||
def _deflate_raw(data):
|
||||
"""Raw deflate (no zlib wrapper), which is what a zip method-8 entry holds.
|
||||
|
||||
zlib.compress() prepends a 2-byte zlib header. Some readers tolerate it, some
|
||||
do not, and the failure reads as a corrupt entry rather than a bad compressor
|
||||
call, so use wbits=-15 and get it right the first time.
|
||||
"""
|
||||
import zlib
|
||||
c = zlib.compressobj(9, zlib.DEFLATED, -15)
|
||||
return c.compress(data) + c.flush()
|
||||
|
||||
|
||||
def _write_aligned_zip(out_path, plan):
|
||||
"""Write a zip whose STORED entries can be relied on for offset alignment.
|
||||
|
||||
Padding rule, and why it is not just `(-offset) % 4`: 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 therefore CANNOT be expressed in the extra
|
||||
field. When that happens this writer inserts a stored filler entry of exactly
|
||||
the needed size instead -- the filler has a computable size, so the following
|
||||
entry still lands on the boundary.
|
||||
"""
|
||||
import struct
|
||||
import zlib
|
||||
|
||||
order = []
|
||||
meta = {}
|
||||
offset = 0
|
||||
scratch = out_path + '.tmp'
|
||||
|
||||
with open(scratch, 'wb') as out:
|
||||
for name, info, data, keep_stored in plan:
|
||||
name_b = name.encode('utf-8')
|
||||
if keep_stored:
|
||||
method, payload = 0, data
|
||||
else:
|
||||
method, payload = 8, _deflate_raw(data)
|
||||
crc = zlib.crc32(data) & 0xFFFFFFFF
|
||||
|
||||
# reach the boundary before the local header of an aligned entry
|
||||
if _needs_alignment(name):
|
||||
need = (ALIGN - ((offset + 30 + len(name_b)) % ALIGN)) % ALIGN
|
||||
if need in (1, 2, 3):
|
||||
off2, fill_meta = _emit_filler(out, offset, need)
|
||||
order.append(FILLER_NAME)
|
||||
meta[FILLER_NAME] = fill_meta
|
||||
offset = off2
|
||||
|
||||
extra, extralen = b'', 0
|
||||
if _needs_alignment(name):
|
||||
head = 30 + len(name_b)
|
||||
if (offset + head) % ALIGN != 0:
|
||||
# expressible pad: one (id,size,payload) record >= 4 bytes
|
||||
pad = (ALIGN - ((offset + head) % ALIGN)) % ALIGN
|
||||
if pad < 4:
|
||||
pad += ALIGN
|
||||
extra = b'\xfe\xca' + struct.pack('<H', pad - 4) + b'\x00' * (pad - 4)
|
||||
extralen = len(extra)
|
||||
|
||||
flags = getattr(info, 'flag_bits', 0)
|
||||
local_header_offset = offset # the CD points at the HEADER
|
||||
out.write(_local_header(len(name_b), method, crc, len(payload),
|
||||
len(data), info.date_time, flags, extralen))
|
||||
out.write(name_b)
|
||||
out.write(extra)
|
||||
data_off = offset + 30 + len(name_b) + extralen
|
||||
if out.tell() != data_off:
|
||||
raise RuntimeError('offset bookkeeping drift for %s' % name)
|
||||
if _needs_alignment(name) and data_off % ALIGN != 0:
|
||||
raise RuntimeError('%s not aligned: data at 0x%x' % (name, data_off))
|
||||
out.write(payload)
|
||||
offset = out.tell()
|
||||
|
||||
order.append(name)
|
||||
meta[name] = {'name_b': name_b, 'method': method, 'crc': crc,
|
||||
'csize': len(payload), 'usize': len(data),
|
||||
'local_offset': local_header_offset,
|
||||
'date_time': info.date_time, 'flag_bits': flags,
|
||||
'external_attr': getattr(info, 'external_attr', 0)}
|
||||
|
||||
cd_start = out.tell()
|
||||
for name in order:
|
||||
m = meta[name]
|
||||
t, dd = _dos_word(m['date_time'])
|
||||
# central directory: sig, ver_made, ver_need, flags, method, time,
|
||||
# date, crc, csize, usize, namelen, extralen, commentlen, disk,
|
||||
# int_attr, ext_attr, local_header_offset
|
||||
out.write(struct.pack(
|
||||
'<IHHHHHHIIIHHHHHII', 0x02014B50, 20, 20, m.get('flag_bits', 0),
|
||||
m['method'], t, dd, m['crc'], m['csize'], m['usize'],
|
||||
len(m['name_b']), 0, 0, 0, 0,
|
||||
(m['external_attr'] >> 16) & 0xFFFF, m['local_offset']))
|
||||
out.write(m['name_b'])
|
||||
cd_size = out.tell() - cd_start
|
||||
out.write(struct.pack('<IHHHHIIH', 0x06054B50, 0, 0, len(order), len(order),
|
||||
cd_size, cd_start, 0))
|
||||
|
||||
shutil.move(scratch, out_path)
|
||||
return order
|
||||
|
||||
|
||||
def _emit_filler(out, offset, size):
|
||||
"""Insert a stored, zero-filled entry of exactly `size` payload bytes."""
|
||||
import struct
|
||||
import zlib
|
||||
name_b = FILLER_NAME.encode('utf-8')
|
||||
payload = b'\x00' * size
|
||||
crc = zlib.crc32(payload) & 0xFFFFFFFF
|
||||
out.write(_local_header(len(name_b), 0, crc, size, size, (1980, 1, 1, 0, 0, 0)))
|
||||
out.write(name_b)
|
||||
out.write(payload)
|
||||
return out.tell(), {'name_b': name_b, 'method': 0, 'crc': crc,
|
||||
'csize': size, 'usize': size,
|
||||
'local_offset': offset,
|
||||
'date_time': (1980, 1, 1, 0, 0, 0), 'external_attr': 0}
|
||||
|
||||
|
||||
def check_alignment(apk):
|
||||
"""Report storage + 4-byte alignment of the entries Android insists on.
|
||||
|
||||
Returns a list of complaint strings; empty means the install gate is satisfied.
|
||||
"""
|
||||
import struct
|
||||
bad = []
|
||||
with zipfile.ZipFile(apk, 'r') as z, open(apk, 'rb') as fh:
|
||||
for i in z.infolist():
|
||||
if i.filename in STORE_ONLY or os.path.basename(i.filename) in STORE_ONLY:
|
||||
rows.append('%s method=%d (0=STORED)' % (i.filename, i.compress_type))
|
||||
if not (i.filename in ALIGNED_STORED or _needs_alignment(i.filename)):
|
||||
continue
|
||||
fh.seek(i.header_offset)
|
||||
lh = fh.read(30)
|
||||
nlen, elen = struct.unpack('<HH', lh[26:30])
|
||||
data_off = i.header_offset + 30 + nlen + elen
|
||||
if i.compress_type != 0:
|
||||
bad.append('%s is compressed (method=%d); Android R+ requires '
|
||||
'STORED' % (i.filename, i.compress_type))
|
||||
if i.filename != FILLER_NAME and data_off % ALIGN != 0:
|
||||
bad.append('%s data offset 0x%x is not %d-byte aligned'
|
||||
% (i.filename, data_off, ALIGN))
|
||||
return bad
|
||||
|
||||
|
||||
def zip_report(apk):
|
||||
"""Entry summary, including the storage/alignment facts Android gates on."""
|
||||
import struct
|
||||
rows = []
|
||||
with zipfile.ZipFile(apk, 'r') as z, open(apk, 'rb') as fh:
|
||||
for i in z.infolist():
|
||||
base = os.path.basename(i.filename)
|
||||
needs = (i.filename in STORE_ONLY or base in STORE_ONLY
|
||||
or _needs_alignment(i.filename))
|
||||
if needs:
|
||||
fh.seek(i.header_offset)
|
||||
lh = fh.read(30)
|
||||
nlen, elen = struct.unpack('<HH', lh[26:30])
|
||||
data_off = i.header_offset + 30 + nlen + elen
|
||||
verdict = 'aligned' if data_off % ALIGN == 0 else 'NOT ALIGNED'
|
||||
stored = 'STORED' if i.compress_type == 0 else \
|
||||
'COMPRESSED(method=%d)' % i.compress_type
|
||||
rows.append('%-40s %-22s offset=0x%-8x %s'
|
||||
% (i.filename, stored, data_off, verdict))
|
||||
if re.match(r'^classes\d*\.dex$', i.filename):
|
||||
rows.append('%s %d bytes method=%d crc=%08x' % (
|
||||
i.filename, i.file_size, i.compress_type, i.CRC))
|
||||
@@ -448,6 +640,15 @@ def main():
|
||||
log('== zip layout of final apk')
|
||||
for row in zip_report(out):
|
||||
log(' ' + row)
|
||||
alignment_problems = check_alignment(out)
|
||||
if alignment_problems:
|
||||
log('== ALIGNMENT/STORAGE GATE FAILED -- do not ship this build:')
|
||||
for p in alignment_problems:
|
||||
log(' - %s' % p)
|
||||
log(' Android R+ (targetSdk 30+) refuses to install an APK whose '
|
||||
'resources.arsc is compressed or not 4-byte aligned.')
|
||||
else:
|
||||
log('== alignment gate: resources.arsc STORED and 4-byte aligned (OK)')
|
||||
|
||||
ok = verify_apk(out, tools, signer_jar, expect_signed=not args.no_sign)
|
||||
if not args.no_sign:
|
||||
|
||||
@@ -28,7 +28,16 @@ import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8080
|
||||
if len(sys.argv) > 1 and sys.argv[1] in ('-h', '--help'):
|
||||
print(__doc__)
|
||||
sys.exit(0)
|
||||
|
||||
try:
|
||||
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8080
|
||||
except ValueError:
|
||||
print('error: listen_port must be a number, got %r\n' % sys.argv[1])
|
||||
print(__doc__)
|
||||
sys.exit(2)
|
||||
LOGF = sys.argv[2] if len(sys.argv) > 2 else None
|
||||
_lock = threading.Lock()
|
||||
_logf = open(LOGF, 'a', encoding='utf-8') if LOGF else None
|
||||
|
||||
Reference in New Issue
Block a user