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.
115 lines
6.5 KiB
Markdown
115 lines
6.5 KiB
Markdown
# Cross-Platform Runtimes
|
|
|
|
Load this when the app's UI is not native, or when Java-layer hooks produce no hits at all.
|
|
|
|
Many apps ship a native shell plus a cross-platform runtime. Their business logic, their UI, and their
|
|
feature gates live **inside the runtime**, not in the dex. Patching the dex then does nothing — the Java
|
|
code you can see is mostly glue. Determine the runtime before choosing where to patch.
|
|
|
|
## Identify the runtime early
|
|
|
|
Signals in the APK:
|
|
|
|
| Runtime | Giveaways |
|
|
|---|---|
|
|
| Flutter | `libflutter.so`, `libapp.so`, `assets/flutter_assets/`, Dart snapshot |
|
|
| React Native | `libhermes.so` or `libjsc.so`, `index.android.bundle`, `assets/index.android.bundle` |
|
|
| Unity | `libunity.so`, `libil2cpp.so`, `assets/bin/Data/`, `globalgamemanagers` |
|
|
| Cordova / Ionic | `assets/www/`, `cordova.js` |
|
|
| Xamarin / .NET | `libmonodroid.so`, `assemblies/` in the APK |
|
|
|
|
A `classes.dex` that is small relative to the total payload is another strong hint that the real code
|
|
lives elsewhere.
|
|
|
|
## The layer trap
|
|
|
|
The expensive mistake: seeing a UI element (dialog, paywall, gate) and assuming it is a native control,
|
|
then spending a long time looking for it in the dex.
|
|
|
|
If it is drawn by the runtime, the dex contains only the bridge. Symptoms of being on the wrong layer:
|
|
|
|
- hooking the obvious Java dialog/Activity classes produces **zero** hits while the UI clearly appears;
|
|
- the classes that do appear in stacks are the runtime's own, with obfuscated names;
|
|
- hooking a Java method changes nothing about the visible behaviour.
|
|
|
|
Establish layer ownership **before** investing in a patch direction.
|
|
|
|
### How to tell which layer drew a given UI
|
|
|
|
1. Enumerate the Java dialog and presentation classes and hook their show/creation paths.
|
|
2. Reproduce the UI. If any hook fires, the caller stack names the detection and you are on the Java
|
|
layer.
|
|
3. If nothing fires while the UI is on screen, the UI is runtime-drawn or native-drawn. Switch axis.
|
|
4. Expect runtime-owned windows to appear as ordinary system `Dialog`/`Presentation` objects used as
|
|
**containers** for the runtime's surface. A hit on such a container is not evidence that the visible
|
|
element is a native dialog — read the class name carefully before drawing conclusions.
|
|
|
|
## Flutter specifics
|
|
|
|
- Business logic is compiled ahead-of-time into **`libapp.so`**; UI is rendered by `libflutter.so` with
|
|
no per-widget Java objects.
|
|
- **Widgets, dialogs, and paywalls are not Java views.** They will never appear in Java stack traces,
|
|
and no Java hook can intercept them.
|
|
- The Java side is thin: one Activity, plus plugin classes. Calls cross the boundary through the
|
|
platform-channel mechanism, whose class names are frequently obfuscated.
|
|
- Because channel classes may be renamed, locate them **by method signature and call shape**, not by
|
|
class name.
|
|
|
|
### Where to intervene in a Flutter app
|
|
|
|
| Goal | Layer | Notes |
|
|
|---|---|---|
|
|
| Stop the app being killed / refused at startup | host native library | earliest point, see `native-and-so.md` |
|
|
| Observe what the runtime is told | platform channel | shows the messages that drive the UI |
|
|
| Change a decision permanently | `libapp.so` | most direct, hardest to locate |
|
|
| Change startup behaviour only | host native library constructor | survives runtime restarts |
|
|
|
|
Editing `libapp.so` is the most durable but locating Dart AOT code is genuinely hard: strings are the
|
|
most reliable entry, because Dart string objects and the code referencing them can be found by scanning
|
|
the snapshot rather than by pattern-matching machine code.
|
|
|
|
## Locating logic without symbols
|
|
|
|
- **Strings first.** User-visible text (dialog bodies, feature labels) is the cheapest anchor in any
|
|
runtime. Find it, then find what references it.
|
|
- **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
|
|
|
|
- Budget for the runtime layer **up front**. If the app is Flutter/RN/Unity, the dex is not the main
|
|
battlefield and a dex-only plan will stall.
|
|
- Any behavioural claim must be validated **through the runtime's own UI**, because that is what the
|
|
user sees. A patch that changes internal state without changing the UI is not a fix.
|
|
- Keep the layer you are working in explicit in your notes. "Patched the paywall" is meaningless without
|
|
saying which layer owned it.
|