Progressive-disclosure skill for Android APK reverse engineering: recon, ad removal, membership limits, dex surgical patching, repack/sign, runtime data, Frida, environment setup, and a hard-won failure catalogue.
5.3 KiB
Membership, paywalls, and the honest limits of client-side patching
Read this before investing hours. Most "unlock VIP / remove the paywall" requests cannot be satisfied by patching the client, and knowing that early is the most valuable output you can produce.
First question: who decides?
| Signal | Owner | Client patch can... |
|---|---|---|
| A local boolean/int/string field in an app model, computed locally | Client | Fully determine the outcome |
| A field that comes from an API response and is merely read by the UI | Server | Change display only — not access |
| Download/stream/play URLs issued by an API | Server | Nothing. You cannot mint a valid URL. |
| Entitlement checked on every protected action with a server round-trip | Server | Nothing |
| A local cache of a server value | Server (cached) | Temporarily fool the UI until it refreshes |
Decision rule: if the thing you want (a media URL, a decrypted payload, premium data) is delivered by the server, the client is not the gate. The gate is server-side and is not patchable.
How to prove it in minutes
Do not guess. Test the API directly (references/server-api.md).
1. Call the protected endpoint with no credentials. -> 401/403 means auth-gated
2. Call it with an obviously forged token. -> another 401 confirms it is validated
3. Call the metadata endpoint. -> often 200, giving you the shape,
but deliberately omitting the asset URL
4. Compare: is the valuable field simply absent? -> then no client change can invent it
A concrete, real result pattern worth remembering:
GET /videos/{id}→ 200, full metadata, cast list, source list — but no playback URL at allGET /v2/sections/{id}/play-url→ 401 with no token- same, with
Authorization: Bearer <forged>→ 401 GET /user/mewith forged token → 401
That combination is conclusive: metadata is public, the asset URL is server-minted, and the token is verified. Client-side patching cannot unlock it.
What is legitimately achievable client-side
Don't overclaim, and don't underclaim either. Genuinely client-side wins:
- Remove ads / SDKs. Ads are a client behavior (
references/ad-removal.md). - Remove a UI gate where the underlying data is already delivered and only a local check hides it (a "locked" tab whose content is already fetched, a disabled button).
- Skip promotional interstitials and self-promo popups driven by local flags.
- Unlock client-only features: debug menus, extra player settings, higher local bitrate selection, gesture options.
- Grant a client-side reward only if the reward is neither validated nor authoritative server-side (check first — a reward that later fails server validation is worse than no reward).
- Bypass a client-side trial timer whose enforcement is purely local (but expect the server to re-assert on next sync).
What is NOT achievable, and how to say so
- Paid content whose URL comes from the server. State it plainly and show the 401 evidence.
- Server-validated rewards / purchases / credits.
- Anything the server re-checks on the next request. The patch may look like it works for one screen and then silently revert.
Wording that is both honest and useful:
The client cannot unlock this. Metadata is public, but the asset URL is issued by the server, and the endpoint returns 401 for both anonymous and forged credentials — so no local change can produce a valid URL. What is removable client-side is X, Y, Z. To actually get access you need a real account with the required entitlement.
The fake-VIP trap
Forcing a local isVip()-style method to return true is the classic mistake. Two failure modes:
- Cosmetic only — UI shows VIP styling, but protected actions still fail server-side.
- Actively harmful — the client now believes it holds entitlements it does not, so it enters code paths that expect server data it never receives. Observed result: blank screen (process alive, nothing rendered, only a swallowed uncaught exception in the crash buffer).
If you patch such a method, patch it in a conservatively correct way, and verify all screens, not just the one you cared about:
- Do not fabricate entitlements that gate data fetching.
- Prefer patching presentation consumers of the flag over the flag's computation, if the flag is also used for access decisions.
Local state vs code — the deliverable question
A value can be "fixed" in two places:
| Where | Survives fresh install? | Fits an APK deliverable? |
|---|---|---|
| Code (patched dex) | Yes | Yes |
| App data (datastore/prefs/db) | No | No — unless you also patch the read path |
If a fix works on your device but a user reports it gone after reinstall, this is why (references/pitfalls.md P11). Re-implement the fix by patching the read path so it always yields the desired value.
Reporting template
Goal: <what the user asked for>
Gate owner: client | server | mixed (evidence: ...)
Achievable: <list, with evidence each>
Not achievable: <list, with the 401/absent-field evidence>
Residual: <what remains and the exact coupling that prevents removing it>
Verified on: <device, build hash, what was exercised>