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