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:
+130
-6
@@ -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.
|
||||
- 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
|
||||
|
||||
- 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.
|
||||
- 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).
|
||||
Emulators are the right place to iterate and the wrong place to conclude.
|
||||
|
||||
- **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
|
||||
|
||||
@@ -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.
|
||||
|
||||
### 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
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
- **Watch the boundary, not the interior.** For cross-platform apps it is usually far cheaper to observe
|
||||
what crosses between layers than to reverse the interior of the runtime.
|
||||
- **Do not assume the obfuscated names are stable or meaningful.** They are not, and treating them as
|
||||
identities leads to conclusions that break on the next build.
|
||||
- **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
|
||||
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
A handover is the record plus three things, written for someone with **no** memory of the task:
|
||||
|
||||
@@ -101,12 +101,82 @@ Stripped and obfuscated libraries still expose structure:
|
||||
|
||||
## Cross-architecture notes
|
||||
|
||||
- **arm64** is the common case on modern devices; prefer it and make sure the host library you pick
|
||||
actually exists for that ABI.
|
||||
- **x86_64** appears on emulators. Shells and payloads are ABI-specific: a file built for one ABI will
|
||||
not be loaded on the other, and the failure mode looks like a missing library rather than a mismatch.
|
||||
- Keep your build artifacts per-ABI and label them. Mixing them is a common source of "it crashed for no
|
||||
reason" when the same APK behaves differently on an emulator versus a device.
|
||||
Architecture is not background information here — it decides **which artifact you must edit** and
|
||||
**whether your hook can observe anything at all**. Get it from the runtime, not from the manifest.
|
||||
|
||||
### What the device claims vs what is executing
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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.
|
||||
- Prefer driving the prompt to completion over waiting it out — the same prompt usually recurs, so
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
- Editing data while the app is running is unreliable: in-memory caches win.
|
||||
- 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.
|
||||
- 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 — 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`).
|
||||
- A restored data directory needs ownership fixed again after every reinstall, because the uid increments.
|
||||
|
||||
Reference in New Issue
Block a user