Add deliverable-form discipline, execution identity, and four reusable scripts

Generalized from a long hardened-target engagement. Additions target the failure
modes that cost the most time and are not yet covered; nothing here is sample-specific.

New discipline
- SKILL.md: a third override rule -- deliver in the environment the request requires.
  A root-only or live-instrumentation result can look like success while failing the
  actual requirement (unrooted, installable, self-contained). Also adds two classify
  questions: what form the deliverable must take, and which architecture/library is
  actually executing.
- long-task-discipline.md: "the most expensive drift" -- a constraint table (privilege,
  modification form, ABI, network, persistence, distribution), the checkpoint question,
  and how to report a privileged workaround as a fallback rather than a deliverable.
  Plus "bound every wait": timeouts on every command, budgets calibrated from
  measurement so slow and hung are distinguishable, and sampling an observable you can
  inspect instead of sleeping blind.
- environment.md: look at the screen instead of driving blind; which of the two screen
  evidence kinds is trustworthy (an empty accessibility tree is common for
  runtime-rendered UI, where the image is primary rather than a fallback); emulator
  console control and adb recovery; preflight before every experiment block.
- native-and-so.md: what the device claims vs what the package manager chose vs what is
  actually mapped; translation layers and their consequences for hooks and native
  checks; fat-APK ABI selection.
- runtime-data.md: when the app rewrites your edit (server refresh vs local
  regeneration), why chmod/chown cannot work because the app deletes and recreates the
  file, and the immutable-attribute fix; decoding a value that only looks encrypted.
- framework-runtimes.md: string encoding traps (a UTF-8 scan of a UTF-16 snapshot
  returns nothing and reads as "the strings were stripped"), and non-ASCII obfuscated
  identifiers -- resolve by shape, not by name.
- pitfalls.md: P23 the fix lives in a file the app rewrites; P24 the artifact changed
  but the wrong one is executing; P25 "search found nothing" treated as "not there".

New scripts (parameterized, path-agnostic, all with explicit timeouts)
- preflight.py  environment check: device, root, ABI/translation, clock skew, leftover
                proxy/forwards, dead device server, chosen ABI, free space
- lib_map.py    what is actually mapped into a live process: path, base, architecture
                from the ELF header, and whether it came from the APK or was
                materialized at runtime
