diff --git a/README.md b/README.md index fb4ca84..da1448f 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,12 @@ directly. SKILL.md decision tree, workflow, hard constraints, indexes references/ loaded on demand, one topic each recon.md identify packer, SDKs, code location, tamper checks; unpacking + packers.md hardened targets: rejection signals, measuring the validation + boundary with single-variable tests, choosing a native host + framework-runtimes.md Flutter / React Native / Unity: which layer owns the UI + native-and-so.md .so hosts, DT_NEEDED vs JNI_OnLoad, relocation limits, + relocation-free bootstrapping, replacing Java methods natively + long-task-discipline.md live record, conclusion grading, drift control, handover ad-removal.md ad taxonomy, wrapper mapping, callback trap, global gates, verification membership-and-limits.md server vs client authority; what is and is not patchable server-api.md probe an app's API; prove who owns the gate diff --git a/SKILL.md b/SKILL.md index 797386d..f4c1bf6 100644 --- a/SKILL.md +++ b/SKILL.md @@ -25,7 +25,8 @@ Answer these before touching a tool. Every one of them changes the whole plan. - Feature flag, UI gate, debug switch → usually client-side - Anything decided by an API response → server-side → `references/server-api.md` 3. **Is the app's own code in plain dex, or moved to native/Flutter/Unity?** - Plain dex → you can patch. Flutter (`libflutter.so` + `libapp.so`) / Unity (`libil2cpp.so`) / pure native → different toolchain entirely. See `references/recon.md` §code-location. + Plain dex → you can patch. Flutter (`libflutter.so` + `libapp.so`) / Unity (`libil2cpp.so`) / pure native → different toolchain entirely. See `references/recon.md` §code-location and `references/framework-runtimes.md`. + **Runtime check (cheap -- do it before committing to a layer):** hook the obvious Java classes for the UI you care about, then reproduce that UI. If those hooks fire, the behavior is Java-owned. If they fire **zero times** while the UI is plainly on screen, the behavior is drawn by the runtime or by native code, and a dex-only plan will stall. Do not keep hunting in dex after a zero-hit probe -- that is the most expensive wrong turn in this skill's history. 4. **Does the app verify its own signature, or does the server?** App-side → you must bypass it. Server-side → re-signing silently breaks the app later. See `references/repack-and-sign.md` and `references/server-api.md`. 5. **What is your device situation?** → `references/environment.md` @@ -35,6 +36,11 @@ Answer these before touching a tool. Every one of them changes the whole plan. 7. **Was the input a build you did not produce** (a "cracked"/"modded" APK circulating online)? → `references/third-party-builds.md`. Audit it before adopting it: such builds are frequently re-protected (sometimes with *more* layers than the original) and may carry injected components or endpoints. Never use one as a patching workbench. + **Long-task rule:** if this is likely to run long, open `references/long-task-discipline.md` now + and keep its record updated as you go. Re-read the refuted-conclusions and dead-routes sections + before starting any new experiment. Losing earlier findings is the most expensive failure in this + skill, and it is entirely preventable. + ## The workflow, end to end 1. **Recon** — `references/recon.md`. Manifest, package name, version, ABI, dex count, packer, embedded SDKs, where the app's own code lives. Ten minutes here saves hours. **If it is packed, unpack before anything else** (`references/recon.md` §unpacking): you cannot patch code you cannot read, the encrypted payload lengths tell you which dumped dex is the original, and a memory dump must be de-duplicated by hash and structurally validated before any of it is trusted. @@ -43,7 +49,7 @@ Answer these before touching a tool. Every one of them changes the whole plan. 4. **Decide the patch layer** — client SDK call / client rendering / client data consumption / server contract. See the table in `references/ad-removal.md`. 5. **Patch surgically** — `references/dex-patching.md`. Prefer **dexlib2 method-level rewriting** over whole-tree smali round-trip. Whole-tree round-trip damages R8-optimized dex in ways that only show up at runtime. 6. **Repack and sign** — `references/repack-and-sign.md`. **Do not strip the whole `META-INF/`.** This single mistake destroys otherwise-correct builds. -7. **Verify on device** — `references/environment.md` + `references/verification.md`. Check: launches, no logcat exception, the changed behavior actually changed, and nothing unrelated broke. +7. **Verify on device** — `references/environment.md` + `references/verification.md`. Check: launches, the changed behavior actually changed, nothing unrelated broke, and **the app reaches its normal UI with no blocking dialog**. First prove the artifact actually changed on the device -- a package manager reporting success does not prove an interposed confirmation was accepted (P18). Capture continuously for the first ~20 seconds after launch; sampling gaps are how a blocking modal goes unseen (P20). 8. **Log what you learned** — if a failure cost you more than thirty minutes, add it to `references/pitfalls.md`. That file is the most valuable artifact in this skill. ## Non-negotiable constraints @@ -54,6 +60,9 @@ Answer these before touching a tool. Every one of them changes the whole plan. - **Do not patch a method that is widely shared.** Before patching any helper, count its callers (`scripts/find_refs.py`). A `Long.valueOf` wrapper with 30 callers is not an ad-specific hook. - **Do not make an API fail to suppress a UI element.** A 404/400 on an endpoint that other features depend on takes the whole screen down with it. Suppress at the data-consumption or render layer instead. - **Every claim needs evidence.** "Probably", "should be", "in theory" are not findings. Either you observed it, or you label it unverified. + - **"Done" means the user-visible outcome**, not an internal signal. A blocking dialog still on screen means the task is not done, however many errors disappeared from the log. Absence of a log line is absence of evidence, never evidence of success. + - **Do not discard a route on compound evidence.** If a failure followed two simultaneous changes, the attribution is a hypothesis, not a finding. Re-run it single-variable before writing the route off -- mis-attributed failures have removed viable approaches for a long time. + - **Prove the device changed before measuring.** Install success describes the request, not the app on disk. Confirm the artifact actually advanced, or every following observation describes the previous build. - **Attribute a failure to the right layer before patching again.** When something stops working after a rebuild, first check whether the **unmodified original** fails the same way on the same device and network. Feature-scoped network failures in particular are frequently the app's own TLS/certificate problem (`references/tls-and-cert.md`); chasing a signature check that does not exist burns hours. ## Reference index @@ -63,6 +72,9 @@ Load only what the current step needs. | File | Load when | |---|---| | `references/recon.md` | Starting any new sample; identifying packer, SDKs, code location, ABI | +| `references/packers.md` | The app is packed/hardened, or an edit makes it die before your code runs. Also load before discarding any route as "blocked by the shell" | +| `references/framework-runtimes.md` | The UI is not native (Flutter / React Native / Unity / Cordova), or Java-layer hooks fire zero times while the UI clearly works | +| `references/native-and-so.md` | Patching in a `.so`, needing code to run before the app's own code, or hand-built native payloads that crash inside the linker | | `references/ad-removal.md` | Task involves ads, trackers, sponsored cards, splash/interstitial/reward | | `references/membership-and-limits.md` | Task involves VIP, subscription, paid content, unlock, "fully cracked" | | `references/server-api.md` | The behavior is decided by a response, or you need to know if a patch can even matter | @@ -74,6 +86,7 @@ Load only what the current step needs. | `references/verification.md` | Defining what "done" means; building the evidence chain | | `references/tls-and-cert.md` | One feature fails at runtime (login, registration, payment, an API-backed screen) while the rest of the app works | | `references/third-party-builds.md` | The input is a "cracked"/"modded" build you did not produce — audit it before trusting it | +| `references/long-task-discipline.md` | The task will run long, or you are resuming one. Live record, conclusion grading, drift checkpoints, handover | | `references/pitfalls.md` | Always worth a skim before building. This is the failure catalogue. | ## Script index diff --git a/references/framework-runtimes.md b/references/framework-runtimes.md new file mode 100644 index 0000000..f20ec01 --- /dev/null +++ b/references/framework-runtimes.md @@ -0,0 +1,88 @@ +# Cross-Platform Runtimes + +Load this when the app's UI is not native, or when Java-layer hooks produce no hits at all. + +Many apps ship a native shell plus a cross-platform runtime. Their business logic, their UI, and their +feature gates live **inside the runtime**, not in the dex. Patching the dex then does nothing — the Java +code you can see is mostly glue. Determine the runtime before choosing where to patch. + +## Identify the runtime early + +Signals in the APK: + +| Runtime | Giveaways | +|---|---| +| Flutter | `libflutter.so`, `libapp.so`, `assets/flutter_assets/`, Dart snapshot | +| React Native | `libhermes.so` or `libjsc.so`, `index.android.bundle`, `assets/index.android.bundle` | +| Unity | `libunity.so`, `libil2cpp.so`, `assets/bin/Data/`, `globalgamemanagers` | +| Cordova / Ionic | `assets/www/`, `cordova.js` | +| Xamarin / .NET | `libmonodroid.so`, `assemblies/` in the APK | + +A `classes.dex` that is small relative to the total payload is another strong hint that the real code +lives elsewhere. + +## The layer trap + +The expensive mistake: seeing a UI element (dialog, paywall, gate) and assuming it is a native control, +then spending a long time looking for it in the dex. + +If it is drawn by the runtime, the dex contains only the bridge. Symptoms of being on the wrong layer: + +- hooking the obvious Java dialog/Activity classes produces **zero** hits while the UI clearly appears; +- the classes that do appear in stacks are the runtime's own, with obfuscated names; +- hooking a Java method changes nothing about the visible behaviour. + +Establish layer ownership **before** investing in a patch direction. + +### How to tell which layer drew a given UI + +1. Enumerate the Java dialog and presentation classes and hook their show/creation paths. +2. Reproduce the UI. If any hook fires, the caller stack names the detection and you are on the Java + layer. +3. If nothing fires while the UI is on screen, the UI is runtime-drawn or native-drawn. Switch axis. +4. Expect runtime-owned windows to appear as ordinary system `Dialog`/`Presentation` objects used as + **containers** for the runtime's surface. A hit on such a container is not evidence that the visible + element is a native dialog — read the class name carefully before drawing conclusions. + +## Flutter specifics + +- Business logic is compiled ahead-of-time into **`libapp.so`**; UI is rendered by `libflutter.so` with + no per-widget Java objects. +- **Widgets, dialogs, and paywalls are not Java views.** They will never appear in Java stack traces, + and no Java hook can intercept them. +- The Java side is thin: one Activity, plus plugin classes. Calls cross the boundary through the + platform-channel mechanism, whose class names are frequently obfuscated. +- Because channel classes may be renamed, locate them **by method signature and call shape**, not by + class name. + +### Where to intervene in a Flutter app + +| Goal | Layer | Notes | +|---|---|---| +| Stop the app being killed / refused at startup | host native library | earliest point, see `native-and-so.md` | +| Observe what the runtime is told | platform channel | shows the messages that drive the UI | +| Change a decision permanently | `libapp.so` | most direct, hardest to locate | +| Change startup behaviour only | host native library constructor | survives runtime restarts | + +Editing `libapp.so` is the most durable but locating Dart AOT code is genuinely hard: strings are the +most reliable entry, because Dart string objects and the code referencing them can be found by scanning +the snapshot rather than by pattern-matching machine code. + +## Locating logic without symbols + +- **Strings first.** User-visible text (dialog bodies, feature labels) is the cheapest anchor in any + runtime. Find it, then find what references it. +- **Compare two builds.** The same feature in a slightly different version often reveals the code path. +- **Watch the boundary, not the interior.** For cross-platform apps it is usually far cheaper to observe + what crosses between layers than to reverse the interior of the runtime. +- **Do not assume the obfuscated names are stable or meaningful.** They are not, and treating them as + identities leads to conclusions that break on the next build. + +## What this changes about your plan + +- Budget for the runtime layer **up front**. If the app is Flutter/RN/Unity, the dex is not the main + battlefield and a dex-only plan will stall. +- Any behavioural claim must be validated **through the runtime's own UI**, because that is what the + user sees. A patch that changes internal state without changing the UI is not a fix. +- Keep the layer you are working in explicit in your notes. "Patched the paywall" is meaningless without + saying which layer owned it. diff --git a/references/long-task-discipline.md b/references/long-task-discipline.md new file mode 100644 index 0000000..f895262 --- /dev/null +++ b/references/long-task-discipline.md @@ -0,0 +1,110 @@ +# Long-Task Discipline + +Load this when a task is likely to run long: many rounds, many experiments, or a conversation that will +exceed what can be held in context. Also load it before resuming a task someone else (or a past you) +started. + +The failure mode this file prevents is not tooling — it is **judgement drift**. Over a long task, three +things reliably go wrong, and each is expensive in a way that is invisible while it happens: + +1. **You re-derive what you already proved.** Rounds get spent rediscovering a boundary or a mechanism + that was established earlier and then lost. +2. **You re-walk a route that was already excluded.** The exclusion is real, but the reason has been + forgotten, so the route looks promising again. +3. **A wrong conclusion keeps steering.** An early mis-attribution is written into your mental model and + silently removes good options, or props up bad ones, for a long time afterwards. + +All three come from the same cause: **conclusions living in context instead of on disk**. + +## Keep a live record, not a log + +Maintain one artifact — a single file — that is the task's authoritative state. Update it as things are +learned, not at the end. What matters is not size; it is that every entry is **actionable later**. + +Keep these sections: + +| Section | Contents | Why | +|---|---|---| +| **Operating rules** | things that must be true every run (how to install, how to launch, how to capture) | prevents repeating a mechanical mistake | +| **Confirmed facts** | each with the run that proved it | the basis everything else builds on | +| **Refuted conclusions** | what you believed, why it was wrong, what replaced it | stops a dead idea from coming back | +| **Dead routes** | ruled out, with the evidence | the single biggest time saver | +| **Open questions** | what is still unknown | keeps the next step honest | +| **Environment** | device, ports, tool paths, credentials locations | avoids re-discovery | + +Two rules make this file worth having: + +- **Every claim carries its evidence.** "The shell rejects this" must include the run that showed it, + including the exact failure text. An unattributed claim is how a false conclusion gets adopted later. +- **Refutations are first-class entries.** When you find that an earlier conclusion was wrong, record + *both* the wrong conclusion and why it was wrong. Deleting the mistake loses the most valuable thing + you learned. + +## Grade your own conclusions + +Label every non-obvious statement with its strength, and never let a weaker label inherit the authority +of a stronger one: + +| Grade | Meaning | May it justify a decision? | +|---|---|---| +| **Observed** | reproduced it, with the exact command and output | yes | +| **Inferred** | follows from an observation, but the step is reasoned | provisionally | +| **Hypothesis** | plausible, untested | only as something to test | +| **Refuted** | tested and found false | never — keep it only as a warning | + +Most long-task damage comes from hypotheses drifting upward into "facts" simply by being repeated. If +you catch yourself referring to something as established, check the grade in the record. If it is not +observed, go observe it or label it again. + +## Single-variable discipline across the whole task + +The most common source of a wrong *and durable* conclusion is a compound experiment: two changes, one +failure, one invented explanation. Because the failure is real, the explanation feels earned. + +Consequences to enforce: + +- When a route is about to be abandoned, **re-read why it was abandoned**. If the evidence was compound, + the abandonment is not yet justified — re-run it single-variable before writing it off. +- Keep a semantic control in your pipeline: a build with **no** changes, run through the same + install-and-launch path. If the control fails, nothing else you measure is meaningful. + +## Guard against drift at natural checkpoints + +Do these cheaply, and only when they can change a decision — not as ceremony. + +- **Before starting a new experiment**: read the refuted-conclusions and dead-routes sections. If your + plan appears there, stop and read why. +- **Before declaring progress**: ask what the *user-visible* outcome is right now. An internal signal + improving is not progress (see P19). Re-state the actual target. +- **After any surprise**: write it down immediately with evidence, before you have a theory. Theories + written after the fact are hard to distinguish from observations. +- **When context feels long**: prefer writing to the record over re-reading conversation. The record is + what survives; the conversation is what gets truncated. +- **When resuming**: read the record first, and treat anything not in it as unknown, even if it feels + familiar. + +## Handover + +A handover is the record plus three things, written for someone with **no** memory of the task: + +1. **Operating rules first** — anything mechanical that silently wastes time if unknown. +2. **Honest current state** — including what is *not* achieved. An optimistic summary costs the next + person far more than an accurate one, because they will build on it. +3. **The next best step, and why** — plus the dead routes, so they do not start there. + +Two things belong in every handover, because they are the hardest to recover: + +- **What you got wrong**, with the evidence that corrected it. +- **What you never actually verified**, stated plainly. "Never confirmed" is more useful than silence. + +## Avoiding the opposite failure + +Discipline can itself become a burden. Guard against that too: + +- **Do not log everything.** Record only what would change a future decision or prevent a repeated + mistake. A record nobody reads is worse than no record, because it looks like coverage. +- **Do not treat the record as authority.** It is a summary; the environment is ground truth. If the + record and a fresh observation disagree, re-test — do not defend the record. +- **Do not let grades calcify.** A hypothesis that becomes testable should be tested, not archived. +- **Do not let the record's structure dictate the work.** If a section is empty because it is not + relevant to this task, leave it empty. diff --git a/references/native-and-so.md b/references/native-and-so.md new file mode 100644 index 0000000..6a0ff7e --- /dev/null +++ b/references/native-and-so.md @@ -0,0 +1,121 @@ +# Native & SO Layer + +Load this when the Java/dex layer is blocked or unsuitable, when you need code to run **before** the app's +own code, or when a native library is the only editable place left. + +The Java layer is usually the right choice. Go native when you need one of these: + +- execution **earlier than any app code** (before `Application.onCreate`, before static initialisers), +- a change that survives the app re-loading or re-initialising its Java state, +- a host the integrity checks do not cover while the dex is covered. + +## Picking a host library + +A library is usable only if all three hold. Verify each; do not assume. + +1. **Loaded in your scenario** — confirm in `/proc//maps`, not by inspecting the APK. +2. **Editable** — per the boundary map you built in `packers.md`. +3. **Has a call the runtime makes for you** — an exported `JNI_OnLoad` is ideal, because the runtime + invokes it automatically when the library is loaded by the app, with no trigger of your own. + +Prefer a host whose exported JNI entry point exists *and* which appears in `maps`. + +## The auto-load trap: `DT_NEEDED` does not call `JNI_OnLoad` + +Adding a `DT_NEEDED` entry so one library drags in another is a natural idea. It maps the second library, +but **`JNI_OnLoad` is not invoked for it** — that callback only fires when the runtime loads a library +through the normal library-load API. A dependency loaded purely by the dynamic linker is mapped, not +initialised. + +If you rely on `DT_NEEDED`, your entry point must be an **ELF constructor** or a `DT_INIT` entry, not +`JNI_OnLoad`. Conversely, if your host exports `JNI_OnLoad` and you control when the app loads it, that is +the simplest reliable trigger. + +Related: a constructor/`DT_INIT` runs so early that the VM may not exist yet. Retry, and treat failure as +non-fatal — a library that logs nothing must never take the process down. + +## Relocations and page permissions + +This is where most hand-built native payloads fail, and the failure looks like a crash **inside the +dynamic linker**, not inside your code. + +Rules that follow from how Android maps libraries: + +- **A relocation target must be writable at load time.** The linker writes the computed address into the + target slot. If the slot lives in a section that is mapped read-only, the write faults + (`SEGV_ACCERR` in `plain_relocate_impl`). +- **Modern Android refuses W+X segments.** Adding a read/write/execute segment to get both properties is + rejected; the loader will not give you one. +- Together these mean: relative relocations cannot heal pointer slots that sit in an executable-only page. + +### Bootstrapping when relocations cannot work + +If your payload's internal pointers all live in the same page as the code that reads them, and that page +must be executable, you cannot use relocations. Do the fixup at runtime instead: + +1. derive the runtime page base (a PC-relative instruction gives you this without any relocation), +2. make the page writable/executable via a direct `mprotect` **syscall** (do not depend on the libc + symbol being imported; a syscall needs no linkage), +3. write the pointer slots yourself, computed from the runtime base, +4. restore the page to read+execute, +5. then branch into your entry point. + +Every pointer you need is `runtime_base + fixed_offset`. No relocation is added, so the segment +properties never have to change. + +Do **not** try to make a page writable by re-mapping it over itself with an anonymous fixed mapping: +that severs the file mapping, and any other thread executing code on that page faults immediately. Use a +permission change, not a re-map. + +## Replacing a Java method from native code + +You can redirect a Java method's implementation without touching dex: + +- Look up the method id, walk to the `ArtMethod` structure, and rewrite its **quick entry point**. +- Emit a short stub: load a target address from a literal pool and branch to it; return values follow the + normal calling convention (arguments are already in place, return in the usual register). +- **Always preserve the original code you overwrote.** If the method can be entered through its own + entry point, a naive redirect makes it jump into your stub, which jumps back into the same entry — + infinite recursion. Copy the overwritten instructions into a trampoline and branch back after them. +- **Do not patch a method whose entry still points at a shared interpreter bridge.** Methods that have + not been compiled by JIT/AOT share an entry; rewriting it breaks every method that shares it. + A practical guard: if two unrelated target methods report the identical entry address, both are still + on the bridge — refuse to patch rather than corrupting the process. +- Also refuse when the first instruction at the entry is PC-relative (address computed from the current + program counter): copying it elsewhere silently changes what it points to. + +Make installation **fail-safe**: if any precondition is not met, skip the patch and let the app run +unmodified. A hook that silently does not apply is survivable; a hook that corrupts a shared entry is not. + +## Finding call sites without symbols + +Stripped and obfuscated libraries still expose structure: + +- Scan the code section for the pattern that reaches your target (a PC-relative address computation + followed by an indirect branch through a table slot). A method reached through a **cached table slot** + will ignore a rewrite of its own entry point, because callers never read that field — another reason + the entry-point rewrite must be validated per call site. +- Dump the executable range and disassemble around candidate offsets instead of trusting symbol names. +- When a payload was produced by a just-in-time compiler on the target, the displacement between its + pages is often zero, which is exactly why relocation-free bootstrapping is required (above). + +## Cross-architecture notes + +- **arm64** is the common case on modern devices; prefer it and make sure the host library you pick + actually exists for that ABI. +- **x86_64** appears on emulators. Shells and payloads are ABI-specific: a file built for one ABI will + not be loaded on the other, and the failure mode looks like a missing library rather than a mismatch. +- Keep your build artifacts per-ABI and label them. Mixing them is a common source of "it crashed for no + reason" when the same APK behaves differently on an emulator versus a device. + +## Verification + +A native patch earns no credit until the behaviour changes **and** the app stays healthy: + +- log a marker from your entry point so you know it ran at all; +- confirm the library is in `maps`; +- confirm the app reaches its normal UI afterwards; +- keep the original library so you can produce an unmodified control build. + +Never conclude "the hook works" from the absence of a crash. Absence of effect is the normal outcome of a +hook that never installed. diff --git a/references/packers.md b/references/packers.md new file mode 100644 index 0000000..2dc34f1 --- /dev/null +++ b/references/packers.md @@ -0,0 +1,142 @@ +# Hardened / Packed Targets + +Load this when recon says the app's `Application` class is not the app's own, or when edits make the +app die before your code ever runs. Packing is the single most common reason a correct patch appears to +"do nothing", so treat it as its own phase with its own evidence standard. + +## What a packer actually does + +A shell (jiagu / weapon / leggu / 360 / ijiami / bangcle class) replaces the manifest's +`application android:name` with its own stub. At process start the stub: + +1. loads its own native library from a directory it controls (often extracted at first launch), +2. decrypts and loads the real dex, usually from `assets/` or a same-named container, +3. optionally verifies that nothing about the APK, its own components, or the environment changed, +4. only then forwards to the app's real `Application`. + +Consequences that shape every later decision: + +- **Your code in the app's own dex may never run** if the shell aborts first. +- **The shell's native library is the gatekeeper**, and it is usually the only thing that decides + "tampered vs clean". +- **The shell's own entry is native**, so Java-level reflection into it is usually a dead end. + +## Reading the rejection signal + +The most informative failure looks like this: + +``` +UnsatisfiedLinkError: JNI_ERR returned from JNI_OnLoad in "/.jiagu/lib.so" +``` + +Read it precisely: the shell's **own** `JNI_OnLoad` returned `JNI_ERR`. That means the shell +**decided** to refuse — it is not a crash, not a missing library, not an ABI problem. Distinguish it from +the look-alikes: + +| Message | Meaning | +|---|---| +| `JNI_ERR returned from JNI_OnLoad in .so` | shell actively refused (integrity or environment) | +| `dlopen failed: library "X" not found` | a dependency you added does not exist — **your** bug | +| `CANNOT LINK EXECUTABLE` / relocation errors | ELF you produced is malformed | +| `ClassNotFoundException` during `Application` init | a manifest component you renamed no longer resolves | + +This distinction matters more than it looks: several classes of "the shell rejected me" are actually +self-inflicted. Always grep the crash for `linker`, `CANNOT LINK`, `dlopen` and `not found` **before** +concluding that the shell detected anything. + +## Map the validation boundary with single-variable tests + +Do not guess what the shell checks. Measure it. The only reliable method is one change at a time, each +with its own install-and-launch run: + +1. **Baseline control** — repack with no changes at all, re-sign, install, launch. If this fails, stop: + your pipeline is the problem, not the shell. +2. **Existing file, semantically inert change** — flip one byte in a section that nothing reads + (a `.comment` string, a duplicated `DT_NEEDED` entry). Survives ⇒ edits to existing files of that + class are allowed. +3. **New file added** — add one benign ELF under the native library directory. Very commonly this alone + is enough to trip a check, which is a distinct policy from (2). +4. **Component rename in the manifest** — equal-length rename of one lazy component + (an `activity`). Survives ⇒ you have a cheap way to neutralise features. +5. **Structural change** — move a program header table, add a segment, add a relocation. + +Record every result. The boundary is usually **not uniform**: typical real outcomes are +"existing native libraries editable, new ones not", "activities renamable, providers not", +"this dex fingerprinted, that container ignored". + +### Why one variable at a time is not optional + +The most expensive mistake in this phase is testing two hypotheses in one build. If you both edit a +shell component **and** add a library, and the app dies, you have learned nothing — and you will likely +blame the wrong one, discard a viable route, and lose hours (or in this skill's history, dozens of +rounds) re-deriving what a cleaner experiment would have shown in one run. + +### Component-type rules worth knowing up front + +- **Providers are instantiated by the system during process start.** Renaming one so its class no + longer resolves fails inside `Application.onCreate` and kills the process before anything else runs. + Providers are therefore a poor rename target; prefer disabling the feature that consumes them, or + hooking it. +- **Activities and services are resolved lazily**, only when actually used. Renaming them is a cheap, + low-risk way to make a feature unable to display, and it survives most integrity checks because it + changes nothing about the shell. +- **Equal-length renames** keep binary XML chunk structure valid, so no manifest re-encoding is needed: + replace the name bytes in `AndroidManifest.xml` in place, size unchanged. + +## Native libraries as a place to live + +When the shell blocks dex-level changes, native libraries often remain open — but only some of them are +useful. A library is a valid host for your code **only if all three hold**: + +1. **It is actually loaded** in the scenario you care about. +2. **It is editable** (per your boundary map above). +3. **Something in it is called early enough** to matter. + +Condition 1 is the one people skip, and it is the one that wastes the most time. A library shipped in +the APK is not necessarily loaded. **Check `/proc//maps`** after a real launch rather than assuming: + +``` +grep -o '/[^ ]*\.so' /proc//maps | sort -u +``` + +Libraries belonging to optional SDKs are frequently lazy: they exist, they may even be unpacked to disk, +and they are never mapped during the startup path you are trying to influence. Unpacked-but-unmapped is +the signature of a lazy SDK, not evidence that your file was rejected. + +Pick a host that is (a) present in `maps`, (b) exports a JNI entry point the runtime must call, and +(c) editable. A library that exports `JNI_OnLoad` and appears in `maps` is the strongest candidate, +because the runtime calls `JNI_OnLoad` on its own, without any trigger from you. + +## Where integrity evidence actually comes from + +Shells commonly check a mix of: APK signature, `AndroidManifest.xml`, their own native libraries, the +dex they load, and environment signals (root, emulator, hooking frameworks, debugging). + +Practical consequences: + +- **Your own re-signing is itself a detected change** in most signature-based schemes. Assume any + signature check will see your build as tampered and plan around it rather than hoping otherwise. +- **The environment matters.** Hooking frameworks, debugging, and non-standard filesystems are commonly + probed. A patch that works on a clean device may be refused on a rooted one, and vice versa. +- **Absence of a log line is not absence of a check.** A shell that fails silently looks identical to a + shell that never checked. + +## Locating what the dialog/detection is attached to + +Blocking dialogs ("tampered", "third-party environment", "abnormal") are the usual user-visible symptom. +Establish **which layer draws it** before trying to suppress it: + +- If it is a Java dialog, hooking the dialog class shows a caller stack that names the detection. +- If hooking the dialog classes produces **no** hits, the UI is likely drawn by a cross-platform runtime + (see `framework-runtimes.md`) or by native code, and Java-level hunting is the wrong axis entirely. + +The cheapest high-value experiment, and one that is routinely skipped: run the **unmodified official +build** and the **repackaged build** through the *same* probe and diff what differs. The difference is +your detection signal. Doing this early is far cheaper than enumerating candidate checks one by one. + +## Reporting + +State the boundary you measured, the evidence for each entry, and what remains untested. "The shell +rejects X" must always carry the run that proves it, including the exact failure string. An unverified +claim about integrity behaviour is worse than no claim, because it silently removes routes from +consideration. diff --git a/references/pitfalls.md b/references/pitfalls.md index f41a776..35e819f 100644 --- a/references/pitfalls.md +++ b/references/pitfalls.md @@ -359,3 +359,137 @@ All of these produce the same observable: nothing happens. A zero-byte screensho - 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. + +--- + +## P18. The package manager reported success, but the build was never installed + +**Symptom** +Every install logs `Success`, so you run the next experiment and read its result — but the app being +tested is still the previous build. Screenshots show stale UI or the launcher, and results look +"unchanged". + +**Root cause** +On many ROMs a package installer interposes its own confirmation. The install command can return success +for the *request*, while the actual install waits on a prompt — sometimes a password or account +confirmation — that nobody fills in. The app stays at its old version indefinitely. + +This is the most expensive failure in this skill's history: because the command "succeeded", the stale +behaviour was measured across many rounds, and each measurement looked like a genuine negative result. + +**Why it is hard to see** +The success signal is real; it just answers a different question than the one you asked. Nothing in a +normal install/launch script distinguishes "installed" from "install requested". + +**Do instead** +- After installing, **prove the artifact changed**: compare `dumpsys package | grep -E 'versionName|lastUpdateTime'` before and after, or hash the on-device APK and compare it to what you built. +- If a confirmation UI exists, drive it explicitly (type the credential, press the confirm control) and then re-verify. +- Make the check a precondition of the run, not an afterthought: if the version did not change, **abort** rather than measuring. +- Keep build artifacts named after the change they contain so a stale install is obvious from a screenshot. + +--- + +## P19. Substituting an internal signal for the user-visible outcome + +**Symptom** +An error disappears from the log, no crash is recorded, and you report the problem solved. The user +immediately shows you the same problem still on screen. + +**Root cause** +The internal signal and the user-visible outcome are different claims. Suppressing one error path does +not remove the symptom if the symptom is produced by a **different** path — and a blocking dialog often +is. The log going quiet proves that one code path was affected; it says nothing about whether the user's +problem is gone. + +**Why it is hard to see** +The signal is specific, measurable, and genuinely changed. It is a true statement being used to support a +false one. + +**Do instead** +- Define "done" as the **user-visible behaviour**: the blocking UI is gone, the app reaches its normal + screen, the feature works. Nothing else counts. +- Treat the absence of a log line as absence of evidence, never as evidence of success. +- When a symptom persists after an internal signal improves, assume there is **another** producer of the + symptom and go find it, rather than assuming your fix is merely incomplete. +- Also verify the opposite direction: confirm the original symptom is reproducible **before** you patch, + so you know what disappearing would even look like. + +--- + +## P20. "I did not capture it" treated as "it is not there" + +**Symptom** +Screenshots taken every few seconds after launch show no blocking dialog, so the dialog is declared gone — +then it turns out to be present the whole time. + +**Root cause** +Sampling is not observation. A transient state that appears and is then covered (a second window, a +navigation, a system prompt) can fall entirely between samples. The modal appears, gets occluded, and +every frame you happened to take shows something else. + +**Why it is hard to see** +The captures are real and consistently show the same thing, which feels like corroboration. Conviction +grows with the number of frames, even though all of them share the same blind spot. + +**Do instead** +- For anything time-sensitive, capture **continuously** (recording) or in a dense burst immediately after + launch, not on a fixed slow interval. +- **Look at every frame**, not only at file sizes. A byte-size cluster that "looks familiar" is not a + reading. +- State conclusions with their sampling: "not observed in N consecutive seconds of recording" is honest; + "does not occur" is not. +- When something is reported present by a human who is looking at the screen, believe the screen. Your + capture gap is the more likely explanation. + +--- + +## P21. Changing two things at once, then attributing the result + +**Symptom** +A build fails, and you conclude that the mechanism you were most curious about is the culprit — then +exclude it from consideration for a long time. Later, a clean experiment shows it was the other change +all along. + +**Root cause** +Two edits, one observation, no attribution. The failure is real; the explanation is invented. Worse, the +invented explanation survives because it sounds plausible and no one re-tests it. + +**Why it is hard to see** +The experiment "worked" in the sense that it produced a result. Acting on a wrong attribution feels +exactly like acting on a right one until much later. + +**Do instead** +- One variable per install-and-launch cycle. Where a combination is unavoidable, add a third run that + isolates each half. +- Write the attribution into your notes **with the run that proves it**. An unproven cause is a + hypothesis; keep it labelled as one. +- When a route is about to be discarded, re-check whether the evidence was actually single-variable. A + discarded route with compound evidence should be re-opened before being abandoned. +- Prefer semantically inert controls (a change nothing reads) to prove "edits of this class are allowed" + separately from "this specific edit is allowed". + +--- + +## P22. Waiting for something that requires a human to advance + +**Symptom** +An automation loop polls for minutes or longer, waiting for a state that never arrives on its own. Time +is consumed while nothing at all can change. + +**Root cause** +The awaited state is gated on a human action — a consent prompt, a permission dialog, an installer +confirmation, a captcha. No amount of waiting resolves it. Automated polling is the wrong instrument for +a state whose transition is external. + +**Why it is hard to see** +Polling is cheap-looking and the loop reports progress (timestamps, unchanged screenshots), which creates +an impression of work being done. + +**Do instead** +- Before waiting on a state, ask what would cause it to change. If the answer is "a person", stop waiting + and either perform the action programmatically or hand it back. +- Bound every wait with a deadline and an explicit failure branch that **does something different**, not + just a longer timeout. +- Detect stalls by change, not by elapsed time: if N consecutive samples are identical, break out. +- Prefer driving the prompt to completion over waiting it out — the same prompt usually recurs, so + automating it once pays back immediately.