feat: apk-reverse Agent Skill

Progressive-disclosure skill for Android APK reverse engineering:
recon, ad removal, membership limits, dex surgical patching, repack/sign,
runtime data, Frida, environment setup, and a hard-won failure catalogue.
This commit is contained in:
inliver233
2026-09-19 21:25:35 +08:00
commit c66658b6b3
33 changed files with 3713 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
# build artifacts
*.class
*.jar
!scripts/dexpatch/*.java
# working data -- never commit samples or outputs
*.apk
*.aab
*.dex
*.odex
*.vdex
*.oat
*.so
*.jks
*.keystore
*.pk8
*.pem
*.p12
# local scratch
_tmp/
out/
work/
*.log
*.jsonl
*.pcap
*.png
*.jpg
# python / editors
__pycache__/
*.pyc
.venv/
.vscode/
.idea/
.DS_Store
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 apk-reverse contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+98
View File
@@ -0,0 +1,98 @@
# apk-reverse
An Agent Skill for Android APK reverse engineering, debloating, ad removal, surgical
dex patching, repacking, and runtime/server analysis.
It is a **skill**, not a tutorial: it is written to be loaded by an agent (Claude Code,
Codex, or any harness that supports the Agent Skills format) while it works, so it is
organized for progressive disclosure — a short decision-oriented `SKILL.md`, detailed
references loaded only when a step needs them, and parameterized scripts you can run
directly.
## What it is good at
- Deciding **fast** whether a request is even achievable client-side, instead of
burning hours on a paywall that is enforced by a server.
- Choosing the **safest patch layer** for a given change, and avoiding the layers that
break the app.
- Avoiding the specific mistakes that produce an APK that builds perfectly and dies at
runtime.
## Structure
```
SKILL.md decision tree, workflow, hard constraints, indexes
references/ loaded on demand, one topic each
recon.md identify packer, SDKs, code location, tamper checks
ad-removal.md ad taxonomy, wrapper mapping, callback trap, verification
membership-and-limits.md server vs client authority; what is and is not patchable
server-api.md probe an app's API; prove who owns the gate
dex-patching.md patch-layer table + dexlib2 technique in depth
repack-and-sign.md repack rules, signing, install, post-install hazards
runtime-data.md DataStore / SharedPreferences / SQLite / protobuf
dynamic-frida.md Frida setup, hooking strategy, anti-instrumentation
environment.md device/emulator setup, ADB, offline devices, log signals
verification.md the claim ladder; what "done" means
pitfalls.md the failure catalogue -- read before building
scripts/ parameterized, path-agnostic
smtool.py baksmali/smali wrapper with a configurable classpath
dexpatch/ dexlib2 method-level surgical rewriter (preferred tool)
patch_smali.py method-body replacement in a smali tree
dex_strpatch.py byte-level string patch with a string_ids ordering guard
dex_classdiff.py prove a dex edit was surgical
dex_strings.py strings/URLs/SDK markers without a decompiler
find_refs.py count callers of a method before patching it
repack.py rebuild APK, strip only signatures, sign
devsh.py quoting-safe ADB shell helper
usb_net_proxy.py give an offline device network over USB
datastore_inject.py encode/inject AndroidX DataStore preferences safely
probe_api.py probe an HTTP API with the right headers
grab_crash.py recover stacks hidden by a crash-reporter SDK
install_test.py install + launch health check with logcat signal scan
```
## Install
Drop this directory where your agent finds skills, e.g.:
```
<skills-dir>/apk-reverse/SKILL.md
```
The agent loads `SKILL.md` when a task matches its description, and pulls in
`references/*` only as needed. No global state, no machine-specific paths.
## Requirements
Nothing is mandatory; each script checks what it needs.
| Tool | Used for |
|---|---|
| Python 3.9+ | all scripts |
| `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 |
| `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 |
## Read this first
`references/pitfalls.md`. It is the most valuable file here — every entry is a failure
that produced a broken artifact while looking completely healthy.
The three that hurt most:
1. Stripping the whole `META-INF/` during a repack deletes ServiceLoader registrations
and the app dies at startup with an error that names an unrelated library.
2. Patching a byte-level string without preserving `string_ids` ordering gets the whole
dex rejected, while checksums and signatures verify perfectly.
3. Rebuilding a dex with a whole-tree smali round-trip damages R8 output invisibly —
class tables compare clean, and it only blows up at runtime.
## Scope
Built for working on your own applications, on samples you are authorized to analyze,
and in CTF/competition sandboxes. It contains no vendored third-party binaries and no
target-specific data.
+90
View File
@@ -0,0 +1,90 @@
---
name: apk-reverse
description: Reverse engineer, debloat, de-ad, patch, or re-sign Android APKs, and analyze their runtime and server-side behavior. Use when a task involves an .apk/.aab/.dex/.so sample, smali or dex patching, Frida/objection runtime hooking, repacking and re-signing, removing ads or SDK trackers, probing a mobile app's HTTP API, or deciding whether a client-side patch is even capable of achieving the goal. Covers recon, anti-tamper, ad removal, membership/paywall limits, dex-level surgical patching, repack pitfalls, device and emulator setup, and a hard-won failure catalogue.
---
# APK Reverse Engineering & Patching
Goal of this skill: get to a **verified, installable, still-working artifact** fast, and avoid the whole class of mistakes that silently destroy an APK.
Two rules override everything else in this skill:
1. **Never ship an unverified APK.** "It assembles" is not "it works". Install it, launch it, exercise the feature you changed, on a real device or a close emulator.
2. **Know which layer owns the behavior before you patch.** Ads, paywalls, and feature gates live in different places. Patching the wrong layer either does nothing or breaks the app. Classify first (see step 2 below), patch second.
## Start here: classify the target in five questions
Answer these before touching a tool. Every one of them changes the whole plan.
1. **Is the app packed/hardened?** → `references/recon.md`
Read the manifest's `application android:name`. If it is a third-party shell class rather than the app's own Application, you have a packer and must handle it first.
2. **Where does the behavior you want to change actually live?**
- Ad SDK (Pangle/GDT/AnyThink/Kuaishou/Baidu/Sigmob…) → usually **client-side and removable** → `references/ad-removal.md`
- Server-issued ad config / sponsored cards → **client renders server data** → `references/ad-removal.md` §server-driven
- Membership / VIP / paid content → **usually server-authorized, client patch is cosmetic** → `references/membership-and-limits.md` (read this *before* spending hours)
- Feature flag, UI gate, debug switch → usually client-side
- Anything decided by an API response → server-side → `references/server-api.md`
3. **Is the app's own code in plain dex, or moved to native/Flutter/Unity?**
Plain dex → you can patch. Flutter (`libflutter.so` + `libapp.so`) / Unity (`libil2cpp.so`) / pure native → different toolchain entirely. See `references/recon.md` §code-location.
4. **Does the app verify its own signature, or does the server?**
App-side → you must bypass it. Server-side → re-signing silently breaks the app later. See `references/repack-and-sign.md` and `references/server-api.md`.
5. **What is your device situation?** → `references/environment.md`
Rooted real device (best), emulator with root, or no device (static only). Also: this determines whether Frida is usable.
## The workflow, end to end
1. **Recon** — `references/recon.md`. Manifest, package name, version, ABI, dex count, packer, embedded SDKs, where the app's own code lives. Ten minutes here saves hours.
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.
6. **Repack and sign** — `references/repack-and-sign.md`. **Do not strip the whole `META-INF/`.** This single mistake destroys otherwise-correct builds.
7. **Verify on device** — `references/environment.md` + `references/verification.md`. Check: launches, no logcat exception, the changed behavior actually changed, and nothing unrelated broke.
8. **Log what you learned** — if a failure cost you more than thirty minutes, add it to `references/pitfalls.md`. That file is the most valuable artifact in this skill.
## Non-negotiable constraints
- **Read-only inputs.** Keep the original APK/dex untouched; work on copies. Always keep a known-good baseline to diff against.
- **One variable at a time.** If you change two things and it breaks, you learn nothing. Build a control (same pipeline, zero patches) and compare.
- **Verify structure after every dex edit.** `scripts/dex_classdiff.py` must report zero differences in class set and access flags for classes you did not intend to change.
- **Do not patch a method that is widely shared.** Before patching any helper, count its callers (`scripts/find_refs.py`). A `Long.valueOf` wrapper with 30 callers is not an ad-specific hook.
- **Do not make an API fail to suppress a UI element.** A 404/400 on an endpoint that other features depend on takes the whole screen down with it. Suppress at the data-consumption or render layer instead.
- **Every claim needs evidence.** "Probably", "should be", "in theory" are not findings. Either you observed it, or you label it unverified.
## Reference index
Load only what the current step needs.
| File | Load when |
|---|---|
| `references/recon.md` | Starting any new sample; identifying packer, SDKs, code location, ABI |
| `references/ad-removal.md` | Task involves ads, trackers, sponsored cards, splash/interstitial/reward |
| `references/membership-and-limits.md` | Task involves VIP, subscription, paid content, unlock, "fully cracked" |
| `references/server-api.md` | The behavior is decided by a response, or you need to know if a patch can even matter |
| `references/dex-patching.md` | Any actual editing of dex/smali, choosing a patch layer, choosing a tool |
| `references/repack-and-sign.md` | Rebuilding, signing, installing, or a repacked app misbehaves |
| `references/runtime-data.md` | Local state matters: DataStore, SharedPreferences, SQLite, protobuf caches, tokens |
| `references/dynamic-frida.md` | Frida setup, hooking strategy, tracing caller chains, finding the real call site |
| `references/environment.md` | Device/emulator setup, root, ADB, networking, offline devices |
| `references/verification.md` | Defining what "done" means; building the evidence chain |
| `references/pitfalls.md` | Always worth a skim before building. This is the failure catalogue. |
## Script index
All scripts are parameterized and path-agnostic; pass paths explicitly. Run `--help` or read the header of each.
| Script | Purpose |
|---|---|
| `scripts/smtool.py` | baksmali/smali wrapper with a bundled classpath (assemble/disassemble dex) |
| `scripts/patch_smali.py` | Method-body replacement in a smali tree, matched by signature |
| `scripts/dex_strpatch.py` | Byte-level string constant patch with **string_ids ordering guard** |
| `scripts/dex_classdiff.py` | Compare two dex class tables (set + access flags) to prove a patch was surgical |
| `scripts/dex_strings.py` | Dump/extract strings and endpoints from dex without a decompiler |
| `scripts/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/devsh.py` | Quoting-safe ADB shell helper for rooted devices |
| `scripts/usb_net_proxy.py` | Give an offline device network over USB (adb reverse + local proxy) |
| `scripts/datastore_inject.py` | Encode/inject AndroidX DataStore preferences (protobuf) safely |
| `scripts/probe_api.py` | Probe an app's HTTP API with correct headers, report status/shape |
| `scripts/install_test.py` | Install a build and run a launch/health check with logcat signal extraction |
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""One-shot builder: copy the battle-tested tools from the project workspace into
the skill, stripping machine-specific hardcoding.
Run from anywhere: python build_scripts.py <project_work_dir> <skill_scripts_dir>
"""
import os
import re
import shutil
import sys
SRC = sys.argv[1] if len(sys.argv) > 1 else r'E:\ciyuan\work'
DST = sys.argv[2] if len(sys.argv) > 2 else r'E:\ciyuan\apk-reverse\scripts'
# file -> (new name, list of (pattern, replacement))
COPY = {
r'tools\dex_strpatch.py': ('dex_strpatch.py', []),
r'tools\dex_classdiff.py': ('dex_classdiff.py', []),
r'tools\patch_smali.py': ('patch_smali.py', []),
r'tools\repack.py': ('repack.py', [
# strip machine-specific defaults; make them explicit args
(re.compile(r'KS_PATH\s*=\s*r?["\'][^"\']*["\']'), 'KS_PATH = None # pass --ks on the command line'),
(re.compile(r"workdir'?,?\s*default=r?['\"][^'\"]*['\"]"), "workdir', default=None"),
]),
r'tools\cyc_proxy.py': ('usb_net_proxy.py', []),
r'tools\mk_video_prefs.py': ('datastore_inject.py', []),
r'tools\probe_adverts.py': ('probe_api.py', []),
r'tools\grab_crash.py': ('grab_crash.py', []),
}
def clean(text):
"""Remove absolute paths that point at one particular machine."""
text = re.sub(r"r?'[A-Za-z]:\\\\[^'\"]*'", "''", text)
text = re.sub(r'"[A-Za-z]:\\\\[^"]*"', '""', text)
return text
def main():
os.makedirs(DST, exist_ok=True)
os.makedirs(os.path.join(DST, 'dexpatch'), exist_ok=True)
for rel, (new, subs) in COPY.items():
src = os.path.join(SRC, rel)
if not os.path.isfile(src):
print('[skip missing] %s' % rel)
continue
t = open(src, encoding='utf-8', errors='replace').read()
for pat, rep in subs:
t = pat.sub(rep, t)
out = os.path.join(DST, new)
with open(out, 'w', encoding='utf-8', newline='\n') as f:
f.write(t)
print('[copied] %-24s -> %s' % (rel, new))
# dexlib2 patcher sources
for rel, new in [(r'tools\dexpatch\PatchMethod.java', 'PatchMethod.java'),
(r'tools\dexpatch\PatchClasses7v2.java', 'PatchMethodExample.java')]:
src = os.path.join(SRC, rel)
if os.path.isfile(src):
t = open(src, encoding='utf-8', errors='replace').read()
with open(os.path.join(DST, 'dexpatch', new), 'w',
encoding='utf-8', newline='\n') as f:
f.write(t)
print('[copied] %-24s -> dexpatch/%s' % (rel, new))
else:
print('[skip missing] %s' % rel)
# cp.txt content -> smali_cp.txt template
cp = os.path.join(SRC, 'tools', 'dexpatch', 'cp.txt')
if os.path.isfile(cp):
raw = open(cp, encoding='utf-8').read().strip()
jars = [j for j in raw.split(';') if j.strip()]
with open(os.path.join(DST, 'smali_cp.txt'), 'w', encoding='utf-8', newline='\n') as f:
f.write('# One jar path per line. Adjust the directory to your own tools.\n')
f.write('# Needed: smali baksmali dexlib2 util antlr-runtime stringtemplate '
'jcommander guava\n')
for j in jars:
f.write(os.path.basename(j) + '\n')
print('[wrote] smali_cp.txt (%d jars)' % len(jars))
if __name__ == '__main__':
main()
+97
View File
@@ -0,0 +1,97 @@
# Ad removal
Goal: ads stop appearing, and **nothing else breaks**. Read `references/pitfalls.md` P5 and P6 before patching anything here.
## Step 1: identify which ads exist and who owns them
Do not assume "ads" is one thing. Enumerate first.
**SDK-integrated ads** (the app calls an SDK):
- Search dex strings for SDK markers: `openadsdk`, `TTAdSdk`, `TTAdNative`, `Pangle`, `pangolin`, `com.qq.e`, `GDTAd`, `gdt_plugin`, `anythink`, `ATSDK`, `ATRewardVideoAd`, `bdxadsdk`, `sigmob`, `ksad`, `mobads`, `beizi`.
- **Aggregators** (AnyThink, TopOn, and similar) are wrappers: the visible ad networks underneath are *their* adapters, not app code. Patching an individual network's class is usually pointless — the aggregator still runs and still reaches the network.
- The app almost always funnels every call through **one wrapper class**. Find it; that is your patch surface.
**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 `references/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.
**Legit content that looks like an ad.** Verify before acting. A `/adverts?position=banner` response containing anime titles and poster images is the home-page carousel, not an advertisement. Removing it removes real functionality.
## Step 2: map the wrapper
Find the app's own helper and enumerate its public surface. Typical shape:
```
helper.init(Application) -> Sdk.init(appId, appKey); Sdk.start()
helper.showSplash(Activity, ViewGroup, onClose: () -> Unit)
helper.showInterstitial(Activity)
helper.showReward(Activity, onStart: () -> Unit, onFinish: (Boolean) -> Unit)
helper.preload*(Activity)
helper.canShow*(Activity): Boolean
```
Read each method body and classify:
| Method | Contains | Safe patch |
|---|---|---|
| Init | `Sdk.init(...)` + `Sdk.start()` | Replace body with `return-void` |
| Show splash/interstitial | constructs an ad object, `loadAd()` / `show()` | Replace body, **but must still invoke the completion callback** |
| Show reward | same, plus success/failure lambdas | Replace body; decide deliberately whether to grant the reward |
| Preload/warm-up | `RewardVideoAutoAd.init(...)` | `return-void` |
| Gate check | `isAdReady`, `canShow` | return `false` / `0` |
### The callback trap (this one breaks startup)
A splash/loading ad usually takes a **completion lambda** as a parameter. The app's startup state machine waits for that lambda before it dismisses the splash screen.
**If you replace the method with a bare `return-void`, the lambda never fires, and the app hangs forever on the splash screen.**
Correct shape — skip the ad, still complete:
```
.method public final showSplash(Landroid/app/Activity;Landroid/view/ViewGroup;Lkotlin/jvm/functions/Function0;)V
.registers N
invoke-interface {p3}, Lkotlin/jvm/functions/Function0;->invoke()Ljava/lang/Object;
return-void
.end method
```
**Before patch, prove the contract.** Read the listener class the method constructs and find which callback triggers the completion lambda (`onAdDismiss`? `onAdError`? `onAdLoadTimeout`?). Only that tells you whether to call it on success, failure, or unconditionally.
For a reward ad whose lambdas are `(onStart, (Boolean) -> Unit)`, granting the reward client-side is a choice: `onFinish(true)` will make the app treat the reward as earned. Check whether the reward is validated server-side before promising it.
## Step 3: choose the patch layer
From safest to riskiest (see `references/dex-patching.md` for the full table):
1. **Kill SDK init** — the SDK never starts; its networks are never contacted. Highest-value single patch. Verify nothing else depends on the SDK being initialized.
2. **No-op the show/preload methods** — preserving callbacks.
3. **Neutralize the gate** — `isAdReady`/`canShow` return false.
4. **Filter server-issued ad data at the consumption layer** — drop entries for the target position before they reach UI state.
5. *(avoid)* **Renderer-level suppression** — only if the component is genuinely ad-exclusive. Check `references/pitfalls.md` P6 first.
6. *(avoid)* **Transport-level blocking** — see `references/pitfalls.md` P5.
## Step 4: verify — and verify it properly
Logcat silence alone is weak evidence. Use several independent signals:
1. **Process stays alive** through startup and across a few screens; no `FATAL EXCEPTION`, no `VerifyError`, no `uncaughtException`.
2. **SDK log keywords absent** — `anythink`, `ATSDK`, `Pangle`, `TTAd`, `GDT`, etc. should not appear at all.
3. **DNS / socket level (strongest, and cheap)** — hook or observe name resolution. If the ad SDK's network stack is initialized it will resolve its own domains. Many ad SDKs **bypass the system proxy**, so proxy logs may miss them; a runtime `InetAddress` hook does not.
- Before: a burst of ad/tracker domains.
- After: **none at all** — meaning the init path never ran, not merely "shown but not rendered".
4. **Exercise the screens that had ads** — splash, home banner, detail page, player, reward button.
5. **Confirm nothing unrelated regressed** — images load, video plays, lists scroll, login works.
A useful framing for the report: distinguish *"the ad was hidden"* from *"the ad subsystem never started"*. The second is a much stronger claim, and DNS/socket evidence can prove it.
## Step 5: what is usually NOT removable
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.
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.
+195
View File
@@ -0,0 +1,195 @@
# Dex patching — choosing and executing the surgical edit
The single most important decision in a patch task. Get this wrong and you produce an APK that assembles perfectly and dies at runtime.
## Pick the least destructive technique that can express your change
Ordered from safest to most dangerous.
| # | Technique | Expresses | Structure risk | Use when |
|---|---|---|---|---|
| 1 | **dexlib2 method-level rewrite** | Replace a method's implementation (return a constant, no-op, emit a fixed value) | **Lowest** — only the target `code_item` changes | Default choice. Any behavior change that can be expressed as "this method now does X instead" |
| 2 | **Byte-level string constant patch** | Change a string literal to another of **equal length** | Medium — must preserve `string_ids` ordering (see P2) | Renaming a path/key/protocol token |
| 3 | **Resource / asset edit** | Change a config file, JSON, image, or bundled jar | Low (no dex change) | The behavior is data-driven |
| 4 | **Manifest edit** | Disable a component, drop a permission, flip a flag | Low, but re-signing required | Turning off a service/receiver/activity |
| 5 | **Whole-tree smali round-trip** | Arbitrary code edits | **High** — breaks R8 output (see P3) | Last resort, and verify by running |
**Do not reach for #5 because it is familiar.** It is the most likely to produce a broken build.
## Where to patch: pick the layer, not the symptom
Ads and gates can be suppressed at several layers. Higher in this list = smaller blast radius.
| Layer | What it looks like | Example | Risk |
|---|---|---|---|
| **SDK initialization** | A single helper that calls `Sdk.init()` | `AdHelper.k(Application)` | Low — SDK never starts, nothing else depends on it |
| **Feature entry point** | The app method that shows the thing | `AdHelper.showSplash()`, `showReward()` | Low–medium — must preserve callbacks the caller awaits |
| **Data consumption** | Where a server list is filtered/rendered | filter out a `position` before it reaches UI state | Medium — must not break sibling data |
| **Renderer** | The composable/view for a **specific** element | a dedicated ad card composable | **High** — generic components are shared (see P6) |
| **Transport** | Blocking/repointing an endpoint | `/app/adverts` → dead path | **Very high** — shared endpoints take screens down (see P5) |
**Rule:** prefer the highest layer (closest to the SDK/feature) that produces the required effect. Never go to the transport layer to hide a UI element.
## Before you patch a method: blast-radius check
```bash
python scripts/find_refs.py <smali_tree_or_dex_dir> 'Lcom/pkg/Helper;->methodName(args)RetType'
```
- **1–3 callers, all in the same feature area** → safe to patch.
- **Many callers, or callers across unrelated packages** → it is a general utility. Do not patch it. Go one level up and patch the specific caller instead.
- **Look at the parameters too.** If the signature mentions `Modifier`, `ContentScale`, `ColorFormat`, `Shape`, `View`, or a content-generic model type, it is not specific to your target.
Real example: a composable `(AdModel, ColorScheme, Modifier, Function0, ContentScale, Shape, Composer, II)V` looked ad-specific because of its first parameter. It was actually the shared image-card renderer; neutering it removed all cover art and broke playback.
## Technique 1: dexlib2 method-level rewrite (preferred)
Concept: load the dex, find the target class, replace **only** the target method's implementation, write a new dex. Nothing else is touched.
See `scripts/dexpatch/` for a working Java implementation and build instructions.
Shape of the code:
```java
DexFile dex = DexFileFactory.loadDexFile(new File(in), Opcodes.forApi(API));
List<ClassDef> out = new ArrayList<>();
for (ClassDef cd : dex.getClasses()) {
List<Method> direct = new ArrayList<>(); for (Method m : cd.getDirectMethods()) direct.add(m);
List<Method> virtual = new ArrayList<>(); for (Method m : cd.getVirtualMethods()) virtual.add(m);
boolean touched = false;
// ... for the target class: find the method, build new instructions, list.set(i, newMethod)
out.add(touched
? new ImmutableClassDef(cd.getType(), cd.getAccessFlags(), cd.getSuperclass(), cd.getInterfaces(),
cd.getSourceFile(), cd.getAnnotations(), cd.getStaticFields(),
cd.getInstanceFields(), direct, virtual)
: cd);
}
DexFileFactory.writeDexFile(out, new ImmutableDexFile(Opcodes.forApi(API), out));
```
### Practical details that decide success
- **`direct` vs `virtual`.** `static`, `private`, and constructors live in `directMethods`; everything else (including `public final`) lives in `virtualMethods`. **Scan both** when searching. A "method not found" that is actually "in the other list" wastes a lot of time.
- **Preserve the register count.** Keep `m.getImplementation().getRegisterCount()`. `registers` must be `>= parameterRegisters`, and parameter registers are the **highest-numbered** ones: with `registers = N` and `k` parameters (including `this` for non-static), `p0 = N - k`.
- **Method parameters** come from the prototype. For `(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;` on an instance method, `k = 3` → `p0=3, p1=4, p2=5` when `registers = 6`.
- **`const-wide` needs the 64-bit instruction form.** `ImmutableInstruction51l`, not `31i`.
- **Instruction register operands in `35c` form** (invoke) are `(opcode, registerCount, regC, regD, regE, regF, regG, ref)`. `invoke-static {v1, v2}, ...` → `registerCount=2, regC=1, regD=2`.
- **Every method body must end with a return of the right type.**
- **One dex, one write pass.** If you need two edits to the same dex, do them in the same program (see P4).
- **Null `tryBlocks` / `debugItems` on the rewritten method is fine** — you are replacing the whole body.
### Common rewrites
**Return a boolean constant** (`Z`):
```
const/4 v0, 0x1
return v0
```
`registers` = original count is fine; `const/4` needs a 4-bit register (`v0`–`v15`).
**No-op** (`V`):
```
return-void
```
**Return an object/empty collection**: build the constant, then `return-object`. For `java.lang.Long`, `const-wide` + `Long->valueOf(J)` + `move-result-object`.
**Coerce a suspend/Flow lambda to always yield a fixed value** — this is what "make a local flag permanently true" usually looks like in practice. See the worked example below.
### Worked example: permanently "not expired" without touching a shared helper
**Situation.** A local preference (`<key>_expires_at`) gates a promo popup. Writing a large value into the datastore works on the current device but is **runtime data**, so a fresh install loses it (see P11). The read path is a Flow built at construction time, so the getter cannot be patched meaningfully, and the generic `Long.valueOf` wrapper has 30+ callers (see P6).
**Target.** The dedicated map lambda, e.g. `feature/data/SomeStore$currentX$$inlined$map$1$2`:
```
.method public final emit(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
iget-object v0, p0, L<same class>;->b:Lkw2; # downstream collector
const-wide v1, <big value>
invoke-static {v1, v2}, Ljava/lang/Long;->valueOf(J)Ljava/lang/Long;
move-result-object v1
invoke-interface {v0, v1, p2}, Lkw2;->emit(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
move-result-object v0
return-object v0
.end method
```
Note the shape: the lambda class holds the downstream collector in a field (here `b:Lkw2;`). Always read that field name from the actual smali — do not assume.
This class has exactly one purpose, so patching it is safe.
## Technique 2: byte-level string patch
Only for **equal-length** replacements. Must preserve `string_ids` ordering.
```bash
python scripts/dex_strpatch.py <in.dex> <out.dex> "<old>" "<new>"
```
The script:
1. requires `len(new) == len(old)`,
2. requires exactly one occurrence,
3. looks up the entry's neighbours in `string_ids` and **rejects** the replacement unless `prev < new < next`,
4. recomputes `signature` (SHA-1 over bytes from offset 32) and `checksum` (adler32 over bytes from offset 12).
If a candidate is rejected, try another string that sorts inside the same interval. `/app/noadver` was rejected (`n > c` against neighbour `/app/configs/`); `/app/blocked` was accepted.
**Do not** use this technique to change behavior. Strings are renamed, not logic.
## Technique 3: smali tree editing (only when you must)
Use when the change cannot be expressed as a single-method rewrite (adding a field, restructuring control flow).
Tools: `scripts/smtool.py` (baksmali/smali with a bundled classpath) and `scripts/patch_smali.py` (method-body replacement by signature).
```bash
python scripts/smtool.py d <in.dex> <out_tree>
python scripts/patch_smali.py <tree> <patch.json> [--dry-run]
python scripts/smtool.py a <tree> <out.dex>
```
Then **must** verify (P3 will bite otherwise):
```bash
python scripts/dex_classdiff.py <original.dex> <rebuilt.dex>
```
Expect: same class count, `only_in_A=0`, `only_in_B=0`, `ACC_INTERFACE mismatch=0`.
If the class table is clean but the app crashes with `IncompatibleClassChangeError` / `VerifyError`, the round-trip damaged code items that table comparison cannot see. Fall back to technique 1.
Patch spec format for `patch_smali.py`:
```json
[
{
"file": "com/pkg/Helper.smali",
"method": ".method public final showAd(Landroid/app/Activity;)V",
"registers": 3,
"body": ["return-void"],
"note": "why this is safe"
}
]
```
## Finding the call site
Reverse-lookup from a smali tree or a set of disassembled dex:
```bash
python scripts/find_refs.py <tree> 'Lcom/pkg/AdHelper;->showSplash(...)V'
```
Search strategies that work:
- Search the **string table** first (`scripts/dex_strings.py`). API paths, keys, and SDK class names are usually string literals, and they are unique.
- Search for the **SDK's entry class** (`Sdk.init`, `Sdk.show`). The app's wrapper is almost always the only caller.
- When the decompiler is confusing, trust the **byte offsets** — `grep` the dex for the literal and note where it sits relative to other strings of the same family.
## Order of operations for a real patch task
1. Identify every place the behavior is produced (`recon` + `find_refs`).
2. Pick the highest safe layer per the table above.
3. Write all edits for a given dex into **one** dexlib2 program.
4. Patch each dex **once**.
5. `dex_classdiff` every patched dex against the original → expect zero structural drift.
6. Repack, sign, install, launch, exercise, read logcat.
7. If anything is off, revert that dex to the original and re-verify the control build.
+129
View File
@@ -0,0 +1,129 @@
# Dynamic analysis with Frida
Static analysis tells you what code exists. Frida tells you what code **runs**. When they disagree, runtime wins (`references/pitfalls.md` P10).
Use Frida when you need to answer:
- Which method actually renders this element? (static decompilation routinely misleads)
- What is the real call chain at the moment of interest?
- What does the app actually do on startup, in order?
- Which domains does it resolve, and when?
- Is the app detecting my instrumentation?
## Setup
1. **Matching versions.** The `frida` Python package on the host and `frida-server` on the device must be the same version. Mismatch produces confusing handshake failures.
2. **Run the server as root**, and give it a non-obvious name — many hardened apps look for processes/files named `frida-server`.
```bash
adb push frida-server-<ver>-android-<abi> /data/local/tmp/
adb shell "su -c 'mkdir -p /data/local/tmp/.svc'"
adb shell "su -c 'cp -f /data/local/tmp/frida-server-<ver>-android-<abi> /data/local/tmp/.svc/kwork'"
adb shell "su -c 'chmod 755 /data/local/tmp/.svc/kwork'"
adb shell "su -c '/data/local/tmp/.svc/kwork' &"
```
3. **Keep the server alive.** Launching it through a shell that exits can get it reaped. Hold the connection open from the host (spawn it from a script that stays alive), or use a supervisor on device.
4. **Pick the right ABI** — `getprop ro.product.cpu.abi`.
5. **Spawn, don't attach, when timing matters.** Attaching usually misses startup (init, first network calls, splash logic).
```python
device = frida.get_usb_device()
pid = device.spawn([pkg])
session = device.attach(pid)
# ... load script, wait until hooks are installed ...
device.resume(pid) # only resume AFTER hooks are ready
```
**Common bug:** resuming before the script has finished loading loses the first ~100–300 ms, which is exactly where init happens. Have the script `send()` a ready signal and resume only after receiving it.
## Frida 17+ gotchas
- The **built-in Java bridge was removed**. `Java.perform(...)` is not available unless you inline the bridge yourself:
```python
bridge = open(<site-packages>/frida_tools/bridges/java.js, encoding='utf-8').read()
script = bridge + "\nObject.defineProperty(globalThis,'Java',{value:bridge,configurable:true});\n" + my_js
```
Pass the whole thing through a small loader script (below).
- **Export lookup renamed.** Use a compatibility helper rather than a single API:
```javascript
function resolveExport(name) {
try { if (Module.getGlobalExportByName) return Module.getGlobalExportByName(name); } catch (e) {}
try { if (Module.findExportByName) return Module.findExportByName(null, name); } catch (e) {}
try { const l = Process.findModuleByName('libc.so'); if (l) return l.getExportByName(name); } catch (e) {}
return null;
}
```
- **Large script injection times out.** A multi-hundred-KB bridge + script can exceed the transport timeout. Use a tiny loader and post the real payload:
```python
loader = "recv('go', m => { eval(m.payload.code); }); send('loader-ready');"
sc = session.create_script(loader)
sc.load()
sc.on('message', on_message) # wait for 'loader-ready'
sc.post({'type': 'go', 'payload': {'code': BIG_JS}})
```
## Hook strategy
### Java layer (Kotlin/Java app)
```javascript
Java.perform(() => {
const Cls = Java.use('com.example.Helper');
Cls.showAd.overload('android.app.Activity').implementation = function (a) {
send({ tag: 'Helper.showAd', stack: Java.use('android.util.Log').getStackTraceString(
Java.use('java.lang.Throwable').$new()) });
return this.showAd(a); // observe, then delegate
};
});
```
- **Use `overload(...)`** — obfuscated classes often have several methods with the same name.
- To find obfuscated names, enumerate at runtime rather than guessing from decompiled short names:
```javascript
Java.enumerateLoadedClasses({
onMatch: n => { if (n.indexOf('example') >= 0) send(n); },
onComplete: () => send('done')
});
```
- **Call stacks are the highest-value signal.** `Log.getStackTraceString(new Throwable())` or `Java.use('android.util.Log').getStackTraceString(...)` gives you the real chain from the framework down to the method — far more reliable than reading smali.
### Native layer
```javascript
const f = resolveExport('open');
Interceptor.attach(f, {
onEnter(args) { this.p = args[0].readCString(); },
onLeave(ret) { if (this.p) send({ tag: 'open', path: this.p }); }
});
```
### Network / domain observation (cheap and very informative)
```javascript
const Inet = Java.use('java.net.InetAddress');
Inet.getAllByName.overload('java.lang.String').implementation = function (h) {
send({ tag: 'dns', host: h });
return this.getAllByName(h);
};
```
This is often the **decisive evidence** for an ad-removal claim: if the ad SDK's domains are *never resolved*, the subsystem never started — a much stronger statement than "logcat was quiet". It also works for SDKs that bypass the system HTTP proxy.
## Reading obfuscated code at runtime
When names are meaningless (`a7`, `x6`, `uf0`), do not patch from names. Instead:
1. Find a **unique anchor**: an API path, a preference key, or an SDK class name from the string table (`scripts/dex_strings.py`).
2. Locate the small class/method that references it (byte search in the dex + a targeted disassembly).
3. Hook that anchor, capture the **stack**, and read off the real chain.
4. Hook the classes the stack reveals — that is where semantics become visible.
## Anti-instrumentation
Signs: the app exits or freezes shortly after attach; `logcat` shows a generic process death with no Java stack; strings like `frida`, `xposed`, `magisk`, `substrate` are referenced by **app code** (not just by an SDK's string list).
Countermeasures, in order of least disruption:
1. Spawn + late resume, so hooks are installed before the check runs.
2. Rename `frida-server` and move it out of obvious paths.
3. Hook the detector itself and neuter it — find it by hooking the suspicious API (e.g. `/proc/self/maps` reads, `File.exists`, `Runtime.exec`) and inspecting the stack.
4. Only then consider a gadget-based approach.
**Remember:** most `frida`/`root` strings in a decompiled APK belong to third-party SDKs' own detection lists, not to the app. Verify that **app code** references them before doing anti-anti work.
## What to capture for the record
- spawn time, hook-ready time, resume time
- every hook hit with a stack (bounded — cap output)
- DNS hosts in order, with timestamps
- the exact build under test (hash) and the control build's results
+124
View File
@@ -0,0 +1,124 @@
# Environment — device, emulator, tooling, networking
## Pick your target
| Option | Pros | Cons |
|---|---|---|
| **Rooted physical device, native ABI** | Truest behavior; native libs load natively; Frida works well | One at a time; USB flakiness |
| **Emulator with root** | Disposable; snapshots; easy reset | Different ABI; some apps detect it or refuse to run; native ARM libs may need translation |
| **Static only** | No device needed | Cannot verify anything. Never claim success from static analysis alone. |
**Rule:** the artifact must be verified where the user will run it. An emulator that runs the app is **not** evidence about a physical ARM device, and vice versa.
**Important:** if the app ships only `arm64-v8a` native libraries and your emulator is x86_64, it may still run via ARM translation — but translation changes timing, and some native checks misbehave. Prefer a real ARM device for the final verification pass.
## ADB basics worth pinning down
```bash
adb devices -l
adb -s <serial> shell getprop ro.product.cpu.abi
adb -s <serial> shell getprop ro.build.version.release
adb -s <serial> shell getprop ro.build.version.sdk
```
Root:
```bash
adb -s <serial> shell "su -c id"
```
`pm grant` and `pm install` frequently require root on OEM builds; the `shell` user gets `SecurityException`.
**Always specify `-s <serial>` when more than one device is attached** — otherwise adb errors out or picks the wrong one.
## Shell quoting (a real time sink)
The host shell expands `$`, `|`, `>`, and quotes **before** adb sees them. Windows PowerShell additionally mangles `$var:`, `[^"...`, and `$(...)`.
Symptom: "Could not find a part of the path", "Missing type name after '['", "no closing quote".
**Fix:** never inline device commands in the host shell. Use a small helper that calls adb from a script file and passes the command as a single argument:
```python
subprocess.run([ADB, '-s', SERIAL, 'shell', 'su -c "%s"' % cmd])
```
See `scripts/devsh.py`.
Same class of bug: `javac` reads sources using the platform default encoding. Pass `-encoding UTF-8` or non-ASCII comments break the build.
## Giving an offline device network over USB
Use when the device has no usable network (broken Wi-Fi, no SIM, restricted network) but your host does. **`adb reverse` runs on the device's loopback, so it needs no device-side network interface at all.**
```bash
# 1) host HTTP/HTTPS proxy (CONNECT-capable)
python scripts/usb_net_proxy.py 8080 proxy.log
# 2) forward device-localhost:8080 to host:8080
adb -s <serial> reverse tcp:8080 tcp:8080
# 3) point the device at it
adb -s <serial> shell "su -c 'settings put global http_proxy 127.0.0.1:8080'"
```
Notes:
- `adb reverse` **does not survive a device reboot** — recreate it after any restart.
- Keep the host proxy process alive; if it dies, the device goes offline again.
- Some SDKs bypass the system proxy entirely (many ad SDKs do). Proxy logs therefore **undercount** traffic — do not conclude "no ad traffic" from proxy logs alone; confirm with a runtime DNS hook.
- Clean up when done: `settings put global http_proxy :0`.
## Emulator notes
- Verify the app can install at all. INSTALL failures on emulators are common (ABI, min SDK, vendor checks) and are usually not related to your patch.
- Snapshot/rollback is the main advantage — use it to A/B two builds quickly.
- Never quote emulator behavior as proof for a device-only question (and vice versa).
## Install / reinstall
```bash
adb -s <serial> shell "su -c 'pm uninstall <pkg>'"
adb push out.apk /data/local/tmp/x.apk
adb -s <serial> shell "su -c 'pm install -r -t -d /data/local/tmp/x.apk'"
```
- `-r` reinstall (keep data, same signing key) · `-t` allow test-only · `-d` allow downgrade
- Different signing key than the installed app → must uninstall first (data is lost)
- Vendor installers may reject `adb install`; pushing + `su -c pm install` usually works
**After reinstalling:** if you restored a data directory, fix ownership, or the app crashes in a DB-init path:
```bash
su -c "chown -R <uid>:<uid> /data/user/0/<pkg>"
su -c "restorecon -R /data/user/0/<pkg>"
```
`<uid>` from `dumpsys package <pkg> | grep userId=`.
## Signal extraction (what to actually read)
```bash
adb -s <serial> logcat -c # clear before the run
adb -s <serial> shell "am start -n <pkg>/<activity>"
adb -s <serial> logcat -d -v brief | grep -E '<pkg>|FATAL|VerifyError|IncompatibleClassChange|uncaughtException'
```
Failure signatures worth memorizing:
| Log line | Likely cause |
|---|---|
| `FATAL EXCEPTION` + Java stack | App-level crash — read the stack |
| `VerifyError` / `IncompatibleClassChangeError` | Damaged dex (round-trip or bad rewrite) → `pitfalls.md` P3 |
| `Failure starting process` (no stack, by `ActivityManager`) | ART refused the dex, or device state is broken → `pitfalls.md` P4, P9 |
| `ClassNotFoundException: <App.Application>` | Dex rejected wholesale (ordering / structure) → `pitfalls.md` P2 |
| `uncaughtException` with **no stack**, right after launch | A crash-reporter SDK swallowed it. Check whether the process is gone, and look for the SDK's own log file under the app's data dir |
| App alive but nothing rendered | A swallowed exception in the UI path; hunt the crash-reporter's log file |
**Crash-reporter SDKs hide your stack traces.** When an app installs a global uncaught-exception handler (友盟/UCrash/Bugly etc.), the Java stack never reaches `logcat` — only a line like `uncaughtException time: ...`. Three ways to get the stack:
1. **Frida**, hooking `Thread.setDefaultUncaughtExceptionHandler` or the handler class (most reliable).
2. **Race the reporter's log file**: it writes a file under `<app data>/<sdk>/...` then uploads and deletes it. Poll it every ~0.2 s from the device shell and copy on sight:
```bash
while [ $i -lt 400 ]; do
for f in <dir>/*.log; do [ -f "$f" ] && cp -f "$f" /data/local/tmp/capture.log; done
i=$((i+1)); sleep 0.2
done
```
3. Run a build with the reporter disabled (not always possible).
## Determinism
Before concluding anything from a failure: **reboot the device and retry**, and **run a control build with zero patches**. A surprising share of "my patch broke it" turns out to be device state (`pitfalls.md` P9).
+90
View File
@@ -0,0 +1,90 @@
# Membership, paywalls, and the honest limits of client-side patching
Read this **before** investing hours. Most "unlock VIP / remove the paywall" requests cannot be satisfied by patching the client, and knowing that early is the most valuable output you can produce.
## First question: who decides?
| Signal | Owner | Client patch can... |
|---|---|---|
| A local boolean/int/string field in an app model, computed locally | Client | Fully determine the outcome |
| A field that comes from an API response and is merely *read* by the UI | Server | Change display only — not access |
| Download/stream/play URLs issued by an API | Server | Nothing. You cannot mint a valid URL. |
| Entitlement checked on every protected action with a server round-trip | Server | Nothing |
| A local cache of a server value | Server (cached) | Temporarily fool the UI until it refreshes |
**Decision rule:** if the thing you want (a media URL, a decrypted payload, premium data) is *delivered by the server*, the client is not the gate. The gate is server-side and is not patchable.
## How to prove it in minutes
Do not guess. Test the API directly (`references/server-api.md`).
```
1. Call the protected endpoint with no credentials. -> 401/403 means auth-gated
2. Call it with an obviously forged token. -> another 401 confirms it is validated
3. Call the metadata endpoint. -> often 200, giving you the shape,
but deliberately omitting the asset URL
4. Compare: is the valuable field simply absent? -> then no client change can invent it
```
A concrete, real result pattern worth remembering:
- `GET /videos/{id}` → **200**, full metadata, cast list, source list — **but no playback URL at all**
- `GET /v2/sections/{id}/play-url` → **401** with no token
- same, with `Authorization: Bearer <forged>` → **401**
- `GET /user/me` with forged token → **401**
That combination is conclusive: metadata is public, the asset URL is server-minted, and the token is verified. **Client-side patching cannot unlock it.**
## What *is* legitimately achievable client-side
Don't overclaim, and don't underclaim either. Genuinely client-side wins:
- **Remove ads / SDKs.** Ads are a client behavior (`references/ad-removal.md`).
- **Remove a UI gate** where the underlying data is already delivered and only a local check hides it (a "locked" tab whose content is already fetched, a disabled button).
- **Skip promotional interstitials and self-promo popups** driven by local flags.
- **Unlock client-only features**: debug menus, extra player settings, higher local bitrate selection, gesture options.
- **Grant a client-side reward** only if the reward is neither validated nor authoritative server-side (check first — a reward that later fails server validation is worse than no reward).
- **Bypass a client-side trial timer** whose enforcement is purely local (but expect the server to re-assert on next sync).
## What is NOT achievable, and how to say so
- **Paid content whose URL comes from the server.** State it plainly and show the 401 evidence.
- **Server-validated rewards / purchases / credits.**
- **Anything the server re-checks on the next request.** The patch may look like it works for one screen and then silently revert.
Wording that is both honest and useful:
> The client cannot unlock this. Metadata is public, but the asset URL is issued by the server, and the endpoint returns 401 for both anonymous and forged credentials — so no local change can produce a valid URL. What *is* removable client-side is X, Y, Z. To actually get access you need a real account with the required entitlement.
## The fake-VIP trap
Forcing a local `isVip()`-style method to return `true` is the classic mistake. Two failure modes:
1. **Cosmetic only** — UI shows VIP styling, but protected actions still fail server-side.
2. **Actively harmful** — the client now believes it holds entitlements it does not, so it enters code paths that expect server data it never receives. Observed result: **blank screen** (process alive, nothing rendered, only a swallowed uncaught exception in the crash buffer).
If you patch such a method, patch it in a **conservatively correct** way, and verify all screens, not just the one you cared about:
- Do not fabricate entitlements that gate data fetching.
- Prefer patching *presentation* consumers of the flag over the flag's computation, if the flag is also used for access decisions.
## Local state vs code — the deliverable question
A value can be "fixed" in two places:
| Where | Survives fresh install? | Fits an APK deliverable? |
|---|---|---|
| Code (patched dex) | Yes | Yes |
| App data (datastore/prefs/db) | **No** | No — unless you also patch the read path |
If a fix works on your device but a user reports it gone after reinstall, this is why (`references/pitfalls.md` P11). Re-implement the fix by patching the **read path** so it always yields the desired value.
## Reporting template
```
Goal: <what the user asked for>
Gate owner: client | server | mixed (evidence: ...)
Achievable: <list, with evidence each>
Not achievable: <list, with the 401/absent-field evidence>
Residual: <what remains and the exact coupling that prevents removing it>
Verified on: <device, build hash, what was exercised>
```
+250
View File
@@ -0,0 +1,250 @@
# Pitfalls — the failure catalogue
Every entry here cost real time and produced a **silently broken artifact**. Skim this before building. When you lose more than thirty minutes to something new, add it here.
Each entry: **symptom → root cause → why it is hard to see → what to do instead.**
---
## P1. Stripping the whole `META-INF/` breaks the app at startup
**Symptom**
```
java.lang.IllegalStateException: Module with the Main dispatcher is missing.
Add dependency providing the Main dispatcher, e.g. 'kotlinx-coroutines-android'
```
App dies immediately on launch. Sometimes a different `ClassNotFoundException` for an unrelated library class.
**Root cause**
`META-INF/` is not only signatures. It holds **ServiceLoader registrations** that Android reads at runtime. Deleting the directory to prepare for re-signing deletes them too.
Real examples found in one APK:
```
META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory -> kc
META-INF/services/io.ktor.client.engine.HttpClientEngineContainer -> OkHttpEngineContainer
META-INF/services/io.ktor.serialization.kotlinx.KotlinxSerializationExtensionProvider
META-INF/services/kotlinx.coroutines.CoroutineExceptionHandler -> wc
META-INF/services/com.arialyy.aria.core.inf.IUtil
META-INF/services/com.arialyy.aria.core.listener.IEventListener
```
**Why it is hard to see**
The error message names Kotlin coroutines, not your repack. You will spend an hour blaming the dex.
**Do instead**
Strip **only** signature artifacts, and only at the top level of `META-INF/`:
```python
SIG = ('MANIFEST.MF',)
SIG_EXT = ('.SF', '.RSA', '.DSA', '.EC')
def is_signature_entry(name):
if not name.upper().startswith('META-INF/'):
return False
rest = name[len('META-INF/'):]
if '/' in rest: # services/, androidx/, native-image/ ... keep
return False
up = rest.upper()
return up in SIG or up.endswith(SIG_EXT)
```
`scripts/repack.py` already does this.
---
## P2. Byte-level string patching without an ordering check rejects the whole dex
**Symptom**
App cannot load any class:
```
ClassNotFoundException: Didn't find class "<App.Application>" on path: DexPathList[[zip file ".../base.apk"]]
```
`Application` construction fails, process never starts.
**Root cause**
Dex requires the `string_ids` table to be **sorted**. Replacing a string with an equal-length string keeps all offsets valid, but changes where that entry *should* sit in the sorted order. If the new value crosses its neighbours, the loader rejects the entire dex.
Concrete case: `/app/adverts` sat between `/api/v1/crashtrack/upload?chk=` and `/app/configs/`. Replacing it with `/app/noadver` put `n` after `c` → out of order → whole dex rejected. Replacing with `/app/blocked` (`b < c`) was accepted.
**Why it is hard to see**
`checksum` (adler32) and `signature` (SHA-1) recompute perfectly, so every integrity check passes. `baksmali` parses the file fine. Only the runtime loader cares about ordering.
**Do instead**
Always run the ordering guard: `scripts/dex_strpatch.py` looks up the target's neighbours in `string_ids` and refuses any replacement outside `(prev, next)`. Pick a candidate that stays inside the interval.
---
## P3. Whole-tree smali round-trip damages R8-optimized dex
**Symptom**
App installs, then dies with:
```
java.lang.IncompatibleClassChangeError: Found interface io.ktor.client.engine.HttpClientEngine,
but class was expected
at io.ktor.client.engine.HttpClientEngine.access$checkExtensions(...)
```
(or `VerifyError`, or a class-load failure in an unrelated library)
**Root cause**
`baksmali` → `smali` rebuild does not faithfully reproduce R8's synthetic access bridges / optimization artifacts. The class is still declared as an interface, but the bridge method that ART expects to find as a class member is gone.
**Why it is hard to see**
A structural diff of `class_def` entries shows **nothing**: same class count, zero `ACC_INTERFACE` mismatches, zero access-flag differences. The damage is at the code-item / reference level, invisible to table-level checks. It only appears at runtime.
**Do instead**
Use **method-level surgical rewriting** with dexlib2 — read the dex, replace only the target method's implementation, write it back. See `scripts/dexpatch/`. Never rebuild the whole tree for a one-method change.
---
## P4. Rewriting the same dex twice makes ART refuse to start the process
**Symptom**
```
E/ActivityManager: Failure starting process <pkg>
I/ActivityManager: Force stopping <pkg> appid=... user=0: start failure
```
No Java exception anywhere. `logcat` shows a splash screen appearing then vanishing. `baksmali` still parses the dex, and `smali` re-assembles it fine.
**Root cause**
Serializing a dex a second time loses metadata that the first serialization preserved. Chaining two patch tools over the same file (`patch A → patch B → repack`) triggers this.
**Why it is hard to see**
Both intermediate files look valid and round-trip cleanly. Only ART rejects the final one.
**Do instead**
**Combine all edits to one dex into a single read/write pass.** One program, one `loadDexFile`, apply every change, one `writeDexFile`.
---
## P5. Killing an endpoint to hide a UI element takes the whole screen down
**Symptom**
Home screen becomes the app's generic error state ("something went wrong / retry"), or a blank screen, after redirecting or 404-ing an ad endpoint.
**Root cause**
The ad request is a **child request** of the screen's main data load. When it throws, the parent load fails with it.
Concrete case: `/app/adverts` was redirected to a nonexistent path. That endpoint is requested from inside `MainScreenStore.loadData` as a sub-request, so the home screen's entire data load failed.
**Why it is hard to see**
Removing ads *sounds* like it should only remove ads. The coupling is invisible until runtime.
**Do instead**
Suppress at the **data-consumption** or **render** layer, not at the transport layer. Let the request succeed and discard/ignore the result. Never make a shared endpoint fail.
---
## P6. Patching a shared helper breaks unrelated features
**Symptom**
Images stop loading, video playback fails, or downloads break — after patching something that looked ad-specific.
**Root causes, two variants**
- Patching a generic utility: a `Long.valueOf`-style boxing helper had **30+ callers** across player, download, paging and history sync. Patching it would have broken all of them.
- Patching a "card" renderer you assumed was ad-only: the composable's signature was `(itemModel, ColorScheme, Modifier, onClick, ContentScale, Shape, Composer, II)` — a **generic image card** shared by normal content. Making it `return-void` killed all cover art and the player pipeline.
**Why it is hard to see**
The class name and the model type it consumes suggest it is ad-specific. It is not.
**Do instead**
Before patching any method, **count its callers** (`scripts/find_refs.py`). If it has many, or if its parameters look content-generic (image/graphics/`Modifier`/`ContentScale` parameters), it is not specific to your target. Also check whether the parameter model type is shared with non-ad content.
---
## P7. Client-side VIP forgery breaks the app instead of unlocking it
**Symptom**
Blank screen; or logged in, but playback fails.
**Root cause**
The authoritative gate is server-side. Real access is granted by an API response. Forcing the client's local `isVip()` to `true` makes the client believe it has rights the server will not honor, so it walks a path that assumes data it never receives.
Concrete case: `/v2/sections/{id}/play-url` returns **401** with no token and **401** with a forged `Bearer` token; metadata endpoints returned 200 but deliberately omitted any play URL. Patching local VIP state produced a white screen.
**Do instead**
Determine server authority **before** patching. See `references/membership-and-limits.md`. If the gate is server-side, the honest deliverable is "not achievable client-side", plus any genuinely client-side wins (unlocking UI, removing ads).
---
## P8. DataStore / protobuf hand-editing fails silently
**Symptom**
App dies with a bare `uncaughtException` and **no stack trace** (a crash-reporter SDK swallowed it). Or the app launches but ignores your injected value.
**Root cause (encoding)**
AndroidX `Preferences` maps are protobuf `map<string, Value>` fields. An entry needs **two** levels of tag:
```
outer : 0A <len(entry)>
inner : 0A <len(key)> <key> 12 <len(value)> <value>
```
Writing only the inner part produces a file `DataStore` cannot deserialize.
**Root cause (lifecycle)**
`DataStore` caches in memory and writes back. Editing the file while the app runs is either ignored or overwritten. Also the file is owned by the app's uid — a file written as root with the wrong owner is unreadable to the app.
**Do instead**
- Encode with `scripts/datastore_inject.py` (implements both tag levels).
- `force-stop` the app first, write, then start.
- Preserve ownership: write via `su`, then `chown` to the app uid (or `cp -f` over the existing file, which keeps its owner).
- If a value must survive a **fresh install**, code-level patching is the only way — runtime data is not part of the APK.
---
## P9. Blaming the patch when the device or environment is broken
**Symptom**
Every build fails, including a completely unmodified original.
**Root causes seen in practice**
- Device in a bad state: `Failure starting process` for *all* builds (including stock). **A device reboot fixed it.**
- Offline device: app shows a generic network error, easily mistaken for a server rejection or signature problem.
- App data directory uid mismatch after reinstall: crashes in a database-init path (`Cannot open database ... Directory ... doesn't exist`). Fix with `chown -R <uid>:<uid> /data/user/0/<pkg>`.
- Emulator that cannot run the app at all (different ABI, missing platform pieces). A working emulator is not evidence about a real device.
**Do instead**
**Always run a control.** Install and launch the **unmodified original** under the exact same conditions. If the original fails too, stop debugging your patch.
---
## P10. Trusting static decompilation over runtime behavior
**Symptom**
You patch what the decompiler showed, and nothing changes; or the app crashes in a path you did not know existed.
**Root cause**
Decompiler output is a guess reconstructed from bytecode. Interface/class relationships, inlined code, and obfuscated bridges are regularly misrepresented. Also, dead code and shadowed branches look identical to live ones.
**Do instead**
Rank evidence: **live runtime behavior > captured network traffic > served assets > current process/config state > persisted state > generated artifacts > source > comments and dead code.** Use source to *explain* runtime, not to *override* it.
---
## P11. Assuming a repackaged APK ships runtime data
**Symptom**
A fix verified on the target device does not work after a fresh install.
**Root cause**
Some fixes are **runtime data**, not code: a DataStore value, a preferences file, a cached token. Those live in `/data/data/<pkg>/` and are gone on a clean install.
**Do instead**
Ask, for every fix: *is this in the APK or in app data?* If it is app data and the deliverable is an APK, re-implement it as a **code-level** change (patch the read path so it always yields the desired value).
---
## P12. PowerShell (or any shell) eats device-side commands
**Symptom**
`adb shell "su -c '...'"` fails with host-side path or parsing errors: "Could not find a part of the path", "Missing type name after '['", unexpanded `$VAR`, or a regex that got mangled.
**Root cause**
The host shell expands `$`, `|`, `>`, and quotes **before** adb sees them. Windows PowerShell additionally mangles `$var:`, `[^"]` and `$(`.
**Do instead**
Never build device commands inline in the host shell. Either:
- call adb from a small script file (`scripts/devsh.py`), or
- put the device-side logic in a script that you push and execute.
Same rule for `javac`: always pass `-encoding UTF-8` when sources contain non-ASCII, or the compiler reads them as the platform default and fails.
+112
View File
@@ -0,0 +1,112 @@
# Recon — build the picture before touching anything
Ten minutes here prevents hours of wrong work. Answer the five questions from `SKILL.md` with concrete evidence.
## 1. Identity
```bash
# Without a full decompiler (fast, works everywhere):
aapt dump badging app.apk | head -20
# or, from the extracted manifest:
# package name, versionName, versionCode, sdkVersion, targetSdkVersion
```
Record: package name, version name/code, min/target SDK, all requested permissions, all declared components.
**Note:** some `aapt` builds choke on non-ASCII paths. Normalize sample paths to ASCII before running tooling.
## 2. Is it packed?
Read `AndroidManifest.xml` → `application android:name`.
| What you see | Meaning |
|---|---|
| The app's own class (e.g. `com.example.app.App`) | **No packer.** Dex is directly editable. |
| A vendor class (`com.stub.StubApp`, `com.secneo...`, `com.tencent.StubShell`, `s.h.e.l.l.*`, `com.nagain.*`, …) | **Packed.** Unpack first. |
| `android:appComponentFactory` overridden to a vendor class | **Packed or wrapped** — often a runtime shell that loads a second APK. |
| `assets/` contains a second `.apk`, `.jar`, or an encrypted blob | Wrapped/loader build. |
Signs in `lib/`: lone `.so` files with names unlike normal libraries (a custom-named shell lib), plus unusual `assets/` artifacts.
**Loaded-shell check (highest fidelity):** if the app runs and its own classes are reachable, it is unpacked *at runtime* regardless of what the manifest says. If the app's own code is not among the dex classes, it is packed on disk.
## 3. Where does the app's own code live?
| Indicator | Code location |
|---|---|
| Plain `classes*.dex`, app classes visible in them | **Dex** — patchable with dexlib2/smali |
| `libflutter.so` + `libapp.so` | **Flutter (Dart AOT)** — dex contains only a thin shell |
| `libil2cpp.so` + `assets/bin/Data/` | **Unity (IL2CPP)** — native |
| `libmono*.so` + `assets/bin/Data/Managed/` | **Unity (Mono)** — managed DLLs under `assets/` |
| `libreactnativejni.so` + `assets/index.android.bundle` | **React Native** — JS bundle under `assets/` |
| `libhermes.so` | **Hermes bytecode** |
| Almost everything in `.so` | **Native** — different toolchain entirely |
For a **Kotlin/Java app**, note the module layout: app code often lives in a small subset of dex files while large SDKs occupy the rest. Finding which dex holds the app's package is a huge time-saver:
```bash
python scripts/dex_strings.py <dex_dir> --find 'Lcom/example/app/' --per-file
```
Consequence for editing: you usually only need to replace **one or two dex files**, which keeps the repack minimal.
## 4. SDK and library inventory
Extract strings across all dex and group by vendor markers. This reveals ads, analytics, crash reporting, attribution, and any anti-tamper SDK, in one pass.
```bash
python scripts/dex_strings.py <dex_dir> --urls
python scripts/dex_strings.py <dex_dir> --find 'anythink|openadsdk|com.qq.e|umeng|bugly|crashsdk'
```
What to look for:
- **Ad networks / aggregators**: `openadsdk` `TTAdSdk` `Pangle` `com.qq.e` `GDTAd` `anythink` `ATSDK` `ksad` `mobads` `sigmob` `bdxadsdk`
- **Analytics / crash / attribution**: `umeng` `bugly` `crashsdk` `appsflyer` `adjust` `UMCrash`
- **Identity / device id**: `oaid` `msa` `SupplementaryDID` `freemme`
- **Anti-tamper / root / hook detection**: `libsgcore` `libInno` `libqmcheat`, strings like `frida` `xposed` `magisk` `substrate`, `/system/xbin/su`
- **Media stack**: `libmpv` `libavcodec` `libplayer` `libgdx`
Important distinction: **a vendor marker in the string table does not mean the app uses that feature**. Presence of `xposed`/`frida` strings is often just an SDK's own detection list. Decide based on *behavior*, not on strings (`references/verification.md`).
## 5. Signature and tamper checks
Search the app's **own** packages only (ignore third-party SDKs) for:
- `getPackageInfo`, `PackageInfo`, `signatures`, `GET_SIGNATURES`, `Signature` → app-side signature verification
- hardcoded SHA-1/SHA-256 hex constants, base64 license blobs → certificate pinning to the original signer
- `checkSignature`, `verifySignature`, `signCheck` → a named check
**Beware false positives.** `SignatureCheck` and `verifySignature` exist inside `okhttp3` (`SuppressSignatureCheck`, `BasicCertificateChainCleaner.verifySignature`) — TLS plumbing, not app tamper checks. Likewise a native `lib*.so` named like a security library may be an ad SDK's payload decryptor, not a shell.
Confirm by checking whether **app code** references it. If the only callers are library-internal, it is not your problem.
## 6. API surface
```bash
python scripts/dex_strings.py <dex_dir> --urls
```
Collect: base URLs, DoH/DoT lookups, path constants, CDN hosts, custom headers.
Treat **unique** strings as navigation aids — they can often be searched byte-wise in the dex to find the owning class (see `references/dex-patching.md` §finding-the-call-site).
Two patterns worth recognizing early:
- **Dynamic gateway**: the real API host is fetched at runtime (e.g. via a DNS TXT record over DoH). The hardcoded host in dex may be only a fallback. This affects server-side analysis, not patching.
- **Minimal headers / no request signing**: if auth is just `Authorization: Bearer` plus a static app-name header, then a repackaged client is not distinguishable to the server by request signature — which matters for `references/server-api.md`.
## Recon output
Write a short profile before patching. Minimum:
```
package / version / ABI
packed? (evidence)
code location (dex / flutter / native)
app code in which dex files
ad SDK(s) and the app's wrapper class
analytics/crash SDKs
anti-tamper: present? where? app-side or library-internal?
API base + notable endpoints
device plan (rooted real device / emulator / static only)
```
+125
View File
@@ -0,0 +1,125 @@
# Repack and sign
Rebuilding is the step where otherwise-correct patches die. Two mistakes account for nearly all of it: **stripping `META-INF/`** and **recompressing entries that must stay stored**.
## The rules
**1. Strip only signature artifacts, never the whole directory.**
`META-INF/` contains ServiceLoader registrations the app needs at runtime. Details and the exact failure in `references/pitfalls.md` P1.
```python
SIG_EXT = ('.SF', '.RSA', '.DSA', '.EC')
def is_signature_entry(name):
if not name.upper().startswith('META-INF/'):
return False
rest = name[len('META-INF/'):]
if '/' in rest: # keep services/, androidx/, native-image/ ...
return False
up = rest.upper()
return up == 'MANIFEST.MF' or up.endswith(SIG_EXT)
```
**2. `AndroidManifest.xml` and `resources.arsc` must stay uncompressed (STORED).**
Recompressing them produces builds that fail to install or misbehave.
**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/`.
**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**.
## Pipeline
`scripts/repack.py` implements all of the above. Conceptually:
```
open original (zip)
for each entry:
skip entries that are signature artifacts (per rule 1)
skip entries being replaced
copy through, preserving compression + attributes
write replacement entries
close
sign (v1 + v2 + v3)
verify
```
Usage:
```bash
python scripts/repack.py --apk original.apk --dexdir extracted_dex_dir --out out.apk
# or replace specific dex files:
python scripts/repack.py --apk original.apk --dexdir extracted_dex_dir \
--dex "classes7.dex=patched/classes7.dex" \
--dex "classes8.dex=patched/classes8.dex" \
--out out.apk
```
`--dexdir` supplies the untouched dex files; `--dex name=path` overrides specific ones.
## Signing
Two viable routes.
**Route A — `uber-apk-signer` (handy, wraps zipalign + apksigner):**
```bash
java -jar uber-apk-signer.jar --apks in.apk --ks ks.jks \
--ksAlias <alias> --ksPass <pass> --ksKeyPass <pass> -o out_dir
```
Known quirk: `-o` and `--overwrite` are mutually exclusive; passing both errors out. Also, it **silently skips already-signed APKs** (`0 processed`) — so de-sign first.
**Route B — `zipalign` + `apksigner` directly (most explicit):**
```bash
zipalign -p -f 4 unsigned.apk aligned.apk
apksigner sign --ks ks.jks --ks-pass pass:<pass> --key-pass pass:<pass> \
--v1-signing-enabled true --v2-signing-enabled true --v3-signing-enabled true \
--out signed.apk aligned.apk
apksigner verify --print-certs -v signed.apk
```
Enable **v1 + v2 + v3**. v1 is needed for older Android; v2/v3 for modern verification.
Generate a keystore once:
```bash
keytool -genkeypair -v -keystore ks.jks -alias <alias> \
-keyalg RSA -keysize 2048 -validity 36500 \
-storepass <pass> -keypass <pass> \
-dname "CN=<name>, OU=dev, O=dev, L=NA, ST=NA, C=NA"
```
## Installing over an existing app
| Situation | Command |
|---|---|
| Same signing key as installed version | `pm install -r` — keeps app data (login state, caches) |
| Different signing key | `pm uninstall` first, then install. **App data is lost.** |
| 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'` |
| 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.
## Post-install / post-upgrade hazards
- **Data directory uid mismatch.** After reinstall the app uid increments; a restored `/data/user/0/<pkg>` directory owned by the old uid is unreadable. Symptom: crash in a DB-init path (`Cannot open database`). Fix:
```
su -c "chown -R <uid>:<uid> /data/user/0/<pkg>"
su -c "restorecon -R /data/user/0/<pkg>"
```
Get `<uid>` from `dumpsys package <pkg> | grep userId=`.
- **Restoring a data backup can itself cause this.** Prefer `cp -f` over an existing file (preserves owner) rather than deleting and re-extracting.
## Verify the build, not just the signature
Signature verification proves the file is well-formed. It does **not** prove the app works. Always:
1. `apksigner verify` (or equivalent) passes.
2. Install succeeds.
3. Launch succeeds; process stays alive.
4. `logcat` shows no `FATAL EXCEPTION` / `VerifyError` / `IncompatibleClassChangeError` / `uncaughtException`.
5. `META-INF/services/*` count matches the original.
6. Unchanged dex files are byte-identical to the original (compare hashes).
Build a **control** at least once: same pipeline, zero patches. If the control fails, your pipeline or environment is at fault, not your patch (`references/pitfalls.md` P9).
+112
View File
@@ -0,0 +1,112 @@
# Runtime data — DataStore, SharedPreferences, SQLite, protobuf
Sometimes the cheapest fix is not code at all, but a value in app data. And sometimes that is exactly the wrong fix, because it does not survive a fresh install (`pitfalls.md` P11).
## Decide first: data or code?
| Question | Answer |
|---|---|
| Does the deliverable need to work after a clean install? | Then it must be **code**, not data |
| Are you iterating on your own device? | Data edits are a fast way to test a hypothesis **before** writing the patch |
| Is the value written by the app itself (a token, a cache, a counter)? | Data — and the server may re-assert it |
**Recommended workflow:** use a data edit to *prove the hypothesis* (cheap), then implement the same effect in code (durable), then verify with a clean install.
## Where app data lives
```
/data/data/<pkg>/ (== /data/user/0/<pkg>)
├── files/
│ ├── datastore/*.preferences_pb AndroidX DataStore (protobuf)
│ └── ...
├── shared_prefs/*.xml SharedPreferences
├── databases/*.db SQLite (Room, etc.)
└── <sdk caches>/ crash logs, download state, ad configs
```
Read with root:
```bash
adb shell "su -c 'ls -la /data/data/<pkg>/files/datastore/'"
adb shell "su -c 'xxd /data/data/<pkg>/files/datastore/<name>.preferences_pb | head -20'"
```
## AndroidX DataStore (protobuf)
Format:
```proto
PreferenceMap { map<string, Value> preferences = 1; }
Value {
oneof value {
bool boolean = 1; float float = 2; int32 integer = 3; int64 long = 4;
string string = 5; StringSet string_set = 6; double double = 7; bytes bytes = 8;
}
}
```
**Encoding an entry requires TWO tag levels:**
```
outer (map field 1, wire type 2) : 0A <len(inner)>
inner (one map entry) : 0A <len(key)> <key> 12 <len(value)> <value>
value payload (e.g. int64) : 20 <varint> # field 4, wire type 0
value payload (e.g. int32) : 18 <varint> # field 3, wire type 0
value payload (e.g. bool) : 08 <0x00|0x01> # field 1, wire type 0
value payload (e.g. string) : 2A <len> <utf8> # field 5, wire type 2
```
Omitting the **outer** tag produces a file the app cannot deserialize, and the failure is usually a **stack-less crash** (`pitfalls.md` P8). Use `scripts/datastore_inject.py` rather than hand-rolling it.
Procedure:
1. `am force-stop <pkg>` (DataStore caches in memory and writes back)
2. Write the file (as root), preserving ownership — `cp -f` over the existing file keeps its owner
3. `restorecon` if SELinux is enforcing
4. Start the app and observe
Verify by reading the file back with `xxd` before launching.
## SharedPreferences (XML)
Simple XML. Edit with the app stopped. Types are explicit (`<boolean>`, `<int>`, `<long>`, `<string>`). Same ownership rules.
## SQLite
```bash
adb shell "su -c 'sqlite3 /data/data/<pkg>/databases/<db>.db \".tables\"'"
adb shell "su -c 'sqlite3 ... \"select * from <table> limit 5;\"'"
```
Useful for: auth sessions (tokens), user profile caches (often a raw JSON blob), and any server state the app persists. A JSON column often contains the exact server payload — the fastest way to learn field names.
## Ownership — the silent killer
The app runs as its own uid. A file written by root with the wrong owner is unreadable, and directory permissions are `drwx------`.
```bash
uid=$(adb shell "dumpsys package <pkg> | grep userId=" | tr -dc '0-9')
adb shell "su -c 'chown -R $uid:$uid /data/user/0/<pkg>'"
adb shell "su -c 'restorecon -R /data/user/0/<pkg>'"
```
Symptom of getting this wrong: crash in a database-init path right after launch, often `Cannot open database ... Directory ... doesn't exist`.
Note: after every reinstall the app's uid **increments**, so a restored data directory needs this again.
## Introducing a value that does not exist yet
If the key is absent, the app uses a default. Two options:
1. **Add the key** with the exact protobuf shape above.
2. **Confirm the default first** — sometimes removing a key already yields the behavior you want (e.g. clearing an expiry so it reads as "not set").
## Making the fix durable
If the effect must ship inside an APK, move it into code. Patterns, in order of preference:
- **Patch the read path** so the value is always the desired one. Find where the Flow/getter is built and make it emit a constant.
- **Patch the decision**, not the data: find the comparison that consumes the value and make it resolve the way you want.
- **Do not patch the generic encoder/boxer** used by the whole app (`pitfalls.md` P6).
Reference case: a promo popup was gated by `<key>_expires_at`. Writing a large value via DataStore suppressed it on the test device, but a fresh install lost it. The durable fix replaced the dedicated map-lambda that produces the value so it always yields a far-future timestamp — a single-purpose class, safe to patch, no effect on the shared serialization helpers.
## Cautions
- Editing data while the app is running is unreliable: in-memory caches win.
- The server may overwrite your value on next sync. If the app re-fetches and re-persists the authoritative value, a data edit is temporary by design — which is exactly why the durable version belongs in code.
- Do not edit a token you do not own and expect it to be accepted; tokens are validated server-side (`references/server-api.md`).
+92
View File
@@ -0,0 +1,92 @@
# Server-side analysis — deciding whether a patch can even matter
If the behavior you want to change is produced by a response, patch the client all you like: nothing real changes. Establishing server authority early is the cheapest high-value step in any "crack this" task.
## Harvest the API surface from the client
```bash
python scripts/dex_strings.py <dex_dir> --urls
python scripts/dex_strings.py <dex_dir> --find 'https?://'
```
Collect: base URL(s), path constants, auth headers, and any dynamic-gateway mechanism.
Two patterns to watch for:
- **Static base URL** — straightforward.
- **Dynamic gateway** — the real host is fetched at runtime (commonly a DNS TXT record over DoH, or a small config endpoint). The hardcoded host may be a fallback. Symptom: requests go to a host that differs from anything in the dex.
```bash
# TXT lookup, the usual shape:
# https://<doh-provider>/resolve?name=<domain>&type=txt
# returns something like "https://<gateway-host>"
```
This is an anti-blocking technique; it does not block repacking, but it means your static host guess may be stale.
## Reproduce requests faithfully
```bash
python scripts/probe_api.py --base <base_url> --path /some/path --header 'X-App-Name: <value>'
```
Rules that make the difference between 403 and 200:
- **Send the app's real headers.** A single custom header (e.g. an app-name header) frequently decides whether a WAF lets the request through. The header value is a string constant in the dex.
- **`User-Agent` matters** — often `okhttp/x.y.z` or a runtime-specific default (e.g. `ktor-client`).
- **Watch for a CDN/WAF in front.** A 403 with an HTML body naming a WAF means your request shape is wrong, not that the endpoint is gone. Compare with what the app sends (capture it from the device).
- **Beware your own egress.** If your host goes through a proxy/VPN, a WAF may block by geography while the device succeeds. Do not conclude "the server rejects the app" from a host-side 403.
- **`/health`-style endpoints are gold** — a 200 with a small JSON body proves the backend and its response envelope are alive before you debug anything else.
## Response envelope
Most apps wrap responses uniformly, e.g. `{"code":0,"msg":"","data":{...}}`. Learn it once, then read endpoints by shape rather than guessing:
- `code != 0` + a message → business error, and **the message often enumerates valid inputs** (this is how an ad `position` whitelist was discovered).
- `data:null` with HTTP 200 → soft failure.
- `401` with a null envelope → auth gate.
## Prove who owns the gate
Run this matrix and record it. It is the single most decisive artifact for a paywall or entitlement question.
| Request | Expected if client-owned | Expected if server-owned |
|---|---|---|
| protected endpoint, no credentials | would still succeed | **401 / 403** |
| same, with a forged token | would still succeed | **401 / 403** |
| metadata endpoint | — | 200, often **omitting** the protected field |
| list/config endpoints | — | 200 with data |
If the valuable field is simply **absent** from an otherwise-200 metadata response, no client change can invent it. Say so, with the evidence.
## Also test the *unknown input* case
When you plan to change a request parameter (a path, a `position`, an id), probe what the server does with an **invalid** value first:
- A `400` with a validation message tells you the field is whitelisted, and hands you the full list of valid values.
- A `404` tells you the path is exact.
**This matters before you patch**: redirecting a parameter to an invalid value may produce an error status that the client treats as fatal (`pitfalls.md` P5). Knowing the failure mode in advance prevents breaking the app.
## Behaviors that help you and behaviors that do not
**Repack-friendly (do not block you):**
- Minimal auth: `Authorization: Bearer <token>` and a static app-name header, with **no request signing, no nonce, no HMAC**.
- No client-certificate pinning to the app's own signature.
**Repack-hostile (will break a re-signed client):**
- Signature/certificate checked server-side, or pinned in native code.
- Request signing derived from the APK signature.
- Device attestation (Play Integrity / SafetyNet, OEM attestation).
Determine which you face **before** shipping a repack. A client that authenticates fine on first launch but silently fails features later is the classic signature-validation symptom.
## What to record
```
base url / gateway mechanism
required headers (exact values)
auth mechanism (token? signed? attested?)
response envelope shape
endpoints actually used, and their auth requirement
protected-asset endpoints, with status codes for: none / forged / (real if available)
invalid-input behavior for any parameter you plan to change
```
+119
View File
@@ -0,0 +1,119 @@
# Verification — what "done" means
The difference between a hobby patch and a usable deliverable is verification. "It assembles" and "the log is quiet" are not verification.
## The claim ladder
Each rung is stronger evidence. Climb as high as the task requires, and **state clearly which rung you reached**.
| Rung | Claim | Evidence required |
|---|---|---|
| 0 | "The dex was edited" | Byte-level diff of the target method |
| 1 | "The structure is intact" | `dex_classdiff`: same classes, zero `ACC_INTERFACE` drift, unchanged classes byte-identical |
| 2 | "It builds and signs" | Signature verification passes |
| 3 | "It installs and launches" | Process alive, `logcat` free of fatal signatures |
| 4 | "The target behavior changed" | The specific feature observed changed (screen exercised, endpoint observed, UI confirmed) |
| 5 | "Nothing else regressed" | Adjacent features exercised: images, playback, lists, login, settings |
| 6 | "The mechanism is proven" | Independent evidence of *why* — e.g. the SDK's domains are never resolved because init never ran |
**Rung 3 is the minimum for any deliverable. Rung 4 for a claim that the patch solved the problem. Rung 5 before handing it to a user. Rung 6 when the claim is "the subsystem is dead", not merely "the ad is hidden".**
## The control build rule
Before blaming a patch, build a control: **the same pipeline with zero patches**.
```bash
python scripts/repack.py --apk original.apk --dexdir <extracted> --out control.apk
```
- Control fails → your pipeline, the environment, or the device is at fault. Stop debugging the patch (`pitfalls.md` P9).
- Control passes, patched fails → the patch is implicated. Bisect: revert one dex at a time.
Do this **whenever something unexpectedly fails**, and at least once per project.
## Structural verification (automate it)
After every dex edit:
```bash
python scripts/dex_classdiff.py <original.dex> <patched.dex>
```
Expect exactly:
```
only_in_A=0 only_in_B=0
ACC_INTERFACE mismatch: 0
access_flags diff (same interface-ness): 0
```
Caveat, and it is an important one: **this check cannot detect code-item damage.** A whole-tree smali round-trip can pass every table check and still crash with `IncompatibleClassChangeError` (`pitfalls.md` P3). Passing this check is necessary, not sufficient.
Also confirm you changed what you intended and nothing more:
- Reverse the patched dex and inspect the target method.
- Compare unmodified classes byte-for-byte against the original where feasible.
## Runtime verification
```bash
adb -s <serial> logcat -c
adb -s <serial> shell "am start -n <pkg>/<activity>"
sleep 5; adb -s <serial> shell "pidof <pkg>" # empty == died
sleep 20; adb -s <serial> shell "pidof <pkg>" # same pid == stable
adb -s <serial> logcat -d -v brief | grep -E 'FATAL|VerifyError|IncompatibleClassChange|uncaughtException|Failure starting process'
```
**Sample the pid twice, spaced apart.** A single reading catches apps that die in a crash loop.
Failure signature → cause table lives in `references/environment.md`.
**Logcat is often not enough.** If the app installs a crash handler (友盟/UCrash/Bugly), the Java stack never reaches logcat. A process that dies with only `uncaughtException time: ...` and no stack **has crashed** — treat it as a failure, not as noise. Extraction techniques: `references/environment.md` §signal extraction.
## Behavioral verification (the part people skip)
Launching proves nothing about your change. Exercise it.
- **Ads removed** → open the screens that had ads (splash, home, detail, player, reward button). Confirm absence *and* confirm the screens still function.
- **A gate patched** → walk the gated flow end to end.
- **A timeout/limit patched** → trigger it.
- **Data patched** → stop the app, clear/re-write the value, cold start, re-check (proves it is not an in-memory artifact).
**And then test the neighbours.** The most common real-world regression is a patch that fixed its target and quietly broke something unrelated:
- Images / cover art render?
- Video or audio playback works?
- Lists scroll and paginate?
- Login / session still valid?
- Settings screens open?
- Downloads still start?
This is exactly how a "generic image-card composable" patch removed all cover art while the ad still showed (`pitfalls.md` P6).
## Screenshot discipline
Capture before/after screenshots for each claim. Text descriptions are not evidence; a screenshot of the screen without the ad, alongside one from the control build with it, is.
## Reporting
```
Build: <path> <sha256>
Base: <original apk name/version>
Changes: <dex → what changed>, one line each
Structural: dex_classdiff result (per dex)
Signature: verified (v1/v2/v3)
Device: <model / android / abi / rooted?>
Runtime: pid stable over N s; no fatal signatures
Behavior: <screens exercised>, <before/after>
Regressions: <adjacent features checked>
Rung reached: 0..6
Residual: <what is NOT fixed, and the exact reason>
```
**Be explicit about residuals.** "Ad X remains because it is delivered inside the screen's main data payload, and suppressing it at the transport layer takes the whole screen down" is a useful, honest result. Silently omitting it is not.
## Anti-patterns
- Declaring success from a successful assembly.
- Claiming "no ads" from a quiet logcat when the SDK never printed anything either way.
- Concluding from a single launch without exercising the feature.
- Quoting emulator results as device results.
- Reporting a fixed feature without checking adjacent features.
- Hiding a known residual failure.
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""向 app 的 DataStore(video_preferences) 注入 ad_free_expires_at 大值,验证它能否抑制广告 UI。
Preferences 的 protobuf 结构:
PreferenceMap { map<string, Value> preferences = 1; }
entry: 0x0A <len(key)> <key> 0x12 <len(value)> <value>
Value : boolean=1(float wire), float=2, integer=3(varint), long=4(varint), string=5, ...
所以要写一个 longKey:"ad_free_expires_at" -> Value{ long = 大值 }
Value 载荷 = 0x20 <varint(long)> (field 4, wire type 0 -> tag 0x20)
"""
import struct
import sys
def varint(n):
out = bytearray()
while True:
b = n & 0x7F
n >>= 7
if n:
out.append(b | 0x80)
else:
out.append(b)
return bytes(out)
def map_entry(key, value_payload):
"""编码 PreferenceMap 里的一个 map entry。
★关键(踩过坑):map<string,Value> preferences = 1 是一个 map 字段,
编码时必须**两层** 0x0A:
外层: 0x0A <len(entry)> <- 字段1的 tag + 长度
内层: 0x0A <len(key)> <key> 0x12 <len(value)> <value>
只写内层(丢掉外层 tag+长度)会让 DataStore 反序列化失败,
app 直接 uncaughtException 崩溃,而且没有堆栈(被 UCrash 吞掉),极难定位。
"""
kb = key.encode('utf-8')
inner = (b'\x0a' + varint(len(kb)) + kb
+ b'\x12' + varint(len(value_payload)) + value_payload)
return b'\x0a' + varint(len(inner)) + inner
def entry(key, value_payload):
"""兼容旧调用:返回 map_entry。"""
return map_entry(key, value_payload)
def main():
# 保留已有键:player_page_open_count = 1 (int32, field 3 -> tag 0x18)
existing = entry('player_page_open_count', b'\x18\x01')
# 注入一个远超任何现实时间的时间戳(毫秒/秒都覆盖)
BIG = 9999999999999
injected = entry('ad_free_expires_at', b'\x20' + varint(BIG))
data = existing + injected
out = sys.argv[1] if len(sys.argv) > 1 else 'video_preferences.preferences_pb'
with open(out, 'wb') as f:
f.write(data)
print('[ok] wrote %s (%d bytes)' % (out, len(data)))
print('[hex] %s' % data.hex(' '))
print('[note] ad_free_expires_at = %d, varint=%s' % (BIG, varint(BIG).hex(' ')))
if __name__ == '__main__':
main()
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Quoting-safe ADB helper.
Why this exists: if you build device commands inside a host shell, the host expands
`$`, `|`, `>`, and quotes *before* adb sees them. Symptoms are host-side errors that
look like device failures -- "Could not find a part of the path", "no closing quote",
"Missing type name after '['", unexpanded variables. Windows PowerShell is the worst
offender (it also eats `$var:`, `[^"...`, `$(...)`).
Calling adb from Python with an argument list avoids all of it.
Configuration (first match wins):
1. CLI flags: --serial / --adb
2. env: ADB_SERIAL / ADB_PATH
3. defaults below
Usage
-----
python devsh.py sh "pm list packages | grep myapp" # as the shell user
python devsh.py su "pm grant <pkg> android.permission.X"
python devsh.py sh "dumpsys window | grep mCurrentFocus"
python devsh.py pull /data/local/tmp/x.apk ./x.apk
python devsh.py push ./x.apk /data/local/tmp/x.apk
python devsh.py dev # list devices
`su` wraps the command in `su -c "..."`; internal double quotes are escaped.
"""
import argparse
import os
import subprocess
import sys
DEFAULT_ADB = 'adb'
DEFAULT_SERIAL = None
def resolve():
ap = argparse.ArgumentParser(add_help=False)
ap.add_argument('--adb')
ap.add_argument('--serial')
known, _ = ap.parse_known_args()
adb = known.adb or os.environ.get('ADB_PATH') or DEFAULT_ADB
serial = known.serial or os.environ.get('ADB_SERIAL') or DEFAULT_SERIAL
return adb, serial
def run(adb, serial, args, timeout=300):
cmd = [adb]
if serial:
cmd += ['-s', serial]
cmd += args
r = subprocess.run(cmd, capture_output=True, text=True, errors='replace', timeout=timeout)
out = r.stdout or ''
if (r.stderr or '').strip():
out += '\n[stderr]\n' + r.stderr
return r.returncode, out
def main():
if len(sys.argv) < 2:
print(__doc__)
return 2
adb, serial = resolve()
argv = [a for a in sys.argv[1:] if not a.startswith('--adb=') and not a.startswith('--serial=')]
# strip flag/value pairs
cleaned = []
skip = False
for a in argv:
if skip:
skip = False
continue
if a in ('--adb', '--serial'):
skip = True
continue
cleaned.append(a)
argv = cleaned
if len(argv) < 2 and argv and argv[0] not in ('dev',):
print(__doc__)
return 2
mode = argv[0]
if mode == 'dev':
rc, out = run(adb, None, ['devices', '-l'])
elif mode in ('sh', 'shell'):
rc, out = run(adb, serial, ['shell', argv[1]])
elif mode == 'su':
cmd = argv[1].replace('"', '\\"')
rc, out = run(adb, serial, ['shell', 'su -c "%s"' % cmd])
elif mode == 'suf': # read a device text file
cmd = argv[1].replace('"', '\\"')
size = argv[2] if len(argv) > 2 else '6000'
rc, out = run(adb, serial, ['shell', 'su -c "head -c %s %s"' % (size, cmd)])
elif mode == 'pull':
rc, out = run(adb, serial, ['pull', argv[1], argv[2]])
elif mode == 'push':
rc, out = run(adb, serial, ['push', argv[1], argv[2]])
else:
print(__doc__)
return 2
sys.stdout.write(out)
return 0 if rc == 0 else 1
if __name__ == '__main__':
sys.exit(main())
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""对比两个 dex 的 class_defs:类名 + access_flags(重点看 ACC_INTERFACE 0x200)。
用途:验证 baksmali -> smali 整树往返是否损伤了 dex 的 interface/class 关系。
ICCE "Found interface X, but class was expected" 就是这类损伤的典型症状。
用法:python dex_classdiff.py <a.dex> <b.dex> [过滤子串]
"""
import struct
import sys
def uleb128(data, off):
result = 0
shift = 0
while True:
b = data[off]
off += 1
result |= (b & 0x7F) << shift
if not (b & 0x80):
break
shift += 7
return result, off
def read_strings(data):
off = struct.unpack('<I', data[0x3C:0x40])[0]
size = struct.unpack('<I', data[0x38:0x40][:4])[0]
size = struct.unpack('<I', data[0x38:0x3C])[0]
out = []
for i in range(size):
sdata_off = struct.unpack('<I', data[off + i * 4: off + i * 4 + 4])[0]
n, p = uleb128(data, sdata_off)
raw = data[p:p + n]
try:
out.append(raw.decode('utf-8', 'replace'))
except Exception:
out.append('')
return out
def class_map(path):
data = open(path, 'rb').read()
strings = read_strings(data)
type_ids_off = struct.unpack('<I', data[0x44:0x48])[0]
n_types = struct.unpack('<I', data[0x40:0x44])[0]
types = []
for i in range(n_types):
idx = struct.unpack('<I', data[type_ids_off + i * 4: type_ids_off + i * 4 + 4])[0]
types.append(strings[idx] if idx < len(strings) else '')
class_defs_size = struct.unpack('<I', data[0x60:0x64])[0]
class_defs_off = struct.unpack('<I', data[0x64:0x68])[0]
out = {}
for i in range(class_defs_size):
base = class_defs_off + i * 32
class_idx, access_flags = struct.unpack('<II', data[base:base + 8])
name = types[class_idx] if class_idx < len(types) else '?'
out[name] = access_flags
return out
def main():
a, b = sys.argv[1], sys.argv[2]
filt = sys.argv[3] if len(sys.argv) > 3 else None
A = class_map(a)
B = class_map(b)
print('A classes=%d B classes=%d' % (len(A), len(B)))
only_a = sorted(set(A) - set(B))
only_b = sorted(set(B) - set(A))
print('only_in_A=%d only_in_B=%d' % (len(only_a), len(only_b)))
for n in only_a[:10]:
print(' A-only:', n)
for n in only_b[:10]:
print(' B-only:', n)
diff_iface = []
diff_flags = []
for name in sorted(set(A) & set(B)):
fa, fb = A[name], B[name]
ia, ib = bool(fa & 0x200), bool(fb & 0x200)
if ia != ib:
diff_iface.append((name, hex(fa), hex(fb)))
elif fa != fb:
diff_flags.append((name, hex(fa), hex(fb)))
print('\n*** ACC_INTERFACE mismatch: %d ***' % len(diff_iface))
for name, fa, fb in (diff_iface if not filt else [x for x in diff_iface if filt in x[0]]):
print(' %-70s A=%s B=%s' % (name, fa, fb))
print('\naccess_flags diff (same interface-ness): %d' % len(diff_flags))
shown = 0
for name, fa, fb in diff_flags:
if filt and filt not in name:
continue
if shown < 25:
print(' %-70s A=%s B=%s' % (name, fa, fb))
shown += 1
if __name__ == '__main__':
main()
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Extract strings, URLs and vendor markers from dex files without a decompiler.
Fast recon: dex strings are stored as plain MUTF-8, so ASCII substrings can be found
by scanning the raw bytes. This answers most classification questions in seconds --
which dex holds the app's code, which ad SDKs ship with it, what endpoints it calls.
Usage
-----
# all URLs across a directory of dex files
python dex_strings.py <dex_dir> --urls
# find which dex files contain a marker (ASCII substring)
python dex_strings.py <dex_dir> --find 'openadsdk|com.qq.e|anythink' --per-file
# dump a class/package inventory per dex (type descriptors)
python dex_strings.py <dex_dir> --classes 'Lcom/example/app/'
# raw string-table dump, length-filtered
python dex_strings.py <dex_dir> --strings --min 8 --max 60
# compare two dex files' string tables (what changed after a patch)
python dex_strings.py --diff a.dex b.dex
Notes
-----
* A marker in the string table means the class/URL is *present*, not that the app
*uses* that feature. Confirm with runtime behavior before acting.
* `--urls` output is the fastest way to see an app's whole API surface and its
third-party endpoints at once.
"""
import argparse
import glob
import os
import re
import struct
import sys
URL_RE = re.compile(rb'https?://[A-Za-z0-9\.\-_:/%\.\?=&~#]{4,200}')
ASCII_RE = re.compile(rb'[\x20-\x7e]{4,200}')
def uleb128(data, off):
r = 0
s = 0
while True:
b = data[off]
off += 1
r |= (b & 0x7F) << s
if not (b & 0x80):
break
s += 7
return r, off
def dex_strings(path):
"""Yield the dex string table in table order."""
data = open(path, 'rb').read()
if data[:4] != b'dex\n':
return
n = struct.unpack('<I', data[0x38:0x3C])[0]
off = struct.unpack('<I', data[0x3C:0x40])[0]
for i in range(n):
sdata = struct.unpack('<I', data[off + i * 4: off + i * 4 + 4])[0]
ln, p = uleb128(data, sdata)
yield bytes(data[p:p + ln])
def iter_dex(target):
if os.path.isdir(target):
return sorted(glob.glob(os.path.join(target, '*.dex')))
return [target]
def main():
ap = argparse.ArgumentParser(add_help=True)
ap.add_argument('target')
ap.add_argument('--urls', action='store_true')
ap.add_argument('--strings', action='store_true')
ap.add_argument('--classes', metavar='PREFIX')
ap.add_argument('--find', metavar='REGEX')
ap.add_argument('--per-file', action='store_true')
ap.add_argument('--diff', nargs=2, metavar=('A', 'B'))
ap.add_argument('--min', type=int, default=6)
ap.add_argument('--max', type=int, default=200)
a = ap.parse_args()
if a.diff:
A = set(dex_strings(a.diff[0]))
B = set(dex_strings(a.diff[1]))
only_a = sorted(x for x in A - B if a.min <= len(x) <= a.max)
only_b = sorted(x for x in B - A if a.min <= len(x) <= a.max)
print('A strings=%d B strings=%d' % (len(A), len(B)))
print('only in A: %d' % len(only_a))
for x in only_a[:40]:
print(' -', x.decode('utf-8', 'replace'))
print('only in B: %d' % len(only_b))
for x in only_b[:40]:
print(' +', x.decode('utf-8', 'replace'))
return 0
pat = re.compile(a.find.encode()) if a.find else None
files = iter_dex(a.target)
if a.find and a.per_file:
for fp in files:
data = open(fp, 'rb').read()
hits = len(pat.findall(data))
if hits:
print('%-22s hits=%d' % (os.path.basename(fp), hits))
return 0
seen = set()
for fp in files:
for raw in dex_strings(fp):
s = raw.decode('utf-8', 'replace')
if not (a.min <= len(s) <= a.max):
continue
if pat and not pat.search(s):
continue
if a.classes and not s.startswith(a.classes):
continue
if a.urls and not raw.startswith((b'http://', b'https://')):
continue
if s in seen:
continue
seen.add(s)
if a.urls:
print(s)
elif a.classes:
print('%-18s %s' % (os.path.basename(fp), s))
else:
print('%s' % s)
if a.urls and not seen:
# fall back to raw byte scan (covers URLs stored outside the string table)
print('[note] no URLs in string tables; raw scan:')
for fp in files:
for m in set(URL_RE.findall(open(fp, 'rb').read())):
print(os.path.basename(fp), m.decode('utf-8', 'replace'))
return 0
if __name__ == '__main__':
sys.exit(main())
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""字节级定点 patch dex 里的字符串常量,完全不做 smali 往返。
为什么不用 smali 往返:实测 classes7.dex 经 baksmali->smali 整树重建后,
`io.ktor.client.engine.HttpClientEngine` 的 synthetic access bridge 被破坏,
运行时报 IncompatibleClassChangeError(Found interface, but class was expected)。
class_def 的 access_flags 看不出问题,所以只能改用"只改字节"的方式。
本脚本做法(对 dex 结构零改动):
1. 把目标字符串替换成**等长**的另一个字符串 —— 长度相同则 string_data_item
的 uleb128 长度前缀不变,dex 的偏移、索引、表全部不变;
2. 重算 dex header 的 signature(SHA-1, 从 offset 32 起) 与 checksum(adler32, 从 offset 12 起)。
用法:python dex_strpatch.py <in.dex> <out.dex> <old_str> <new_str>
要求 len(new) == len(old),且 old 在 dex 中出现次数恰好为 1。
"""
import hashlib
import struct
import sys
import zlib
def _uleb128(data, off):
result = 0
shift = 0
while True:
b = data[off]
off += 1
result |= (b & 0x7F) << shift
if not (b & 0x80):
break
shift += 7
return result, off
def _read_strings(data):
off = struct.unpack('<I', data[0x3C:0x40])[0]
size = struct.unpack('<I', data[0x38:0x3C])[0]
out = []
for i in range(size):
sdata_off = struct.unpack('<I', data[off + i * 4: off + i * 4 + 4])[0]
n, p = _uleb128(data, sdata_off)
out.append((sdata_off, bytes(data[p:p + n])))
return out
def _order_ok(data, off, ob, nb):
"""检查把 ob 换成 nb 后是否仍满足 string_ids 的字典序约束。
注意:string_ids 里存的是 string_data_item 的偏移,也就是**长度前缀**的位置
(在 UTF-8 数据前若干字节),所以不能直接拿字符串数据偏移去匹配 ——
这里改为按内容匹配目标条目。
"""
entries = _read_strings(data)
idx = None
for i, (_sdata_off, raw) in enumerate(entries):
if raw == ob:
idx = i
break
if idx is None:
print('[order] target string not found in string_ids, skip check')
return True
prev = entries[idx - 1][1] if idx > 0 else None
nxt = entries[idx + 1][1] if idx + 1 < len(entries) else None
if prev is not None and nb <= prev:
print('[order] FAIL: new %r <= prev %r' % (nb, prev))
return False
if nxt is not None and nb >= nxt:
print('[order] FAIL: new %r >= next %r' % (nb, nxt))
return False
print('[order] OK: %r < %r < %r' % (prev, nb, nxt))
return True
def main():
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):
print('[FAIL] length mismatch: %d vs %d (must be equal)' % (len(ob), len(nb)))
return 1
data = bytearray(open(src, 'rb').read())
if data[:8] != b'dex\n035\x00' and data[:4] != b'dex\n':
print('[warn] unexpected magic: %r' % bytes(data[:8]))
n = data.count(ob)
print('[info] occurrences of %r = %d' % (old, n))
if n != 1:
print('[FAIL] expected exactly 1 occurrence')
return 1
off = data.find(ob)
# 关键:dex 规范要求 string_ids 表按字符串内容有序。等长替换虽然不动偏移,
# 但会改变该条目在表中的字典序位置 —— 一旦越界,整个 dex 会被 ClassLoader
# 拒绝(表现为 ClassNotFoundException: <Application>)。所以这里先做区间校验。
if not _order_ok(data, off, ob, nb):
print('[FAIL] replacement would break string_ids ordering. '
'Pick a string that stays between its two neighbours.')
return 1
data[off:off + len(ob)] = nb
print('[ok] patched at offset 0x%x: %r -> %r' % (off, old, new))
# signature = SHA-1 over data[32:], stored at 12..32
sig = hashlib.sha1(bytes(data[32:])).digest()
data[12:32] = sig
# checksum = adler32 over data[12:], stored at 8..12 (little endian)
chk = zlib.adler32(bytes(data[12:])) & 0xFFFFFFFF
data[8:12] = struct.pack('<I', chk)
print('[ok] signature=%s checksum=0x%08x' % (sig.hex(), chk))
open(dst, 'wb').write(bytes(data))
print('[ok] wrote %s (%d bytes)' % (dst, len(data)))
return 0
if __name__ == '__main__':
sys.exit(main())
+97
View File
@@ -0,0 +1,97 @@
import org.jf.dexlib2.DexFileFactory;
import org.jf.dexlib2.Opcode;
import org.jf.dexlib2.Opcodes;
import org.jf.dexlib2.iface.ClassDef;
import org.jf.dexlib2.iface.Method;
import org.jf.dexlib2.iface.MethodImplementation;
import org.jf.dexlib2.builder.MutableMethodImplementation;
import org.jf.dexlib2.immutable.ImmutableMethod;
import org.jf.dexlib2.writer.pool.DexPool;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
/**
* dexlib2 定点 patch:把指定方法的实现整体替换为 return-void(或 return 常量)。
*
* 为什么不用 baksmali/smali 整树往返:实测本项目的 classes7.dex 往返重建后运行时报
* IncompatibleClassChangeError: Found interface io.ktor.client.engine.HttpClientEngine,
* but class was expected
* (R8 生成的 synthetic access bridge 被破坏)。本工具只改目标方法的 code_item,
* 其余类/方法/字符串表原样保留,因此不会有这类损伤。
*
* 用法: java PatchMethod <in.dex> <out.dex> <descriptor> <methodName> <returnType>
* 例: java PatchMethod classes7.dex out.dex Lx6; d V
*/
public class PatchMethod {
public static void main(String[] args) throws Exception {
if (args.length < 5) {
System.err.println("usage: PatchMethod <in.dex> <out.dex> <descriptor> <methodName> <returnType>");
System.exit(2);
}
String inPath = args[0], outPath = args[1], desc = args[2], mName = args[3], ret = args[4];
org.jf.dexlib2.iface.DexFile dex =
DexFileFactory.loadDexFile(new File(inPath), Opcodes.forApi(34));
List<ClassDef> outClasses = new ArrayList<ClassDef>();
int patched = 0;
for (ClassDef cd : dex.getClasses()) {
List<Method> direct = new ArrayList<Method>();
for (Method m : cd.getDirectMethods()) direct.add(m);
List<Method> virtual = new ArrayList<Method>();
for (Method m : cd.getVirtualMethods()) virtual.add(m);
boolean isTargetClass = cd.getType().equals(desc);
boolean changed = false;
if (isTargetClass) {
for (int pass = 0; pass < 2; pass++) {
List<Method> list = (pass == 0) ? direct : virtual;
for (int i = 0; i < list.size(); i++) {
Method m = list.get(i);
if (!m.getName().equals(mName) || !m.getReturnType().equals(ret)) continue;
// 用 return-void 替换整个方法体。
// 寄存器数必须保留原值:Compose/协程方法体里可能仍被其它指令引用同一寄存器窗口,
// 且 dex 校验要求 registers >= 参数寄存器数。
int regs = m.getImplementation() != null ? m.getImplementation().getRegisterCount() : 0;
MethodImplementation impl = new org.jf.dexlib2.immutable.ImmutableMethodImplementation(
regs,
java.util.Collections.singletonList(
new org.jf.dexlib2.immutable.instruction.ImmutableInstruction10x(Opcode.RETURN_VOID)),
null,
null);
Method nm = new ImmutableMethod(
m.getDefiningClass(), m.getName(), m.getParameters(), m.getReturnType(),
m.getAccessFlags(), m.getAnnotations(), m.getHiddenApiRestrictions(),
impl);
list.set(i, nm);
patched++;
changed = true;
System.out.println("[patch] " + desc + "->" + mName + m.getReturnType()
+ " registers=" + regs);
}
}
}
if (!changed) {
outClasses.add(cd);
} else {
outClasses.add(new org.jf.dexlib2.immutable.ImmutableClassDef(
cd.getType(), cd.getAccessFlags(), cd.getSuperclass(),
cd.getInterfaces(), cd.getSourceFile(), cd.getAnnotations(),
cd.getStaticFields(), cd.getInstanceFields(), direct, virtual));
}
}
DexFileFactory.writeDexFile(outPath,
new org.jf.dexlib2.immutable.ImmutableDexFile(Opcodes.forApi(34), outClasses));
System.out.println("[done] patched=" + patched + " -> " + outPath);
if (patched == 0) {
System.err.println("[warn] no method matched!");
System.exit(1);
}
}
}
+108
View File
@@ -0,0 +1,108 @@
import org.jf.dexlib2.DexFileFactory;
import org.jf.dexlib2.Opcode;
import org.jf.dexlib2.Opcodes;
import org.jf.dexlib2.iface.ClassDef;
import org.jf.dexlib2.iface.Method;
import org.jf.dexlib2.iface.instruction.Instruction;
import org.jf.dexlib2.immutable.ImmutableClassDef;
import org.jf.dexlib2.immutable.ImmutableDexFile;
import org.jf.dexlib2.immutable.ImmutableMethod;
import org.jf.dexlib2.immutable.ImmutableMethodImplementation;
import org.jf.dexlib2.immutable.instruction.ImmutableInstruction11x;
import org.jf.dexlib2.immutable.instruction.ImmutableInstruction22c;
import org.jf.dexlib2.immutable.instruction.ImmutableInstruction35c;
import org.jf.dexlib2.immutable.instruction.ImmutableInstruction51l;
import org.jf.dexlib2.immutable.reference.ImmutableFieldReference;
import org.jf.dexlib2.immutable.reference.ImmutableMethodReference;
import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
/**
* classes7.dex 定点改动(单次读写,绝不要连续重写两次)。
*
* 只做一件事:把免广告到期时间的读取 lambda 改成恒返回"未过期"。
*
* VideoDataStore$currentAdFreeExpiresAt$$inlined$map$1$2.emit(Object; Continuation)Object
* -> 无条件向下游 collector 发一个超大 Long
*
* 效果等价于 DataStore 里 ad_free_expires_at 永远没有过期,
* 「看广告领特权」弹窗因此不再出现,而且这个改动写死在代码里,
* 全新安装同样生效(DataStore 那份是运行时数据,随 APK 分发不了)。
*
* ⚠️ 不要顺手 patch Lx6;->d:那是通用的图片卡片 Composable,
* 内容和广告共用,改成 return-void 会导致正常图片/播放链路一起失效。
*/
public class PatchClasses7v2 {
private static final String AD_CLASS =
"Lcyc/data/datastore/VideoDataStore$currentAdFreeExpiresAt$$inlined$map$1$2;";
private static final long BIG = 4102444800000L; // ~2100-01-01 (ms)
public static void main(String[] args) throws Exception {
String in = args[0], out = args[1];
org.jf.dexlib2.iface.DexFile dex =
DexFileFactory.loadDexFile(new File(in), Opcodes.forApi(34));
List<ClassDef> outClasses = new ArrayList<ClassDef>();
int p = 0;
for (ClassDef cd : dex.getClasses()) {
List<Method> direct = new ArrayList<Method>();
for (Method m : cd.getDirectMethods()) direct.add(m);
List<Method> virtual = new ArrayList<Method>();
for (Method m : cd.getVirtualMethods()) virtual.add(m);
boolean touched = false;
if (cd.getType().equals(AD_CLASS)) {
for (int pass = 0; pass < 2; pass++) {
List<Method> list = (pass == 0) ? direct : virtual;
for (int i = 0; i < list.size(); i++) {
Method m = list.get(i);
if (!m.getName().equals("emit")
|| !m.getReturnType().equals("Ljava/lang/Object;")
|| m.getImplementation() == null) {
continue;
}
List<Instruction> body = new ArrayList<Instruction>();
// v0 = collector, v1:v2 = long, p0=3 p1=4 p2=5
body.add(new ImmutableInstruction22c(Opcode.IGET_OBJECT, 0, 3,
new ImmutableFieldReference(AD_CLASS, "b", "Lkw2;")));
body.add(new ImmutableInstruction51l(Opcode.CONST_WIDE, 1, BIG));
body.add(new ImmutableInstruction35c(Opcode.INVOKE_STATIC, 2, 1, 2, 0, 0, 0,
new ImmutableMethodReference("Ljava/lang/Long;", "valueOf",
Collections.singletonList("J"), "Ljava/lang/Long;")));
body.add(new ImmutableInstruction11x(Opcode.MOVE_RESULT_OBJECT, 1));
body.add(new ImmutableInstruction35c(Opcode.INVOKE_INTERFACE, 3, 0, 1, 5, 0, 0,
new ImmutableMethodReference("Lkw2;", "emit",
Arrays.asList("Ljava/lang/Object;", "Lwj1;"),
"Ljava/lang/Object;")));
body.add(new ImmutableInstruction11x(Opcode.MOVE_RESULT_OBJECT, 0));
body.add(new ImmutableInstruction11x(Opcode.RETURN_OBJECT, 0));
list.set(i, new ImmutableMethod(m.getDefiningClass(), m.getName(),
m.getParameters(), m.getReturnType(), m.getAccessFlags(),
m.getAnnotations(), m.getHiddenApiRestrictions(),
new ImmutableMethodImplementation(6, body, null, null)));
p++;
touched = true;
System.out.println("[patch] emit -> BIG=" + BIG + " (list=" + pass + ")");
}
}
}
if (!touched) outClasses.add(cd);
else outClasses.add(new ImmutableClassDef(
cd.getType(), cd.getAccessFlags(), cd.getSuperclass(), cd.getInterfaces(),
cd.getSourceFile(), cd.getAnnotations(), cd.getStaticFields(),
cd.getInstanceFields(), direct, virtual));
}
DexFileFactory.writeDexFile(out, new ImmutableDexFile(Opcodes.forApi(34), outClasses));
System.out.println("[done] patched=" + p);
if (p == 0) System.exit(1);
}
}
+78
View File
@@ -0,0 +1,78 @@
# dexlib2 method-level patcher
The **preferred** way to change app behavior. It replaces only a target method's
implementation and writes a new dex, leaving every other class, method, string and
reference untouched.
Why not whole-tree smali round-trip: `baksmali` → `smali` rebuilds misrepresent R8's
synthetic access bridges. The class table still looks perfect — same class count, zero
`ACC_INTERFACE` mismatches — but at runtime you get
`IncompatibleClassChangeError: Found interface X, but class was expected`.
See `../../references/pitfalls.md` P3.
## Build
Needs the same eight jars `smtool.py` uses:
```
smali-2.5.2.jar antlr-runtime-3.5.2.jar stringtemplate-3.2.1.jar
baksmali-2.5.2.jar util-2.5.2.jar jcommander-1.64.jar
guava-27.1-android.jar dexlib2-2.5.2.jar
```
```bash
# point at your jars (or reuse ../smali_cp.txt)
CP=$(cat ../smali_cp.txt | grep -v '^#' | tr '\n' ';' | sed 's/;$//')
javac -encoding UTF-8 -cp "$CP" PatchMethod.java
```
**Always pass `-encoding UTF-8`** if the source has non-ASCII comments — `javac`
otherwise reads them with the platform default encoding and fails.
## Run
```bash
java -cp "$CP;." PatchMethod <in.dex> <out.dex> <descriptor> <methodName> <returnType>
# example: make Lx6;->d(...)V a no-op
java -cp "$CP;." PatchMethod classes7.dex out.dex 'Lx6;' d V
```
`PatchMethod` replaces the body with `return-void`. It scans **both** `directMethods`
and `virtualMethods` — a method you cannot find is often in the other list
(`static`/`private`/`<init>` are direct; everything else, including `public final`,
is virtual).
Use `PatchMethodExample.java` as the template for anything non-trivial (returning a
constant, emitting a fixed value from a Flow lambda, applying **several** edits to the
same dex in one pass).
## Rules that decide success
1. **One dex, one write pass.** Never chain two patchers over the same dex — the second
serialization drops metadata and ART then refuses to start the process
(`Failure starting process`, no Java stack). Combine all edits into one program.
2. **Preserve the register count.** `registers` must be `>=` the parameter register
count. With `registers = N` and `k` parameters (counting `this` for non-static),
`p0 = N - k`. For `(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;`
on an instance method, `k = 3`, so with `registers = 6` you get `p0=3, p1=4, p2=5`.
3. **`const-wide` needs the 64-bit instruction form** (`ImmutableInstruction51l`), not
the 32-bit one.
4. **`invoke` operand order in `35c`** is `(opcode, registerCount, regC, regD, regE, regF, regG, ref)`.
`invoke-static {v1, v2}, ...` → `registerCount=2, regC=1, regD=2`.
5. **End every body with a return of the correct type.**
6. **Check the blast radius first** — `../find_refs.py`. A generic helper with dozens of
callers is not your patch point (`../../references/pitfalls.md` P6).
## Verify
```bash
python ../dex_classdiff.py <original.dex> <patched.dex>
python ../smtool.py d <patched.dex> <tmp_tree> # confirm it parses
# then read the target method in tmp_tree to confirm it says what you intended
```
Expect `only_in_A=0`, `only_in_B=0`, `ACC_INTERFACE mismatch: 0`. Then install and
launch — table checks cannot see code-item damage, so runtime verification is
mandatory (`../../references/verification.md`).
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Find every reference to a method/field/class, so you can judge blast radius
BEFORE patching it.
This is the check that prevents the classic mistake: patching something that looked
ad-specific but is actually a general utility with dozens of callers.
Usage
-----
# references to a method, from a smali tree or a directory of dex files
python find_refs.py <tree_or_dex_dir> 'Lcom/pkg/Helper;->showAd(Landroid/app/Activity;)V'
# a whole class (all of its members)
python find_refs.py <tree_or_dex_dir> 'Lcom/pkg/Helper;'
# a field
python find_refs.py <tree_or_dex_dir> 'Lcom/pkg/Helper;->count:I'
# just the counts, no listing
python find_refs.py <tree_or_dex_dir> 'Lcom/pkg/Helper;->showAd' --count-only
Reading the result
------------------
few callers, all inside one feature area -> safe to patch
many callers, or callers in unrelated pkgs -> general utility. DO NOT patch it.
Go one level up and patch the
specific caller instead.
Also inspect the signature. If it mentions Modifier / ContentScale / Shape / View /
ColorScheme or a content-generic model type, it is shared UI, not your target.
"""
import argparse
import os
import re
import sys
TEXT_EXT = ('.smali',)
REF_RE_TMPL = r'invoke[^\n]*%s|sget[^\n]*%s|sput[^\n]*%s|new-instance[^\n]*%s|check-cast[^\n]*%s'
def iter_files(root):
if os.path.isdir(root):
for dp, _, fs in os.walk(root):
for fn in fs:
if fn.endswith(TEXT_EXT):
yield os.path.join(dp, fn)
else:
yield root
def method_owner(text, idx):
"""Map a character offset to the enclosing .method declaration."""
head = text.rfind('.method ', 0, idx)
if head < 0:
return '?'
end = text.find('\n', head)
return text[head:end].strip()
def main():
ap = argparse.ArgumentParser(add_help=True)
ap.add_argument('root')
ap.add_argument('needle')
ap.add_argument('--count-only', action='store_true')
ap.add_argument('--max-print', type=int, default=60)
a = ap.parse_args()
esc = re.escape(a.needle)
# match the needle plus one more char so we do not match a longer prefix
pat = re.compile(REF_RE_TMPL % (esc, esc, esc, esc, esc))
total = 0
per_file = []
printed = 0
for fp in iter_files(a.root):
try:
t = open(fp, encoding='utf-8', errors='replace').read()
except Exception:
continue
hits = list(pat.finditer(t))
if not hits:
continue
total += len(hits)
per_file.append((len(hits), fp, hits, t))
per_file.sort(reverse=True)
print('[total refs] %d across %d files' % (total, len(per_file)))
print()
for cnt, fp, hits, t in per_file:
rel = fp
print('%-70s %d' % (rel, cnt))
if a.count_only:
continue
for m in hits:
if printed >= a.max_print:
print(' ... (truncated)')
return 0
owner = method_owner(t, m.start())
print(' in %s' % owner)
print(' %s' % m.group(0).strip())
printed += 1
if total == 0:
print('[note] no references found. Check the descriptor prefix "L...;" and '
'exact obfuscated names, or disassemble the dex first with smtool.py.')
return 1
return 0
if __name__ == '__main__':
sys.exit(main())
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Capture a crash stack when the app's crash-reporter SDK is hiding it.
Problem this solves
-------------------
Many apps install a global uncaught-exception handler (友盟/UCrash, Bugly, Firebase
Crashlytics in some configs). The Java stack then never reaches `logcat` -- you only
see something like:
W/UCrash.Java: uncaughtException time: <ts>
...and the process dies. Without a stack you are guessing.
Three ways to get it, in order of reliability:
1. Frida: hook Thread.setDefaultUncaughtExceptionHandler (use references/dynamic-frida.md)
2. Race the reporter's own log file (this script): it writes a file under the app's
data dir, then zips, uploads, and deletes it. Poll fast and copy on sight.
3. Use a build with the reporter disabled.
Usage
-----
python grab_crash.py --pkg com.example.app --activity .MainActivity
python grab_crash.py --pkg com.example.app --activity .MainActivity \
--scan-dir /data/data/com.example.app --wait 25
Notes
-----
* `--scan-dir` defaults to /data/data/<pkg>; the script searches a few levels deep for
newly appeared *.log / *.txt / *.stacktrace files, so you do not have to know which
SDK is installed.
* The watcher loop self-terminates after ~80 s, so nothing is left running on device.
* Requires root (the app's data dir is private).
"""
import argparse
import subprocess
import sys
import time
CAPTURE = '/data/local/tmp/_skill_crash_capture.log'
def adb(args, serial=None, timeout=180):
cmd = ['adb']
if serial:
cmd += ['-s', serial]
cmd += args
r = subprocess.run(cmd, capture_output=True, text=True, errors='replace', timeout=timeout)
return (r.stdout or '') + (('\n[stderr]\n' + r.stderr) if (r.stderr or '').strip() else '')
def su(cmd, serial=None):
return adb(['shell', 'su -c "%s"' % cmd.replace('"', '\\"')], serial)
def main():
ap = argparse.ArgumentParser(add_help=True)
ap.add_argument('--pkg', required=True)
ap.add_argument('--activity', required=True)
ap.add_argument('--serial')
ap.add_argument('--scan-dir')
ap.add_argument('--wait', type=int, default=25)
a = ap.parse_args()
scan = a.scan_dir or ('/data/data/%s' % a.pkg)
s = a.serial
# On-device watcher: copy any *.log/*.txt that appears under the app dir.
watch = (
"i=0; while [ $i -lt 400 ]; do "
" find {d} -maxdepth 5 -type f \\( -name '*.log' -o -name '*.txt' -o -name '*.stacktrace' "
" -o -name '*.crash' \\) -newermt '-3 minutes' -exec cp -f {{}} {c} \\; 2>/dev/null; "
" i=$((i+1)); sleep 0.2; "
"done"
).format(d=scan, c=CAPTURE)
su('am force-stop %s' % a.pkg, s)
su('rm -f %s' % CAPTURE, s)
print('[grab] arming on-device watcher over %s' % scan)
subprocess.Popen(['adb'] + (['-s', s] if s else []) +
['shell', "su -c '%s'" % watch],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
time.sleep(1.5)
print('[grab] starting app')
print(adb(['shell', 'am start -n %s/%s' % (a.pkg, a.activity)], s))
time.sleep(a.wait)
pid = adb(['shell', 'pidof %s' % a.pkg], s).strip()
print('[grab] pid: %s' % (pid or '(dead -> likely crashed)'))
print('[grab] ==== captured file ====')
out = su('head -c 6000 %s' % CAPTURE, s)
if 'No such file' in out or not out.strip():
print('(nothing captured)')
print('[hint] the reporter may have deleted it faster than 0.2 s, or it writes '
'outside --scan-dir. Use the Frida approach instead.')
else:
print(out)
return 0
if __name__ == '__main__':
sys.exit(main())
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Install an APK and run a launch health check, extracting the signals that matter.
Automates the verification loop from references/verification.md:
install -> clear logcat -> launch -> sample pid twice -> scan for fatal signatures
-> capture a screenshot.
Why the pid is sampled TWICE: a crash loop shows a live pid at any single instant.
The second sample catches it.
Usage
-----
python install_test.py --apk out/app.apk --pkg com.example.app --activity .MainActivity
python install_test.py --apk out/app.apk --pkg com.example.app --activity .MainActivity \
--shots out/shot.png --uninstall-first --grant READ_PHONE_STATE --grant ACCESS_FINE_LOCATION
Useful flags
------------
--uninstall-first wipe app data (needed when the signing key changed, and the only
way to test "does this survive a fresh install")
--keep-data reinstall over the existing app (requires the same signing key;
preserves login state -- valuable when the feature under test
needs auth)
--grant NAME runtime permission to grant (repeatable). Must be granted as
root: the shell user gets SecurityException.
--wait N seconds between the two pid samples (default 18)
Exit code is 0 only when the app launched AND both pid samples agree.
"""
import argparse
import os
import re
import subprocess
import sys
import time
FATAL = re.compile(
r'FATAL EXCEPTION|VerifyError|IncompatibleClassChangeError|'
r'ClassNotFoundException|NoClassDefFoundError|LinkageError|'
r'Failure starting process|uncaughtException',
re.I)
def adb(args, serial=None, timeout=600):
cmd = ['adb']
if serial:
cmd += ['-s', serial]
cmd += args
r = subprocess.run(cmd, capture_output=True, text=True, errors='replace', timeout=timeout)
return (r.stdout or '') + (('\n[stderr]\n' + r.stderr) if (r.stderr or '').strip() else '')
def sh(cmd, serial=None):
return adb(['shell', cmd], serial)
def su(cmd, serial=None):
return adb(['shell', 'su -c "%s"' % cmd.replace('"', '\\"')], serial)
def main():
ap = argparse.ArgumentParser(add_help=True)
ap.add_argument('--apk', required=True)
ap.add_argument('--pkg', required=True)
ap.add_argument('--activity', required=True, help='e.g. .MainActivity or fully qualified')
ap.add_argument('--serial')
ap.add_argument('--uninstall-first', action='store_true')
ap.add_argument('--keep-data', action='store_true')
ap.add_argument('--grant', action='append', default=[])
ap.add_argument('--wait', type=int, default=18)
ap.add_argument('--shots')
a = ap.parse_args()
s = a.serial
print('[0] devices:'); print(adb(['devices', '-l']))
if a.uninstall_first:
print('[1] uninstall'); print(su('am force-stop %s; pm uninstall %s' % (a.pkg, a.pkg), s))
stage = '/data/local/tmp/_skill_stage.apk'
print('[2] push'); print(adb(['push', a.apk, stage], s))
print('[3] install'); print(su('pm install -r -t -d %s' % stage, s))
for perm in a.grant:
p = perm if perm.startswith('android.permission.') else 'android.permission.' + perm
su('pm grant %s %s' % (a.pkg, p), s)
print('[4] clear logcat'); adb(['logcat', '-c'], s)
comp = '%s/%s' % (a.pkg, a.activity if a.activity.startswith('.') or '.' in a.activity
else a.activity)
print('[5] launch'); print(sh('am start -n %s' % comp, s))
time.sleep(6)
p1 = sh('pidof %s' % a.pkg, s).strip()
print('[6] pid @+6s : %s' % (p1 or '(dead)'))
time.sleep(a.wait)
p2 = sh('pidof %s' % a.pkg, s).strip()
print('[7] pid @+%ds : %s' % (6 + a.wait, p2 or '(dead)'))
print('[8] focus : %s' % sh('dumpsys window | grep mCurrentFocus', s).strip())
if a.shots:
su('screencap -p /sdcard/_skill_shot.png', s)
print(adb(['pull', '/sdcard/_skill_shot.png', a.shots], s))
print('[9] fatal signatures:')
log = adb(['logcat', '-d', '-v', 'brief'], s)
hits = [ln for ln in log.splitlines() if FATAL.search(ln)]
if hits:
for ln in hits[-25:]:
print(' ', ln)
else:
print(' none')
ok = bool(p1) and p1 == p2 and not hits
print()
print('[result] %s' % ('OK (launched, stable, no fatal signatures)'
if ok else 'CHECK (see above)'))
print('[note] stability is NOT proof the patch worked. Exercise the changed '
'feature and its neighbours -- see references/verification.md.')
return 0 if ok else 1
if __name__ == '__main__':
sys.exit(main())
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
方法级 smali 补丁器。
设计要点(踩坑后确定):
- 只替换「.method 声明行的下一行」到「.end method 之前」的指令体,
保留原 .method / .end method / .annotation 段不动,避免破坏修饰符与注解。
- 补丁清单要求显式给出自洽的 .registers 值:smali 汇编器会校验寄存器不越界,
registers 必须 >= max(参数寄存器数) 且 >= 指令里用到的最大寄存器编号 + 1。
参数寄存器数:static 方法 = 参数个数;非 static = 参数个数 + 1(this)。
- 补丁清单是 JSON 列表,每条:
{
"file": "com/example/Helper.smali", # path relative to smali tree root
"method": ".method public final show(Landroid/app/Activity;)V", # exact .method line
"registers": 3,
"body": ["invoke-interface {p3}, ...;", "return-void"],
"note": "干掉插屏广告" # 可选,仅用于报告
}
用法:
python patch_smali.py <smali_tree> <patch.json> [--dry-run]
"""
import json
import os
import re
import sys
def split_methods(text):
"""把 smali 文本切成 [(method_decl, start_idx, end_idx)],按行索引。"""
lines = text.split('\n')
out = []
start = None
decl = None
for i, ln in enumerate(lines):
s = ln.strip()
if s.startswith('.method ') and start is None:
start, decl = i, s
elif s == '.end method' and start is not None:
out.append((decl, start, i))
start, decl = None, None
return lines, out
def patch_one(path, decl_wanted, registers, body):
with open(path, 'r', encoding='utf-8') as f:
text = f.read()
lines, methods = split_methods(text)
hits = [m for m in methods if m[0] == decl_wanted.strip()]
if not hits:
return False, 'method not found: %s' % decl_wanted
if len(hits) > 1:
return False, 'ambiguous (%d matches): %s' % (len(hits), decl_wanted)
_, start, end = hits[0]
# 保留 .method 行;重写内部:.registers + body
new_inner = [' .registers %d' % registers, '']
for b in body:
new_inner.append(' ' + b)
new_lines = lines[:start + 1] + new_inner + [''] + lines[end:]
with open(path, 'w', encoding='utf-8', newline='\n') as f:
f.write('\n'.join(new_lines))
return True, 'ok'
def main():
if len(sys.argv) < 3:
print(__doc__)
return 2
tree = sys.argv[1]
spec = sys.argv[2]
dry = '--dry-run' in sys.argv
with open(spec, 'r', encoding='utf-8') as f:
patches = json.load(f)
ok = fail = 0
for p in patches:
full = os.path.join(tree, p['file'].replace('/', os.sep))
if not os.path.isfile(full):
print('[MISS-FILE] %s' % p['file'])
fail += 1
continue
if dry:
with open(full, 'r', encoding='utf-8') as f:
_, methods = split_methods(f.read())
found = any(m[0] == p['method'].strip() for m in methods)
print('[%s] %s :: %s' % ('DRY-OK' if found else 'DRY-MISS', p['file'], p['method']))
ok += 1 if found else 0
fail += 0 if found else 1
continue
okk, msg = patch_one(full, p['method'], p['registers'], p['body'])
print('[%s] %s :: %s (%s)' % ('OK' if okk else 'FAIL', p['file'], p['method'].split('(')[0].replace('.method ', ''), msg))
ok += 1 if okk else 0
fail += 0 if okk else 1
print('\npatched=%d failed=%d%s' % (ok, fail, ' [dry-run]' if dry else ''))
return 0 if fail == 0 else 1
if __name__ == '__main__':
sys.exit(main())
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Probe an app's HTTP API the way the app does.
Purpose-built for the common frustration: your request gets 403 while the app
works fine. Almost always the app sends a header you are not sending (frequently
one custom header), or your egress IP differs from the device's.
Usage
-----
python probe_api.py --base https://api.example.com --path /health
python probe_api.py --base https://api.example.com --path /adverts \
--param position=banner --header 'X-App-Name: myapp'
python probe_api.py --base https://api.example.com \
--path "/items?page=1" --raw
Options
-------
--base Base URL (required)
--path Path, may already contain a query string (required)
--param k=v Repeatable; appended as query parameters
--header k=v Repeatable; sent as-is. Always send the app's real headers.
--token T Convenience: adds 'Authorization: Bearer T'
--ua UA User-Agent (default: okhttp/4.12.0)
--no-proxy Ignore environment proxy settings (recommended)
--show Print the first N bytes of the body (default 800)
Interpreting results
--------------------
200 + data -> alive; read the envelope shape
200 + empty list -> valid input, nothing to return. Do NOT assume "ad removed":
it may simply be that no item is configured.
400 + message -> input is validated; the message often lists valid values
401/403, no creds -> auth-gated (server-side ownership)
401/403, forged -> token is verified server-side; client patching cannot mint one
403 + HTML body -> a WAF/CDN refused your *request shape* or egress, not the endpoint
404 -> exact path mismatch
"""
import argparse
import json
import urllib.error
import urllib.parse
import urllib.request
def build_opener(no_proxy):
if no_proxy:
return urllib.request.build_opener(urllib.request.ProxyHandler({}))
return urllib.request.build_opener()
def main():
ap = argparse.ArgumentParser(add_help=True)
ap.add_argument('--base', required=True)
ap.add_argument('--path', required=True)
ap.add_argument('--param', action='append', default=[])
ap.add_argument('--header', action='append', default=[])
ap.add_argument('--token')
ap.add_argument('--ua', default='okhttp/4.12.0')
ap.add_argument('--no-proxy', action='store_true')
ap.add_argument('--show', type=int, default=800)
a = ap.parse_args()
url = a.base.rstrip('/') + a.path
if a.param:
params = []
for p in a.param:
if '=' not in p:
print('[FAIL] --param expects k=v, got %r' % p)
return 2
k, v = p.split('=', 1)
params.append((k, v))
sep = '&' if '?' in url else '?'
url += sep + urllib.parse.urlencode(params)
headers = {
'User-Agent': a.ua,
'Accept': 'application/json',
}
for h in a.header:
if ':' not in h:
print('[FAIL] --header expects "Name: value", got %r' % h)
return 2
k, v = h.split(':', 1)
headers[k.strip()] = v.strip()
if a.token:
headers['Authorization'] = 'Bearer %s' % a.token
print('[req] GET %s' % url)
for k, v in headers.items():
shown = v if k.lower() != 'authorization' else v[:24] + '...'
print(' %s: %s' % (k, shown))
req = urllib.request.Request(url, headers=headers)
op = build_opener(a.no_proxy)
try:
with op.open(req, timeout=30) as r:
body = r.read().decode('utf-8', 'replace')
print('[res] %s %s' % (r.status, r.headers.get('Content-Type', '')))
print(body[:a.show])
if body.strip().startswith(('{', '[')):
try:
parsed = json.loads(body)
if isinstance(parsed, dict) and 'data' in parsed:
d = parsed['data']
if isinstance(d, dict) and 'list' in d:
print('[shape] data.list length=%s' % len(d['list'] or []))
except Exception:
pass
except urllib.error.HTTPError as e:
body = e.read().decode('utf-8', 'replace')
print('[res] HTTP %s' % e.code)
print(body[:a.show])
except Exception as e:
print('[ERR] %s' % e)
return 1
return 0
if __name__ == '__main__':
raise SystemExit(main())
+266
View File
@@ -0,0 +1,266 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Repack + resign pipeline for the ciyuan Android app.
Pipeline: replace dex files -> strip META-INF/ -> zip (AndroidManifest.xml and
resources.arsc kept STORED) -> zipalign + sign via uber-apk-signer -> verify.
Keystore defaults (generated on first use with keytool):
file : <work>/out/cyc.keystore
alias : cyc
storepass / keypass : cyc123456
Usage examples:
# roundtrip: reuse original dexes unchanged, proves the pipeline itself works
python tools/repack.py --apk work/orig.apk --dexdir work/x_orig --out work/out/roundtrip.apk
# real patch: swap a single dex (name=path), other dexes kept from the apk
python tools/repack.py --apk work/orig.apk --dex classes8.dex=work/x_orig/patch/classes8.dex \
--out work/out/patched.apk
Notes:
* All java tooling is invoked through python subprocess (never through the
PowerShell argument passing path, which drops arguments).
* uber-apk-signer silently skips already signed apks (reports 0 processed),
which is why META-INF/ is always stripped before signing.
"""
import argparse
import os
import re
import shutil
import subprocess
import sys
import zipfile
JAVA = r'C:\Program Files\Java\jdk-17.0.4.1\bin\java.exe'
KEYTOOL = r'C:\Program Files\Java\jdk-17.0.4.1\bin\keytool.exe'
JARSIGNER = r'C:\Program Files\Java\jdk-17.0.4.1\bin\jarsigner.exe'
SIGNER_JAR = os.environ.get('APK_SIGNER_JAR', 'uber-apk-signer.jar')
KS_PATH = None # pass --ks on the command line
KS_ALIAS = os.environ.get('APK_KS_ALIAS', 'apkreverse')
KS_PASS = os.environ.get('APK_KS_PASS', '')
STORE_ONLY = ('AndroidManifest.xml', 'resources.arsc')
def log(msg):
print(msg, flush=True)
def run(cmd, **kw):
r = subprocess.run(cmd, capture_output=True, text=True, errors='replace', **kw)
return r
def ensure_keystore(path=KS_PATH, alias=KS_ALIAS, password=KS_PASS):
if os.path.exists(path):
log('[ks] reuse %s' % path)
return path
os.makedirs(os.path.dirname(path), exist_ok=True)
cmd = [KEYTOOL, '-genkeypair', '-keystore', path, '-alias', alias,
'-keyalg', 'RSA', '-keysize', '2048', '-validity', '10000',
'-storepass', password, '-keypass', password,
'-dname', 'CN=apkreverse, OU=dev, O=dev, L=NA, ST=NA, C=NA']
r = run(cmd)
if r.returncode != 0:
log('[ks] keytool failed rc=%d\n%s\n%s' % (r.returncode, r.stdout, r.stderr))
raise SystemExit(1)
log('[ks] generated %s (alias=%s pass=%s)' % (path, alias, password))
return path
def collect_replacement_dex(dexdir=None, dex_pairs=None):
repl = {}
if dexdir:
for name in sorted(os.listdir(dexdir)):
if re.match(r'^classes\d*\.dex$', name):
repl[name] = os.path.join(dexdir, name)
for pair in (dex_pairs or []):
if '=' not in pair:
raise SystemExit('--dex expects name=path, got %r' % pair)
name, p = pair.split('=', 1)
repl[name] = p
return repl
def is_signature_entry(name):
"""True only for JAR/APK signature artifacts directly under META-INF/.
META-INF/services/**, META-INF/androidx/**, META-INF/native-image/** and all
other subdirectories are RUNTIME RESOURCES and must survive repacking.
"""
if not name.upper().startswith('META-INF/'):
return False
rest = name[len('META-INF/'):]
if '/' in rest:
return False
up = rest.upper()
if up == 'MANIFEST.MF':
return True
return up.endswith(('.SF', '.RSA', '.DSA', '.EC'))
def build_unsigned(apk, repl, out_apk, drop_meta=True):
"""Write a new apk with dex replaced and only signature entries dropped.
IMPORTANT (root cause of a crash, do not regress):
Stripping the WHOLE META-INF/ breaks ServiceLoader-based runtime wiring.
This APK ships ServiceLoader registrations that Android reads at runtime:
META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory -> kc
META-INF/services/io.ktor.client.HttpClientEngineContainer -> OkHttpEngineContainer
META-INF/services/io.ktor.serialization.kotlinx.KotlinxSerializationExtensionProvider
META-INF/services/kotlinx.coroutines.CoroutineExceptionHandler -> wc
META-INF/services/com.arialyy.aria.* , META-INF/services/wt2 , META-INF/services/xx1
Deleting them makes the app die at startup with:
IllegalStateException: Module with the Main dispatcher is missing ...
So only signature artifacts are removed here; META-INF subdirectories are kept.
"""
seen = set()
replaced_names = set(repl)
with zipfile.ZipFile(apk, 'r') as zin, zipfile.ZipFile(out_apk, 'w', zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
name = item.filename
base = os.path.basename(name)
if item.is_dir():
continue
if drop_meta and is_signature_entry(name):
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 f:
data = f.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)
return seen
def zip_report(apk):
rows = []
with zipfile.ZipFile(apk, 'r') as z:
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 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))
return rows
def sign_apk(unsigned_apk, workdir, ks, alias=KS_ALIAS, password=KS_PASS):
outdir = os.path.join(workdir, 'signed')
if os.path.isdir(outdir):
shutil.rmtree(outdir, ignore_errors=True)
os.makedirs(outdir, exist_ok=True)
cmd = [JAVA, '-jar', SIGNER_JAR,
'--apks', unsigned_apk,
'--ks', ks, '--ksAlias', alias,
'--ksPass', password, '--ksKeyPass', password,
'-o', outdir, '--verbose']
r = run(cmd)
log('[sign] rc=%d' % r.returncode)
log(r.stdout.strip())
if r.stderr.strip():
log('[sign][stderr] ' + r.stderr.strip())
cand = os.path.join(outdir, os.path.basename(unsigned_apk))
if not os.path.exists(cand):
outs = [os.path.join(outdir, f) for f in os.listdir(outdir)] if os.path.isdir(outdir) else []
if not outs:
raise SystemExit('signing produced no output')
cand = outs[0]
return cand, r.stdout
def verify_apk(apk):
log('== verify: %s' % apk)
ok = True
# 1) uber-apk-signer verify (also re-checks zipalign)
r = run([JAVA, '-jar', SIGNER_JAR, '-a', apk, '-y'])
log('[verify:signer] rc=%d\n%s' % (r.returncode, r.stdout.strip()))
if r.returncode != 0 or 'Verified' not in r.stdout:
ok = ok and ('DOES NOT VERIFY' not in r.stdout)
# 2) jarsigner (v1 / jAR signature)
r2 = run([JARSIGNER, '-verify', '-certs', apk])
txt = (r2.stdout or '') + (r2.stderr or '')
log('[verify:jarsigner] rc=%d\n%s' % (r2.returncode, txt.strip()[:2000]))
# 3) keytool cert dump
r3 = run([KEYTOOL, '-printcert', '-jarfile', apk])
log('[verify:keytool] rc=%d\n%s' % (r3.returncode, r3.stdout.strip()[:1500]))
# 4) v2/v3 APK Signing Block presence
with open(apk, 'rb') as f:
blob = f.read()
has_v2 = b'APK Sig Block 42' in blob
log('[verify:v2block] %s' % ('present' if has_v2 else 'MISSING'))
return ok and (r.returncode == 0)
def fingerprint(apk):
r = run([KEYTOOL, '-printcert', '-jarfile', apk])
sha = re.findall(r'(SHA1|SHA256):\s*([0-9A-F:]+)', r.stdout)
return sha
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--apk', required=True)
ap.add_argument('--out', required=True)
ap.add_argument('--dexdir')
ap.add_argument('--dex', action='append', default=[], help='name=path, repeatable')
ap.add_argument('--ks', default=KS_PATH)
ap.add_argument('--no-sign', action='store_true')
ap.add_argument('--workdir', default=None)
args = ap.parse_args()
apk = os.path.abspath(args.apk)
out = os.path.abspath(args.out)
if not os.path.exists(apk):
raise SystemExit('missing apk: %s' % apk)
os.makedirs(os.path.dirname(out), exist_ok=True)
os.makedirs(args.workdir, exist_ok=True)
repl = collect_replacement_dex(args.dexdir, args.dex)
log('[in ] %s (%d bytes)' % (apk, os.path.getsize(apk)))
log('[repl] %s' % ', '.join('%s<-%s' % (k, v) for k, v in sorted(repl.items())))
unsigned = os.path.join(args.workdir, 'unsigned.apk')
if os.path.exists(unsigned):
os.remove(unsigned)
build_unsigned(apk, repl, unsigned)
log('[zip] unsigned written: %d bytes' % os.path.getsize(unsigned))
for row in zip_report(unsigned):
log('[zip] ' + row)
if args.no_sign:
shutil.copy2(unsigned, out)
else:
ks = ensure_keystore(args.ks)
signed, _ = sign_apk(unsigned, args.workdir, ks)
shutil.copy2(signed, out)
log('[out] %s (%d bytes)' % (out, os.path.getsize(out)))
log('== zip layout of final apk')
for row in zip_report(out):
log(' ' + row)
ok = verify_apk(out)
sha = fingerprint(out)
for algo, val in sha:
log('[cert] %s %s' % (algo, val))
log('[result] %s' % ('OK' if ok else 'CHECK-FAILED'))
return 0 if ok else 2
if __name__ == '__main__':
sys.exit(main())
+10
View File
@@ -0,0 +1,10 @@
# One jar path per line. Adjust the directory to your own tools.
# Needed: smali baksmali dexlib2 util antlr-runtime stringtemplate jcommander guava
smali-2.5.2.jar
antlr-runtime-3.5.2.jar
stringtemplate-3.2.1.jar
baksmali-2.5.2.jar
util-2.5.2.jar
jcommander-1.64.jar
guava-27.1-android.jar
dexlib2-2.5.2.jar
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""baksmali / smali wrapper.
Assembling and disassembling dex requires smali + baksmali + several runtime jars on
the classpath. Getting the set wrong produces a bare `ClassNotFoundException` that
looks like a broken tool rather than a missing jar, so this wrapper centralizes it.
Two ways to configure the classpath, in priority order:
1. --cp "jar1;jar2" (explicit, per invocation)
2. APK_REVERSE_SMALI_CP env var
3. a `smali_cp.txt` next to this script (one jar path per line, '#' comments ok)
Required jars: smali, baksmali, dexlib2, util, antlr-runtime, stringtemplate,
jcommander, guava.
Usage
-----
python smtool.py d <in.dex> <out_dir> # disassemble to a smali tree
python smtool.py a <in_tree> <out.dex> # assemble a smali tree back to dex
python smtool.py check # print the resolved classpath and exit
Notes
-----
* Always pass the tree directory as its own argument; do not rely on the shell.
* Call java from Python rather than from a shell: PowerShell in particular mangles
arguments containing ';' or '$', which silently drops jars from the classpath.
"""
import os
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
CP_FILE = os.path.join(HERE, 'smali_cp.txt')
JAR_NAMES = [
'smali-2.5.2.jar',
'antlr-runtime-3.5.2.jar',
'stringtemplate-3.2.1.jar',
'baksmali-2.5.2.jar',
'util-2.5.2.jar',
'jcommander-1.64.jar',
'guava-27.1-android.jar',
'dexlib2-2.5.2.jar',
]
def resolve_cp(explicit=None):
if explicit:
return explicit
env = os.environ.get('APK_REVERSE_SMALI_CP')
if env:
return env
if os.path.isfile(CP_FILE):
parts = []
for line in open(CP_FILE, encoding='utf-8'):
line = line.strip()
if line and not line.startswith('#'):
parts.append(line)
if parts:
return os.pathsep.join(parts)
# last resort: look for the jars in a 'tools' dir next to this script
cand = [os.path.join(HERE, 'tools', n) for n in JAR_NAMES]
if all(os.path.isfile(c) for c in cand):
return os.pathsep.join(cand)
return None
def run(cp, main_class, args):
cmd = ['java', '-cp', cp, main_class] + args
r = subprocess.run(cmd, capture_output=True, text=True, errors='replace')
out = (r.stdout or '') + (('\n[stderr]\n' + r.stderr) if (r.stderr or '').strip() else '')
return r.returncode, out
def main():
if len(sys.argv) < 2:
print(__doc__)
return 2
explicit = None
argv = sys.argv[1:]
if '--cp' in argv:
i = argv.index('--cp')
explicit = argv[i + 1]
del argv[i:i + 2]
cp = resolve_cp(explicit)
if not cp:
print('[FAIL] no classpath. Provide --cp, set APK_REVERSE_SMALI_CP, '
'or create smali_cp.txt next to this script.')
return 1
cmd = argv[0]
if cmd == 'check':
print('classpath: %s' % cp)
for j in cp.split(os.pathsep):
print(' %s %s' % ('OK ' if os.path.isfile(j) else 'MISSING', j))
return 0
if cmd in ('d', 'dis', 'disassemble'):
if len(argv) < 3:
print('usage: smtool.py d <in.dex> <out_dir>')
return 2
rc, out = run(cp, 'org.jf.baksmali.Main', ['d', argv[1], '-o', argv[2]])
elif cmd in ('a', 'asm', 'assemble'):
if len(argv) < 3:
print('usage: smtool.py a <in_tree> <out.dex>')
return 2
rc, out = run(cp, 'org.jf.smali.Main', ['a', argv[1], '-o', argv[2]])
else:
print(__doc__)
return 2
print(out.strip()[-4000:])
print('rc=%d' % rc)
return 0 if rc == 0 else 1
if __name__ == '__main__':
sys.exit(main())
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
用于给「无网络」Android 设备供网的极简 HTTP/HTTPS 代理。
场景:手机 WiFi DHCP 失败 -> 无默认网络 -> app 所有请求失败。
解法:手机没有可用网络接口也没关系 —— adb reverse 会在**设备本机的 loopback**
上开监听,不需要任何网络接口。于是:
1) 电脑跑本代理 (0.0.0.0:8080)
2) adb -s <serial> reverse tcp:8080 tcp:8080
3) 设备端 su -c "settings put global http_proxy 127.0.0.1:8080"
4) app 的 HTTP/HTTPS 流量经 USB 到电脑,由电脑代发
支持:
- CONNECT 隧道(HTTPS 主用)
- 明文 HTTP 绝对 URI 转发
仅做转发,不做 MITM(不解密 TLS,也不改流量),因此不干扰证书校验。
用法:python usb_net_proxy.py [listen_port] [logfile]
"""
import socket
import sys
import threading
import time
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8080
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
def log(msg):
line = '[%s] %s' % (time.strftime('%H:%M:%S'), msg)
print(line, flush=True)
if _logf:
with _lock:
_logf.write(line + '\n')
_logf.flush()
def pump(a, b):
"""a -> b 单向搬运,直到任一端关闭。"""
try:
while True:
r, _, _ = select_select([a], [], [], 30)
if not r:
break
data = a.recv(65536)
if not data:
break
b.sendall(data)
except Exception:
pass
finally:
for s in (a, b):
try:
s.shutdown(socket.SHUT_RDWR)
except Exception:
pass
try:
s.close()
except Exception:
pass
def select_select(rlist, wlist, xlist, timeout):
import select
return select.select(rlist, wlist, xlist, timeout)
def read_head(sock, limit=65536):
data = b''
while b'\r\n\r\n' not in data and len(data) < limit:
chunk = sock.recv(4096)
if not chunk:
return data
data += chunk
return data
def handle(client, addr):
client.settimeout(30)
try:
head = read_head(client)
if not head:
client.close()
return
first = head.split(b'\r\n', 1)[0].decode('latin-1')
parts = first.split(' ')
if len(parts) < 3:
client.close()
return
method, target = parts[0].upper(), parts[1]
rest = head.split(b'\r\n\r\n', 1)[1] if b'\r\n\r\n' in head else b''
if method == 'CONNECT':
host, _, port = target.rpartition(':')
port = int(port or 443)
log('CONNECT %s:%d' % (host, port))
remote = socket.create_connection((host, port), timeout=20)
client.sendall(b'HTTP/1.1 200 Connection Established\r\n\r\n')
client.settimeout(None)
remote.settimeout(None)
t = threading.Thread(target=pump, args=(client, remote), daemon=True)
t.start()
pump(remote, client)
else:
# 明文 HTTP:target 一般是绝对 URI
if target.startswith('http://'):
without = target[len('http://'):]
hostport = without.split('/', 1)[0]
path = '/' + without.split('/', 1)[1] if '/' in without else '/'
else:
hostport = None
for ln in head.split(b'\r\n'):
if ln.lower().startswith(b'host:'):
hostport = ln.split(b':', 1)[1].strip().decode()
path = target
if not hostport:
client.close()
return
host, _, port = hostport.rpartition(':')
port = int(port or 80)
log('HTTP %s%s' % (hostport, path))
remote = socket.create_connection((host, port), timeout=20)
req = ('%s %s HTTP/1.1\r\n' % (method, path)).encode()
hdrs = head.split(b'\r\n')[1:]
for h in hdrs:
if h.lower().startswith(b'proxy-connection'):
continue
req += h + b'\r\n'
req += b'\r\n' + rest
remote.sendall(req)
client.settimeout(None)
remote.settimeout(None)
t = threading.Thread(target=pump, args=(client, remote), daemon=True)
t.start()
pump(remote, client)
except Exception as e:
log('ERR %s %s' % (addr, e))
try:
client.close()
except Exception:
pass
def main():
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(('0.0.0.0', PORT))
srv.listen(128)
log('proxy listening on 0.0.0.0:%d' % PORT)
while True:
c, a = srv.accept()
threading.Thread(target=handle, args=(c, a), daemon=True).start()
if __name__ == '__main__':
main()