Worked end-to-end on a real unobfuscated commercial app (single-dex, no packer,
server-driven launch screen) and folded back everything that cost time. Net +17
files, no new dependencies, no sample-specific identifiers.
New references
byte-level-patching.md
The technique that was missing entirely. Equal-length in-place edits beat
method rebuilding (measured on a real sample: method rewrite grew debug_info
924 B -> 22.8 KB and the dex 4.32 MB -> 7.73 MB), but four failure modes are
silent: same-length is not enough, branch polarity, verifier legality, and
stale dex header fields. Covers locating an instruction's exact offset (why
listings and byte-pattern guesses both fail), the instruction width table and
the two traps that desynchronise a decode, neutralise-a-branch vs redirect-it,
and the header's signature-then-checksum order.
server-config-and-updates.md
The most common shape of "ad" in a modern app, and the one usually
mis-diagnosed: the server supplies UI the client renders. The two-layer fetch
(local fallback then remote override) that proves it, finding the config DTOs
through the field names data classes keep, why you patch the decision and not
the data, deciding the scope of "remove", and remote re-enable / cached config
durability.
New scripts
dexutil.py dependency-free dex reader: structural walk, exact
instruction decode, dex header recompute/verify in the
correct order, branch-target and operand helpers
dex_find_insn.py locate an instruction by decoded semantics, print its exact
byte offset with context and BOTH sides of any branch
dex_patch_bytes.py equal-length byte patches from a JSON spec: semantic match,
polarity pin via expect_next, equal-length enforcement,
verifier check, header recompute, re-decode to prove it landed
dex_check_verifier.py does any conditional branch target a move-result (bypassing
its producer)? Compares builds and separates pre-existing
findings from regressions the patch introduced
coldstart.py cold-launch capture: timed screenshot burst + logcat signals
+ installed-build facts + launch timing, and a warning when
the foreground activity is not the app
check_repo.py maintenance check: every script parses and answers --help,
every referenced path resolves, every file is documented
Fixes found while running the above
repack.py wrote archives with a plain zipfile writer, which cannot satisfy
the Android R+ install gate: resources.arsc must be STORED *and*
4-byte aligned, and a zip extra record cannot express a 1-3 byte
pad (a stored filler entry is inserted instead). Also fixes the
central-directory pointer and adds an alignment gate to the output.
usb_net_proxy.py, dex_strpatch.py
crashed on --help with a traceback instead of printing usage.
Updated
toolchain.md ddc added (single-binary dex->Java with query subcommands,
with measured timings and its real limits); "not on PATH is
not not-installed" with the search commands; which signer
to use and why jarsigner breaks alignment
repack-and-sign.md alignment requirement and its arithmetic; dex header
recompute; jarsigner hazard; vendor install interception
([N] with no symbolic constant -> root pm install); the
installer can keep owning the screen after an install
recon.md resolve package identity from a tool, not from hand-read
AXML strings (a wrong package sends all later device queries
nowhere); snapshot the config surface during recon
long-task-discipline.md captures you never looked at are not evidence (incl. the
consistent-but-wrong-window variant); long-context decay and
the record shape that survives it
dynamic-frida.md when live attach cannot work at all: static instrumentation,
static read, and self-recorded evidence, with the control
build caveat each one carries
ad-removal.md server-driven config is usually the EASIEST case, not the
hardest; a three-way zero result is a positive finding;
scope note on local brand splash vs promotional content
pitfalls.md P37 stale dex header checksum, P38 [-124] alignment,
P39 bare-numeric install failure, P40 wrong-window evidence,
P41 inverted branch polarity, P42 decode desynchronisation
SKILL.md, README.md symptom index, reference/script indexes and requirements
updated for all of the above
14 KiB
Ad removal
Goal: ads stop appearing, and nothing else breaks. Read pitfalls.md P5 and P6 before patching anything here.
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 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):
- This is the most common shape in a modern app, and it is usually the EASIEST to
remove — not the hardest. Go to
server-config-and-updates.mdfor the full procedure. The short version: the client keeps a complete "do not show it" branch so the operator can turn the slot off, so neutralising that one branch is a small, local edit. - Look for endpoints like
/adverts,/adv,/banner,/config, and DTOs namedAdvertisement*,Advert*,Banner*,Promotion*, or — more common in practice — a generic*Configpayload carrying per-feature blocks (splash,noticePopup,updatePopup,tabbar,banner). - The client renders whatever the list contains. If the list is empty there is nothing
to show, but see
pitfalls.mdP5 on how not to empty it: never make a shared request fail. - Sponsored cards that look like content (a VPN promo, a network-accelerator card, a third-party product) are usually exactly this.
A note on what "no SDK found" means. If you have grepped the dex, enumerated the loaded classes at runtime, and counted the SDK log tags in a capture, and all three are zero (see Step 1), then there is no SDK, and continuing to search for one is a dead end rather than a thorough approach. Re-classify against this list instead. A zero result on all three signals is a strong positive finding about where the behaviour lives, and it should move you to the server-config path immediately.
Legit content that looks like an ad. Verify before acting. A /adverts?position=banner response containing anime titles and poster images is the home-page carousel, not an advertisement. Removing it removes real functionality.
Step 2: find the single convergence point
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() // 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 // == isReady / isLoaded / isAdReady
Preferred play: readiness always false + init is a no-op.
- Make the readiness predicate constant
false(isReady/canShow*/isLoaded/isAdReady). - Make
initan empty method (return-void) so the SDK never builds a network stack and never requests a plugin. - 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
falseroutes 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(), 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, 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 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 and 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 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) 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 hookSharedPreferences.getLong/getInt/getStringand 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 example4102444800000L). AvoidLong.MAX_VALUE: implementations often computeend - nowand 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.onCreateneeds a coroutine or a blocking write; seeruntime-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 (full technique table in dex-patching.md):
- 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.
- Neutralize the readiness gate —
isReady/canShowreturn false; routes existing "no ad" branches. - No-op the show/preload methods — only when 1 and 2 are insufficient, always preserving callbacks.
- Global gate state — one startup write covers every reader (timestamp / preference keys above).
- Filter server-issued ad data at the consumption layer — drop entries for the target position before they reach UI state.
- (avoid) Renderer-level suppression — only if the component is genuinely ad-exclusive. Check
pitfalls.mdP6 first. - (avoid) Transport-level blocking — see
pitfalls.mdP5.
Step 4: verify — three independent kinds of evidence
Logcat silence alone is weak evidence. A credible "ads are gone" claim needs all three, plus the neighbours:
- UI, screen by screen — splash, home banner, detail page, reader/player, reward button, unlock prompt. Capture before/after screenshots (
verification.md§screenshot discipline). - Logs — the SDK's characteristic tags are entirely absent, not merely quiet:
anythink,ATSDK,Pangle,TTAd,GDT,ksad, and so on. Make this a count, not an impression. Aggregators log a recognisable burst while they probe which networks are wired in (a "SDK id N not integrated" style line, once per network). Capture the same fixed window on the original and on your build and compare numbers:8 -> 0is a measurement; "I did not see it" is not. A tag that goes quiet without reaching zero usually means the SDK still starts and simply failed to load an ad this run — a different, weaker result than "the subsystem never started". - Filesystem, the strongest signal — the app's private directory contains no ad SDK working directory at all:
For a dynamic-plugin SDK, an absent
adb shell "su -c 'ls -la /data/data/<app.package>/files/'"<sdk>_ptree 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.
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
Be honest about residual ads rather than breaking the app to chase them:
- Content promos embedded deep in a feature's own data payload, where the screen's
load depends on the same request. Distinguish this from a dedicated config block:
if the promo arrives in its own
splash/banner/popupfield with its ownenabledflag, it is removable (Step 1, server-driven). It is only genuinely hard when the same list is both the content and the ad, with no marker separating them. - Ads delivered as content (a sponsored "article" or a native card with no SDK marker) — indistinguishable from real content without runtime tracing.
- Ads whose SDK init also enables other features. Removing init can break functionality that silently depended on it. Verify before shipping; a working app with one residual ad beats a broken app.
- Server-issued entitlements gated behind watching an ad. The gate can be opened client-side, the entitlement cannot be created client-side.
- A purely local brand launch screen. If all you see is the app's own logo, no third-party image and no network-sourced content, that is a splash screen, not an ad. Removing it is a judgement call about the user's stated goal — say which one you removed rather than silently treating "startup screen" as "ad".
If a residual ad cannot be removed without breaking something, say so, and say exactly which coupling caused it. That is a better deliverable than a broken APK.