Broaden skill with unpacking, TLS triage, third-party build auditing, runtime probe

references: recon gains packer identification + unpacking workflow (payload/plaintext size mapping, dump validation, hash de-duplication, business-dex heuristic); repack-and-sign gains the de-shelled repack pipeline, why apktool over binary AXML, and the apksigner scheme-selection caveat; new tls-and-cert documents feature-scoped network failures, dual trust chains and the fix template; new third-party-builds covers auditing a cracked/modded APK before trusting it; dynamic-frida gains version pinning, remote-device path, the four-layer probe and hook-never-fires triage; environment gains UI automation pitfalls; ad-removal gains the single convergence point, dynamic-plugin SDKs and the global timestamp gate; pitfalls gains P13-P17; verification gains rung 7 and the three-signal ad check.

scripts: new frida_probe.js (four-layer runtime probe), run_probe.py and tls_check.py; repack.py fixes two crash-on-default bugs and a misleading META-INF note; datastore_inject.py, PatchMethodExample.java and build_scripts.py lose hardcoded values - the example class name did not match its file and could not compile.
This commit is contained in:
inliver233
2026-09-19 21:44:00 +08:00
parent c66658b6b3
commit 1a5cb6245f
18 changed files with 3089 additions and 304 deletions
+87 -39
View File
@@ -1,53 +1,75 @@
# Ad removal
Goal: ads stop appearing, and **nothing else breaks**. Read `references/pitfalls.md` P5 and P6 before patching anything here.
Goal: ads stop appearing, and **nothing else breaks**. Read `pitfalls.md` P5 and P6 before patching anything here.
## Step 1: identify which ads exist and who owns them
## Step 1: enumerate what "ads" means in this app
Do not assume "ads" is one thing. Enumerate first.
**SDK-integrated ads** (the app calls an SDK):
- Search dex strings for SDK markers: `openadsdk`, `TTAdSdk`, `TTAdNative`, `Pangle`, `pangolin`, `com.qq.e`, `GDTAd`, `gdt_plugin`, `anythink`, `ATSDK`, `ATRewardVideoAd`, `bdxadsdk`, `sigmob`, `ksad`, `mobads`, `beizi`.
- **Aggregators** (AnyThink, TopOn, and similar) are wrappers: the 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.
- **Aggregators** (AnyThink, TopOn, and similar) are wrappers: the ad networks underneath are *their* adapters, not app code. Patching an individual network's class is usually pointless; the aggregator still runs and still reaches the network.
- The app almost always funnels every call into **one wrapper class**. That class is the patch surface (Step 2).
**Dynamic-plugin ad SDKs** (the SDK is not in the APK). Aggregators increasingly ship as a runtime plugin: the app downloads an APK on first launch into its private directory and loads it through a plugin classloader.
```
/data/data/<app.package>/files/<sdk>_p/<plugin.pkg>/version-*/apk/base-1.apk
/data/data/<app.package>/files/<sdk>_p/<plugin.pkg>/version-*/lib/<abi>/*
```
- Consequence: **deleting bundled `assets/`, `lib/*.so`, or the SDK's classes from the dex does not kill this SDK.** The loader re-downloads on the next launch, and a half-deleted plugin is worse than an intact one.
- The kill switch is the **init gate** (Step 2). Killing init also prevents the working directory from ever being created, which is the strongest verification signal available (Step 4).
- The plugin may also be bundled. Two copies still means one init gate; patch that, not the payload.
**Server-driven ads / sponsored content** (the server returns the ad, the client renders it):
- 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.
- The client renders whatever the list contains. If the list is empty there is nothing to show, but see `pitfalls.md` P5 on **how not** to empty it.
- Sponsored cards that look like content (a VPN promo, a network-accelerator card, a third-party product) are usually exactly this.
**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
## Step 2: find the single convergence point
Find the app's own helper and enumerate its public surface. Typical shape:
Almost every ad-enabled app wraps one SDK in a **single singleton helper**: `init`, `loadSplash`, `loadBanner`, `loadFullScreen`, `loadReward` all live in the same class. Find that class, and patch nothing else if you can.
```
helper.init(Application) -> Sdk.init(appId, appKey); Sdk.start()
helper.init(Application) -> Sdk.init(appId, appKey); Sdk.start() // plus plugin download
helper.showSplash(Activity, ViewGroup, onClose: () -> Unit)
helper.showInterstitial(Activity)
helper.showReward(Activity, onStart: () -> Unit, onFinish: (Boolean) -> Unit)
helper.preload*(Activity)
helper.canShow*(Activity): Boolean
helper.canShow*(Activity): Boolean // == isReady / isLoaded / isAdReady
```
Read each method body and classify:
**Preferred play: readiness always false + init is a no-op.**
| Method | Contains | Safe patch |
1. Make the readiness predicate constant `false` (`isReady` / `canShow*` / `isLoaded` / `isAdReady`).
2. Make `init` an empty method (`return-void`) so the SDK never builds a network stack and never requests a plugin.
3. Only then touch show/load methods, and only for slots that bypass the readiness predicate.
Why this order works:
- Callers of a readiness predicate **already own a "no ad available" branch**, and the app ships and tests it. Returning `false` routes every slot through that existing path: no crash, no hang, no new code path.
- Killing init removes the SDK's network, cache, and download activity entirely. That is the difference between "the ad is hidden" and "the ad subsystem never started", and only the second claim is provable (Step 4).
- Per-slot patching (splash this week, interstitial next) leaves the SDK alive and downloading. Avoid it unless a slot ignores the predicate.
Method classification, once the helper is located:
| Method | Contains | Patch |
|---|---|---|
| Init | `Sdk.init(...)` + `Sdk.start()` | 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` |
| Init | `Sdk.init(...)` + `Sdk.start()`, plugin download | body -> `return-void` |
| Readiness / gate | `isAdReady`, `canShow`, `isLoaded` | return `false` / `0` |
| Show splash / interstitial | constructs an ad object, `loadAd()` / `show()` | `return-void`, **but preserve the completion callback** |
| Show reward | same, plus success/failure lambdas | `return-void`; decide deliberately whether to grant the reward |
| Preload / warm-up | `RewardVideoAutoAd.init(...)`, cache priming | `return-void` |
### The callback trap (this one breaks startup)
A splash/loading ad usually takes a **completion lambda** as a parameter. The app's startup state machine waits for that lambda before it dismisses the splash screen.
A splash/loading ad usually takes a **completion lambda** as a parameter, and the app's startup state machine waits for that lambda before dismissing the splash screen.
**If you replace the method with a bare `return-void`, the lambda never fires, and the app hangs forever on the splash screen.**
**If you replace the method with a bare `return-void`, the lambda never fires and the app hangs on the splash screen forever.** A patch that "removed the ad" and produced a frozen splash is this bug, not a repackaging bug.
Correct shape — skip the ad, still complete:
Correct shape, skip the ad and still complete:
```
.method public final showSplash(Landroid/app/Activity;Landroid/view/ViewGroup;Lkotlin/jvm/functions/Function0;)V
@@ -57,34 +79,59 @@ Correct shape — skip the ad, still complete:
.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.
**Before patching, prove the contract.** Read the listener class the method constructs and find which callback triggers the completion lambda (`onAdDismiss`? `onAdError`? `onAdLoadTimeout`?). That tells you whether to call it on success, on failure, or unconditionally.
For a reward ad whose lambdas are `(onStart, (Boolean) -> Unit)`, granting the reward client-side is a choice: `onFinish(true)` will make the app treat the reward as earned. Check whether the reward is validated server-side before promising it.
For a reward ad whose lambdas are `(onStart, (Boolean) -> Unit)`, granting the reward client-side is a choice: `onFinish(true)` makes the app treat the reward as earned. Check whether the reward is validated server-side before promising it.
### Global timestamp gates (one write, many slots)
Some "free reading + ads" apps do not gate ads per slot. They store one local timestamp and every slot, cooldown, and interval check reads it: splash, chapter-break interstitial, unlock prompt, "next ad in N minutes". Patching call sites one by one always misses some.
- **Find it:** dump the app's preference keys and filter for ad-ish names (`ad`, `free`, `cooldown`, `interval`, `end_time`, `expire`, `next_`). Then hook `SharedPreferences.getLong/getInt/getString` and watch which key is read immediately before a slot appears. The key read across several screens is the gate.
- **Patch it:** in `Application.onCreate()`, write a far-future value as early as possible (for example `4102444800000L`). Avoid `Long.MAX_VALUE`: implementations often compute `end - now` and can misjudge an extreme value. One write disables every reader at once, which is far more stable than editing each consumer.
- **If the app overwrites it later:** observe what it writes back under different start sequences. A single startup write is not automatically permanent for keys the app re-asserts; then patch the writer or the reader instead.
- **Same technique, two key semantics:** "ad-free until `<date>`" keys and "cooldown/interval" keys both respond to this, but the value differs. For a cooldown key, a far-future value means "next ad is never due", which is what you want, while a progress/check-in flow reading the same key may behave oddly.
- **Side effects are mandatory in the delivery notes.** The same key is often reused as a business field (the anchor for a new-user ad-free window, a remaining-quota counter). Forcing it to a far-future value changes those business judgements. Usually harmless for a de-ad goal, but state it explicitly and confirm the related screens do not show a broken state.
- With DataStore instead of SharedPreferences, a write inside `Application.onCreate` needs a coroutine or a blocking write; see `runtime-data.md`, and never block the main thread on persistence.
### "Ads" vs ad-gating UI
Some blocking dialogs contain no ad slot at all ("watch an ad to unlock", skip/continue prompts, cooldown notices). If the goal is "the app is usable", they count as ads and must be handled too. Handle them by letting the gate pass, not by deleting the dialog view.
- Where the gate reads the timestamp key above, the timestamp patch already covers it.
- Where it reads a boolean or a `canUnlock()` method, patch that predicate.
- **Business-coupled entrances need a check first.** "Watch a video to earn benefits" may be a real feature. Decide with evidence: find the callers (`scripts/find_refs.py`) and see whether the entrance leads to a **server request** or a **local entitlement write**. A server-issued entitlement cannot be created client-side, so releasing the gate only fools the UI while the server still refuses; an entrance that changes purely local UI state is safe to release.
## Step 3: choose the patch layer
From safest to riskiest (see `references/dex-patching.md` for the full table):
From safest to riskiest (full technique table in `dex-patching.md`):
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.
1. **Kill SDK init** (Step 2) — the SDK never starts; its networks, caches, and plugin downloads never happen. Highest-value single patch. Verify nothing else silently depended on init.
2. **Neutralize the readiness gate** — `isReady`/`canShow` return false; routes existing "no ad" branches.
3. **No-op the show/preload methods** — only when 1 and 2 are insufficient, always preserving callbacks.
4. **Global gate state** — one startup write covers every reader (timestamp / preference keys above).
5. **Filter server-issued ad data at the consumption layer** — drop entries for the target position before they reach UI state.
6. *(avoid)* **Renderer-level suppression** — only if the component is genuinely ad-exclusive. Check `pitfalls.md` P6 first.
7. *(avoid)* **Transport-level blocking** — see `pitfalls.md` P5.
## Step 4: verify — and verify it properly
## Step 4: verify — three independent kinds of evidence
Logcat silence alone is weak evidence. Use several independent signals:
Logcat silence alone is weak evidence. A credible "ads are gone" claim needs all three, plus the neighbours:
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.
1. **UI, screen by screen** — splash, home banner, detail page, reader/player, reward button, unlock prompt. Capture before/after screenshots (`verification.md` §screenshot discipline).
2. **Logs** — the SDK's characteristic tags are **entirely absent**, not merely quiet: `anythink`, `ATSDK`, `Pangle`, `TTAd`, `GDT`, `ksad`, and so on.
3. **Filesystem, the strongest signal** — the app's private directory contains **no ad SDK working directory at all**:
```bash
adb shell "su -c 'ls -la /data/data/<app.package>/files/'"
```
For a dynamic-plugin SDK, an absent `<sdk>_p` tree proves the init gate never ran. It is not an artifact you can fake by hiding a view. If the tree exists, something still initializes the SDK: the init patch did not take effect, or a second init path exists.
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.
Additional cheap evidence:
4. **DNS / socket level** — a runtime `InetAddress` hook. Before: a burst of ad/tracker domains. After: none. Many ad SDKs bypass the system proxy, so a proxy log can miss them while this does not.
5. **Exercise the screens that had ads** and confirm nothing unrelated regressed: images, playback, lists, pagination, downloads, login.
6. **Clean install** — uninstall first, then install. Data left from a previous run can hide a failed init patch (`pitfalls.md` P11).
Frame the result at the right strength: *"the ad was hidden"* vs *"the ad subsystem never started"*. Only evidence 3 and 4 support the second (`verification.md` §the claim ladder).
## Step 5: what is usually NOT removable
@@ -92,6 +139,7 @@ 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.
- **Ads whose SDK init also enables other features.** Removing init can break functionality that silently depended on it. Verify before shipping; a working app with one residual ad beats a broken app.
- **Server-issued entitlements gated behind watching an ad.** The gate can be opened client-side, the entitlement cannot be created client-side.
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.
+252 -21
View File
@@ -9,31 +9,68 @@ Use Frida when you need to answer:
- Which domains does it resolve, and when?
- Is the app detecting my instrumentation?
## Setup
## Setup — version alignment is a hard gate
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.
**Do this first.** Nearly every "Frida is broken on this device" report is a version mismatch, and the error text rarely says so. The host `frida` package and the device-side `frida-server` must be the **same version**, and that version must actually support the device's Android release. Align, then debug everything else.
| Symptom | Cause | Fix |
|---|---|---|
| `unable to locate Android dynamic linker` | host/server too new for this Android release | drop to an older frida line (16.x is a safe baseline for older ROMs) |
| `Java is not defined` / `Java.perform is not a function` | that build ships no bundled Java bridge | align to a build that has it, or inline the bridge (see *17+ gotchas*) |
| `Failed to connect to remote frida-server` / `unexpected message` / `invalid message` | host package and device server differ | download both from the same release tag |
| `Java.choose` / `Java.use` throws immediately | bridge present but the VM is not ready | wrap everything in `Java.perform` |
| Attached, but hooks fire in the wrong process | USB auto-selection grabbed the emulator | use an explicit remote device (below) |
| `HOOK-OK` prints and nothing ever fires | not a version problem | go to *Hook never fires* |
### Prefer a remote device over USB
With a physical device *and* an emulator attached, `frida.get_usb_device()` can silently pick the emulator — you then attach to the wrong process and chase phantom failures for a long time. Address the device explicitly:
```bash
adb devices -l # get <serial>
adb -s <serial> forward tcp:27042 tcp:27042
frida -H 127.0.0.1:27042 -f <app.package> -l probe.js --no-pause
```
```python
device = frida.get_device_manager().add_remote_device('127.0.0.1:27042')
```
Keep `-s <serial>` on every other adb call too, or you will drift between targets mid-session.
### Start the device server so it survives
Run it as root, give it a non-obvious name out of obvious paths, and **detach it from the shell's session**. A process started with `nohup ... &` from an `adb shell` that then exits gets reaped mid-run — the hooks work for a minute and then stop.
```bash
adb push frida-server-<ver>-android-<abi> /data/local/tmp/
adb shell "su -c 'mkdir -p /data/local/tmp/.svc'"
adb shell "su -c 'cp -f /data/local/tmp/frida-server-<ver>-android-<abi> /data/local/tmp/.svc/kwork'"
adb shell "su -c 'chmod 755 /data/local/tmp/.svc/kwork'"
adb shell "su -c 'setsid /data/local/tmp/.svc/kwork >/dev/null 2>&1 </dev/null &'"
adb shell "su -c 'pgrep -f kwork'" # must still be alive after the shell returned
```
If `setsid` is unavailable, hold the adb connection open instead: run `adb shell "su -c '/data/local/tmp/.svc/kwork'"` as a host-side background job and leave it running for the whole session.
### Spawn, don't attach, when timing matters
ABI must match the device (`getprop ro.product.cpu.abi`). Attaching usually misses startup — init, the first network calls, and splash logic all happen before you get a session.
```python
device = frida.get_device_manager().add_remote_device('127.0.0.1:27042')
pid = device.spawn([pkg])
session = device.attach(pid)
# ... load the script, wait until it reports that hooks are installed ...
device.resume(pid) # resume ONLY after hooks are ready
```
**Common bug:** resuming before the script has finished loading loses the first ~100–300 ms, which is exactly where init happens. Have the script `send()` a ready signal (the probe template below sends `PROBE-READY`) and resume only after receiving it.
## Frida 17+ gotchas
The preferred fix for every symptom in the table above is **version alignment**. Use these only when you genuinely must run a 17+ build.
- The **built-in Java bridge was removed**. `Java.perform(...)` is not available unless you inline the bridge yourself:
```python
bridge = open(<site-packages>/frida_tools/bridges/java.js, encoding='utf-8').read()
@@ -100,6 +137,200 @@ Inet.getAllByName.overload('java.lang.String').implementation = function (h) {
```
This is often the **decisive evidence** for an ad-removal claim: if the ad SDK's domains are *never resolved*, the subsystem never started — a much stronger statement than "logcat was quiet". It also works for SDKs that bypass the system HTTP proxy.
## The four-layer probe template
When the question is "why did this request fail / where did it go", do not hook one class and hope. Ship **one long-lived script that hooks four layers at once**; whichever layer fires first localizes the problem immediately. This is the single most productive artifact of a runtime investigation — reuse it verbatim.
The layers, and why each one is there:
1. **The app's own network wrapper** — enumerate `getDeclaredMethods` and wrap *every* overload, so you do not have to guess the entry point.
2. **OkHttp end to end** — `newCall`, `Request$Builder.build`, `RealCall.execute`, `AsyncCall.run`, `RealInterceptorChain.proceed`. The last two are the ones that fire for asynchronous calls.
3. **`java.net.URL.openConnection`** — plenty of login/register paths never touch OkHttp; they use `HttpsURLConnection` and a completely different trust configuration.
4. **`Throwable.getMessage`** — pulls out the original text of exceptions an upper layer caught and swallowed. Without it, a caught failure looks like "nothing happened".
Rules that make the difference between a usable probe and a wasted session:
- **Write to a file as well as to `send()`.** Stdout is lossy and the CLI drops messages; the file is the evidence.
- **Let it stay resident.** Hook before the app touches the network, then leave it running while you drive the UI. A one-shot script misses everything that happens after its first second.
- **Wrap every hook in its own `try/catch`.** One missing class must not take down the other three layers.
- **Never capture an overload in a `var`.** Inside a loop, `var ov = ...` leaves every hook pointing at the *last* overload: they install cleanly and then mis-report forever. Use `let` or `.forEach()`.
`scripts/frida_probe.js` is the same probe in a fuller form (per-arity dispatch, DNS layer, dedup and hard caps). The script below is the minimal portable one — paste it into any loader and it adapts to the target.
```javascript
// probe.js — four-layer network + swallowed-exception probe.
const APP_PKG = '<app.package>';
const APP_NET_CLASS = 'com.example.app.net.HttpHelper'; // the app's own wrapper, once you have found it
const LOGFILE = '/data/user/0/' + APP_PKG + '/files/probe.log'; // app-private: always writable by the app
const MAX_THROW = 400; // Throwable.getMessage is hot — cap it
const INTERESTING = /(network|http|ssl|cert|fail|timeout|refused|unable|error|exception)/i; // widen for the app's UI language
let out = null;
try { out = new File(LOGFILE, 'a'); } catch (e) {}
function log(ev, data) {
const line = JSON.stringify(Object.assign({ t: Date.now(), ev: ev }, data));
try { send(line); } catch (e) {}
try { if (out !== null) { out.write(line + '\n'); out.flush(); } } catch (e) {}
}
function stack(n) {
try {
const t = Java.use('java.lang.Throwable').$new();
return Java.use('android.util.Log').getStackTraceString(t).split('\n').slice(1, (n || 6) + 1).join(' | ');
} catch (e) { return '<no stack>'; }
}
function sa(args) { // a hook must never die inside its own logging
try {
const o = {};
for (let i = 0; i < args.length; i++) o['a' + i] = args[i] === null ? 'null' : '' + args[i];
return o;
} catch (e) { return { err: '' + e }; }
}
// 1) the app's own wrapper: every declared method, every overload, separately wrapped
function hookAppNet(cls) {
try {
const C = Java.use(cls);
const ms = C.class.getDeclaredMethods();
for (let i = 0; i < ms.length; i++) {
try {
const name = ms[i].getName();
const ps = ms[i].getParameterTypes();
const sig = [];
for (let j = 0; j < ps.length; j++) sig.push(ps[j].getName());
const ov = C[name].overload.apply(C[name], sig);
ov.implementation = function () {
try { log('APP-NET', { cls: cls, m: name, args: sa(arguments), stack: stack(5) }); }
catch (e) { log('PROBE-ERR', { at: 'app-net', msg: '' + e }); }
return ov.apply(this, arguments);
};
} catch (e) { log('HOOK-SKIP', { cls: cls, m: ms[i].getName(), msg: '' + e }); }
}
log('HOOK-OK', { layer: 'app-net', cls: cls, n: ms.length });
} catch (e) { log('HOOK-FAIL', { layer: 'app-net', cls: cls, msg: '' + e }); }
}
// 2) OkHttp end to end — internal class names moved between major versions, so try every location
function hookOkHttp() {
const targets = [
['okhttp3.OkHttpClient', 'newCall'],
['okhttp3.Request$Builder', 'build'],
['okhttp3.RealCall', 'execute'],
['okhttp3.RealCall$AsyncCall', 'run'],
['okhttp3.internal.http.RealInterceptorChain', 'proceed'], // okhttp 3.x
['okhttp3.internal.connection.RealInterceptorChain', 'proceed'] // okhttp 4.x / 5.x
];
for (let i = 0; i < targets.length; i++) {
const cn = targets[i][0], mn = targets[i][1];
try {
const ovs = Java.use(cn)[mn].overloads; // every overload of that name; use .overload('<sig>') if this is undefined
for (let j = 0; j < ovs.length; j++) {
const ov = ovs[j]; // block-scoped: every hook keeps its own overload
ov.implementation = function () {
try { log('OKHTTP', { cls: cn, m: mn, args: sa(arguments), stack: stack(6) }); }
catch (e) { log('PROBE-ERR', { at: cn, msg: '' + e }); }
return ov.apply(this, arguments);
};
}
log('HOOK-OK', { layer: 'okhttp', cls: cn, m: mn, overloads: ovs.length });
} catch (e) { log('HOOK-SKIP', { layer: 'okhttp', cls: cn, m: mn, msg: '' + e }); }
}
}
// 3) java.net.URL — login/register frequently bypasses OkHttp entirely
function hookRawUrl() {
try {
const U = Java.use('java.net.URL');
const o0 = U.openConnection.overload();
o0.implementation = function () {
try { log('RAW-URL', { url: '' + this.toString(), stack: stack(6) }); } catch (e) {}
return o0.apply(this, arguments);
};
try {
const o1 = U.openConnection.overload('java.net.Proxy');
o1.implementation = function () {
try { log('RAW-URL', { url: '' + this.toString(), proxy: true, stack: stack(6) }); } catch (e) {}
return o1.apply(this, arguments);
};
} catch (e) {}
log('HOOK-OK', { layer: 'url' });
} catch (e) { log('HOOK-FAIL', { layer: 'url', msg: '' + e }); }
}
// 4) Throwable.getMessage — the original text of an exception an upper layer caught and swallowed
function hookThrowable() {
try {
const T = Java.use('java.lang.Throwable');
const seen = {};
let n = 0;
T.getMessage.implementation = function () {
const msg = this.getMessage(); // self-call inside the replacement is fine (Frida guards it)
try {
if (msg !== null && msg !== undefined && n < MAX_THROW) {
const cls = '' + this.getClass().getName();
const interesting = cls.indexOf('Exception') >= 0 || cls.indexOf('Error') >= 0 || INTERESTING.test('' + msg);
const key = cls + '|' + msg;
if (interesting && seen[key] === undefined) { // dedup: this method is on a very hot path
seen[key] = 1; n++;
log('THROW', { cls: cls, msg: '' + msg, stack: stack(4) });
}
}
} catch (e) {}
return msg;
};
log('HOOK-OK', { layer: 'throwable' });
} catch (e) { log('HOOK-FAIL', { layer: 'throwable', msg: '' + e }); }
}
Java.perform(function () {
hookAppNet(APP_NET_CLASS);
hookOkHttp();
hookRawUrl();
hookThrowable();
log('PROBE-READY', { pkg: APP_PKG, logfile: LOGFILE }); // the host waits for this before resuming
});
```
Read the log after driving the UI. `RAW-URL` (with no `OKHTTP` events) is how you learn the request went over `HttpsURLConnection` — a different trust path entirely (`references/server-api.md` § TLS). `THROW` is how you learn an upper layer swallowed the real error.
```bash
# pull the record (app-private path needs root to read)
adb -s <serial> shell "su -c 'cat /data/user/0/<app.package>/files/probe.log'" > probe.log
```
## Hook never fires — debug in this order
`HOOK-SKIP` / `HOOK-FAIL` lines answer this before you start guessing. When every layer reports `HOOK-OK` and still no event arrives:
1. **Does the class / method / overload actually exist?** A misspelled obfuscated name, a renamed okhttp internal class, or an overload taking `Object` instead of `String` all produce a wrapper that is simply never invoked.
2. **Is the ClassLoader the right one?** Multi-dex, plugin-loaded and packed apps can hold several loaders; `Java.use` uses the app loader by default and throws `ClassNotFoundException` for a class that is plainly in the APK. Enumerate and switch:
```javascript
Java.enumerateClassLoaders({
onMatch: function (l) { try { if (l.findClass('<app.net.Class>')) Java.classFactory.loader = l; } catch (e) {} },
onComplete: function () {}
});
```
3. **Is that code path reached at all?** Layer 4 settles it: if `THROW` events show the app failing earlier, your hook's call site is never executed and no amount of hooking will help.
4. **Did your UI action trigger business logic?** A tap that looks fine but fails a local form check returns before any network call. Verify the input actually reached the field (`references/environment.md` § UI automation), not just that the button animated.
## `Java.choose` also matches dead instances
`Java.choose('com.example.app.MainActivity', ...)` returns **every** instance the VM still tracks, including finished and destroyed ones. Walking their view tree then yields an empty list — that is normal, not a bug in your script. Filter first, and prefer a direct reflective call over simulating a tap:
```javascript
Java.choose('com.example.app.MainActivity', {
onMatch: function (a) {
try {
if (a.isFinishing() || a.isDestroyed()) return; // dead instance: skip it
send({ tag: 'live', view: '' + a.findViewById(<viewId>) });
} catch (e) {}
},
onComplete: function () {}
});
```
## Reading obfuscated code at runtime
When names are meaningless (`a7`, `x6`, `uf0`), do not patch from names. Instead:
+55
View File
@@ -89,6 +89,61 @@ su -c "restorecon -R /data/user/0/<pkg>"
```
`<uid>` from `dumpsys package <pkg> | grep userId=`.
## Driving the UI from adb
Automated UI interaction is where verification loops usually break, and most of the breakage looks like a bug in your script. Most of it is not.
### `input tap` may simply not work on a given control
On some OEM ROMs `input tap <x> <y>` is silently ignored for certain controls while working fine for others at neighboring coordinates. **Do not conclude that your automation is wrong.** Before doubting the script:
```bash
adb -s <serial> shell "su -c 'uiautomator dump /sdcard/ui.xml'"
adb -s <serial> pull /sdcard/ui.xml
# bounds="[x1,y1][x2,y2]" -> tap the center: ((x1+x2)/2, (y1+y2)/2)
adb -s <serial> shell "su -c 'input tap <cx> <cy>'"
```
If the control still does not react, bypass the UI path entirely — `am start -n <app.package>/<activity>`, or invoke the logic from Frida. A finished task needs the *code path*, not the tap.
`input` requires `INJECT_EVENTS`, which the plain `shell` user does not have. An unprivileged `input` **fails silently** — no error, no effect. Always wrap it: `su -c 'input tap ...'`.
`input text` additionally mangles or drops non-ASCII input. For CJK text, either install an ADB-driven helper IME or set the field from a runtime call rather than typing.
### Screen evidence without pixels
`screencap` returns a 0-byte file on some ROMs. First try writing on-device and pulling:
```bash
adb -s <serial> shell "su -c 'screencap -p /sdcard/x.png'" && adb -s <serial> pull /sdcard/x.png
```
If that is also empty, do not fight it: `uiautomator dump`'s XML is **better** evidence — text, diffable, and it records the real control tree and its contents. (If the dump fails with `could not get idle state`, retry once; a paused animation is usually the cause.)
### Verify form input by reading it back
Filling a form and tapping submit is **not** a verified interaction. Read the actual field contents and lengths back first:
```bash
adb -s <serial> shell "su -c 'input text <value>'" # no literal spaces; %s encodes one
adb -s <serial> shell "su -c 'uiautomator dump /sdcard/ui.xml'"
adb -s <serial> pull /sdcard/ui.xml
grep -o 'text="[^"]*"' ui.xml # every populated field
```
A whole class of "the button does nothing" is really a local validation rejecting the input — two password fields of different length, a required field empty, a format check. The app returns **before** issuing any request, so a network probe stays silent and the tap looks broken. Compare the lengths you read back, fix the input, retry.
### ROM background freezing kills your hooks
Aggressive ROMs freeze backgrounded apps; the logcat signature is a process state transition `state: R -> F` (running to frozen). Once frozen, hooks stop firing and network calls stop, which looks exactly like a broken probe.
```bash
adb -s <serial> shell "su -c 'dumpsys deviceidle whitelist +<app.package>'"
adb -s <serial> shell "su -c 'cmd appops set <app.package> RUN_IN_BACKGROUND allow'"
```
While debugging, **do not press HOME** — backgrounding the app is what triggers the freeze. Return to it with `am start -n <app.package>/<activity>` and keep it in the foreground for the whole session.
## Signal extraction (what to actually read)
```bash
+111
View File
@@ -248,3 +248,114 @@ Never build device commands inline in the host shell. Either:
- 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.
---
## P13. Trusting `apksigner verify` output at face value
**Symptom**
You signed with v1+v2+v3 explicitly enabled, then verification reports:
```
Verified using v1 scheme (JAR signing): false
Verified using v2 scheme (APK Signature Scheme v2): false
Verified using v3 scheme (APK Signature Scheme v3): true
```
Looks like v1/v2 silently did not happen, so you go re-engineer the signing step.
**Root cause**
`apksigner verify` decides **which schemes it is meaningful to check from the APK's own `minSdkVersion`**. When `minSdkVersion >= 24`, v1 is not required for install, and the tool reports it as `false` rather than "not applicable". The signatures are present and valid.
**Why it is hard to see**
Nothing in the output says "skipped because of minSdk". It reads exactly like a failure.
**Do instead**
Always verify with an explicit range so every scheme is evaluated:
```
apksigner verify --print-certs --verbose --min-sdk-version 21 --max-sdk-version 34 <apk>
```
Cross-check the fact independently: a real v1 signature means `META-INF/*.SF` and `META-INF/*.RSA` exist in the zip.
---
## P14. Treating same-size dex dumps as duplicates
**Symptom**
You deduplicate a memory dump by file size, keep one of each, and later find the kept dex is unusable (or silently wrong).
**Root cause**
Two dumps from the same process can have **identical byte length but different content** — different classes, even different dex version headers. One may additionally be structurally broken (header fields inconsistent with body, parser walks off the end of a table).
**Why it is hard to see**
Size equality is a tempting shortcut and is usually right for *file* duplicates. Here it is coincidence: two distinct dex objects were allocated to equal-length blocks.
**Do instead**
- Deduplicate by hash, never by size.
- Validate every candidate before trusting it: check the `dex\n0xx` magic, then sanity-check the reported `file_size` / `header_size` / map offsets against the actual byte length.
- Cross-check against the packing format when possible: with length-preserving encryption, **the encrypted payload's byte length equals the plaintext dex's byte length** — that mapping is the strongest signal for which dump is the original.
---
## P15. Frida version skew produces errors that look like a broken target
**Symptom**
Attach fails or the script dies immediately with errors such as:
```
unable to locate Android dynamic linker
Java is not defined
```
on a device where Frida is clearly running.
**Root cause**
A host `frida` package newer than the device's `frida-server` (or the reverse) is unsupported. Additionally, some newer host versions dropped the built-in Java bridge, so `Java.perform` is undefined unless you inline the bridge yourself.
**Why it is hard to see**
The error names the linker or a missing global, not a version mismatch. It reads like an Android compatibility problem or an anti-instrumentation defense.
**Do instead**
- Pin host package and device server to the **identical** version before debugging anything else. Print both versions side by side first.
- If the target is an older Android release, prefer the oldest version that still supports your API needs rather than the newest.
- On a device with multiple attached targets, do not rely on automatic USB selection — see `references/dynamic-frida.md`.
---
## P16. Blaming your own patch for a server-side TLS failure
**Symptom**
After repacking, the app launches and browsing works, but **login / registration** fails with a network error. The obvious suspect is the new signature breaking the API contract, so you start hunting for a signature check in the client.
**Root cause**
The failure is at the TLS layer, not the application layer: the API host's certificate is expired (or the chain does not validate), and that particular request path validates against the **system trust store**. Shipping a different signature is irrelevant.
Crucially, one app can carry **two independent trust chains**: requests through the app's own HTTP client (which may install a permissive `SSLSocketFactory` and `HostnameVerifier`) succeed, while requests through `java.net.URL.openConnection()` use the system defaults and fail. That is why "some features work" and "login does not".
**Why it is hard to see**
The user-visible message is a generic "network error". The real exception is usually swallowed by the app's own `try/catch`. And a client-side patch is the most recent change, so it gets blamed by default.
**Do instead**
- Capture the whole exception chain before theorizing. The give-away is
`CertPathValidatorException: timestamp check failed` → `CertificateException: Chain validation failed` → `SSLHandshakeException: Chain validation failed`.
- Confirm independently of the app: strictly validate the host's certificate from your host machine and check `notAfter` against the device clock. See `references/tls-and-cert.md`.
- Run the control: does the **unmodified original** fail the same way on the same device and network? If yes, it was never your patch.
---
## P17. Trusting UI automation to prove whether a patch worked
**Symptom**
Your script taps a button, nothing happens, and you conclude the patch broke the control. Or you tap, see no visible change, and conclude the feature is dead.
**Root cause**
Device input and screenshots are far less reliable than they look:
- `input tap` can silently fail on specific widgets even with correct coordinates (ROM-dependent).
- `input` needs `INJECT_EVENTS`; under a plain shell it fails quietly.
- `screencap` can return a **zero-byte** file on some ROMs.
- Form submission can be rejected by local validation before any request is made, so "the button does nothing" is a validation failure, not a broken handler.
**Why it is hard to see**
All of these produce the same observable: nothing happens. A zero-byte screenshot often goes unnoticed and is treated as "no change".
**Do instead**
- Read back the widget tree (`uiautomator dump`) instead of trusting pixels: it gives real `bounds`, control text, and **field contents with lengths**. Verify every field is populated correctly *before* submitting.
- Compare field values, not just presence — one case that burned an hour was two password fields of different length, causing local validation to `return` before any network call.
- Treat "no visible change" as unproven, not as a negative result: confirm with an independent signal (logcat, a runtime probe, or a server-side request appearing in the capture).
- If a tap does not register, fall back to launching the Activity directly or invoking the handler, rather than retrying coordinates.
+49 -4
View File
@@ -23,13 +23,57 @@ Read `AndroidManifest.xml` → `application android:name`.
|---|---|
| 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. |
| A short meaningless name that does not match the app's package (e.g. `com.bc41`), plus `android:appComponentFactory` pointing at another short class | **Dex-level whole-APK packer.** The shell registers its own Application/proxy under names unrelated to the app. |
| `classes.dex` is only tens of KB and holds nothing but shell classes | The real code is not on disk. |
| A shell library in `lib/<abi>/` (`libDexHelper.so`, `libjiagu*.so`, `libshell*.so`, …) | Native half of the shell: decrypts and loads the payload. |
| One or more large high-entropy `assets/` blobs, possibly **disguised as JPEG** (valid magic bytes, no decodable image) | Encrypted payload. |
| `assets/` contains a second `.apk` or `.jar` | Wrapped/loader build. |
Signs in `lib/`: lone `.so` files with names unlike normal libraries (a custom-named shell lib), plus unusual `assets/` artifacts.
Decide from sizes and structure, not from names:
```bash
unzip -l app.apk | sort -k1 -n | tail -20 # biggest entries: assets? libs? dex?
```
**Loaded-shell check (highest fidelity):** if the app runs and its own classes are reachable, it is unpacked *at runtime* regardless of what the manifest says. If the app's own code is not among the dex classes, it is packed on disk.
### Unpacking a dex-level packer
**1. Map payload → dumped dex by length.** If the shell uses **length-preserving** encryption, each encrypted `assets/` payload has **exactly the same byte count as the plaintext dex** it decrypts to. That equality is the strongest single discriminator for "which dump is a real original dex" — far more reliable than sorting dumps by size. Measure it per file before trusting any dump.
**2. Check how the real dex is loaded.** A log line like `InMemoryDexFile[cookie=[0, ...]]` means the dex is built in memory from the payload and never written to disk, so there is no dropped file to find. Use a **memory-scanning** dumper (`frida-dexdump`, or your own script walking the class loaders) instead of hunting for artifacts under the app's data directory. See `references/dynamic-frida.md`.
**3. Two dumper caveats that cost real time.**
- `-D <device-id>` does **not** accept a remote device (`Device '<id>' not found`). Register a remote device with `-H <host>:<port>`; keep `-D` for local/USB ids.
- The `-o <dir>` output directory **must already exist** — the tool does not create it. Otherwise every dex write fails and the run still ends with `All done`, which reads exactly like a successful dump of nothing. `mkdir -p out` first, and judge the result by the file count, never by the final message.
**4. Filter the dumps before using any of them.** A memory dump contains three kinds of garbage:
- **Duplicate copies of one dex.** Identical byte count does **not** imply identical content — files of the same size have been measured to differ in ~73% of their bytes, carry a different dex version, and fail to parse. Dedupe by **content hash**, never by size.
- **SDK plugin dexes downloaded at runtime**, which were never part of the APK.
- **Structurally broken dexes** whose header and body disagree.
Cheap header check that catches the third class, plus the hash used for the first:
```python
import struct, hashlib
d = open(p, 'rb').read()
assert d[:4] == b'dex\n' # magic
assert struct.unpack_from('<I', d, 32)[0] == len(d) # file_size == actual size
assert struct.unpack_from('<I', d, 36)[0] == 112 # header_size
sha = hashlib.sha256(d).hexdigest()
```
Then confirm survivors actually parse (`baksmali`/dexlib2) or load on device. Header consistency alone is not proof of a usable dex.
**5. Which dump is the app's own business dex.** Count string-feature hits per dump instead of trusting file order:
```bash
python scripts/dex_strings.py <dump_dir> --find 'Lcom/example/app/' --per-file
python scripts/dex_strings.py <dump_dir> --find 'Lcom/example/adsdk/' --per-file
```
Heuristic: the business dex mixes the app's package prefix (hundreds of hits) with an ad-SDK prefix (thousands of hits). A pure SDK plugin dex has one vendor prefix at its maximum count and **zero** app-package hits. Zero app-package hits means it is not the app.
## 3. Where does the app's own code live?
| Indicator | Code location |
@@ -48,7 +92,7 @@ For a **Kotlin/Java app**, note the module layout: app code often lives in a sma
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.
Consequence for editing: you usually only need to replace **one or two dex files**, which keeps the repack minimal. When the app is packed, run this check against the **dumped** dexes, not the ones on disk (§2).
## 4. SDK and library inventory
@@ -102,6 +146,7 @@ Write a short profile before patching. Minimum:
```
package / version / ABI
packed? (evidence)
if packed: packer family, unpack method, kept dumps + sha256
code location (dex / flutter / native)
app code in which dex files
ad SDK(s) and the app's wrapper class
+21 -3
View File
@@ -30,6 +30,17 @@ Recompressing them produces builds that fail to install or misbehave.
**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**.
## Repacking an unpacked (de-shelled) app
When the sample was packed, the dex you are about to patch came out of a memory dump while the APK still carries the shell. Build a **de-shelled base APK** first, then treat it as an ordinary APK for the rest of this document.
1. **Assemble the base.** Start from the original APK: replace `classes*.dex` with the dumped real dexes, and delete the shell library under `lib/<abi>/` plus the encrypted `assets/` payloads. Strip **only** signature artifacts — `META-INF/*.SF|*.RSA|*.DSA|*.EC` and the top-level `MANIFEST.MF`. Never delete the whole `META-INF/` (`references/pitfalls.md` P1).
2. **Decode with `apktool`.** `apktool d base.apk` gives the smali tree and a **readable text manifest**.
3. **Fix the manifest.** Point `application android:name` at the app's real Application class and **remove** `android:appComponentFactory`. Grep the decoded tree for leftover shell class references before building.
4. **Patch, then rebuild.** `apktool b` → `zipalign -p -f 4` → `apksigner` (v1+v2+v3) → install and verify against the checklist at the end of this file.
Why `apktool` rather than editing binary AXML in place: changing `android:name` in binary AXML means hand-editing the string pool and the attribute/chunk sizes around it, and a mis-sized chunk produces an APK that installs but throws far from the edit, typically in component lookup at startup. The text round-trip moves the risk to "did the rebuild preserve everything else", which item 5 of the checklist verifies directly.
## Pipeline
`scripts/repack.py` implements all of the above. Conceptually:
@@ -76,7 +87,8 @@ 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
apksigner verify --print-certs --verbose \
--min-sdk-version 21 --max-sdk-version 34 signed.apk
```
Enable **v1 + v2 + v3**. v1 is needed for older Android; v2/v3 for modern verification.
@@ -89,6 +101,12 @@ keytool -genkeypair -v -keystore ks.jks -alias <alias> \
-dname "CN=<name>, OU=dev, O=dev, L=NA, ST=NA, C=NA"
```
## `apksigner verify` picks schemes from the APK's own minSdk
`apksigner verify` decides which signature schemes to check from the APK's own `minSdkVersion`. With `minSdk >= 24` it prints v1/v2 as `false` **by design**, even though `META-INF/*.SF` is present and the signature is valid. That output is indistinguishable from "signing did not apply" and sends you into a re-signing loop over a file that was never wrong.
Always pass the explicit range (Route B above) and read the scheme list from that run only. Never judge a build from a default-argument `apksigner verify`.
## Installing over an existing app
| Situation | Command |
@@ -115,11 +133,11 @@ Useful detail: keeping the same keystore across builds lets you iterate with `-r
Signature verification proves the file is well-formed. It does **not** prove the app works. Always:
1. `apksigner verify` (or equivalent) passes.
1. `apksigner verify` passes with the explicit SDK range above — a default-args run can report v1/v2 `false` on a perfectly valid signature.
2. Install succeeds.
3. Launch succeeds; process stays alive.
4. `logcat` shows no `FATAL EXCEPTION` / `VerifyError` / `IncompatibleClassChangeError` / `uncaughtException`.
5. `META-INF/services/*` count matches the original.
6. Unchanged dex files are byte-identical to the original (compare hashes).
6. Unchanged dex files are byte-identical to the original (compare hashes); in a de-shelled build, compare against the de-shelled base instead.
Build a **control** at least once: same pipeline, zero patches. If the control fails, your pipeline or environment is at fault, not your patch (`references/pitfalls.md` P9).
+139
View File
@@ -0,0 +1,139 @@
# Third-party builds — auditing a "cracked" or "modded" APK before trusting it
## Why this exists
A modded build of the exact app you are targeting is the most tempting shortcut available:
it appears to prove the goal is achievable, and it may already contain the patch you were
about to write. It is also the single most common source of wasted hours and of real risk,
for two reasons.
1. **It is usually not editable.** Authors who redistribute mods protect them, often with
more layers than the original app ever had. You cannot treat someone else's build as a
patching workbench.
2. **Its target-audience behaviour is unverified.** The binary may carry injected components,
extra endpoints, or embedded credentials. "It runs and the ads are gone" tells you nothing
about what else it does.
Treat a third-party build as an **untrusted sample to be audited**, never as a baseline.
The useful output of an audit is a decision plus evidence — not a binary you start from.
## Step 1: Can you even edit it?
Find the app's own package prefix (the one that appears in its own class definitions, e.g.
`com/<vendor>/<app>/`) and count how many times it appears as a **defined class** in each
plaintext dex.
```
business-prefix hits per dex:
classes.dex 0 <- shell
classes2.dex 0
classes3.dex 0
classes4.dex 0
```
Zero everywhere means the business code has been **moved out of the plaintext dexes** — the
build is protected at a level you cannot patch directly, even though it launches and works.
Stop here and treat it as a black box.
Also check the reported dex count against the file count: a manifest-declared app with only
library classes present is the same signal.
## Step 2: How many protection layers does it carry?
Mods frequently stack protection from multiple vendors, sometimes because the author
re-protected an already-protected app. Look for independent, coexisting families:
| Layer kind | Typical static evidence |
|---|---|
| Commercial packer | shell `Application`/`appComponentFactory` in the manifest, a `lib*protect*.so`, encrypted assets, `assets/*.jar` with near-maximal entropy |
| DEX-to-native (dex2c) | a "stub" `lib*.so` containing the tool's name, `EntryPoint`/`protect`-style symbols, and **business class names as strings** alongside a near-empty dex layer |
| Anti-debugging / RASP | strings such as `TracerPid`, `ptrace`, `NoNewPrivs`, `CheckPtraceSelf`, `AntiFrida`, plus a self-protection log path |
| Generic hardening marker files | small text files under `assets/` naming a protection vendor and a protection timestamp |
The practical consequence of an anti-debugging layer is that Frida-based dynamic work will hit
a self-check before it hits your hook. Note it, and prefer static conclusions on such a sample.
## Step 3: What did the author ADD?
This is where the audit earns its keep. Diff against the **original build of the same version**
(referenced as `<original>` below).
### New components
Build the set difference of `activity` / `service` / `receiver` / `provider` declarations,
then check whether each new class name actually exists in the plaintext dexes.
- Class exists and is readable → you can judge its purpose.
- Class name appears **nowhere** in plaintext but shows up inside a native library's strings →
the logic is in the native/dex2c layer, purpose unknown. Say so.
- Nothing registered with an implicit-launch intent-filter (`BOOT_COMPLETED`, `MAIN`) → it cannot
be started implicitly, which limits (but does not eliminate) the risk.
- A provider with an `authorities` value containing a package-name rewrite (a suffix/prefix that
is not the current package) is a strong hint the build was produced by **repackaging or app
cloning**, not by editing the original.
### New outbound endpoints
Extract every URL/domain/IP from the modded build's `lib/*.so`, `assets/*`, and dex strings,
and subtract the original's set. Any domain that exists **only** in the modded build is the most
important finding in the audit.
Rank what you find:
- A domain that also serves install attribution or update checks is explainable.
- A domain paired with an authorization-looking path (e.g. a `GET...`-style endpoint name
nearby in the same string neighbourhood) is *not* explainable by de-ad work.
### Embedded secrets
Search native libraries and assets for private-key markers:
```
-----BEGIN ... PRIVATE KEY----- , PRIVATE KEY , PKCS#8 , RSA PRIVATE
```
A **private key shipped inside a client artifact** means its "authorization" or "integrity"
path is not a security boundary — anyone can extract the key. Report it as a red flag on the
build's trust claims, regardless of intent.
## Step 4: Capability picture — read both sides
A native library's import table is only half the story.
- **What it does not have** is meaningful: no `socket`/`connect`, no `execve`/`fork`/`system`,
no `ptrace`, no installer-session strings → no direct evidence of command execution or
silent install.
- **What it references** can override that impression: string references to Java-layer
`java/net/Socket`, `InetSocketAddress`, reflection entry points, or executor services mean the
capability exists **through the Java layer**, off the native import table.
State findings as "capability present / no evidence of use" rather than "safe" — an import table
is evidence of shape, not of intent.
## Step 5: The verdict
Do not hand back a binary. Hand back a decision with evidence and explicit unknowns.
```
VERDICT: use / use with caution / do not use
EVIDENCE:
- business code plaintext? yes/no (+ counts)
- protection layers list
- injected components list, each with purpose known/unknown
- endpoints present only in this build list
- embedded credentials yes/no
UNCONFIRMED:
- <each item you could not establish, stated plainly>
```
"Use with caution" is the honest answer whenever a build is un-analysable **and** carries new
endpoints or unknown injected components. Do not upgrade it to "safe" because it launches
cleanly, and do not call it malicious without evidence — both are guesses.
## Using the audit result
Two legitimate outcomes:
1. The audit shows the mod is an honest de-ad of the same version → you may use it to **learn the
approach** (diff its patch points against the original), but still rebuild from the original
yourself so the artifact you ship has a known provenance.
2. The audit shows it is re-protected, injected, or un-analysable → discard it and do the work on
the original. That is usually faster than reverse-engineering someone else's protection *and*
your target's at the same time.
+178
View File
@@ -0,0 +1,178 @@
# TLS and certificate failures
**Symptom class:** after a repack, login or registration fails with a TLS error while the rest of the app behaves normally. This is the most common way a *correct* patch gets blamed for a *pre-existing, server-side* condition. Work the five steps below before changing a single byte of the app.
## Fast path
| # | Action | Evidence |
|---|---|---|
| 1 | Read the full exception chain, prove it is a date rule | `THROW` events from the probe, or logcat |
| 2 | Validate the certificate from a host you trust | `python scripts/tls_check.py <host>` |
| 3 | Compare another host from the same app, and the device clock | same script, `adb shell date` |
| 4 | Decide which trust chain owns the failing request | `RAW-URL` vs `OKHTTP` probe events |
| 5 | Only then patch the system-trust path | fix template below |
## Symptoms
- Login or registration reports a generic network failure: `Network request failed: Chain validation failed`.
- Everything else works: home, feed, images, playback, guest browsing.
- The full chain, visible only if something captures swallowed exceptions:
```
javax.net.ssl.SSLHandshakeException: Chain validation failed
caused by: java.security.cert.CertificateException: Chain validation failed
caused by: java.security.cert.CertPathValidatorException: timestamp check failed
```
- `timestamp check failed` is the decisive token. The validator rejected the chain on a **date rule**, not on an untrusted issuer or a hostname mismatch. Exactly two conditions produce it: the certificate is expired or not yet valid, or the **clock of whoever performs the check is wrong**.
## Step 1: get the real host out of the runtime
Never trust a host recovered from strings alone; a build config can hold dead or staging hosts. Get the host the app actually connects to:
- Run the four-layer probe: `scripts/frida_probe.js`, launched and streamed to a log by `scripts/run_probe.py` (setup, version alignment, and hooking strategy: `dynamic-frida.md`). Three event labels carry what you need.
- `RAW-URL`: the request went through `java.net.URL.openConnection` / `openStream`, the system trust path.
- `OKHTTP`: the request went through the app's own OkHttp client, a different trust path.
- `THROW`: the original text of an exception an upper layer caught and swallowed. This is where the real TLS message appears when the UI only shows a generic network error.
Output you need: the exact URL, its host, and which label logged it.
## Step 2: validate the chain out of band (no app involved)
This step is independent of the app, the patch, and the device. It runs on a host whose clock you trust:
```bash
python scripts/tls_check.py <host> # strict verification + certificate detail
python scripts/tls_check.py <host> <host2> # compare hosts in one run
python scripts/tls_check.py <host> --json # machine-readable; exit 2 if any host fails
```
The script validates strictly (system CAs, expiry, hostname) and, when verification fails, still decodes the peer certificate locally, so a failure arrives with a reason instead of a stack trace:
```
EXPIRED certificate has expired
NOT-YET-VALID certificate is not valid yet
HOSTNAME-MISMATCH chain is fine, the name does not match
UNTRUSTED-CA self-signed, or issuer not in the trust store
CHAIN-BROKEN bad signature / invalid CA / incomplete chain
```
Interpretation:
- `EXPIRED` reported from a trustworthy clock is proof that the chain is rejected by the certificate itself, not by the device, the network, or your patch.
- A clean pass means the certificate is fine and the failure is a client-side trust decision or a clock problem. Go to Step 3.
- Non-443 endpoint, or an address that needs a different SNI name (an IP connect, a CDN edge): `--port <port>` and `--sni <host>`. Getting this wrong produces a `HOSTNAME-MISMATCH` that has nothing to do with the real problem.
Minimal equivalent, if the script is not at hand:
```python
import socket, ssl
ctx = ssl.create_default_context() # strict: system CAs, expiry, hostname
with socket.create_connection(("<host>", 443), timeout=10) as raw:
with ctx.wrap_socket(raw, server_hostname="<host>") as tls:
print("OK", tls.version())
# on failure: ssl.SSLCertVerificationError: ... certificate has expired
```
Dates, subject, and issuer in one line, if you prefer no script at all:
```bash
openssl s_client -connect <host>:443 -servername <host> </dev/null 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates
```
## Step 3: compare another host, and check the device clock
Run the same check against every host you extracted from the app, then compare:
- A very common shape: the business API host is expired while the config, image, or CDN host is valid. Same app, same network, same machine, different certificates. That pattern exonerates the network path and points at one certificate.
- Check the device clock; it costs one command:
```bash
adb shell date # compare against a known-correct time source
```
If Step 2 passes but the device fails, the cause is the device clock, not the certificate. Correcting the clock and relaxing the trust policy are different decisions; do not do both silently.
- Check the **unpatched original** in the same environment. See `verification.md` §the control build rule and `pitfalls.md` P9. If the original fails identically, the patch is not the cause, and no amount of dex work will fix it.
## Step 4: which trust chain owns the failing request
One app can carry two independent trust chains, which is exactly why "login is broken but everything else works".
| Path | Client | Trust configuration | Failure signature |
|---|---|---|---|
| OkHttp | OkHttp / Retrofit, via `newCall` | app-supplied `SSLSocketFactory` + `HostnameVerifier`, often pinning | pin-related messages (`CertificatePinner`, pin verification failed), or nothing at all if the app relaxed it |
| `java.net.URL.openConnection()` | `HttpsURLConnection`, also used by many image loaders, downloaders, and legacy HTTP wrappers | **platform default** trust store and default verifier (`HttpsURLConnection.setDefault*`) | `Chain validation failed` / `timestamp check failed`, the signature above |
Discriminating, in increasing order of certainty:
1. **Probe events.** The failing request appears under exactly one label from Step 1: `RAW-URL` (system path) or `OKHTTP` (app client). This is a fast screen, not proof.
2. **Trust-manager attribution (definitive).** Hook every `X509TrustManager.checkServerTrusted` implementation and print the instance class name plus a short `new Exception().getStackTrace()`. If the caller frames sit in `okhttp3.*`, or in a custom class the app installs on its own client, the OkHttp path owns the failure. If they sit in a platform trust manager reached through `HttpsURLConnection` / `SSLSocket` internals, the system path owns it. Wording alone is version-dependent, so trust the caller frames, not the message.
3. **Cross-check with the fix below.** If OkHttp owns the failing request, the template does nothing for it: that path never reads `HttpsURLConnection` defaults and needs a client-level change (`SSLSocketFactory` / `HostnameVerifier` / pinner; see `dex-patching.md` §where to patch).
Why the asymmetry exists: the app configured its own trust behavior for OkHttp (a relaxed verifier or a pin), so that path never consults the platform date rule, while the legacy `HttpsURLConnection` path still validates against the system trust store and rejects the expired chain.
## Step 5: fix template (system path only)
Install permissive TLS defaults for the `HttpsURLConnection` path at the earliest point of `Application.onCreate()`:
```java
// Call from Application.onCreate() before any other initialization.
private static void installPermissiveTlsDefaults() {
try {
javax.net.ssl.SSLContext ctx = javax.net.ssl.SSLContext.getInstance("TLS");
ctx.init(null, new javax.net.ssl.TrustManager[]{
new javax.net.ssl.X509TrustManager() {
public void checkClientTrusted(java.security.cert.X509Certificate[] c, String a) {}
public void checkServerTrusted(java.security.cert.X509Certificate[] c, String a) {}
public java.security.cert.X509Certificate[] getAcceptedIssuers() {
return new java.security.cert.X509Certificate[0];
}
}
}, new java.security.SecureRandom());
javax.net.ssl.HttpsURLConnection.setDefaultSSLSocketFactory(ctx.getSocketFactory());
javax.net.ssl.HttpsURLConnection.setDefaultHostnameVerifier(
new javax.net.ssl.HostnameVerifier() {
public boolean verify(String hostname, javax.net.ssl.SSLSession session) { return true; }
});
} catch (Exception ignored) {
// Leave platform defaults in place: pre-patch behavior, no new failure mode.
}
}
```
Land it as a small utility class plus its inner classes in smali, or as a dexlib2 rewrite that inserts the call at the head of `Application.onCreate` (`dex-patching.md`). Keep it inside the app's own package; do not edit third-party classes.
Why this shape is correct:
- It changes **one** trust decision: the default `HttpsURLConnection` factory and verifier. OkHttp derives its trust manager through `TrustManagerFactory` and its own socket factory, so app-side pinning and verifier behavior is untouched. You are not silently weakening a control the app deliberately added.
- Call it **before any other initialization**. Globals set after the first `HttpsURLConnection` use still apply, but a library that cached a factory reference earlier keeps the old one, which produces a confusing partial fix.
- The scope is still wider than "login". **Every request that goes through `URL.openConnection()`** inherits it, including image loaders, download managers, and older push or analytics SDKs. State that in the report instead of implying you touched one endpoint.
- The `catch` that leaves platform defaults alone is deliberate: the worst case is the behavior you started with.
- This is a **client-side workaround for an external condition**. The correct fix is renewing the server certificate. Say so, and do not present the patch as the fix.
Do not: change the device clock, install your own CA into the device trust store (device-side, harder to roll back, and not part of a shippable artifact), rewrite the request host to a different domain, or reach for `System.setProperty` tricks. Each of them either misses the actual cause or widens the change far beyond the failing flow.
## If OkHttp is the failing path
Then the failure is about the app's own client configuration, not the platform defaults. Typical causes: a pin that no longer matches, a hostname verifier rejecting a changed host, or a custom `SSLSocketFactory` built from a bundled keystore. Locate it the same way (Step 4, item 2) and patch at the client construction site instead of applying the template above. `dynamic-frida.md` covers finding the real construction site; `dex-patching.md` covers the patch layer.
## Method notes
- **Order of investigation is the lesson.** Every piece of evidence above comes from the server, the network, or the platform before anything is attributed to your patch: full exception chain, real host, out-of-band validation, second-host comparison, control build. Only when all of them point at a client trust decision is a patch justified. `pitfalls.md` P16 is this mistake written up as a failure.
- **This is a control-build case, not a patch case.** `verification.md` §the control build rule applies directly: same pipeline, zero patches. If the control fails the same way, stop debugging the patch.
- **Report the residual honestly.** "The server certificate is expired; the client patch relaxes validation on the `HttpsURLConnection` path only, OkHttp is unchanged, the proper fix is on the server" is the useful statement. "I fixed login" is not.
- **Claim strength.** This evidence chain reaches rung 6 on the `verification.md` claim ladder: the mechanism is proven, not merely observed. Say which rung you reached.
## Triage checklist
```
[ ] Full exception chain captured, contains "timestamp check failed"
[ ] Exact host and URL obtained from a runtime probe (not from strings)
[ ] Standalone strict validation run: EXPIRED / clean pass
[ ] A second host from the same app compared (isolates the certificate)
[ ] Device clock checked against a known-correct source
[ ] Original unpatched build reproduces the same failure (control build)
[ ] Failing request attributed to a trust manager implementation, not to a message
[ ] If system path: permissive defaults installed at the top of Application.onCreate
[ ] If OkHttp path: client-level config is the patch target instead
[ ] Delivery notes state the client-side scope and the server-side root cause
```
+12 -5
View File
@@ -14,9 +14,10 @@ Each rung is stronger evidence. Climb as high as the task requires, and **state
| 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 |
| 6 | "The mechanism is proven" | Independent evidence of *why* — e.g. the SDK's domains are never resolved because init never ran, or the SDK never created its working directory under the app's private storage |
| 7 | "The *distributed* artifact is the verified one" | Re-download the published file and compare its hash against the local build you tested |
**Rung 3 is the minimum for any deliverable. Rung 4 for a claim that the patch solved the problem. Rung 5 before handing it to a user. Rung 6 when the claim is "the subsystem is dead", not merely "the ad is hidden".**
**Rung 3 is the minimum for any deliverable. Rung 4 for a claim that the patch solved the problem. Rung 5 before handing it to a user. Rung 6 when the claim is "the subsystem is dead", not merely "the ad is hidden". Rung 7 whenever the deliverable leaves your machine — an upload can truncate, a build can be re-signed by a pipeline step, and the person downloading it has no way to know.**
## The control build rule
@@ -71,7 +72,7 @@ Failure signature → cause table lives in `references/environment.md`.
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.
- **Ads removed** → open the screens that had ads (splash, home, detail, player, reward button). Confirm absence *and* confirm the screens still function. Use **three independent signals**, not one: the screens look right, the SDK's log tags disappeared, and the SDK never created its working directory under the app's private storage. An SDK that silently failed to fill an ad slot looks exactly like an SDK that was never initialised — only the last two signals tell them apart.
- **A gate patched** → walk the gated flow end to end.
- **A timeout/limit patched** → trigger it.
- **Data patched** → stop the app, clear/re-write the value, cold start, re-check (proves it is not an in-memory artifact).
@@ -98,17 +99,20 @@ 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)
Signature: verified with --min-sdk-version 21 (v1/v2/v3); report the raw command, not just "ok"
Device: <model / android / abi / rooted?>
Runtime: pid stable over N s; no fatal signatures
Behavior: <screens exercised>, <before/after>
Regressions: <adjacent features checked>
Rung reached: 0..6
Distribution: <where it was published> re-downloaded, sha256 matches the tested build
Rung reached: 0..7
Residual: <what is NOT fixed, and the exact reason>
```
**Be explicit about residuals.** "Ad X remains because it is delivered inside the screen's main data payload, and suppressing it at the transport layer takes the whole screen down" is a useful, honest result. Silently omitting it is not.
**Separate the two audiences.** The artifact goes to an end user; the analysis report goes to whoever asked for the work. User-facing notes stay neutral and practical — package name and version, install prerequisites, the signature-conflict warning (a different signer means the old install must be removed first), the checksum, and a scope/disclaimer line. Keep the mechanism, the patch points, and the reasoning in your own report, not in the file the end user opens.
## Anti-patterns
- Declaring success from a successful assembly.
@@ -117,3 +121,6 @@ Residual: <what is NOT fixed, and the exact reason>
- Quoting emulator results as device results.
- Reporting a fixed feature without checking adjacent features.
- Hiding a known residual failure.
- Blaming your own patch for a failure the **unmodified original** also has — run the control build first.
- Accepting a UI automation result as proof when the interaction may never have fired: verify field contents and look for the actual request, not just the screen.
- Treating a feature-scoped network error as a patching problem before checking the server's certificate (`references/tls-and-cert.md`).