From c66658b6b390e0c23d5811b0e11bbcc9a45fb92c Mon Sep 17 00:00:00 2001 From: inliver233 Date: Sat, 19 Sep 2026 21:25:35 +0800 Subject: [PATCH] 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. --- .gitignore | 36 +++ LICENSE | 21 ++ README.md | 98 +++++++++ SKILL.md | 90 ++++++++ build_scripts.py | 84 +++++++ references/ad-removal.md | 97 +++++++++ references/dex-patching.md | 195 +++++++++++++++++ references/dynamic-frida.md | 129 +++++++++++ references/environment.md | 124 +++++++++++ references/membership-and-limits.md | 90 ++++++++ references/pitfalls.md | 250 +++++++++++++++++++++ references/recon.md | 112 ++++++++++ references/repack-and-sign.md | 125 +++++++++++ references/runtime-data.md | 112 ++++++++++ references/server-api.md | 92 ++++++++ references/verification.md | 119 ++++++++++ scripts/datastore_inject.py | 66 ++++++ scripts/devsh.py | 109 ++++++++++ scripts/dex_classdiff.py | 101 +++++++++ scripts/dex_strings.py | 146 +++++++++++++ scripts/dex_strpatch.py | 117 ++++++++++ scripts/dexpatch/PatchMethod.java | 97 +++++++++ scripts/dexpatch/PatchMethodExample.java | 108 +++++++++ scripts/dexpatch/README.md | 78 +++++++ scripts/find_refs.py | 112 ++++++++++ scripts/grab_crash.py | 104 +++++++++ scripts/install_test.py | 125 +++++++++++ scripts/patch_smali.py | 99 +++++++++ scripts/probe_api.py | 121 +++++++++++ scripts/repack.py | 266 +++++++++++++++++++++++ scripts/smali_cp.txt | 10 + scripts/smtool.py | 121 +++++++++++ scripts/usb_net_proxy.py | 159 ++++++++++++++ 33 files changed, 3713 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 SKILL.md create mode 100644 build_scripts.py create mode 100644 references/ad-removal.md create mode 100644 references/dex-patching.md create mode 100644 references/dynamic-frida.md create mode 100644 references/environment.md create mode 100644 references/membership-and-limits.md create mode 100644 references/pitfalls.md create mode 100644 references/recon.md create mode 100644 references/repack-and-sign.md create mode 100644 references/runtime-data.md create mode 100644 references/server-api.md create mode 100644 references/verification.md create mode 100644 scripts/datastore_inject.py create mode 100644 scripts/devsh.py create mode 100644 scripts/dex_classdiff.py create mode 100644 scripts/dex_strings.py create mode 100644 scripts/dex_strpatch.py create mode 100644 scripts/dexpatch/PatchMethod.java create mode 100644 scripts/dexpatch/PatchMethodExample.java create mode 100644 scripts/dexpatch/README.md create mode 100644 scripts/find_refs.py create mode 100644 scripts/grab_crash.py create mode 100644 scripts/install_test.py create mode 100644 scripts/patch_smali.py create mode 100644 scripts/probe_api.py create mode 100644 scripts/repack.py create mode 100644 scripts/smali_cp.txt create mode 100644 scripts/smtool.py create mode 100644 scripts/usb_net_proxy.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..de9bdb2 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2dc2dfb --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..83029de --- /dev/null +++ b/README.md @@ -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.: + +``` +/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. diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..867dc28 --- /dev/null +++ b/SKILL.md @@ -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 | diff --git a/build_scripts.py b/build_scripts.py new file mode 100644 index 0000000..b608c93 --- /dev/null +++ b/build_scripts.py @@ -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 +""" +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() diff --git a/references/ad-removal.md b/references/ad-removal.md new file mode 100644 index 0000000..a6c4fbc --- /dev/null +++ b/references/ad-removal.md @@ -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. diff --git a/references/dex-patching.md b/references/dex-patching.md new file mode 100644 index 0000000..5bf7085 --- /dev/null +++ b/references/dex-patching.md @@ -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 '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 out = new ArrayList<>(); +for (ClassDef cd : dex.getClasses()) { + List direct = new ArrayList<>(); for (Method m : cd.getDirectMethods()) direct.add(m); + List 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 (`_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;->b:Lkw2; # downstream collector + const-wide v1, + 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 "" "" +``` + +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 +python scripts/patch_smali.py [--dry-run] +python scripts/smtool.py a +``` + +Then **must** verify (P3 will bite otherwise): + +```bash +python scripts/dex_classdiff.py +``` +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 '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. diff --git a/references/dynamic-frida.md b/references/dynamic-frida.md new file mode 100644 index 0000000..f99f181 --- /dev/null +++ b/references/dynamic-frida.md @@ -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--android- /data/local/tmp/ + adb shell "su -c 'mkdir -p /data/local/tmp/.svc'" + adb shell "su -c 'cp -f /data/local/tmp/frida-server--android- /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(/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 diff --git a/references/environment.md b/references/environment.md new file mode 100644 index 0000000..d755b19 --- /dev/null +++ b/references/environment.md @@ -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 shell getprop ro.product.cpu.abi +adb -s shell getprop ro.build.version.release +adb -s shell getprop ro.build.version.sdk +``` + +Root: +```bash +adb -s shell "su -c id" +``` +`pm grant` and `pm install` frequently require root on OEM builds; the `shell` user gets `SecurityException`. + +**Always specify `-s ` 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 reverse tcp:8080 tcp:8080 + +# 3) point the device at it +adb -s 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 shell "su -c 'pm uninstall '" +adb push out.apk /data/local/tmp/x.apk +adb -s 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 : /data/user/0/" +su -c "restorecon -R /data/user/0/" +``` +`` from `dumpsys package | grep userId=`. + +## Signal extraction (what to actually read) + +```bash +adb -s logcat -c # clear before the run +adb -s shell "am start -n /" +adb -s logcat -d -v brief | grep -E '|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: ` | 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 `//...` 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 /*.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). diff --git a/references/membership-and-limits.md b/references/membership-and-limits.md new file mode 100644 index 0000000..2290788 --- /dev/null +++ b/references/membership-and-limits.md @@ -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 ` → **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: +Gate owner: client | server | mixed (evidence: ...) +Achievable: +Not achievable: +Residual: +Verified on: +``` diff --git a/references/pitfalls.md b/references/pitfalls.md new file mode 100644 index 0000000..30e4ff5 --- /dev/null +++ b/references/pitfalls.md @@ -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 "" 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 +I/ActivityManager: Force stopping 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` fields. An entry needs **two** levels of tag: + +``` +outer : 0A +inner : 0A 12 +``` + +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 : /data/user/0/`. +- 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//` 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. diff --git a/references/recon.md b/references/recon.md new file mode 100644 index 0000000..50c0493 --- /dev/null +++ b/references/recon.md @@ -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 --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 --urls +python scripts/dex_strings.py --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 --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) +``` diff --git a/references/repack-and-sign.md b/references/repack-and-sign.md new file mode 100644 index 0000000..1616778 --- /dev/null +++ b/references/repack-and-sign.md @@ -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 --ksPass --ksKeyPass -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: --key-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 \ + -keyalg RSA -keysize 2048 -validity 36500 \ + -storepass -keypass \ + -dname "CN=, 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/` directory owned by the old uid is unreadable. Symptom: crash in a DB-init path (`Cannot open database`). Fix: + ``` + su -c "chown -R : /data/user/0/" + su -c "restorecon -R /data/user/0/" + ``` + Get `` from `dumpsys package | 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). diff --git a/references/runtime-data.md b/references/runtime-data.md new file mode 100644 index 0000000..38c4506 --- /dev/null +++ b/references/runtime-data.md @@ -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// (== /data/user/0/) +├── files/ +│ ├── datastore/*.preferences_pb AndroidX DataStore (protobuf) +│ └── ... +├── shared_prefs/*.xml SharedPreferences +├── databases/*.db SQLite (Room, etc.) +└── / crash logs, download state, ad configs +``` + +Read with root: +```bash +adb shell "su -c 'ls -la /data/data//files/datastore/'" +adb shell "su -c 'xxd /data/data//files/datastore/.preferences_pb | head -20'" +``` + +## AndroidX DataStore (protobuf) + +Format: +```proto +PreferenceMap { map 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 +inner (one map entry) : 0A 12 +value payload (e.g. int64) : 20 # field 4, wire type 0 +value payload (e.g. int32) : 18 # field 3, wire type 0 +value payload (e.g. bool) : 08 <0x00|0x01> # field 1, wire type 0 +value payload (e.g. string) : 2A # 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 ` (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 (``, ``, ``, ``). Same ownership rules. + +## SQLite + +```bash +adb shell "su -c 'sqlite3 /data/data//databases/.db \".tables\"'" +adb shell "su -c 'sqlite3 ... \"select * from 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 | grep userId=" | tr -dc '0-9') +adb shell "su -c 'chown -R $uid:$uid /data/user/0/'" +adb shell "su -c 'restorecon -R /data/user/0/'" +``` +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 `_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`). diff --git a/references/server-api.md b/references/server-api.md new file mode 100644 index 0000000..565b41c --- /dev/null +++ b/references/server-api.md @@ -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 --urls +python scripts/dex_strings.py --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:///resolve?name=&type=txt + # returns something like "https://" + ``` + 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 --path /some/path --header 'X-App-Name: ' +``` + +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 ` 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 +``` diff --git a/references/verification.md b/references/verification.md new file mode 100644 index 0000000..0a2056d --- /dev/null +++ b/references/verification.md @@ -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 --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 +``` +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 logcat -c +adb -s shell "am start -n /" +sleep 5; adb -s shell "pidof " # empty == died +sleep 20; adb -s shell "pidof " # same pid == stable +adb -s 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: +Base: +Changes: , one line each +Structural: dex_classdiff result (per dex) +Signature: verified (v1/v2/v3) +Device: +Runtime: pid stable over N s; no fatal signatures +Behavior: , +Regressions: +Rung reached: 0..6 +Residual: +``` + +**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. diff --git a/scripts/datastore_inject.py b/scripts/datastore_inject.py new file mode 100644 index 0000000..45c522a --- /dev/null +++ b/scripts/datastore_inject.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""向 app 的 DataStore(video_preferences) 注入 ad_free_expires_at 大值,验证它能否抑制广告 UI。 + +Preferences 的 protobuf 结构: + PreferenceMap { map preferences = 1; } + entry: 0x0A 0x12 + Value : boolean=1(float wire), float=2, integer=3(varint), long=4(varint), string=5, ... + +所以要写一个 longKey:"ad_free_expires_at" -> Value{ long = 大值 } + Value 载荷 = 0x20 (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 preferences = 1 是一个 map 字段, + 编码时必须**两层** 0x0A: + 外层: 0x0A <- 字段1的 tag + 长度 + 内层: 0x0A 0x12 + 只写内层(丢掉外层 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() diff --git a/scripts/devsh.py b/scripts/devsh.py new file mode 100644 index 0000000..1d0eb05 --- /dev/null +++ b/scripts/devsh.py @@ -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 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()) diff --git a/scripts/dex_classdiff.py b/scripts/dex_classdiff.py new file mode 100644 index 0000000..f8ddc7f --- /dev/null +++ b/scripts/dex_classdiff.py @@ -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 [过滤子串] +""" +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(' 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() diff --git a/scripts/dex_strings.py b/scripts/dex_strings.py new file mode 100644 index 0000000..b7ac8ef --- /dev/null +++ b/scripts/dex_strings.py @@ -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 --urls + + # find which dex files contain a marker (ASCII substring) + python dex_strings.py --find 'openadsdk|com.qq.e|anythink' --per-file + + # dump a class/package inventory per dex (type descriptors) + python dex_strings.py --classes 'Lcom/example/app/' + + # raw string-table dump, length-filtered + python dex_strings.py --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('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 +要求 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(' 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: )。所以这里先做区间校验。 + 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(' + * 例: 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 "); + 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 outClasses = new ArrayList(); + int patched = 0; + + for (ClassDef cd : dex.getClasses()) { + List direct = new ArrayList(); + for (Method m : cd.getDirectMethods()) direct.add(m); + List virtual = new ArrayList(); + 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 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); + } + } +} diff --git a/scripts/dexpatch/PatchMethodExample.java b/scripts/dexpatch/PatchMethodExample.java new file mode 100644 index 0000000..456bfc5 --- /dev/null +++ b/scripts/dexpatch/PatchMethodExample.java @@ -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 outClasses = new ArrayList(); + int p = 0; + + for (ClassDef cd : dex.getClasses()) { + List direct = new ArrayList(); + for (Method m : cd.getDirectMethods()) direct.add(m); + List virtual = new ArrayList(); + 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 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 body = new ArrayList(); + // 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); + } +} diff --git a/scripts/dexpatch/README.md b/scripts/dexpatch/README.md new file mode 100644 index 0000000..e1f8b5e --- /dev/null +++ b/scripts/dexpatch/README.md @@ -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 + +# 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`/`` 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 +python ../smtool.py d # 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`). diff --git a/scripts/find_refs.py b/scripts/find_refs.py new file mode 100644 index 0000000..544b660 --- /dev/null +++ b/scripts/find_refs.py @@ -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 'Lcom/pkg/Helper;->showAd(Landroid/app/Activity;)V' + + # a whole class (all of its members) + python find_refs.py 'Lcom/pkg/Helper;' + + # a field + python find_refs.py 'Lcom/pkg/Helper;->count:I' + + # just the counts, no listing + python find_refs.py '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()) diff --git a/scripts/grab_crash.py b/scripts/grab_crash.py new file mode 100644 index 0000000..f2e741b --- /dev/null +++ b/scripts/grab_crash.py @@ -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: + +...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/; 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()) diff --git a/scripts/install_test.py b/scripts/install_test.py new file mode 100644 index 0000000..0809467 --- /dev/null +++ b/scripts/install_test.py @@ -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()) diff --git a/scripts/patch_smali.py b/scripts/patch_smali.py new file mode 100644 index 0000000..36f9c5e --- /dev/null +++ b/scripts/patch_smali.py @@ -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 [--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()) diff --git a/scripts/probe_api.py b/scripts/probe_api.py new file mode 100644 index 0000000..c588920 --- /dev/null +++ b/scripts/probe_api.py @@ -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()) diff --git a/scripts/repack.py b/scripts/repack.py new file mode 100644 index 0000000..fa93525 --- /dev/null +++ b/scripts/repack.py @@ -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 : /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()) diff --git a/scripts/smali_cp.txt b/scripts/smali_cp.txt new file mode 100644 index 0000000..022f797 --- /dev/null +++ b/scripts/smali_cp.txt @@ -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 diff --git a/scripts/smtool.py b/scripts/smtool.py new file mode 100644 index 0000000..b6a43e1 --- /dev/null +++ b/scripts/smtool.py @@ -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 # disassemble to a smali tree + python smtool.py a # 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 ') + 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 ') + 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()) diff --git a/scripts/usb_net_proxy.py b/scripts/usb_net_proxy.py new file mode 100644 index 0000000..afa33e0 --- /dev/null +++ b/scripts/usb_net_proxy.py @@ -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 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()