- blob_decode.py search (don't guess) the framing of a stored value, then re-encode the
                edited payload with the same parameters
- snap.py       bounded burst screenshots + control-tree capture, with a stall detector
                and an explicit verdict on whether the tree is usable

All four were exercised against a live device while writing: preflight correctly
flags multi-device ambiguity and detected the ARM translation layer; lib_map read real
ELF headers and identified the translation shim; blob_decode round-tripped a
rotation+deflate+base64 blob at realistic size in ~0.2s; snap detected a stalled screen
across three hashes.
This commit is contained in:
inliver233
2026-09-19 22:08:01 +08:00
parent 55432780fc
commit 0ea42265b7
12 changed files with 1537 additions and 34 deletions
+28 -6
View File
@@ -13,13 +13,21 @@ directly.
- Deciding **fast** whether a request is even achievable client-side, instead of - Deciding **fast** whether a request is even achievable client-side, instead of
burning hours on a paywall that is enforced by a server. burning hours on a paywall that is enforced by a server.
- Deciding **what form the deliverable must take** before any work starts — an
unrooted, self-contained artifact is a different problem from "make it work on this
machine", and confusing the two is the most expensive drift in this domain.
- Choosing the **safest patch layer** for a given change, and avoiding the layers that - Choosing the **safest patch layer** for a given change, and avoiding the layers that
break the app. break the app.
- Separating **your own mistakes from the app's or the server's problems** — a - Separating **your own mistakes from the app's or the server's problems** — a
feature-scoped failure (login, registration, payment) is often a TLS/certificate issue on feature-scoped failure (login, registration, payment) is often a TLS/certificate issue on
one code path, not a consequence of the patch you just built. one code path, not a consequence of the patch you just built. Device state, a dead device
server and clock drift masquerade the same way.
- Establishing **which architecture and which library are actually executing**, rather
than trusting what the manifest ships or what the device claims.
- Working through **packed/hardened targets**: identifying the packer, unpacking, and turning a - Working through **packed/hardened targets**: identifying the packer, unpacking, and turning a
memory dump back into a patched, installable APK. memory dump back into a patched, installable APK.
- Keeping a **long task honest**: a live record, graded conclusions, calibrated timeouts, and
bounded waits, so progress is not lost and the same mistake is not made twice.
- Avoiding the specific mistakes that produce an APK that builds perfectly and dies at - Avoiding the specific mistakes that produce an APK that builds perfectly and dies at
runtime. runtime.
@@ -31,10 +39,13 @@ references/ loaded on demand, one topic each
recon.md identify packer, SDKs, code location, tamper checks; unpacking recon.md identify packer, SDKs, code location, tamper checks; unpacking
packers.md hardened targets: rejection signals, measuring the validation packers.md hardened targets: rejection signals, measuring the validation
boundary with single-variable tests, choosing a native host boundary with single-variable tests, choosing a native host
framework-runtimes.md Flutter / React Native / Unity: which layer owns the UI framework-runtimes.md Flutter / React Native / Unity: which layer owns the UI, and how to
find logic when there are no symbols (string encoding traps)
native-and-so.md .so hosts, DT_NEEDED vs JNI_OnLoad, relocation limits, native-and-so.md .so hosts, DT_NEEDED vs JNI_OnLoad, relocation limits,
relocation-free bootstrapping, replacing Java methods natively relocation-free bootstrapping, replacing Java methods natively,
long-task-discipline.md live record, conclusion grading, drift control, handover and which ABI/library is *actually loaded and executing*
long-task-discipline.md live record, conclusion grading, drift control, timeout and
wait calibration, deliverable-form drift, handover
ad-removal.md ad taxonomy, wrapper mapping, callback trap, global gates, verification 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 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 server-api.md probe an app's API; prove who owns the gate
@@ -42,9 +53,11 @@ references/ loaded on demand, one topic each
third-party-builds.md auditing a "cracked"/"modded" APK before trusting it third-party-builds.md auditing a "cracked"/"modded" APK before trusting it
dex-patching.md patch-layer table + dexlib2 technique in depth dex-patching.md patch-layer table + dexlib2 technique in depth
repack-and-sign.md repack rules, unpack-and-repack, signing, post-install hazards repack-and-sign.md repack rules, unpack-and-repack, signing, post-install hazards
runtime-data.md DataStore / SharedPreferences / SQLite / protobuf runtime-data.md DataStore / SharedPreferences / SQLite / protobuf; when the app
rewrites your edit, and decoding a value that looks encrypted
dynamic-frida.md Frida setup, version pinning, the four-layer probe, hook strategy dynamic-frida.md Frida setup, version pinning, the four-layer probe, hook strategy
environment.md device/emulator setup, ADB, UI automation, offline devices, log signals environment.md device/emulator setup, root, ADB, offline devices, log signals,
emulator console control and recovery, preflight, look-at-the-screen
verification.md the claim ladder; what "done" means verification.md the claim ladder; what "done" means
pitfalls.md the failure catalogue -- read before building pitfalls.md the failure catalogue -- read before building
scripts/ parameterized, path-agnostic scripts/ parameterized, path-agnostic
@@ -65,6 +78,15 @@ scripts/ parameterized, path-agnostic
frida_probe.js four-layer runtime probe (app net layer + OkHttp + java.net + exceptions) frida_probe.js four-layer runtime probe (app net layer + OkHttp + java.net + exceptions)
run_probe.py inject the probe, stream it to a log file, stay resident run_probe.py inject the probe, stream it to a log file, stay resident
tls_check.py strict certificate check for one or more hosts tls_check.py strict certificate check for one or more hosts
preflight.py environment check before every experiment block (device, root,
ABI/translation, clock skew, leftover proxy/forwards, dead server)
lib_map.py what is *actually mapped* into a live process: per-library path,
base, architecture, and whether it came from the APK or was
materialized at runtime
blob_decode.py search, don't guess, the framing of a stored value
(base64/hex x rotation x deflate); re-encode the edited payload
snap.py bounded burst screenshots + control-tree capture with a stall
detector, and a verdict on whether the tree is usable at all
``` ```
## Install ## Install
+31 -14
View File
@@ -7,10 +7,11 @@ description: Reverse engineer, debloat, de-ad, patch, or re-sign Android APKs, a
Goal of this skill: get to a **verified, installable, still-working artifact** fast, and avoid the whole class of mistakes that silently destroy an APK. Goal of this skill: get to a **verified, installable, still-working artifact** fast, and avoid the whole class of mistakes that silently destroy an APK.
Two rules override everything else in this skill: Three rules override everything else in this skill:
1. **Never ship an unverified APK.** "It assembles" is not "it works". Install it, launch it, exercise the feature you changed, on a real device or a close emulator. 1. **Deliver it in the environment the request actually requires.** "It works" is not the goal; "it works under the stated constraint" is. A result obtained with root, or with live instrumentation, or with a host proxy, frequently does **not** satisfy a request for an installable artifact that works on a normal device — and it is easy to present that result as a finished one. Write the constraint down as a testable sentence in the first minutes and re-read it at every checkpoint. If you cannot meet it, say so and label any privileged workaround as a fallback, never as the deliverable. Full treatment: `references/long-task-discipline.md` §the most expensive drift.
2. **Know which layer owns the behavior before you patch.** Ads, paywalls, and feature gates live in different places. Patching the wrong layer either does nothing or breaks the app. Classify first (see step 2 below), patch second. 2. **Never ship an unverified APK.** "It assembles" is not "it works". Install it, launch it, exercise the feature you changed, on a real device or a close emulator.
3. **Know which layer owns the behavior before you patch.** Ads, paywalls, and feature gates live in different places. Patching the wrong layer either does nothing or breaks the app. Classify first (see step 2 below), patch second.
## Start here: classify the target in seven questions ## Start here: classify the target in seven questions
@@ -27,13 +28,23 @@ Answer these before touching a tool. Every one of them changes the whole plan.
3. **Is the app's own code in plain dex, or moved to native/Flutter/Unity?** 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 and `references/framework-runtimes.md`. 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. **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?** 4. **What must the deliverable be able to do?** Write the answer as a testable sentence before
planning anything, then re-read it at every checkpoint. This is the drift guard, and the drift it
guards against is the most expensive one in this skill: a runtime-only result (a data edit, a live
hook, a blocked hostname, a host proxy) can look like success while failing the actual requirement.
The axes that decide it: **privilege** (unrooted?), **modification form** (a rebuilt, installable
artifact, or is live instrumentation acceptable?), **ABI/device class**, **network** (must it work
online?), **persistence** (survives restart / upgrade / fresh install?), **distribution** (must the
shipped file be self-contained?). → `references/long-task-discipline.md` §the most expensive drift.
5. **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`. 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` 6. **What is your device situation?** → `references/environment.md`
Rooted real device (best), emulator with root, or no device (static only). Also: this determines whether Frida is usable. Rooted real device (best), emulator with root, or no device (static only). Also: this determines whether Frida is usable. **Run `scripts/preflight.py` before your first experiment**, and again whenever a failure surprises you — device state, a dead device server, a leftover proxy, and clock drift all masquerade as a broken patch (`pitfalls.md` P9).
6. **Is one *specific feature* failing at runtime — login, registration, payment, an API-backed screen — while the rest of the app works?** 7. **Which architecture is actually executing?** → `references/native-and-so.md` §Cross-architecture
`getprop` reports what the device claims and `primaryCpuAbi` reports what the package manager chose — neither is what is running. Only the live mapping is ground truth (`scripts/lib_map.py`). If the library you meant to patch is not mapped, a translator is in play, or the ABI differs from your assumption, that changes the plan more than any patch will.
8. **Is one *specific feature* failing at runtime — login, registration, payment, an API-backed screen — while the rest of the app works?**
→ `references/tls-and-cert.md`. A feature-scoped network failure is very often a **TLS/certificate problem on one code path**, not a consequence of your patch. The app can even carry two independent trust chains, so "other requests work" proves nothing. Rule this out in minutes before hunting for a signature check. → `references/tls-and-cert.md`. A feature-scoped network failure is very often a **TLS/certificate problem on one code path**, not a consequence of your patch. The app can even carry two independent trust chains, so "other requests work" proves nothing. Rule this out in minutes before hunting for a signature check.
7. **Was the input a build you did not produce** (a "cracked"/"modded" APK circulating online)? 9. **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. → `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 **Long-task rule:** if this is likely to run long, open `references/long-task-discipline.md` now
@@ -43,13 +54,13 @@ Answer these before touching a tool. Every one of them changes the whole plan.
## The workflow, end to end ## 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. 1. **Preflight, then Recon** — `scripts/preflight.py` before anything else if a device is involved (it takes seconds and prevents a whole class of false conclusions), then `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.
2. **Extract strings and endpoints** — build a picture of the app's API surface and SDK inventory from the dex string tables. No decompiler needed for this, and it is fast. Scripts: `scripts/dex_strings.py`. 2. **Extract strings and endpoints** — build a picture of the app's API surface and SDK inventory from the dex string tables. No decompiler needed for this, and it is fast. Scripts: `scripts/dex_strings.py`.
3. **Trace to the owning class** — find the class that wraps the behavior (the app almost always wraps third-party SDKs in one helper). Reverse-lookup instructions: `references/dex-patching.md` §finding-the-call-site. 3. **Trace to the owning class** — find the class that wraps the behavior (the app almost always wraps third-party SDKs in one helper). Reverse-lookup instructions: `references/dex-patching.md` §finding-the-call-site.
4. **Decide the patch layer** — client SDK call / client rendering / client data consumption / server contract. See the table in `references/ad-removal.md`. 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. 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. 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, 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). 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, **and look at the captures** — sampling gaps are how a blocking modal goes unseen (P20), and a burst of images that were never inspected is not evidence. If the accessibility tree is empty, the image is the primary evidence rather than a fallback.
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. 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 ## Non-negotiable constraints
@@ -59,6 +70,8 @@ Answer these before touching a tool. Every one of them changes the whole plan.
- **Verify structure after every dex edit.** `scripts/dex_classdiff.py` must report zero differences in class set and access flags for classes you did not intend to change. - **Verify structure after every dex edit.** `scripts/dex_classdiff.py` must report zero differences in class set and access flags for classes you did not intend to change.
- **Do not patch a method that is widely shared.** Before patching any helper, count its callers (`scripts/find_refs.py`). A `Long.valueOf` wrapper with 30 callers is not an ad-specific hook. - **Do not 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. - **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.
- **Look before you conclude — and look while you wait.** Execute, capture, and **inspect**; do not drive and sleep blind. A screen that is actually looked at answers in one step what coordinate-guessing cannot answer in five: the layout moved, a different dialog is up, a countdown is frozen, the text on screen says exactly why. Where the thing you are waiting on is visible, a sample you can inspect beats a duration you hoped was right, and byte-identical samples mean nothing is going to change. `scripts/snap.py`; `references/environment.md` §look at the screen.
- **Put a timeout on every command, and calibrate it from measurement.** An unbounded call turns a stall into "the task stopped making progress", which is indistinguishable from slow work and costs hours silently. Time the operation once, record it, then derive the bound from it — that is what makes slow and hung distinguishable. A deadline that passes is a measurement, not a verdict. `references/long-task-discipline.md` §bound every wait.
- **Every claim needs evidence.** "Probably", "should be", "in theory" are not findings. Either you observed it, or you label it unverified. - **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. - **"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. - **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.
@@ -74,19 +87,19 @@ Load only what the current step needs.
| `references/recon.md` | Starting any new sample; identifying packer, SDKs, code location, ABI | | `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/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/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/native-and-so.md` | Patching in a `.so`, needing code to run before the app's own code, hand-built native payloads that crash inside the linker, or **deciding which library/ABI is actually loaded and executing** |
| `references/ad-removal.md` | Task involves ads, trackers, sponsored cards, splash/interstitial/reward | | `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/membership-and-limits.md` | Task involves VIP, subscription, paid content, unlock, "fully cracked" |
| `references/server-api.md` | The behavior is decided by a response, or you need to know if a patch can even matter | | `references/server-api.md` | The behavior is decided by a response, or you need to know if a patch can even matter |
| `references/dex-patching.md` | Any actual editing of dex/smali, choosing a patch layer, choosing a tool | | `references/dex-patching.md` | Any actual editing of dex/smali, choosing a patch layer, choosing a tool |
| `references/repack-and-sign.md` | Rebuilding, signing, installing, or a repacked app misbehaves | | `references/repack-and-sign.md` | Rebuilding, signing, installing, or a repacked app misbehaves |
| `references/runtime-data.md` | Local state matters: DataStore, SharedPreferences, SQLite, protobuf caches, tokens | | `references/runtime-data.md` | Local state matters: DataStore, SharedPreferences, SQLite, protobuf caches, tokens — **or your data edit keeps being reverted, or a stored value looks encrypted** |
| `references/dynamic-frida.md` | Frida setup, hooking strategy, tracing caller chains, finding the real call site | | `references/dynamic-frida.md` | Frida setup, hooking strategy, tracing caller chains, finding the real call site |
| `references/environment.md` | Device/emulator setup, root, ADB, networking, offline devices | | `references/environment.md` | Device/emulator setup, root, ADB, networking, offline devices, emulator console control and recovery, **the preflight check to run before every experiment block** |
| `references/verification.md` | Defining what "done" means; building the evidence chain | | `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/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/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/long-task-discipline.md` | The task will run long, or you are resuming one. Live record, conclusion grading, drift checkpoints, **deliverable-form drift (rooted-only vs shippable)**, bound-your-waits, handover |
| `references/pitfalls.md` | Always worth a skim before building. This is the failure catalogue. | | `references/pitfalls.md` | Always worth a skim before building. This is the failure catalogue. |
## Script index ## Script index
@@ -111,3 +124,7 @@ All scripts are parameterized and path-agnostic; pass paths explicitly. Run `--h
| `scripts/frida_probe.js` | Four-layer runtime probe: app network layer + OkHttp + java.net + swallowed exception messages | | `scripts/frida_probe.js` | Four-layer runtime probe: app network layer + OkHttp + java.net + swallowed exception messages |
| `scripts/run_probe.py` | Inject a probe, stream it to a timestamped log file, stay resident while you operate the app | | `scripts/run_probe.py` | Inject a probe, stream it to a timestamped log file, stay resident while you operate the app |
| `scripts/tls_check.py` | Strict certificate check for one or more hosts (expired / wrong host / untrusted CA) | | `scripts/tls_check.py` | Strict certificate check for one or more hosts (expired / wrong host / untrusted CA) |
| `scripts/preflight.py` | Read-only environment check before every experiment block: device, root, ABI/translation, clock skew, leftover proxy/forwards, dead device server. Run this before blaming a patch. |
| `scripts/lib_map.py` | What is **actually mapped** into a live process: per-library path, base, architecture (`ELF e_machine`), and classification (system / from-APK / runtime-materialized). Answers "which library and which ABI is really executing". |
| `scripts/blob_decode.py` | Decode an opaque stored value by searching the parameter space (base64/base64url/hex × rotation × deflate/zlib/gzip) instead of guessing, then re-encode an edited payload with the same parameters. |
| `scripts/snap.py` | Bounded burst screenshot + control-tree capture, with a stall detector and an explicit verdict on whether the accessibility tree is usable at all. Use it so you *look* at the screen instead of driving blind. |
+130 -6
View File
@@ -65,11 +65,86 @@ Notes:
- Some SDKs bypass the system proxy entirely (many ad SDKs do). Proxy logs therefore **undercount** traffic — do not conclude "no ad traffic" from proxy logs alone; confirm with a runtime DNS hook. - Some SDKs bypass the system proxy entirely (many ad SDKs do). Proxy logs therefore **undercount** traffic — do not conclude "no ad traffic" from proxy logs alone; confirm with a runtime DNS hook.
- Clean up when done: `settings put global http_proxy :0`. - Clean up when done: `settings put global http_proxy :0`.
## Preflight — run this before every experiment block
`scripts/preflight.py` checks, read-only, everything that silently fakes a failure: device
reachability, whether more than one device is attached without an explicit serial, root, the
runtime translation layer, device/host clock drift, a leftover device-wide proxy setting, stale
port forwards, a dead device server, the ABI the package manager actually chose, and the free
space left for installs.
```bash
python scripts/preflight.py --pkg <app.package> --expect-root
python scripts/preflight.py --cleanup # also clears a leftover proxy and stale forwards
```
**The rule it exists to enforce:** *do not attribute a failure to your patch while preflight is
dirty.* A surprising fraction of "my change broke it" is a device that was already in a bad state.
Costing thirty seconds here is cheaper than costing several rounds to a wrong conclusion, and the
wrong conclusion is the one that gets written down and trusted later.
## Emulator notes ## Emulator notes
- Verify the app can install at all. INSTALL failures on emulators are common (ABI, min SDK, vendor checks) and are usually not related to your patch. Emulators are the right place to iterate and the wrong place to conclude.
- Snapshot/rollback is the main advantage — use it to A/B two builds quickly.
- Never quote emulator behavior as proof for a device-only question (and vice versa). - **Verify the app can install at all.** INSTALL failures on emulators are common (ABI, min SDK,
vendor checks, a stale copy with a different signature) and are usually **not** related to your
patch. Get a clean install of the *unmodified* build working before you change anything.
- **Snapshot/rollback is the main advantage** — use it to A/B two builds quickly, and to get back
to a known-good state after a destructive experiment.
- **Never quote emulator behavior as proof for a device-only question** (and vice versa). An
emulator that runs the app is not evidence about a physical ARM device.
- **Most vendors ship a console binary that is far more reliable than the GUI.** Learn yours early;
it is what you will need when the GUI is unresponsive and adb is already down.
| Vendor | Typical console binary | Useful verbs |
|---|---|---|
| LDPlayer | `dnconsole.exe` (next to the player exe) | `list2`, `launch --index N`, `reboot --index N`, `quitall` |
| MuMu | `MuMuManager.exe` | `info -v all`, `control -v N launch`, `control -v N shutdown` |
| AVD / Android Emulator | `emulator.exe` | `-list-avds`, `@<avd>`, `-no-window` |
| Genymotion | `gmtool` | `admin list`, `admin start` |
- **Recovery order when `adb devices` goes empty.** Do these in order; the first two are the ones
people skip:
1. `adb kill-server && adb start-server` — clears a wedged host daemon.
2. Re-run `adb devices`. If still empty, check whether the **emulator process is actually alive**
(`tasklist` / `ps`). A running process with **no listening port** means the VM never finished
booting its adb bridge — restarting the *device* is the only fix.
3. Restart the instance through the vendor console (`reboot --index N`). Restarting via the
player binary's "launch" verb frequently leaves you with a process and still no adb.
4. Only then consider that something is wrong with the host.
Symptom worth memorising: **the emulator process exists but nothing is listening on the adb
port.** Distinguishing "not running" from "running but not booted" is what makes step 2 worth
doing — they have different fixes and only one of them needs the GUI.
- **Root is a per-vendor setting, not a given.** Most emulators expose it as a toggle in their
settings ("root permission" / "ROOT 权限"); some ship a separate rooted image. Always verify
rather than assume:
```bash
adb -s <serial> shell "su -c id" # want: uid=0(root)
```
A rooted emulator is usually the fastest environment for everything in this skill, which makes it
easy to forget that it is also the *least representative* one.
- **Running two instances.** Different serials make two emulators genuinely disjoint, which is
useful for holding a clean control (original build, untouched state) alongside your workbench.
Keep them labelled, always pass `--serial`, and **do not run the same experiment on both** — the
value of a second device is independence, and duplicating work destroys it. Operate one at a
time; the other is a reference point, not extra throughput.
- **Apps can detect the emulator** and change behaviour or refuse to run. If the app behaves
differently here than on hardware, that is a finding about the emulator, not about the app. Note
it and move the question to a real device.
## Which architecture is actually executing
Device selection and architecture are the same question. `getprop ro.product.cpu.abi` reports what
the device claims; it does **not** report what is executing. Read `native-and-so.md` §Cross-architecture
before you choose which library to patch, and `scripts/lib_map.py` to see the live truth.
## Install / reinstall ## Install / reinstall
@@ -110,15 +185,64 @@ If the control still does not react, bypass the UI path entirely — `am start -
`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. `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 ### Look at the screen — do not drive and wait blind
`screencap` returns a 0-byte file on some ROMs. First try writing on-device and pulling: The most expensive habit in device work is: tap a coordinate, sleep, tap again, sleep, conclude
something about the app. A screen that is *looked at* answers in one step what coordinate-guessing
cannot answer in five — the layout shifted, a different dialog came up, a countdown is frozen, the
button is disabled, the text on screen says exactly why.
**Treat a look as a routine step, not a debugging last resort.** Capture:
- **immediately before** anything time-dependent, so you know the starting state;
- **during** a wait, at intervals — state changes are the information, and a single sample at the end
cannot distinguish "it progressed" from "it never moved";
- **at every decision point**, before choosing the next action;
- **on any surprise**, before forming a theory about it.
`scripts/snap.py` does this with sane bounds, and tells you which kind of evidence you actually got:
```bash
python scripts/snap.py --out shots --tag before
python scripts/snap.py --out shots --tag waiting --count 6 --interval 3
```
**Use the stall detector.** If consecutive samples are byte-identical, nothing is happening and more
waiting cannot help. Stop, and go find out why — that is a different investigation from waiting
longer.
### Two kinds of screen evidence, and which one is trustworthy here
| Evidence | What it gives you | When it is the right one |
|---|---|---|
| **the image** | exactly what is rendered: layout, which dialog, disabled states, drawn text | always available; the **only** evidence for runtime-drawn UI |
| **the control tree** (`uiautomator dump`) | precise `bounds`, exact text, diffable | only when it actually has content |
The tree is more convenient *when it exists* — it gives you tap coordinates and text you can diff. But
**verify it has content before planning around it**:
```bash
adb -s <serial> shell "su -c 'uiautomator dump /sdcard/ui.xml'"
adb -s <serial> pull /sdcard/ui.xml
grep -c '<node' ui.xml # 0 nodes -> the tree is useless for this screen
```
**A runtime-rendered UI frequently exposes no real controls at all.** Cross-platform runtimes, web
views and canvas-drawn surfaces often produce an empty (or text-free) tree, which is why a plan built
on "read the bounds out of the XML" stalls on exactly those apps — and also why tapping a control that
"should be there" silently does nothing. When the tree is empty, the image is the **primary** evidence,
not a fallback, and you read it directly rather than trying to derive coordinates from a tree that does
not exist.
`screencap` itself returns a 0-byte file on some ROMs. Write on-device and pull instead:
```bash ```bash
adb -s <serial> shell "su -c 'screencap -p /sdcard/x.png'" && adb -s <serial> pull /sdcard/x.png 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.) If that is also empty, do not fight it — but note that **a 0-byte capture is not evidence the screen is
blank** (`pitfalls.md` P20). And if `uiautomator dump` fails with `could not get idle state`, retry once;
a paused animation is usually the cause.
### Verify form input by reading it back ### Verify form input by reading it back
+26
View File
@@ -72,11 +72,37 @@ the snapshot rather than by pattern-matching machine code.
- **Strings first.** User-visible text (dialog bodies, feature labels) is the cheapest anchor in any - **Strings first.** User-visible text (dialog bodies, feature labels) is the cheapest anchor in any
runtime. Find it, then find what references it. runtime. Find it, then find what references it.
- **Search them in the encoding the runtime actually uses, or you will conclude they do not exist.**
This is the single most common false negative in cross-platform work. A runtime snapshot does not
necessarily store text as UTF-8; UTF-16 (little-endian on these targets) is common and entirely
valid. A UTF-8 search of such a snapshot returns **zero hits**, which reads as "the strings were
stripped / encrypted, this route is dead" — and that conclusion is wrong.
```python
blob = open('libapp.so', 'rb').read() # or whichever artifact holds the snapshot
for phrase in ('<feature label>', '<dialog title>'):
print(phrase, 'utf-8:', blob.find(phrase.encode('utf-8')),
'utf-16le:', blob.find(phrase.encode('utf-16-le')))
```
Try both, and try a short distinctive substring rather than a long phrase.
- **A phrase that is absent may simply never exist as one literal.** UI text is often assembled from
fragments or templates, so "the whole sentence" can be missing while both halves are present.
Search the shortest distinctive token, and expect the interesting anchor to be a *label* rather
than a sentence.
- **Landing on a string tells you where the text lives, not which code decided to show it.** Treat the
hit as an anchor for reference-hunting, not as the answer.
- **Compare two builds.** The same feature in a slightly different version often reveals the code path. - **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 - **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. 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 - **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. identities leads to conclusions that break on the next build.
- **Obfuscated identifiers can be non-ASCII and invisible.** Renaming passes can replace class and
member names with characters outside ASCII (combining marks, variant selectors and similar). If a
class exists at runtime but "cannot be found" by the name you read from a decompiler, suspect
encoding or normalisation in your tooling rather than a missing class. Resolve such members by
**shape** — parameter counts, types, interface implementation — instead of by name, and prefer
runtime enumeration of loaded classes over guessing identifiers.
## What this changes about your plan ## What this changes about your plan
+110
View File
@@ -83,6 +83,116 @@ Do these cheaply, and only when they can change a decision — not as ceremony.
- **When resuming**: read the record first, and treat anything not in it as unknown, even if it feels - **When resuming**: read the record first, and treat anything not in it as unknown, even if it feels
familiar. familiar.
## The most expensive drift: solving it in an environment the deliverable will never see
There is one drift in this domain that costs more than all the others, because it produces a
result that looks like success and is not one.
**A runtime-only result is easy to obtain and easy to mistake for a finished artifact.**
Editing a data file, hooking a live process, blocking a hostname, holding a proxy open — each of
these can make the app behave correctly *on the machine where you did it*, with no repackaging
required. It is often the fastest path to a visible win. It is also frequently **not the
deliverable at all**, because the requirement was never "make it work here".
The constraint axes that decide this — check every one against the original request, and write
down the answer as a testable sentence before you start:
| Axis | The question | Why it silently invalidates work |
|---|---|---|
| **Privilege** | must it run **unrooted**? | a rooted-only result cannot be given to a normal user at all |
| **Modification form** | must it be a **rebuilt/installable artifact**, or is a live-instrumentation result acceptable? | hooks and data edits do not ship inside an APK |
| **ABI / device class** | which ABI, which device family? | an emulator-only or x86-only result is not evidence for a physical ARM device |
| **Network** | must it work **online**? | a fix that depends on being offline or on a host proxy fails the moment it is used normally |
| **Persistence** | must it survive restart, upgrade, and a fresh install? | in-memory and session-scoped state evaporates |
| **Distribution** | does the *shipped file* have to be self-contained? | anything that needs a helper on the machine is not a shippable artifact |
**Checkpoint question, asked at the same moments as the drift checks above:**
> *If I handed over exactly what exists right now, would it satisfy the constraint sentence I wrote
> at the start?*
If the answer is no, you have made real progress on a **component**, and that is worth stating as
such — but it is not the task. Do not let "it works" stand in for "it works under the constraint".
**What to do instead of over-claiming.** Split the result into two explicitly labelled parts:
1. **What is achievable inside the constraint**, and how far along it is.
2. **The unconstrained workaround** (needs root / needs a host / needs a proxy), stated as a
deliberate fallback, with its requirements made obvious to the reader.
A privileged workaround is genuinely useful — it can be the difference between using the app and
not. It becomes a problem only when it is presented as the deliverable. Report it as a fallback,
keep the constrained goal open, and say plainly which one you have.
**Also watch the inverse**: if the constraint is "must be a rebuilt artifact", do not let a working
runtime result quietly close the investigation. Use it for what it is good for — it proves the
mechanism and identifies the exact code or data to change — then port that finding into the
artifact. The runtime result is the map, not the destination.
## Bound every wait
A long task stalls in ways that produce no information and consume the most valuable resource you
have: wall-clock time and the next person's patience. Three habits prevent almost all of it.
**1. Every command has a timeout, and its absence is the bug.** A helper that shells out without a
timeout can hang forever, and the failure presents as "the task stopped making progress" rather
than "this call blocked". Pass an explicit timeout on every subprocess, every HTTP request, every
device call. When one expires, the result is **unknown**, not failed — record it that way and
re-check state before retrying.
**2. Calibrate the expected duration from measurement, not from a guess.** A timeout only means
something if it is set relative to how long the operation *should* take, because that is what makes
"slow" and "hung" distinguishable. Guessed budgets are either so short that healthy work gets killed,
or so long that a stall looks like patience.
So: **measure once, write it down, then derive the bound.**
| Operation class | How to bound it |
|---|---|
| host-side tool (`baksmali`, `apksigner`, a compiler) | seconds to low minutes; time it once, then set roughly 3–5× observed |
| device shell call (`adb shell …`) | seconds — **except** the first `su` after a reboot, which can prompt and block |
| install / push of a large artifact | minutes; scales with size, not with the app's complexity |
| app cold start to first frame | tens of seconds — **longer with a packer**, which does real work before your code runs |
| a step needing a human or an external service | **unbounded in principle** — do not wait at all (see P22) |
| waiting for an on-screen state change | bound it *and* sample it (point 3) |
Record the observed durations in the live record. They are exactly the kind of mechanical fact that
gets re-derived painfully after a context loss, and having them turns a later timeout into an
interpretable signal instead of a mystery.
**Exceeding the expected window is itself the finding.** It tells you something about state — the
device is wedged, the app never reached that phase, the action never happened — and that is a
different investigation from waiting longer. Re-check state instead of extending the deadline.
**3. Every wait has a deadline, an observable to sample, and a look.** "Wait for the operation to
finish" is not a plan. Name the observable, how often you will sample it, how long you will sample
before giving up, and what you conclude on expiry:
```
waiting for: <observable> e.g. the overlay is gone / the counter incremented
sample: <interval + how> e.g. every 15 s: screenshot + current window focus
give up at: <deadline> e.g. 150 s
on expiry: <what I conclude> e.g. "did not complete within the window" -- not "cannot work"
```
**Sample with your eyes rather than sleeping blind.** When the step is gated on something visible — a
screen, a dialog, a progress indicator — capture it and **look at it** instead of sleeping through the
interval. A fixed sleep either wastes time or measures the wrong instant; a look tells you what
actually happened, and byte-identical consecutive samples tell you nothing is going to change
(`environment.md` §look at the screen). This is the cheapest way to avoid spending rounds on a state
that was never going to arrive.
Three rules that follow:
- **A deadline that passes is a measurement, not a verdict.** "Still not finished after N seconds"
tells you about latency and reliability. It does not tell you the approach is impossible, and
writing it down as impossible is how a viable route gets discarded.
- **Never busy-poll a long job while you have independent work.** Start it, do the other thing, and
collect it when it settles. Polling wastes the same resource the task is already spending.
- **Prefer a bounded observable over a fixed sleep.** Poll the state you actually care about, with a
cap — and prefer a sample you can inspect over a duration you hope is right.
## Handover ## Handover
A handover is the record plus three things, written for someone with **no** memory of the task: A handover is the record plus three things, written for someone with **no** memory of the task:
+76 -6
View File
@@ -101,12 +101,82 @@ Stripped and obfuscated libraries still expose structure:
## Cross-architecture notes ## Cross-architecture notes
- **arm64** is the common case on modern devices; prefer it and make sure the host library you pick Architecture is not background information here — it decides **which artifact you must edit** and
actually exists for that ABI. **whether your hook can observe anything at all**. Get it from the runtime, not from the manifest.
- **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. ### What the device claims vs what is executing
- 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. Three different answers, and they disagree more often than people expect:
| Source | Answers | Command |
|---|---|---|
| The device | which ABIs the system supports | `getprop ro.product.cpu.abi` and `ro.product.cpu.abilist` |
| The package manager | **which ABI it chose for this app on this device** | `dumpsys package <pkg> \| grep primaryCpuAbi` |
| The live process | **what is actually mapped right now** | `scripts/lib_map.py --pkg <pkg>` |
Only the third is ground truth. The first two are predictions, and they are wrong exactly when it
matters: an emulator whose primary ABI is `x86_64` can be running an `arm64-v8a`-only app through a
translator, and a fat APK can have the package manager pick an ABI you did not assume.
**Consequence: if the library you patched does not appear in the live mapping, your change cannot
matter.** That is a plan problem, and no amount of re-patching will fix it.
### Translation layers change what you are observing
When an ARM-only app runs on an x86 host, a translator (Intel Houdini, `libndk_translation`, a
`native_bridge` in general) is executing the guest code. Detect it from the maps — look for
translator marker libraries and for app libraries whose architecture differs from the host's
primary ABI (`lib_map.py` reports both).
What it changes:
- **Timing.** Translation is slower and less predictable. Anything you measured about latency is
about the translator, not the code.
- **Native integrity and anti-tamper checks can behave differently** under translation, in either
direction — a check that fails on hardware may pass here, and vice versa. A pass under translation
is not evidence of a pass on hardware.
- **Hook behaviour.** Intercepting translated code is not the same as intercepting native code:
address spaces, calling conventions, and what the host sees at a syscall boundary all differ. If a
native hook reports nothing while the feature plainly runs, suspect translation before suspecting
your script.
- **Syscall-level observation shows the host's view.** A file or network call made from the guest
may look different on the host side than it would natively.
**Rule: verify the artifact on the ABI the user will actually run.** An emulator-only result is a
mid-task checkpoint, never the final claim (`verification.md`).
### Fat APKs: patching the ABI that never loads
An APK can ship `lib/arm64-v8a/`, `lib/armeabi-v7a/`, `lib/x86_64/`… The package manager extracts
**one** of them into the install-time native library directory, and only that one is loaded.
- Check `primaryCpuAbi` and the live maps before editing a `.so`. Editing `arm64-v8a` while the
device loads `armeabi-v7a` produces a build that is byte-different and behaviorally identical.
- If the deliverable must work for an unknown user, remember their device may select a different
ABI than yours. Either patch every ABI present, or state which ABI your artifact targets.
- If the app's own libraries are written at *runtime* rather than extracted from the APK, the live
paths will not be inside the APK at all (`lib_map.py` marks these `materialized`). Those belong to
whatever produced them, and a repacked APK will not carry them in that location.
### Payloads and tooling are per-ABI too
- A native payload, an injected library, or a shellcode blob built for one ABI **will not load** on
another. The failure usually surfaces as a *missing library* or a generic linker error rather than
a clean "wrong architecture" message — do not read it as "my payload is broken".
- **Pointer size differs** between 32- and 64-bit targets. A script or struct layout that assumes
8-byte words misbehaves silently on a 32-bit target.
- Keep build artifacts per-ABI and label them with the ABI in the filename. Mixing them up is a
reliable source of "it crashed for no reason" when the same APK behaves differently on an emulator
and on a device.
### Quick decision list
1. `scripts/lib_map.py --pkg <pkg> --app-only` → which app libraries are loaded, from where, and at
what architecture.
2. If a translator is present and the task needs reliable native behaviour, move to a matching
device.
3. If the library you meant to patch is absent, stop — pick the right library first.
4. Only then start editing.
## Verification ## Verification
+110
View File
@@ -493,3 +493,113 @@ an impression of work being done.
- Detect stalls by change, not by elapsed time: if N consecutive samples are identical, break out. - 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 - Prefer driving the prompt to completion over waiting it out — the same prompt usually recurs, so
automating it once pays back immediately. automating it once pays back immediately.
---
## P23. The fix lives in a file the app rewrites
**Symptom**
You change a value in the app's data, the write succeeds, the file reads back correctly — and after
the next launch the value is back to what it was. Often byte for byte identical, which makes it look
like nothing happened at all.
**Root cause**
The stored value is a **cache, not a source of truth**. Either the app re-fetches it and re-persists
it, or it rewrites the file from its own defaults on every start. Your edit was never authoritative.
Compounding it: the intuitive way to protect the file — a restrictive mode or a changed owner — **does
not work**, because the app does not open-and-write the existing file. It **deletes the file and
creates a new one**, and a new file is created with the app's own mode and owner. Nothing is inherited,
so `chmod`/`chown` are silently ineffective.
**Why it is hard to see**
Verification is usually done immediately after writing, while the file is still correct. The rewrite
only happens on the next start, which is one step further along than you looked. And the "fix" that
seems obviously right (tighten permissions) fails without any error.
**Do instead**
- **Verify after a relaunch, not after the write.** The write succeeding is not the finding; surviving
a restart is.
- **Distinguish the two causes with one offline launch.** If the value survives with the network down,
it came from the server. If it does not, the app is regenerating it locally — and then a data edit is
the wrong layer entirely; patch the read path instead.
- To make a data edit stick, use the **immutable attribute** and confirm it took effect:
```bash
su -c "chattr +i <file>"; su -c "lsattr <file>" # expect the 'i' flag
```
It is enforced by the filesystem against the delete itself, which is why it holds where permissions
do not. Undo with `chattr -i`.
- Then **exercise the feature**, not just the value. A blocked write the app depends on can make it
misbehave; "the file still has my value" is not "the app still works".
- Remember a lock is **device state, not artifact state** — it does not travel with an APK. Record it
as an environment requirement. The only form that ships is a code patch (`runtime-data.md`).
---
## P24. The artifact changed, but the wrong one is executing
**Symptom**
The build differs from the original, the pipeline reports success, the file on disk is genuinely
modified — and the app behaves exactly as before. Or a native hook reports nothing while the feature
plainly runs.
**Root cause**
Something other than your edit is being used at runtime:
- **Wrong ABI.** A fat APK ships several `lib/<abi>/` directories; the package manager extracts and
loads **one**. Editing `arm64-v8a` while the device loads `armeabi-v7a` produces a byte-different,
behaviorally identical build.
- **The library is not from the APK at all.** Some libraries are written into the app's data directory
at runtime rather than extracted from the package. Patching the APK copy changes a file nobody loads.
- **Multiple processes.** The work was done in, or the check lives in, a different process than the one
you are observing.
- **A stale install.** The package manager reported success for a request that did not replace what is
on disk (`P18`).
**Why it is hard to see**
Every local indicator agrees: the diff is non-empty, the build is signed, the install returned success.
Nothing in the *build* pipeline can detect this, because the build is fine. The contradiction only
exists at runtime.
**Do instead**
- **Ask the running process what it loaded**, before editing: `scripts/lib_map.py --pkg <pkg>`.
Libraries whose path is not under the installed APK's lib directory were materialized at runtime
and belong to whatever produced them.
- Confirm the ABI the package manager actually chose (`dumpsys package <pkg> | grep primaryCpuAbi`)
rather than the one you assumed from the manifest.
- **If the library you patched is not in the live mapping, stop.** No amount of re-patching helps; the
plan is wrong.
- Treat a behaviorally identical rebuild as **positive evidence that your edit is not being loaded**,
not as "the change had no effect". Those are different conclusions and only one of them is actionable.
---
## P25. "The search found nothing" treated as "the data is not there"
**Symptom**
You scan an artifact for a known-present value — a UI label, a marker string, an endpoint — get zero
hits, and conclude the content is stripped, encrypted, or otherwise unavailable. A route gets written
off on that basis.
**Root cause**
The search used the wrong representation. A byte scan for UTF-8 text returns nothing against content
that is stored as UTF-16, or compressed, or framed inside a container, or split across fragments. The
data is present; the needle was encoded differently from the haystack.
**Why it is hard to see**
"Zero results" is a clean, confident-looking output. It feels like a measurement, so it gets recorded
as a finding, and findings propagate into the plan.
**Do instead**
- **Before concluding absence, search more than one encoding.** UTF-8 and UTF-16LE will between them
cover most text storage:
```python
blob.find(needle.encode('utf-8')), blob.find(needle.encode('utf-16-le'))
```
- **Search the shortest distinctive fragment.** Text is often assembled from pieces or templates, so a
full sentence can be absent while its parts are present.
- **Try the value without its framing.** A hit rate of zero is also the expected result for content
that is inside a compressed or encoded container — decode the container first (`runtime-data.md`,
`scripts/blob_decode.py`).
- **State a negative result with its scope**: "no UTF-8 or UTF-16LE literal match in this artifact"
is a finding. "The string does not exist" is a guess wearing a finding's clothes.
+79 -2
View File
@@ -105,8 +105,85 @@ If the effect must ship inside an APK, move it into code. Patterns, in order of
Reference case: a promo popup was gated by `<key>_expires_at`. Writing a large value via DataStore suppressed it on the test device, but a fresh install lost it. The durable fix replaced the dedicated map-lambda that produces the value so it always yields a far-future timestamp — a single-purpose class, safe to patch, no effect on the shared serialization helpers. Reference case: a promo popup was gated by `<key>_expires_at`. Writing a large value via DataStore suppressed it on the test device, but a fresh install lost it. The durable fix replaced the dedicated map-lambda that produces the value so it always yields a far-future timestamp — a single-purpose class, safe to patch, no effect on the shared serialization helpers.
## When the app rewrites your edit
A very common sequence: you change the value, relaunch, and the value is **back** — often byte for
byte identical to what it was before. That is not a failed write. It is the app re-establishing the
value from a source of truth you have not touched yet.
Two causes look identical and need different fixes:
| Cause | How to tell | Fix |
|---|---|---|
| The app **re-fetches from the server** and re-persists | take the app offline and relaunch: does your value survive? | lock the file, block the refresh request, or move the change into code |
| The app **rewrites the file on every start** from its own defaults | your value does not survive even with the network down | the value is not authoritative — patch the read path instead |
Do the offline test first. It costs one launch and separates the two cases.
### Making a data edit stick (in order of durability)
1. **Immutable file attribute** — the reliable, reversible way to stop a rewrite:
```bash
su -c "chattr +i <path/to/prefs.xml>"
su -c "lsattr <path/to/prefs.xml>" # expect the 'i' flag, e.g. -----i-------
# to undo:
su -c "chattr -i <path/to/prefs.xml>"
```
Verify with `lsattr`, not by assuming the command worked.
**Why permissions do not achieve this.** Tightening the mode or changing the owner looks like the
obvious move and does not work, because the app does not open-and-write the existing file — it
**deletes the file and creates a new one**. The new file is created by the app, with the app's own
mode and owner, so a restrictive `chmod` or a `chown root` is simply not inherited. The immutable
attribute is enforced by the filesystem against the delete itself, which is why it holds.
2. **Keep the app offline** so the refresh source is unreachable. Effective for a test, and
sometimes acceptable in use — but be explicit about it, because a fix that only works offline
fails the "works under normal conditions" constraint. See the deliverable-drift section in
`long-task-discipline.md` before presenting this as a result.
3. **Block the refresh request** at the runtime or network layer — durable while your instrumentation
is running, and requires you to have identified the endpoint (`references/server-api.md`).
4. **Move the change into code** — patch the read path or the decision. This is the only form that
ships inside an APK (`§Making the fix durable` above).
### Two cautions about locking
- **Verify the feature, not the value.** Blocking a write the app depends on can make it misbehave
or fail loudly. After locking, exercise the feature you care about — "the file still has my value"
is not the same as "the app still works".
- **A lock is device state; it does not travel with an APK.** If the recipe requires it, record it as
an environment requirement, so nobody later mistakes it for something baked into the artifact.
## Encoded values: do not assume "encrypted"
Preference values that carry server configuration are frequently stored as a single opaque string
rather than readable XML. Before concluding the value is encrypted, check whether it is merely
**framed**: a common shape is `base64( rotate( deflate( json ) ) )`, where the cyclic rotation exists
precisely so a naive decode fails.
`scripts/blob_decode.py` searches the parameter space instead of guessing it — outer encoding
(base64 / base64url / hex), rotation offset, and compression type — and reports every combination
that yields a structured document, plus the exact parameters to re-encode your edited payload:
```bash
# pull the value straight out of a preferences XML and decode it
python scripts/blob_decode.py --prefs-xml prefs.xml --name <key> # (see --help for exact name)
# after editing the decoded payload, rebuild the value with the winning parameters
python scripts/blob_decode.py --encode --file decoded.bin --outer base64 --inner raw --cut <N>
```
The rotation search is cheap — a few tens of thousands of candidates resolve in well under a second
— so there is no reason to guess. **Distinguish framing from real encryption before spending time
on a key:** a framed blob becomes structured the moment you remove the framing, whereas a keyed blob
stays random-looking. If it stays random, treat it as opaque and move to the code path that consumes
it.
## Cautions ## Cautions
- Editing data while the app is running is unreliable: in-memory caches win. - Editing data while the app is running is unreliable: in-memory caches win. Force-stop, edit, then launch.
- The server may overwrite your value on next sync. If the app re-fetches and re-persists the authoritative value, a data edit is temporary by design — which is exactly why the durable version belongs in code. - The server may overwrite your value on next sync — see *When the app rewrites your edit* above.
- Do not edit a token you do not own and expect it to be accepted; tokens are validated server-side (`references/server-api.md`). - Do not edit a token you do not own and expect it to be accepted; tokens are validated server-side (`references/server-api.md`).
- A restored data directory needs ownership fixed again after every reinstall, because the uid increments.
+276
View File
@@ -0,0 +1,276 @@
#!/usr/bin/env python3
"""Decode (and re-encode) an opaque single-value blob cached by an app.
Many apps cache server-issued configuration as ONE opaque string inside a
preferences file or a key-value store. The encoding is usually some stack of:
outer: base64, base64url, or hex
transform: a cyclic rotation of the byte stream (so the compressed stream
does not begin at its header), sometimes preceded by a short
header to skip
inner: raw deflate, zlib, or gzip
You do not have to guess the parameters. This script searches them and reports
every combination that decodes to something structured, then can re-encode your
edited payload with the same parameters so it can be written back.
Why rotation matters: a plain base64->inflate attempt fails on these blobs, and
the failure looks like "this value is encrypted". Exhaustively trying cyclic
shifts is cheap and frequently succeeds, which turns an opaque blob into an
editable JSON/XML document.
Usage:
# decode the value of a key inside a prefs XML
python blob_decode.py --prefs-xml prefs.xml --name flutter.remoteConfig
# decode a blob stored in its own file (or piped on stdin)
python blob_decode.py --file value.txt
cat value.txt | python blob_decode.py
# keep searching harder (slower) and write the best result out
python blob_decode.py --file value.txt --max-skip 64 --out decoded.bin
# re-encode an edited payload with the parameters that worked
python blob_decode.py --encode --file decoded.bin --outer base64 --inner raw --cut 15435 --out value.txt
Notes:
* Output is written to --out; nothing on device is touched.
* The winning parameters are printed as a copy-pasteable line for --encode.
"""
from __future__ import annotations
import argparse
import base64
import binascii
import json
import re
import sys
import time
import zlib
INNERS = [("raw", -15), ("zlib", 15), ("gzip", 31)]
# --------------------------------------------------------------------------- #
# outer encodings
# --------------------------------------------------------------------------- #
def outer_decode(text: str, which: str) -> bytes | None:
t = text.strip().replace("\n", "").replace("\r", "").replace(" ", "")
try:
if which == "base64":
return base64.b64decode(t + "=" * (-len(t) % 4))
if which == "base64url":
return base64.urlsafe_b64decode(t + "=" * (-len(t) % 4))
if which == "hex":
return binascii.unhexlify(re.sub(r"[^0-9a-fA-F]", "", t))
except Exception:
return None
return None
def outer_encode(data: bytes, which: str) -> str:
if which == "base64":
return base64.b64encode(data).decode("ascii")
if which == "base64url":
return base64.urlsafe_b64encode(data).decode("ascii")
if which == "hex":
return binascii.hexlify(data).decode("ascii")
raise ValueError("unknown outer encoding: %s" % which)
def sniff_outer(text: str) -> list[str]:
"""Cheap guess first so the common case is instant."""
t = text.strip()
guesses = []
if re.fullmatch(r"[0-9a-fA-F\s]+", t) and len(t) >= 16:
guesses.append("hex")
if re.fullmatch(r"[A-Za-z0-9+/\s=]+", t):
guesses.append("base64")
if re.fullmatch(r"[A-Za-z0-9\-_\s=]+", t):
guesses.append("base64url")
# always keep base64 last-resort, then everything else
for w in ("base64", "base64url", "hex"):
if w not in guesses:
guesses.append(w)
return list(dict.fromkeys(guesses))
# --------------------------------------------------------------------------- #
# inner transforms
# --------------------------------------------------------------------------- #
def try_inflate(body: bytes, max_out: int | None = None) -> tuple[str, bytes] | None:
best = None
for name, wbits in INNERS:
try:
if max_out is None:
out = zlib.decompress(body, wbits)
else:
d = zlib.decompressobj(wbits)
out = d.decompress(body, max_out)
if out:
if best is None or len(out) > len(best[1]):
best = (name, out)
except Exception:
continue
return best
def inflate_raw(data: bytes) -> bytes:
co = zlib.compressobj(9, zlib.DEFLATED, -15)
return co.compress(data) + co.flush()
def score(out: bytes) -> float:
"""How much does this look like a real document we can edit?"""
if not out:
return -1.0
printable = sum(1 for b in out[:4096] if 32 <= b < 127 or b in (9, 10, 13))
ratio = printable / min(len(out), 4096)
s = ratio * 10.0
head = out[:64].lstrip()
if head[:1] in (b"{", b"[", b"<", b"#"):
s += 6.0
if b'"' in out[:512] or b"=" in out[:512]:
s += 2.0
if len(out) < 32:
s -= 3.0
return s
# --------------------------------------------------------------------------- #
def search(blob: bytes, max_skip: int, log) -> list[dict]:
hits: list[dict] = []
n = len(blob)
t0 = time.time()
for skip in range(0, min(max_skip, n) + 1):
base = blob[skip:]
m = len(base)
if m < 8:
break
for k in range(m):
cand = base[k:] + base[:k]
got = try_inflate(cand, max_out=8_000_000)
if got:
name, out = got
hits.append({
"skip": skip, "cut": k, "inner": name, "size": len(out),
"score": round(score(out), 2), "data": out,
})
if hits and log and skip == 0:
log(" first hit found with skip=0 after %.1fs" % (time.time() - t0))
if log and skip and skip % 16 == 0 and time.time() - t0 > 30:
log(" ... skip=%d, %.0fs elapsed" % (skip, time.time() - t0))
log(" search finished in %.1fs (%d candidate(s))" % (time.time() - t0, len(hits)))
return hits
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
src = ap.add_mutually_exclusive_group()
src.add_argument("--file", help="file containing the encoded value")
src.add_argument("--prefs-xml", help="a preferences XML file to pull the value out of")
ap.add_argument("--name", help="key name when using --prefs-xml")
ap.add_argument("--outer", choices=["base64", "base64url", "hex", "auto"], default="auto")
ap.add_argument("--max-skip", type=int, default=0,
help="also try dropping 0..N leading bytes before the rotation search (slower)")
ap.add_argument("--min-size", type=int, default=200, help="ignore results smaller than this")
ap.add_argument("--top", type=int, default=5, help="how many candidates to print")
ap.add_argument("--out", help="write the best decoded result here")
ap.add_argument("--encode", action="store_true", help="re-encode instead of decoding")
ap.add_argument("--inner", choices=[n for n, _ in INNERS], default="raw")
ap.add_argument("--cut", type=int, default=0, help="rotation used for write-back")
a = ap.parse_args()
def log(msg: str) -> None:
print(msg, file=sys.stderr)
# ------------------------------------------------------------------ encode
if a.encode:
if not a.file:
log("--encode requires --file (the payload to encode)")
return 2
payload = open(a.file, "rb").read()
body = inflate_raw(payload) if a.inner == "raw" else zlib.compress(payload, 9)
if a.inner == "zlib":
body = zlib.compress(payload, 9)
cut = a.cut % max(len(body), 1)
rotated = body[-cut:] + body[:-cut] if cut else body
enc = outer_encode(rotated, "base64" if a.outer == "auto" else a.outer)
if a.out:
open(a.out, "w", encoding="utf-8", newline="\n").write(enc)
log("wrote %d chars to %s" % (len(enc), a.out))
else:
print(enc)
log("write-back parameters: outer=%s inner=%s cut=%d"
% ("base64" if a.outer == "auto" else a.outer, a.inner, cut))
return 0
# ------------------------------------------------------------------ input
if a.prefs_xml:
if not a.name:
log("--prefs-xml requires --name <key>")
return 2
xml = open(a.prefs_xml, encoding="utf-8", errors="replace").read()
m = re.search(r'name="%s"[^>]*>([^<]*)<' % re.escape(a.name), xml)
if not m:
log("key not found in XML: %s" % a.name)
log("keys present: %s" % ", ".join(re.findall(r'name="([^"]+)"', xml)[:40]))
return 2
text = m.group(1)
log("value length: %d" % len(text))
elif a.file:
text = open(a.file, encoding="utf-8", errors="replace").read()
else:
text = sys.stdin.read()
if not text.strip():
log("empty input")
return 2
# ------------------------------------------------------------------ decode
candidates = [a.outer] if a.outer != "auto" else sniff_outer(text)
for which in candidates:
blob = outer_decode(text, which)
if blob is None or len(blob) < 8:
log("outer=%s -> not decodable, skipping" % which)
continue
log("outer=%s -> %d bytes; searching rotation (and skip<=%d)" % (which, len(blob), a.max_skip))
hits = [h for h in search(blob, a.max_skip, log) if h["size"] >= a.min_size]
if not hits:
continue
hits.sort(key=lambda h: (-h["score"], -h["size"]))
print("=" * 66)
print("outer=%s candidates=%d" % (which, len(hits)))
for h in hits[:a.top]:
print(" score=%-6s skip=%-4d cut=%-7d inner=%-5s size=%d"
% (h["score"], h["skip"], h["cut"], h["inner"], h["size"]))
best = hits[0]
print("-" * 66)
print("write-back line:")
print(" python blob_decode.py --encode --file <edited_payload> --outer %s --inner %s --cut %d"
% (which, best["inner"], best["cut"]))
print("-" * 66)
preview = best["data"][:1200]
try:
print(preview.decode("utf-8"))
except UnicodeDecodeError:
print(preview.decode("latin1"))
print("(non-UTF-8; first 64 bytes: %s)" % best["data"][:64].hex())
if a.out:
open(a.out, "wb").write(best["data"])
log("wrote %d bytes to %s" % (len(best["data"]), a.out))
return 0
print("no decodable candidate found.", file=sys.stderr)
print("Next steps:", file=sys.stderr)
print(" * raise --max-skip, or pass --outer explicitly", file=sys.stderr)
print(" * the blob may be genuinely encrypted (a keyed cipher) rather than merely", file=sys.stderr)
print(" encoded -- confirm before spending time on it: a keyed blob should look", file=sys.stderr)
print(" random, whereas an encoded one still has structure once the framing is", file=sys.stderr)
print(" removed. See references/runtime-data.md.", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
+218
View File
@@ -0,0 +1,218 @@
#!/usr/bin/env python3
"""Report which native libraries are ACTUALLY mapped into a live process.
`getprop ro.product.cpu.abi` tells you what the device claims to be. It does not
tell you what is executing. Those differ more often than people expect:
* an x86_64 emulator running an app whose only native libraries are arm64,
because an ARM translation layer is present;
* a hardened app whose real libraries are materialized into its data
directory at runtime, so the paths you see are not the paths inside the APK;
* a fat APK where the package manager picked a different ABI than you assumed,
so you patch a `.so` that never loads.
This script reads the live mapping table and reports, per library: the path, the
base address, and the architecture of the actual ELF on disk. It classifies each
path so the interesting ones stand out (system / from-APK / runtime-materialized).
Requires root to read another process's maps.
Usage:
python lib_map.py --pkg com.example.app
python lib_map.py --pkg com.example.app --app-only
python lib_map.py --pid 1234 --all
python lib_map.py --pkg com.example.app --json
python lib_map.py --pkg com.example.app --grep ssl
Reading it:
* A library whose path sits under the app's data dir (not under the APK's
lib/<abi> dir) was written at runtime. That is a packer/dropper fingerprint
and it will not be present in a repacked APK -- patch or replace with care.
* If app libraries are 64-bit ARM but the device primary ABI is x86_64,
a translator is in play. Timing and some native checks will differ.
* If the library you patched does not appear here at all, your patch cannot
matter. That is a plan problem, not a patch problem.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
from collections import defaultdict
E_MACHINE = {
0x03: "x86", 0x3E: "x86_64", 0x28: "arm", 0xB7: "aarch64",
0x08: "mips", 0xF3: "riscv", 0x16: "s390", 0x15: "ppc",
}
TRANSLATOR_MARKERS = ("houdini", "libndk", "native_bridge", "libntv", "armtrans",
"libhoudini", "ndk_translation", "libarm", "exagear", "box64")
def run(cmd: list[str], timeout: int = 60) -> str:
try:
p = subprocess.run(cmd, capture_output=True, text=True, errors="replace", timeout=timeout)
return (p.stdout or "") + (p.stderr or "")
except Exception as e:
return "ERROR: %s" % e
def classify(path: str, pkg: str | None) -> str:
if path.startswith(("/system/", "/apex/", "/vendor/", "/product/", "/odm/", "/system_ext/")):
return "system"
if re.search(r"/data/app/", path):
return "apk-lib"
if pkg and (("/data/data/%s" % pkg) in path or ("/data/user/0/%s" % pkg) in path):
return "materialized"
if "/data/" in path:
return "data-other"
return "other"
def read_elf_arch(sh_fn, path: str) -> str:
"""Read the ELF header on the device and name the machine. '?' means the read failed."""
hdr = sh_fn("dd if='%s' bs=20 count=1 2>/dev/null | od -An -tx1 2>/dev/null" % path)
hexs = re.sub(r"[^0-9a-fA-F]", "", hdr)
if len(hexs) < 40:
return "?"
b = bytes.fromhex(hexs[:40])
if b[:4] != b"\x7fELF":
return "not-elf"
cls = {1: 32, 2: 64}.get(b[4], 0)
little = b[5] == 1
mach = int.from_bytes(b[18:20], "little" if little else "big")
if mach in E_MACHINE:
return E_MACHINE[mach] # the machine name already implies the width
return "0x%02x/%db" % (mach, cls or 0)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
tgt = ap.add_mutually_exclusive_group(required=True)
tgt.add_argument("--pkg", help="target package name")
tgt.add_argument("--pid", type=int, help="target pid")
ap.add_argument("--adb", default=os.environ.get("ADB", "adb"))
ap.add_argument("--serial", default=None)
ap.add_argument("--all", action="store_true", help="include system libraries")
ap.add_argument("--app-only", action="store_true", help="only APK libs and runtime-materialized libs")
ap.add_argument("--grep", default=None, help="only paths matching this regex")
ap.add_argument("--no-arch", action="store_true", help="skip reading ELF headers (faster)")
ap.add_argument("--max-libs", type=int, default=120, help="cap how many libraries get an arch probe")
ap.add_argument("--json", action="store_true", dest="as_json")
a = ap.parse_args()
def sh(cmd: str, timeout: int = 60) -> str:
base = [a.adb] + (["-s", a.serial] if a.serial else []) + ["shell", cmd]
return run(base, timeout)
def su(cmd: str, timeout: int = 90) -> str:
base = [a.adb] + (["-s", a.serial] if a.serial else []) + ["shell", 'su -c "%s"' % cmd]
return run(base, timeout)
pid = a.pid
if a.pkg and not pid:
out = sh("pidof %s" % a.pkg).strip()
if not out:
print("no running process for %s -- launch it and let it settle first" % a.pkg, file=sys.stderr)
return 2
pid = int(out.split()[0])
maps = su("cat /proc/%d/maps" % pid)
if not maps.strip() or "No such file" in maps:
print("cannot read /proc/%d/maps (root required, or the process is gone)" % pid, file=sys.stderr)
return 2
entries: dict[str, dict] = {}
for line in maps.splitlines():
m = re.match(r"^([0-9a-f]+)-([0-9a-f]+)\s+(\S+)\s+([0-9a-f]+)\s+\S+\s+\S+\s*(.*)$", line)
if not m:
continue
start, end, perms, off, path = m.groups()
path = path.strip()
if not path or path.startswith("["):
continue
if not path.endswith(".so") and ".so." not in path:
continue
e = entries.setdefault(path, {"path": path, "perms": set(), "base": start, "count": 0})
e["perms"].add(perms)
e["count"] += 1
if perms.startswith("r-x") or perms.startswith("r--"):
e["base"] = min(e["base"], start)
dev_abi = sh("getprop ro.product.cpu.abi").strip()
abilist = sh("getprop ro.product.cpu.abilist").strip()
pkg = a.pkg
if not pkg:
cmdline = su("cat /proc/%d/cmdline" % pid).split("\x00")[0].strip()
pkg = cmdline or None
rows = []
probed = 0
for path, e in sorted(entries.items()):
kind = classify(path, pkg)
if a.app_only and kind not in ("apk-lib", "materialized"):
continue
# --grep is an explicit narrow request, so honour it across all kinds
if not a.all and not a.app_only and not a.grep and kind == "system":
continue
if a.grep and not re.search(a.grep, path):
continue
rows.append({"path": path, "base": e["base"], "kind": kind,
"perms": "/".join(sorted(e["perms"])), "maps": e["count"], "arch": "?"})
if not a.no_arch:
for r in rows[:a.max_libs]:
r["arch"] = read_elf_arch(su, r["path"])
probed += 1
# translation markers
markers = sorted({p for p in entries if any(t in p.lower() for t in TRANSLATOR_MARKERS)})
app_archs = sorted({r["arch"] for r in rows if r["kind"] in ("apk-lib", "materialized") and r["arch"] not in ("?", "not-elf")})
host_is_x86 = "x86" in dev_abi
translated = bool(markers) or (host_is_x86 and any(x.startswith(("arm", "aarch64")) for x in app_archs))
if a.as_json:
print(json.dumps({"pid": pid, "pkg": pkg, "device_abi": dev_abi, "abilist": abilist,
"translation_suspected": translated, "translator_markers": markers,
"libs": rows}, indent=2, ensure_ascii=False))
return 0
print("pid=%s pkg=%s" % (pid, pkg or "?"))
print("device primary abi=%s abilist=[%s]" % (dev_abi, abilist))
print("%-13s %-9s %-6s %-6s %s" % ("arch", "kind", "perms", "maps", "path"))
print("-" * 100)
for r in rows:
print("%-13s %-9s %-6s %-6d %s" % (r["arch"], r["kind"], r["perms"], r["maps"], r["path"]))
print("-" * 100)
print("mapped libraries shown: %d" % len(rows))
if not a.no_arch and probed < len(rows):
print("(architecture probed for the first %d; raise --max-libs to cover the rest -- '?' means not probed)"
% probed)
if markers:
print("\nTRANSLATOR MARKERS PRESENT:")
for mk in markers:
print(" " + mk)
print(" -> native ARM code is being translated. Timing differs from a real ARM device,")
print(" and native integrity checks may behave differently. Verify the final artifact")
print(" on the ABI the user actually runs.")
if app_archs:
print("\napp library architectures: %s" % ", ".join(app_archs))
if translated:
print("translation suspected: YES")
mat = [r["path"] for r in rows if r["kind"] == "materialized"]
if mat:
print("\nRUNTIME-MATERIALIZED libraries (not inside the APK):")
for p in mat[:20]:
print(" " + p)
print(" -> written at runtime by the app or its shell. A repacked APK will not contain")
print(" them in this location; account for that before assuming your patch is loaded.")
if not rows:
print("\nnothing matched. Widen with --all, or check that the process is the one that draws the UI.")
return 0
if __name__ == "__main__":
sys.exit(main())
+250
View File
@@ -0,0 +1,250 @@
#!/usr/bin/env python3
"""Preflight: prove the environment is sane BEFORE you blame your patch.
Run this at the start of every experiment block, and again whenever something
fails in a way you did not expect. A large share of "my patch broke the app"
turns out to be device state, a dead device server, a leftover proxy setting, or
a clock skew -- all of which look exactly like a broken artifact.
It is deliberately read-only: it changes nothing except optional cleanup of a
device-wide proxy setting and port forwards it created itself.
Usage:
python preflight.py
python preflight.py --serial <serial> --pkg com.example.app
python preflight.py --pkg com.example.app --expect-root
python preflight.py --cleanup # remove proxy + frida port forwards it finds
python preflight.py --json # machine-readable summary
Exit code is 0 when no BLOCKER was found, 1 otherwise. WARN never fails the run.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
BLOCKER, WARN, OK, INFO = "BLOCKER", "WARN", "OK", "INFO"
class Report:
def __init__(self) -> None:
self.rows: list[tuple[str, str, str, str]] = []
def add(self, level: str, area: str, detail: str, fix: str = "") -> None:
self.rows.append((level, area, detail, fix))
def worst(self) -> str:
for lv in (BLOCKER, WARN, OK, INFO):
if any(r[0] == lv for r in self.rows):
return lv
return OK
def run(cmd: list[str], timeout: int = 60) -> tuple[int, str]:
try:
p = subprocess.run(cmd, capture_output=True, text=True, errors="replace", timeout=timeout)
return p.returncode, (p.stdout or "") + (p.stderr or "")
except FileNotFoundError:
return 127, "not found: %s" % cmd[0]
except subprocess.TimeoutExpired:
return 124, "TIMEOUT after %ss: %s" % (timeout, " ".join(cmd[:4]))
except Exception as e: # pragma: no cover - defensive
return 1, "error: %s" % e
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--adb", default=os.environ.get("ADB", "adb"), help="adb executable (default: $ADB or 'adb')")
ap.add_argument("--serial", default=None, help="device serial; required when more than one is attached")
ap.add_argument("--pkg", default=None, help="target package to check (optional)")
ap.add_argument("--expect-root", action="store_true", help="treat missing root as a BLOCKER")
ap.add_argument("--cleanup", action="store_true", help="remove leftover proxy setting and frida forwards")
ap.add_argument("--json", action="store_true", dest="as_json")
a = ap.parse_args()
r = Report()
ADB = a.adb
# ---- host tools -------------------------------------------------------
for tool, why in (
("java", "dexlib2 patcher, apksigner"),
("python", "all scripts"),
):
found = shutil.which(tool)
r.add(OK if found else WARN, "host:" + tool, found or "not on PATH", "" if found else "install it if you need " + why)
for tool in ("apksigner", "zipalign", "aapt", "aapt2"):
found = shutil.which(tool)
r.add(OK if found else INFO, "host:" + tool, found or "not on PATH",
"" if found else "only needed for signing/manifest work; build-tools on PATH helps")
# ---- adb + device selection ------------------------------------------
if shutil.which(ADB) is None and not os.path.exists(ADB):
r.add(BLOCKER, "adb", "executable not found: %s" % ADB, "pass --adb <path> or set $ADB")
return finish(r, a.as_json)
rc, out = run([ADB, "devices", "-l"])
if rc != 0:
r.add(BLOCKER, "adb", "adb devices failed: %s" % out.strip()[:160], "start the adb server; check USB/emulator")
return finish(r, a.as_json)
devices = []
for line in out.splitlines()[1:]:
line = line.strip()
if not line:
continue
parts = line.split()
if len(parts) >= 2:
devices.append((parts[0], parts[1], line))
online = [d for d in devices if d[1] == "device"]
if not devices:
r.add(BLOCKER, "device", "no device attached", "boot the emulator/connect the device, then re-run")
return finish(r, a.as_json)
if len(online) > 1 and not a.serial:
r.add(BLOCKER, "device", "%d devices online but no --serial given" % len(online),
"pass --serial explicitly, or adb will pick one at random and you will debug the wrong target")
if not online:
bad = ", ".join("%s(%s)" % (d[0], d[1]) for d in devices)
r.add(BLOCKER, "device", "attached but not usable: %s" % bad,
"unauthorized -> accept the USB debugging prompt; offline -> reconnect/reboot the emulator")
return finish(r, a.as_json)
serial = a.serial or online[0][0]
r.add(OK, "device", "using %s (of %d online)" % (serial, len(online)), "")
def sh(cmd: str, timeout: int = 60) -> str:
return run([ADB, "-s", serial, "shell", cmd], timeout=timeout)[1]
def su(cmd: str, timeout: int = 90) -> str:
return run([ADB, "-s", serial, "shell", 'su -c "%s"' % cmd], timeout=timeout)[1]
# ---- device facts -----------------------------------------------------
abi = sh("getprop ro.product.cpu.abi").strip()
abilist = sh("getprop ro.product.cpu.abilist").strip()
rel = sh("getprop ro.build.version.release").strip()
sdk = sh("getprop ro.build.version.sdk").strip()
model = sh("getprop ro.product.model").strip()
sec = sh("getenforce").strip()
r.add(OK, "device:os", "Android %s (sdk %s) %s" % (rel, sdk, model), "")
r.add(OK, "device:abi", "%s abilist=[%s]" % (abi, abilist), "")
r.add(OK if sec.lower() in ("permissive", "") else INFO, "device:selinux", sec or "?", "")
# translation layer / emulator hints -- these change how native code behaves
trans = []
for probe in ("ro.dalvik.vm.native.bridge", "ro.enable.native.bridge.exec", "ro.boot.native_bridge"):
v = sh("getprop " + probe).strip()
if v and v != "0":
trans.append("%s=%s" % (probe, v))
r.add(WARN if trans else OK, "device:translation",
"; ".join(trans) if trans else "no ARM translation declared",
"native arm libraries run through a translator here: timing differs and some native checks misbehave" if trans else "")
if re.search(r"^1$", sh("getprop ro.kernel.qemu").strip()):
r.add(INFO, "device:type", "emulator (ro.kernel.qemu=1)",
"final verification belongs on the real ABI the user will run")
# ---- root -------------------------------------------------------------
idout = su("id")
has_root = "uid=0" in idout
lvl = OK if has_root else (BLOCKER if a.expect_root else WARN)
r.add(lvl, "root", "available" if has_root else "NOT available (%s)" % idout.strip()[:60],
"" if has_root else "anything beyond static analysis needs root: data dirs, Frida, file locks")
# ---- clock (silently breaks TLS work) --------------------------------
dev_epoch = sh("date +%s").strip()
if dev_epoch.isdigit():
drift = abs(int(dev_epoch) - int(__import__("time").time()))
r.add(OK if drift < 120 else WARN, "clock",
"device/host drift %ds" % drift,
"" if drift < 120 else "an expired/wrong certificate can be nothing but this clock; fix before TLS triage")
# ---- leftover state that fakes a failure -----------------------------
proxy = sh("settings get global http_proxy").strip()
if proxy and proxy not in ("null", ":0"):
r.add(WARN, "state:proxy", "device-wide http_proxy=%s" % proxy,
"a leftover proxy makes every request fail; clear with: settings put global http_proxy :0")
if a.cleanup:
sh("settings put global http_proxy :0")
r.add(INFO, "state:proxy", "cleared (--cleanup)", "")
else:
r.add(OK, "state:proxy", "not set", "")
rc_f, fwd = run([ADB, "-s", serial, "forward", "--list"])
if fwd.strip():
r.add(INFO, "state:forwards", fwd.strip().replace("\n", " | ")[:200],
"stale forwards point frida at the wrong place; 'adb forward --remove-all' resets them")
if a.cleanup:
run([ADB, "-s", serial, "forward", "--remove-all"])
r.add(INFO, "state:forwards", "removed (--cleanup)", "")
# ---- frida server reachability ---------------------------------------
srv = su("pgrep -f frida-server || pgrep -f kwork")
if srv.strip().isdigit() or srv.strip().split("\n")[0].strip().isdigit():
r.add(OK, "frida:server", "device server process alive (pid %s)" % srv.strip().split("\n")[0].strip(), "")
else:
r.add(INFO, "frida:server", "no device server process found",
"only needed for dynamic work; see references/dynamic-frida.md (name it out of obvious paths)")
# ---- target package ---------------------------------------------------
if a.pkg:
pm = sh("pm list packages | grep -F %s" % a.pkg)
installed = ("package:" + a.pkg) in pm or a.pkg in pm
r.add(OK if installed else INFO, "target", "installed" if installed else "not installed", "")
if installed:
path = sh("pm path %s" % a.pkg).strip()
uid = su("dumpsys package %s | grep -m1 userId=" % a.pkg).strip()
r.add(INFO, "target:apk", path[:160] or "?", "")
r.add(INFO, "target:uid", uid or "?", "re-check after every reinstall: uid increments")
# the native library dir the package manager will use for this device
nat = sh("dumpsys package %s | grep -m3 primaryCpuAbi" % a.pkg).strip()
if nat:
r.add(INFO, "target:abi", nat.replace("\n", " | ")[:160],
"this is the ABI the package manager chose for THIS device")
maps = su("cat /proc/$(pidof %s | awk '{print $1}')/maps 2>/dev/null | head -1" % a.pkg)
if maps.strip():
r.add(INFO, "target:running", "process is running", "prefer a clean cold start before measuring anything")
else:
r.add(OK, "target:running", "not running", "")
# ---- free space -------------------------------------------------------
# Report by device node, not by the mount-point column: a block device can be
# mounted at more than one path (an ARM translation shim does exactly this on
# some emulators), and `df` will then label /data with an unrelated path.
space = ""
for line in sh("df -h /data 2>/dev/null").splitlines()[1:]:
cols = line.split()
if len(cols) >= 5:
space = "%s: %s available of %s (%s used)" % (cols[0], cols[3], cols[1], cols[4])
break
if space:
r.add(INFO, "device:space", space, "")
return finish(r, a.as_json)
def finish(r: Report, as_json: bool) -> int:
if as_json:
print(json.dumps({"worst": r.worst(),
"rows": [dict(level=l, area=ar, detail=d, fix=f) for l, ar, d, f in r.rows]},
indent=2, ensure_ascii=False))
return 1 if r.worst() == BLOCKER else 0
width = max(len(x[1]) for x in r.rows) if r.rows else 10
for level, area, detail, fix in r.rows:
print("%-8s %-*s %s" % ("[" + level + "]", width, area, detail))
if fix:
print(" " * 9 + "%-*s -> %s" % (width, "", fix))
print("\nworst: %s" % r.worst())
if r.worst() == BLOCKER:
print("Fix every BLOCKER before running another experiment. Do not attribute a failure to your patch yet.")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+203
View File
@@ -0,0 +1,203 @@
#!/usr/bin/env python3
"""Capture what is actually on screen -- and say whether the control tree is usable.
Why this exists
---------------
Driving a UI blind (tap a coordinate, wait, tap again) is the single most
expensive habit in device work. A look at the screen resolves in one step what
coordinate guessing cannot resolve in five: the layout moved, a different dialog
is up, a countdown is frozen, a button is disabled, text says why.
Two independent kinds of evidence, and they are not interchangeable:
* the **image** -- always available, always truthful about what is rendered.
It is the only evidence for UI drawn by a runtime or a web view, where no
real controls exist.
* the **control tree** (`uiautomator dump`) -- precise, diffable, gives exact
bounds and text. Frequently EMPTY for canvas/webview/cross-platform UI.
This script grabs both, reports the size and whether the tree had content, so
you know which one is actually informative before you rely on it. It never
touches app state.
Usage
-----
# one look
python snap.py --out shots --tag before
# watch a wait: 6 samples, 3 s apart (bounded: hard cap of 20 samples)
python snap.py --out shots --tag waiting --count 6 --interval 3
# tree only, no images
python snap.py --out shots --tag tree --tree-only
# when nothing is on screen, capture and it will say so rather than guess
python snap.py --out shots --tag check --serial <serial>
Reading the output
------------------
* `bytes=0` after both capture paths -> the device refused; see the note in the
output rather than concluding the screen is blank.
* `tree=EMPTY` -> do not plan around accessibility bounds. Use the image and
reason from pixels, or drive the app through a non-UI path.
* Identical hashes across samples -> nothing is changing. Stop waiting and go
find out why (this is the stall detector).
"""
from __future__ import annotations
import argparse
import hashlib
import os
import re
import subprocess
import sys
import time
MAX_SAMPLES = 20 # hard cap so a typo cannot produce an unbounded loop
def run(cmd: list[str], timeout: int) -> tuple[int, str, str]:
try:
p = subprocess.run(cmd, capture_output=True, text=True, errors="replace", timeout=timeout)
return p.returncode, p.stdout or "", p.stderr or ""
except subprocess.TimeoutExpired:
return 124, "", "TIMEOUT after %ss: %s" % (timeout, " ".join(cmd[:4]))
except FileNotFoundError:
return 127, "", "not found: %s" % cmd[0]
class Dev:
def __init__(self, adb: str, serial: str | None):
self.adb = adb
self.base = [adb] + (["-s", serial] if serial else [])
self.serial = serial
def sh(self, cmd: str, timeout: int = 60) -> str:
return run(self.base + ["shell", cmd], timeout)[1]
def su(self, cmd: str, timeout: int = 90) -> str:
return run(self.base + ["shell", 'su -c "%s"' % cmd], timeout)[1]
def pull(self, remote: str, local: str, timeout: int = 120) -> bool:
rc, _, err = run(self.base + ["pull", remote, local], timeout)
return rc == 0 and os.path.exists(local) and os.path.getsize(local) > 0
def tree_stats(dev: Dev, workdir: str) -> tuple[str, int, int]:
"""Return (status, node_count, text_count). status in {OK, EMPTY, FAILED}."""
xml = "/data/local/tmp/_snap_ui.xml"
dev.sh("rm -f " + xml)
out = dev.su("uiautomator dump %s" % xml, timeout=90)
if "dumped to" not in out and "UI hierchary" not in out and os.sep not in out:
# some builds print nothing useful; fall through to the file check anyway
pass
local = os.path.join(workdir, "_snap_ui.xml")
if not dev.pull(xml, local, timeout=120):
return "FAILED", 0, 0
try:
data = open(local, encoding="utf-8", errors="replace").read()
except Exception:
return "FAILED", 0, 0
nodes = len(re.findall(r"<node", data))
texts = len([t for t in re.findall(r'text="([^"]*)"', data) if t.strip()])
if nodes == 0:
return "EMPTY", 0, 0
# A tree with nodes but almost no text is usually a shell with no real widgets.
return ("OK" if texts else "SPARSE"), nodes, texts
def capture_image(dev: Dev, workdir: str, name: str) -> tuple[int, str]:
remote = "/data/local/tmp/%s.png" % name
dev.su("rm -f " + remote)
# on-device write then pull: the streaming form returns 0 bytes on some ROMs
dev.su("screencap -p %s" % remote, timeout=90)
local = os.path.join(workdir, name + ".png")
if dev.pull(remote, local, timeout=120):
n = os.path.getsize(local)
if n > 0:
return n, hashlib.sha256(open(local, "rb").read()).hexdigest()[:12]
# fallback: stream to host
rc, out, err = run(dev.base + ["exec-out", "screencap -p"], timeout=120)
if rc == 0:
data = out.encode("latin1", "ignore") if isinstance(out, str) else b""
if data:
open(local, "wb").write(data)
return len(data), hashlib.sha256(data).hexdigest()[:12]
return 0, ""
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--adb", default=os.environ.get("ADB", "adb"))
ap.add_argument("--serial", default=None, help="device serial; required when several are attached")
ap.add_argument("--out", default="snapshots", help="output directory (created if missing)")
ap.add_argument("--tag", default="snap", help="label used in filenames")
ap.add_argument("--count", type=int, default=1, help="samples to take (capped at %d)" % MAX_SAMPLES)
ap.add_argument("--interval", type=float, default=2.0, help="seconds between samples")
ap.add_argument("--tree-only", action="store_true", help="skip images; only read the control tree")
ap.add_argument("--image-only", action="store_true", help="skip the control tree")
a = ap.parse_args()
if a.count > MAX_SAMPLES:
print("count capped at %d (asked for %d)" % (MAX_SAMPLES, a.count), file=sys.stderr)
a.count = MAX_SAMPLES
workdir = os.path.abspath(a.out)
os.makedirs(workdir, exist_ok=True)
dev = Dev(a.adb, a.serial)
# cheap reachability gate with a timeout, so we fail fast and informatively
rc, out, err = run(dev.base + ["shell", "echo __ok__"], timeout=30)
if "__ok__" not in out:
print("device not answering: %s%s" % (out.strip()[:120], (" / " + err.strip()[:120]) if err else ""),
file=sys.stderr)
print("check `adb devices`; if the emulator process is alive but nothing is listening on the adb port,",
file=sys.stderr)
print("restart the instance through the vendor console (see references/environment.md).", file=sys.stderr)
return 2
tree_status = "skipped"
nodes = texts = 0
if not a.image_only:
tree_status, nodes, texts = tree_stats(dev, workdir)
print("control tree: %s (nodes=%d, non-empty text=%d)" % (tree_status, nodes, texts))
if tree_status in ("EMPTY", "FAILED"):
print(" -> the accessibility tree is not usable here. Do NOT plan around bounds from it.")
print(" The image is the primary evidence for this UI. Cross-platform runtimes, web views")
print(" and canvas-drawn UI frequently expose no real controls at all.")
elif tree_status == "SPARSE":
print(" -> tree exists but carries almost no text; treat it as unreliable and read the image.")
prev_hash = None
same_run = 0
for i in range(a.count):
tag = a.tag if a.count == 1 else "%s_%02d" % (a.tag, i)
if a.tree_only:
print("%-14s tree-only sample" % tag)
else:
size, digest = capture_image(dev, workdir, tag)
if size == 0:
print("%-14s bytes=0 -> device refused the capture. Not evidence that the screen is blank." % tag)
else:
note = ""
if digest == prev_hash:
same_run += 1
note = " (identical to previous -> nothing changed)"
else:
same_run = 0
prev_hash = digest
print("%-14s bytes=%-8d sha=%s%s" % (tag, size, digest, note))
print("%-14s %s" % ("", os.path.join(workdir, tag + ".png")))
if same_run >= 2:
print(" -> %d identical samples in a row. This is a stall, not a slow operation." % (same_run + 1))
print(" Stop waiting: inspect the image, or check the app is still alive and in the foreground.")
if i < a.count - 1:
time.sleep(max(0.0, a.interval))
print("\nNow LOOK at the image(s) before deciding the next action.")
print("Driving blind and waiting is how rounds get wasted; the screen usually states the reason.")
return 0
if __name__ == "__main__":
sys.exit(main())