Add code-virtualization/custom-linker reference, doctor and so_constpatch tools
New reference: references/code-virtualization-and-custom-linkers.md
Covers the layer between "packed" and "clean" - manifests whose Application is the
app's own and whose dex is readable, yet a re-signed build dies before user code runs.
Contents: three cheap identification tells (whole classes as native declarations, the
registerNativesForClass/special_clinit pair, SONAME-vs-filename mismatch); the
keep-the-library / delete-the-library deadlock and why both routes fail; the
checker-vs-implementation question that resolves it; the string-redirect technique
(equal-length rewrite of an isolated constant so a loadLibrary call reaches an
already-mapped library, so no offsets move and no death path needs neutralizing);
expected private-loader log shapes; a single-variable experiment to determine whether
the native check reads in-APK certificate material or the PMS-reported signature; and
why a Java-layer signature killer logging success proves nothing.
New tool: scripts/doctor.py
Cheapest first command. Capability report and per-script runnability, with
APKREV_TOOLS support so tools installed off-PATH or as runnable jars are found rather
than reported missing. Surfaces the environment facts that silently poison
experiments: clock skew, leftover adb forward entries, a leftover device proxy, and a
frida process already running on the device (which makes the target look like it
detects instrumentation when nothing has been attached yet).
New tool: scripts/so_constpatch.py
Implements the string-redirect technique. Enforces equal length, refuses to patch a
substring of a longer identifier, reports whether each hit sits in an ELF constant
pool, and patches inside an APK or a bare .so. Field-tested on a real protected
library: find, equal-length replace, unequal-length refusal, multi-hit guard, --force
and absent-name paths all verified.
Updated:
SKILL.md fix question count (ten -> thirteen); index the new reference and
both new scripts; make doctor.py the first step of the workflow;
add the "no packer but still dying" pointer during recon
packers.md cross-reference the new layer so "no packer" is not read as
"editable"
ad-removal.md make log evidence a count rather than an impression (n -> 0 is a
measurement; a tag that goes quiet without reaching zero is a
weaker result)
repack-and-sign.md warn that a full apktool rebuild rewrites obfuscated resource
paths (hundreds of rename pairs) - a tool artifact that must not be
read as breakage - and prefer a dex-only swap when that is the only
edit
pitfalls.md generalize the ServiceLoader example so it is not tied to one
sample's third-party library
README.md document the new files and scripts; document APKREV_TOOLS and the
cross-platform intent
This commit is contained in:
@@ -54,6 +54,14 @@ references/ loaded on demand, one topic each
|
||||
recon.md identify packer, SDKs, code location, tamper checks; unpacking
|
||||
packers.md hardened targets: rejection signals, measuring the validation
|
||||
boundary with single-variable tests, choosing a native host
|
||||
code-virtualization-and-custom-linkers.md
|
||||
the layer between "packed" and "clean": whole classes turned into
|
||||
`native` declarations, a private loader whose SONAME does not match
|
||||
its filename, an embedded self-decrypting payload, a Java-layer
|
||||
"signature killer" that logs success while a native check kills you.
|
||||
The keep-it/drop-it deadlock, how to separate the *checker* from the
|
||||
*implementation*, and the string-redirect technique that ends it
|
||||
without neutralizing anything
|
||||
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)
|
||||
dart-aot.md Dart AOT in depth: version pinning and building a matching decompiler,
|
||||
@@ -106,6 +114,12 @@ references/ loaded on demand, one topic each
|
||||
verification.md the claim ladder; what "done" means
|
||||
pitfalls.md the failure catalogue -- read before building
|
||||
scripts/ parameterized, path-agnostic
|
||||
doctor.py run this first: capability report + per-script runnability, finds
|
||||
tools installed off-PATH or as runnable jars, and surfaces the
|
||||
environment facts that poison experiments (clock skew, leftover
|
||||
adb forward / proxy, a device-side frida process already running)
|
||||
so_constpatch.py same-length in-place rewrite of an isolated string constant, for
|
||||
redirecting a library load instead of defeating a check
|
||||
smtool.py baksmali/smali wrapper with a configurable classpath
|
||||
dexpatch/ dexlib2 method-level surgical rewriter (preferred tool)
|
||||
patch_smali.py method-body replacement in a smali tree
|
||||
@@ -161,7 +175,21 @@ The agent loads `SKILL.md` when a task matches its description, and pulls in
|
||||
|
||||
## Requirements
|
||||
|
||||
Nothing is mandatory; each script checks what it needs.
|
||||
Nothing is mandatory; each script checks what it needs. `scripts/doctor.py` reports which of these
|
||||
are present here, which scripts can therefore run, and — usefully — which tools exist somewhere other
|
||||
than PATH.
|
||||
|
||||
If your toolchain lives outside PATH (a project-local `tools/` directory, a versioned SDK folder, a
|
||||
runnable `.jar` instead of a command), set `APKREV_TOOLS` to one or more directories and `doctor.py`
|
||||
will find them:
|
||||
|
||||
```
|
||||
set APKREV_TOOLS=D:\tools;D:\android\build-tools\35.0.0 # Windows
|
||||
export APKREV_TOOLS=/opt/tools:/opt/android/build-tools # POSIX
|
||||
```
|
||||
|
||||
The scripts themselves are plain `python3` and are intended to work identically on Windows, macOS and
|
||||
Linux; where a snippet is POSIX-only it is labelled. Nothing here assumes a Unix shell.
|
||||
|
||||
| Tool | Used for |
|
||||
|---|---|
|
||||
|
||||
@@ -13,7 +13,7 @@ Three rules override everything else in this skill:
|
||||
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 ten questions
|
||||
## Start here: classify the target in thirteen questions
|
||||
|
||||
Answer these before touching a tool. Every one of them changes the whole plan.
|
||||
|
||||
@@ -80,7 +80,8 @@ Answer these before touching a tool. Every one of them changes the whole plan.
|
||||
|
||||
## The workflow, end to end
|
||||
|
||||
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.
|
||||
1. **Preflight, then Recon** — `scripts/doctor.py` is the cheapest possible first command: it reports which toolchains and scripts can actually run here, and surfaces the environment facts that poison experiments (clock skew, leftover `adb forward`/proxy, a device-side frida process already running, a tool installed off-PATH). Then `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.
|
||||
**If recon says there is no packer but a re-signed build still dies**, you are in the layer `references/code-virtualization-and-custom-linkers.md` covers — do not proceed on the assumption that "no packer" means "editable".
|
||||
**If the app already dies on its own** — especially at a roughly constant time after launch, or with a native crash — locate the mechanism *before* planning any patch (`references/native-tamper-and-suicide.md`, `scripts/native_crash.py`). Record the observed time-to-death: it is the baseline every later attempt is measured against, and without it a surviving run cannot be told from a changed schedule.
|
||||
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.
|
||||
@@ -116,6 +117,7 @@ Load only what the current step needs.
|
||||
|---|---|
|
||||
| `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/code-virtualization-and-custom-linkers.md` | **No packer, dex is readable, and a re-signed build still dies** — whole classes turned into `native` declarations, a private loader with a mismatched SONAME, an embedded self-decrypting payload, or a Java-layer "signature killer" that logs success while a native check kills you. Covers the keep-it/drop-it deadlock and the string-redirect escape |
|
||||
| `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/dart-aot.md` | The logic lives in a Dart AOT snapshot (`libapp.so`): pinning the Dart version, building a matching decompiler, the object pool and reference indexing, register/boolean conventions, locating and patching Dart code |
|
||||
| `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** |
|
||||
@@ -146,6 +148,7 @@ All scripts are parameterized and path-agnostic; pass paths explicitly. Run `--h
|
||||
|
||||
| Script | Purpose |
|
||||
|---|---|
|
||||
| `scripts/doctor.py` | **Run this first.** Capability report and per-script runnability: which tools exist (including ones installed off-PATH or as `java -jar` jars), which scripts can actually run here, and the environment facts that silently poison experiments — clock skew, leftover `adb forward`/proxy, a device-side frida process already running |
|
||||
| `scripts/smtool.py` | baksmali/smali wrapper with a bundled classpath (assemble/disassemble dex) |
|
||||
| `scripts/patch_smali.py` | Method-body replacement in a smali tree, matched by signature |
|
||||
| `scripts/dex_strpatch.py` | Byte-level string constant patch with **string_ids ordering guard** |
|
||||
@@ -168,6 +171,7 @@ All scripts are parameterized and path-agnostic; pass paths explicitly. Run `--h
|
||||
| `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/elf_plt.py` | Resolve a PLT stub to its imported symbol on x86_64 and aarch64 (from the **relocation table**, not from position or a comment), list a symbol's callers, and **byte-diff two libraries naming the symbol each changed stub belongs to**. Run this before patching any stub, and to audit a patch set you inherited. |
|
||||
| `scripts/so_constpatch.py` | Same-length in-place rewrite of an isolated string constant, for **redirecting a library load instead of defeating a check** (`System.loadLibrary("checker")` -> a library that is already mapped). Enforces equal length, refuses to touch a substring of a longer identifier, reports whether each hit sits in a constant pool, and patches inside an APK or a bare `.so`. See `references/code-virtualization-and-custom-linkers.md`. |
|
||||
| `scripts/apk_diff.py` | Entry-level diff of two APKs: what changed, what was **added** (injection candidates), what was removed — by content hash, so same-size replacements are caught. Use it to audit a third-party build and to prove your own build was surgical. |
|
||||
| `scripts/native_crash.py` | Locate a native death from a logcat capture or tombstone: signal, fault address, register state, backtrace split into your libraries vs system, the faulting instruction — plus an explicit flag when the fault looks **arranged** rather than accidental. |
|
||||
| `scripts/grab_crash.py` | Recover a stack that a crash-reporter SDK swallowed, when the log shows the app died but prints no backtrace of its own. |
|
||||
|
||||
@@ -120,6 +120,7 @@ Logcat silence alone is weak evidence. A credible "ads are gone" claim needs all
|
||||
|
||||
1. **UI, screen by screen** — splash, home banner, detail page, reader/player, reward button, unlock prompt. Capture before/after screenshots (`verification.md` §screenshot discipline).
|
||||
2. **Logs** — the SDK's characteristic tags are **entirely absent**, not merely quiet: `anythink`, `ATSDK`, `Pangle`, `TTAd`, `GDT`, `ksad`, and so on.
|
||||
Make this a **count, not an impression**. Aggregators log a recognisable burst while they probe which networks are wired in (a "SDK id N not integrated" style line, once per network). Capture the same fixed window on the original and on your build and compare numbers: `8 -> 0` is a measurement; "I did not see it" is not. A tag that goes quiet without reaching zero usually means the SDK still starts and simply failed to load an ad this run — a different, weaker result than "the subsystem never started".
|
||||
3. **Filesystem, the strongest signal** — the app's private directory contains **no ad SDK working directory at all**:
|
||||
```bash
|
||||
adb shell "su -c 'ls -la /data/data/<app.package>/files/'"
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
# Code Virtualization & Custom Linkers
|
||||
|
||||
Load this when recon says **there is no packer** (the manifest's `application` is the app's own
|
||||
class, dex is readable) and yet a re-signed build dies before your code runs — especially when the
|
||||
death is `SIGSEGV fault addr 0x0` with all registers zero.
|
||||
|
||||
This is the layer between "packed" and "clean". `packers.md` covers a shell that owns
|
||||
`Application`; this file covers protection that leaves `Application` alone and instead
|
||||
**turns individual methods into native code** and hosts a private loader next to them. It is now
|
||||
common enough that treating it as "no packer, therefore easy" is the most expensive recon error
|
||||
in this skill's history.
|
||||
|
||||
## Identification — three cheap tells
|
||||
|
||||
1. **Whole classes whose methods are `native` declarations with no implementation.**
|
||||
Read the smali for the entry Activity / engine host class. Under code virtualization the
|
||||
*entire class* is converted — lifecycle callbacks, getters, everything — each becoming a bare
|
||||
`.method public native ...` with no body. A framework class you *know* ships a Java
|
||||
implementation (a Flutter/Unity/RN host class is the usual victim) sitting there fully
|
||||
`native` is the signal.
|
||||
|
||||
Two `native` methods is normal (JNI). **Sixty is virtualization.**
|
||||
|
||||
2. **A registration call in the class's static initializer.** Someone binds those native
|
||||
declarations to an implementation at class-load time:
|
||||
|
||||
```
|
||||
.method static constructor <clinit>()V
|
||||
invoke-static {v0, v1}, L<somewhere>/DtcLoader;->registerNativesForClass(ILjava/lang/Class;)V
|
||||
invoke-static {v1}, L<somewhere>/hidden/Hidden0;->special_clinit_0_00(Ljava/lang/Class;)V
|
||||
```
|
||||
|
||||
The `Hidden0.special_clinit_*` family is the virtualizer's hook for running its own version of
|
||||
the class initializer. When you see this, the Java in that class **is not what executes**.
|
||||
|
||||
3. **A shipping library whose SONAME does not match its filename.** A private loader is often
|
||||
named for its role while sitting under an innocuous filename. Read `DT_SONAME` from
|
||||
`.dynamic`, not the file name — `probe_deps`-style parsing of `PT_DYNAMIC` (see
|
||||
`native-and-so.md` §forged headers) gives you the truth. A `lib<something>.so` claiming
|
||||
`soname = liblinkerloader.so` is a private ELF loader.
|
||||
|
||||
Supporting tells: `assets/` carrying a small text descriptor naming the protector and its version;
|
||||
native libraries woven into `DT_NEEDED` chains of other libraries (the virtualized code has to be
|
||||
reachable); log lines tagged with the loader's own name appearing during startup.
|
||||
|
||||
## The deadlock that eats hours — and the way out
|
||||
|
||||
A virtualizer that also does integrity checking produces this shape:
|
||||
|
||||
| Action | Result |
|
||||
|---|---|
|
||||
| Keep the private loader in the APK | its embedded validation runs → deliberate crash on a re-signed build |
|
||||
| Delete the private loader | the virtualized class has **no implementation at all** → `UnsatisfiedLinkError: dlopen failed: library "…" not found` |
|
||||
|
||||
Both routes die, and the two failures look nothing alike, so it is easy to conclude the target is
|
||||
impossible. It is not. The way out is one question:
|
||||
|
||||
> **Is that library the checker, or the implementation — and who asks for it?**
|
||||
|
||||
## Separate the checker from the implementation
|
||||
|
||||
Two distinct roles hide behind "a protected library":
|
||||
|
||||
- **The implementation** — the native code that *is* the virtualized class. It must be present and
|
||||
loaded, or the class cannot work.
|
||||
- **The checker** — a validation payload (a signature/integrity payload, a second-stage
|
||||
self-decrypting image) that something *explicitly loads*.
|
||||
|
||||
They are frequently **different libraries**, and the load of the checker is frequently a
|
||||
**single, findable call site** — not a link-time dependency.
|
||||
|
||||
Test which is which before designing a fix:
|
||||
|
||||
```
|
||||
# 1. Does anything name it in DT_NEEDED? Parse PT_DYNAMIC of every shipped .so.
|
||||
# No DT_NEEDED referrer => it is dlopen()ed, i.e. named at some call site.
|
||||
# 2. Delete it and read the exact error: which class/method was executing when the
|
||||
# load failed? That is the caller.
|
||||
```
|
||||
|
||||
In the observed case the error was not "the class is missing" but:
|
||||
|
||||
```
|
||||
java.lang.UnsatisfiedLinkError: dlopen failed: library "lib<checker>.so" not found
|
||||
at java.lang.System.loadLibrary(System.java)
|
||||
at <framework host class>.onCreate(Native Method)
|
||||
```
|
||||
|
||||
Read that stack carefully: the **virtualized** `onCreate` — the very code you feared deleting the
|
||||
library would break — is running fine. It is the *checker* that is absent. The implementation lives
|
||||
elsewhere and was never the problem.
|
||||
|
||||
**That single stack frame converts an impossible target into a trivial one.**
|
||||
|
||||
## Technique: redirect the load by rewriting a string constant
|
||||
|
||||
Once you know the checker is named at a call site, you do not need to patch code, hook anything, or
|
||||
neutralize a crash. **Change what that call site loads.**
|
||||
|
||||
JNI-heavy native code keeps its class/method/string operands in a constant pool inside the library.
|
||||
A `loadLibrary("X")` becomes three facts sitting in `.rodata`/`.data`:
|
||||
|
||||
```
|
||||
… onCreate (Landroid/os/Bundle;)V loadLibrary (Ljava/lang/String;)Ljava/lang/String; …
|
||||
… <SomeUnrelatedClassName> X <unrelated constant> …
|
||||
```
|
||||
|
||||
Find the name as an **isolated NUL-terminated string** and rewrite it **in place, same length** to
|
||||
the name of a library that is *guaranteed already loaded*. On Android, `android` (`libandroid.so`)
|
||||
is reliably mapped — the protected libraries themselves depend on it.
|
||||
|
||||
```
|
||||
apkhuan -> android (7 chars -> 7 chars)
|
||||
```
|
||||
|
||||
Why this is disproportionately effective:
|
||||
|
||||
- **Equal length means zero structural change.** No offsets move, no section resizes, the ELF stays
|
||||
exactly as valid as it was. Nothing for an integrity check to notice about *layout*.
|
||||
- **The load succeeds**, so no `UnsatisfiedLinkError` and no error branch anywhere.
|
||||
- **The checker never gets mapped**, so none of its validation runs — the signature payload, the
|
||||
deliberate-crash payload, all of it becomes dead weight in the APK.
|
||||
- **You never patch a death path**, so the whole `native-tamper-and-suicide.md` rule set (make it
|
||||
*return*, don't make it *not return*) never comes into play. There is nothing to neutralize.
|
||||
|
||||
Requirements and hazards:
|
||||
|
||||
1. **Same byte length is not optional.** Replacing with a shorter/longer name shifts everything
|
||||
after it. Pad only within the same NUL-terminated slot; never spill into the next constant.
|
||||
2. **Verify the string is an isolated constant** — the byte before and after must be `\x00`.
|
||||
A substring of a longer identifier will corrupt that identifier.
|
||||
3. **The replacement must be loadable and already-loaded.** A nonexistent name throws and puts you
|
||||
back in the deadlock; a heavy library changes startup cost.
|
||||
4. **Scan the whole file** for the name, then confirm which occurrence sits in the constant pool.
|
||||
Report the offset in the patch record.
|
||||
|
||||
`scripts/so_constpatch.py` implements this: finds isolated constants, enforces equal length, writes
|
||||
a patched copy, and prints the byte-diff.
|
||||
|
||||
This is one member of a general family — **redirect a reference instead of defeating a check** —
|
||||
and the same shape recurs for class names, method names and file paths in a constant pool.
|
||||
|
||||
## Expected log shapes
|
||||
|
||||
A private loader announces itself. These strings come from the loader, not from the app, and their
|
||||
presence/absence across builds is a usable signal:
|
||||
|
||||
```
|
||||
I/<Loader>: Loading embedded protected image from carrier (self)
|
||||
I/<Loader>: Found embedded protected image at offset 0x…, logical size 0x…
|
||||
E/<Loader>: Invalid ELF magic <-- appears ONLY on a modified build
|
||||
D/<Loader>: ELF Header: type=3, machine=183, entry=0x0, phnum=N
|
||||
D/<Loader>: Starting PrelinkImage for embedded_protected
|
||||
```
|
||||
|
||||
A custom container magic at the tail of the library (a four-byte tag, not `\x7fELF`) marks the
|
||||
embedded image; `entry=0x0` plus `SIGSEGV fault addr 0x0` is the loader failing to decrypt its
|
||||
payload and jumping to a zero entry. Treat "`Invalid ELF magic` appears on my build and not on the
|
||||
original" as **proof the loader is validating the build**, which is exactly what makes the
|
||||
string-redirect route worth trying.
|
||||
|
||||
## What the native check actually reads — measure it, don't guess
|
||||
|
||||
A validation payload that reads "the signature" can be reading several different things, and the
|
||||
answer decides whether *any* repack route exists:
|
||||
|
||||
| It reads | Consequence |
|
||||
|---|---|
|
||||
| the certificate material **inside the APK** | keeping the original signature block while swapping code can pass |
|
||||
| the signature **PackageManager reports** | that trick fails; you must remove the check (this file's technique) or rebuild |
|
||||
| an entry **hash** of the whole file | only the string-redirect / check-removal route survives |
|
||||
|
||||
**Single-variable experiment to tell them apart.** Same APK content both times; flip only the
|
||||
PMS-recorded signature:
|
||||
|
||||
1. Install a build **you signed** normally, so PMS records *your* certificate.
|
||||
2. Root-overwrite that installed `base.apk` with your candidate (one whose *internal* signature
|
||||
block is the original), leaving PMS's record untouched.
|
||||
3. Launch.
|
||||
|
||||
Dies ⇒ the check reads the PMS-reported signature, and preserving in-APK certificate material
|
||||
cannot save you. Survives ⇒ it reads in-APK material, and a "keep the original signature block"
|
||||
route is worth investing in.
|
||||
|
||||
Do this **before** investing in elaborate signature-block plumbing. It is two installs.
|
||||
|
||||
## A Java-layer "signature killer" is a decoy
|
||||
|
||||
A protection bundle may ship a Java class that hooks `PackageManager` so the framework reports a
|
||||
hardcoded certificate, and log `Signature consistency ok` forever — including on your modified
|
||||
build. This is an **anti-tamper feature aimed at other patchers**, not a gate you must satisfy.
|
||||
|
||||
Its corollary is what matters operationally: **`Signature consistency ok` in the log proves
|
||||
nothing.** Do not let a green Java-layer message talk you out of the fact that the process is
|
||||
dying, or send you hunting for a Java signature check. If the death is `SIGSEGV` with a zero
|
||||
program counter, the check that killed you is native and the Java one is irrelevant.
|
||||
|
||||
Two practical notes:
|
||||
|
||||
- **Do not delete such a class to "clean up".** It usually participates in the `Application`
|
||||
inheritance chain; removing it breaks startup and trips unrelated class-hierarchy checks.
|
||||
- Its presence is a strong hint that **the code that matters is virtualized**, because it exists to
|
||||
survive exactly the kind of repack you are attempting.
|
||||
|
||||
## Verification
|
||||
|
||||
A string-redirect fix is verified by the **absence of the loader**, not by the absence of an error:
|
||||
|
||||
1. Loader's log tag count in a full cold-start capture = **0** (previously non-zero).
|
||||
2. `Invalid ELF magic` = 0.
|
||||
3. `SIGSEGV` = 0 **and** `SIGSYS`/seccomp kills = 0 — the secondary kill paths the payload carried
|
||||
disappear with it. A drop from 1 to 0 here is a real result worth recording.
|
||||
4. Process survives past the previously measured time-to-death, on this machine, with nothing
|
||||
attached; then survives real interaction.
|
||||
5. The features that class implements still work — the virtualized code is the *implementation*, so
|
||||
exercise it (start playback, navigate, whatever it owns). A build that starts but whose
|
||||
virtualized feature is dead is not a result.
|
||||
|
||||
Record the original and patched bytes, the offset, and the loader log counts before/after in the
|
||||
patch record — the same audit standard as any other byte-level edit.
|
||||
@@ -4,6 +4,12 @@ Load this when recon says the app's `Application` class is not the app's own, or
|
||||
app die before your code ever runs. Packing is the single most common reason a correct patch appears to
|
||||
"do nothing", so treat it as its own phase with its own evidence standard.
|
||||
|
||||
**Not packed, and still dying?** That is a different layer and a different file:
|
||||
`code-virtualization-and-custom-linkers.md`. There `Application` is the app's own, the dex is fully
|
||||
readable, and yet whole classes are `native` declarations and a private loader carries an embedded
|
||||
validation payload. The single most expensive recon error in this domain is reading "no packer" as
|
||||
"the code is editable" — check for that shape explicitly before planning an edit.
|
||||
|
||||
## What a packer actually does
|
||||
|
||||
A shell (jiagu / weapon / leggu / 360 / ijiami / bangcle class) replaces the manifest's
|
||||
|
||||
@@ -18,15 +18,20 @@ App dies immediately on launch. Sometimes a different `ClassNotFoundException` f
|
||||
**Root cause**
|
||||
`META-INF/` is not only signatures. It holds **ServiceLoader registrations** that Android reads at runtime. Deleting the directory to prepare for re-signing deletes them too.
|
||||
|
||||
Real examples found in one APK:
|
||||
Real examples found in one APK (framework entries first — these are the ones that produce the
|
||||
confusing `ClassNotFoundException`; note that **third-party libraries register the same way**, so the
|
||||
list is not limited to well-known frameworks):
|
||||
```
|
||||
META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory -> kc
|
||||
META-INF/services/io.ktor.client.engine.HttpClientEngineContainer -> OkHttpEngineContainer
|
||||
META-INF/services/io.ktor.serialization.kotlinx.KotlinxSerializationExtensionProvider
|
||||
META-INF/services/kotlinx.coroutines.CoroutineExceptionHandler -> wc
|
||||
META-INF/services/com.arialyy.aria.core.inf.IUtil
|
||||
META-INF/services/com.arialyy.aria.core.listener.IEventListener
|
||||
META-INF/services/<third-party-package>.<SomeInterface> -> <impl class>
|
||||
META-INF/services/<third-party-package>.<SomeListener> -> <impl class>
|
||||
```
|
||||
The last two are the general shape, not a coincidence: a downloader, an HTTP engine, a serialization
|
||||
provider or a plugin SPI shipped as a library all land here, and any of them can be the one whose
|
||||
absence kills startup.
|
||||
|
||||
**Why it is hard to see**
|
||||
The error message names Kotlin coroutines, not your repack. You will spend an hour blaming the dex.
|
||||
|
||||
@@ -28,6 +28,26 @@ Recompressing them produces builds that fail to install or misbehave.
|
||||
|
||||
**4. Changing any dex changes nothing about resources.** If you only edited dex, do not touch `res/`, `assets/`, or `lib/`.
|
||||
|
||||
**4a. A full `apktool b` rebuild rewrites resource paths even when you edited none.** Decoding with
|
||||
`apktool d` (without `-s`) and rebuilding re-encodes resources, and obfuscated short names are
|
||||
expanded back to readable ones — an entry originally shipped as `res/-B.png` comes back as
|
||||
`res/drawable-hdpi/<real-name>.png`. Expect an entry-level diff against the original to show on the
|
||||
order of a thousand "removed + added" pairs that are **pure renames**, not content changes.
|
||||
|
||||
This is expected and usually harmless, but two consequences matter:
|
||||
|
||||
- **Never read that diff as "I broke something".** Compare by identity (rename-aware, or by content
|
||||
hash grouped by size) before drawing a conclusion. A toy diff that reports 1100 changes when you
|
||||
edited one method is a **tool** artifact and will send you hunting a bug that does not exist.
|
||||
- **It changes the byte layout of the whole archive.** On a target that fingerprints its own file
|
||||
against a stored hash, or whose protection binds offsets into a container, a full rebuild is a much
|
||||
larger change surface than a dex-only swap. If your only edit is dex, prefer replacing the
|
||||
`classes*.dex` entries inside the original zip (`compress_type` preserved) over a full rebuild.
|
||||
Keep that dex-only path as a fallback for exactly this reason.
|
||||
|
||||
If you must avoid resource churn entirely, decode with `-s` (do not decode resources) and only
|
||||
rebuild what you changed.
|
||||
|
||||
**5. Signing creates new `MANIFEST.MF`/`*.SF`/`*.RSA`.** That is expected — the check is that **no signature artifact from the ORIGINAL** survives, and **no non-signature entry was lost**.
|
||||
|
||||
## Repacking an unpacked (de-shelled) app
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Environment doctor: what can actually run here, and what is missing.
|
||||
|
||||
Why this exists
|
||||
---------------
|
||||
This skill ships a lot of scripts, and the most common failure mode reported by
|
||||
users is not "the script is wrong" but "the script would not start and I could not
|
||||
tell why" - a missing jar, a Python module that does not exist on this interpreter,
|
||||
a device that is not connected. That wastes a whole round before any reverse
|
||||
engineering happens.
|
||||
|
||||
Run this once before a work block. It answers three questions:
|
||||
|
||||
1. Which capabilities are available right now (static / native / dynamic / device)?
|
||||
2. For every script, is it runnable as-is, or what exactly is missing?
|
||||
3. Are there environment facts that will silently poison experiments
|
||||
(clock skew, a leftover proxy, a dead device server, a wrong-ABI device)?
|
||||
|
||||
It never installs anything and never touches a target. Read the report, then go.
|
||||
|
||||
Usage
|
||||
-----
|
||||
python doctor.py # short capability report
|
||||
python doctor.py --scripts # per-script runnability table
|
||||
python doctor.py --device <serial> # include device checks
|
||||
python doctor.py --json # machine-readable
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# ---------------------------------------------------------------- probing
|
||||
|
||||
def extra_tool_dirs():
|
||||
"""Directories to search beyond PATH.
|
||||
|
||||
Tools like jadx and apktool are frequently installed into a project-local or
|
||||
versioned directory and invoked by full path, so a pure PATH probe reports them
|
||||
missing and the capability table lies. Point APKREV_TOOLS at one or more
|
||||
directories (os.pathsep-separated) and they are searched too.
|
||||
"""
|
||||
dirs = []
|
||||
env = os.environ.get('APKREV_TOOLS')
|
||||
if env:
|
||||
dirs.extend(d for d in env.split(os.pathsep) if d)
|
||||
# a tools/ dir beside the skill is a common convention and safe to probe
|
||||
dirs.append(os.path.join(os.path.dirname(HERE), 'tools'))
|
||||
return [d for d in dirs if os.path.isdir(d)]
|
||||
|
||||
|
||||
def which(name: str):
|
||||
hit = shutil.which(name)
|
||||
if hit:
|
||||
return hit
|
||||
exts = ['']
|
||||
if os.name == 'nt':
|
||||
exts = ['.exe', '.bat', '.cmd', '.ps1', '']
|
||||
for d in extra_tool_dirs():
|
||||
for root, _dirs, files in os.walk(d):
|
||||
if root.count(os.sep) - d.count(os.sep) > 3:
|
||||
continue
|
||||
for f in files:
|
||||
stem, ext = os.path.splitext(f)
|
||||
if stem.lower() == name.lower() and ext.lower() in exts:
|
||||
return os.path.join(root, f)
|
||||
return None
|
||||
|
||||
|
||||
def run(cmd, timeout=15):
|
||||
"""Run a command, returning (rc, stdout+stderr). Never raises, always bounded."""
|
||||
try:
|
||||
p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
timeout=timeout)
|
||||
return p.returncode, p.stdout.decode('utf-8', 'replace').strip()
|
||||
except FileNotFoundError:
|
||||
return 127, 'not found'
|
||||
except subprocess.TimeoutExpired:
|
||||
return 124, 'TIMEOUT after %ss' % timeout
|
||||
except Exception as e: # pragma: no cover
|
||||
return 1, '%s: %s' % (type(e).__name__, e)
|
||||
|
||||
|
||||
def have_module(name: str) -> bool:
|
||||
try:
|
||||
return importlib.util.find_spec(name) is not None
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- capability matrix
|
||||
|
||||
def python_info():
|
||||
v = sys.version_info
|
||||
ok = (v.major, v.minor) >= (3, 9)
|
||||
return {
|
||||
'version': '%d.%d.%d' % (v.major, v.minor, v.micro),
|
||||
'ok': ok,
|
||||
'note': '' if ok else 'scripts target 3.9+; older interpreters may fail on syntax',
|
||||
}
|
||||
|
||||
|
||||
def tool_info():
|
||||
"""name -> (found_path, version_string)"""
|
||||
out = {}
|
||||
probes = [
|
||||
('java', ['java', '-version']),
|
||||
('javac', ['javac', '-version']),
|
||||
('adb', ['adb', 'version']),
|
||||
('frida', ['frida', '--version']),
|
||||
('frida-ps', ['frida-ps', '--version']),
|
||||
('objection', ['objection', '--version']),
|
||||
('apktool', ['apktool', '--version']),
|
||||
('jadx', ['jadx', '--version']),
|
||||
('python', [sys.executable, '--version']),
|
||||
('unzip', ['unzip', '-v']),
|
||||
('git', ['git', '--version']),
|
||||
('sqlite3', ['sqlite3', '--version']),
|
||||
('node', ['node', '--version']),
|
||||
('tshark', ['tshark', '-v']),
|
||||
('mitmdump', ['mitmdump', '--version']),
|
||||
('rizin', ['rizin', '-v']),
|
||||
('rabin2', ['rabin2', '-v']),
|
||||
('gdb', ['gdb', '--version']),
|
||||
('readelf', ['readelf', '--version']),
|
||||
('adb-devices', ['adb', 'devices']),
|
||||
]
|
||||
for name, cmd in probes:
|
||||
if which(cmd[0]) is None and cmd[0] != sys.executable:
|
||||
# A tool that ships as a runnable .jar is not on PATH but is still usable
|
||||
# via `java -jar`. Reporting it as missing makes the capability table lie.
|
||||
jar_hit = None
|
||||
for d in [HERE, os.path.dirname(HERE)] + extra_tool_dirs():
|
||||
if not os.path.isdir(d):
|
||||
continue
|
||||
for root, _dirs, files in os.walk(d):
|
||||
if root.count(os.sep) - d.count(os.sep) > 3:
|
||||
continue
|
||||
for f in files:
|
||||
if f.lower() == (name + '.jar').lower():
|
||||
jar_hit = os.path.join(root, f)
|
||||
break
|
||||
if jar_hit:
|
||||
break
|
||||
if jar_hit:
|
||||
break
|
||||
if jar_hit:
|
||||
out[name] = {'path': jar_hit,
|
||||
'version': 'runnable as: java -jar %s' % os.path.basename(jar_hit),
|
||||
'note': 'jar-only (not a PATH command)'}
|
||||
else:
|
||||
out[name] = {'path': None, 'version': None, 'note': 'not on PATH'}
|
||||
continue
|
||||
rc, txt = run(cmd)
|
||||
first = ''
|
||||
for line in txt.splitlines():
|
||||
line = line.strip()
|
||||
if line:
|
||||
first = line
|
||||
break
|
||||
out[name] = {
|
||||
'path': which(cmd[0]) or cmd[0],
|
||||
'version': first[:160],
|
||||
'note': '' if rc == 0 else 'exit %d: %s' % (rc, txt[:120]),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
# jar/libraries the dex tooling needs; kept as a list so doctor can name them
|
||||
JAR_HINTS = ['baksmali', 'smali', 'dexlib2', 'apksigner', 'uber-apk-signer', 'd2j']
|
||||
|
||||
|
||||
def java_toolchain():
|
||||
"""Find the jars a smali round-trip needs, wherever they live."""
|
||||
found = {}
|
||||
roots = [os.path.join(HERE, 'dexpatch'), HERE,
|
||||
os.path.join(HERE, 'libs'), os.path.join(HERE, 'jar')]
|
||||
extra = os.environ.get('APKREV_JARS')
|
||||
if extra:
|
||||
roots.extend(extra.split(os.pathsep))
|
||||
for r in roots:
|
||||
if not os.path.isdir(r):
|
||||
continue
|
||||
for dirpath, _dirs, files in os.walk(r):
|
||||
for f in files:
|
||||
if f.lower().endswith('.jar'):
|
||||
found.setdefault(f, os.path.join(dirpath, f))
|
||||
return found
|
||||
|
||||
|
||||
def script_dependencies():
|
||||
"""Static, best-effort dependency map: script -> (required_modules, required_bins)."""
|
||||
return {
|
||||
'smtool.py': ([], ['java']),
|
||||
'patch_smali.py': ([], []),
|
||||
'find_refs.py': ([], []),
|
||||
'dex_strings.py': ([], []),
|
||||
'dex_classdiff.py': ([], []),
|
||||
'dex_strpatch.py': ([], []),
|
||||
'repack.py': ([], ['java']),
|
||||
'install_test.py': ([], ['adb']),
|
||||
'preflight.py': ([], ['adb']),
|
||||
'lib_map.py': ([], ['adb']),
|
||||
'apk_diff.py': ([], []),
|
||||
'snap.py': ([], ['adb']),
|
||||
'native_crash.py': ([], []),
|
||||
'grab_crash.py': ([], ['adb']),
|
||||
'blob_decode.py': ([], []),
|
||||
'datastore_inject.py': ([], []),
|
||||
'sig_probe.py': ([], ['java']),
|
||||
'probe_api.py': ([], []),
|
||||
'tls_check.py': ([], []),
|
||||
'usb_net_proxy.py': ([], ['adb']),
|
||||
'devsh.py': ([], ['adb']),
|
||||
'elf_plt.py': ([], []),
|
||||
'so_constpatch.py': ([], []),
|
||||
'dart_pool_strings.py': ([], []),
|
||||
'dart_pprefs.py': ([], []),
|
||||
'dart_disasm.py': ([], []),
|
||||
'run_probe.py': (['frida'], ['adb']),
|
||||
'frida_probe.js': ([], []), # needs a frida host, checked separately
|
||||
}
|
||||
|
||||
|
||||
def script_report(tools, jars):
|
||||
deps = script_dependencies()
|
||||
rows = []
|
||||
for name in sorted(deps):
|
||||
path = os.path.join(HERE, name)
|
||||
mods, bins = deps[name]
|
||||
missing_mods = [m for m in mods if not have_module(m)]
|
||||
missing_bins = [b for b in bins if which(b) is None]
|
||||
# scripts that shell out to the frida host
|
||||
if name in ('run_probe.py', 'frida_probe.js') and which('frida') is None:
|
||||
if 'frida' not in missing_bins:
|
||||
missing_bins.append('frida')
|
||||
ok = os.path.isfile(path) and not missing_mods and not missing_bins
|
||||
rows.append({
|
||||
'script': name,
|
||||
'present': os.path.isfile(path),
|
||||
'runnable': ok,
|
||||
'missing': missing_mods + missing_bins,
|
||||
})
|
||||
return rows, deps
|
||||
|
||||
|
||||
def device_report(serial=None):
|
||||
if which('adb') is None:
|
||||
return {'available': False, 'reason': 'adb not on PATH'}
|
||||
cmd = ['adb'] + (['-s', serial] if serial else []) + ['devices', '-l']
|
||||
rc, txt = run(cmd, timeout=20)
|
||||
if rc != 0:
|
||||
return {'available': False, 'reason': txt[:200]}
|
||||
devices = []
|
||||
for line in txt.splitlines()[1:]:
|
||||
line = line.strip()
|
||||
if not line or line.startswith('*'):
|
||||
continue
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
devices.append({'serial': parts[0], 'state': parts[1],
|
||||
'info': ' '.join(parts[2:])[:160]})
|
||||
out = {'available': True, 'devices': devices, 'raw': txt[:400]}
|
||||
if not devices:
|
||||
out['reason'] = 'no device/emulator attached'
|
||||
return out
|
||||
|
||||
tgt = serial or devices[0]['serial']
|
||||
props = {}
|
||||
for key in ('ro.product.cpu.abi', 'ro.product.cpu.abilist', 'ro.build.version.release',
|
||||
'ro.build.version.sdk', 'ro.product.model'):
|
||||
rc, v = run(['adb', '-s', tgt, 'shell', 'getprop', key], timeout=15)
|
||||
props[key] = v if rc == 0 else ''
|
||||
out['target'] = tgt
|
||||
out['props'] = props
|
||||
|
||||
# root?
|
||||
rc, v = run(['adb', '-s', tgt, 'shell', 'su -c id'], timeout=15)
|
||||
out['root'] = (rc == 0 and 'uid=0' in v)
|
||||
out['root_raw'] = v[:120]
|
||||
|
||||
# clock skew: a classic silent experiment-poisoner
|
||||
rc, dev_epoch = run(['adb', '-s', tgt, 'shell', 'date +%s'], timeout=15)
|
||||
try:
|
||||
dev_t = int(dev_epoch.strip())
|
||||
out['clock_skew_s'] = abs(time.time() - dev_t)
|
||||
except Exception:
|
||||
out['clock_skew_s'] = None
|
||||
|
||||
# leftover forwards and proxies
|
||||
rc, fwd = run(['adb', '-s', tgt, 'forward', '--list'], timeout=15)
|
||||
out['forwards'] = [l for l in fwd.splitlines() if l.strip()][:10]
|
||||
rc, prox = run(['adb', '-s', tgt, 'shell', 'settings get global http_proxy'], timeout=15)
|
||||
out['http_proxy'] = prox.strip()[:120]
|
||||
|
||||
# is a frida server already running on device? (a common source of "the app
|
||||
# suddenly detects instrumentation" while you believe nothing is attached)
|
||||
rc, ps = run(['adb', '-s', tgt, 'shell', 'ps -A'], timeout=20)
|
||||
hits = [l for l in ps.splitlines()
|
||||
if 'frida' in l.lower() and 'grep' not in l.lower()]
|
||||
out['device_frida_processes'] = [h.strip()[:140] for h in hits[:6]]
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- reporting
|
||||
|
||||
def capability_summary(tools, dev, py):
|
||||
caps = {}
|
||||
caps['static (dex/zip/strings)'] = py['ok']
|
||||
caps['repack + sign'] = bool(tools['java']['path']) and py['ok']
|
||||
caps['smali round-trip'] = bool(tools['java']['path']) and py['ok']
|
||||
caps['native ELF patching'] = py['ok']
|
||||
caps['jadx decompile'] = bool(tools['jadx']['path'])
|
||||
caps['apktool unpack'] = bool(tools['apktool']['path'])
|
||||
caps['device work'] = bool(dev.get('available') and dev.get('devices'))
|
||||
caps['root on device'] = bool(dev.get('root'))
|
||||
caps['frida dynamic'] = bool(tools['frida']['path'])
|
||||
caps['emulator/home'] = bool(tools['adb']['path'])
|
||||
return caps
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description='Check what this environment can actually do.')
|
||||
ap.add_argument('--device', default=None, metavar='SERIAL', help='probe this device')
|
||||
ap.add_argument('--scripts', action='store_true', help='per-script runnability table')
|
||||
ap.add_argument('--json', action='store_true', help='emit JSON')
|
||||
args = ap.parse_args()
|
||||
|
||||
py = python_info()
|
||||
tools = tool_info()
|
||||
jars = java_toolchain()
|
||||
dev = device_report(args.device)
|
||||
rows, _deps = script_report(tools, jars)
|
||||
caps = capability_summary(tools, dev, py)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps({'python': py, 'tools': tools, 'jars': jars,
|
||||
'device': dev, 'capabilities': caps, 'scripts': rows},
|
||||
indent=2, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
print('=' * 74)
|
||||
print('apk-reverse environment doctor')
|
||||
print('=' * 74)
|
||||
print('platform : %s %s / %s' % (platform.system(), platform.release(), platform.machine()))
|
||||
print('python : %s %s' % (py['version'], '' if py['ok'] else '<-- ' + py['note']))
|
||||
|
||||
print('\n--- capabilities ---')
|
||||
for k, v in caps.items():
|
||||
print(' [%s] %s' % ('OK ' if v else '-- ', k))
|
||||
|
||||
print('\n--- tools ---')
|
||||
for name, info in tools.items():
|
||||
if name in ('adb-devices',):
|
||||
continue
|
||||
mark = 'OK ' if info['path'] else '-- '
|
||||
ver = info['version'] or info['note']
|
||||
print(' [%s] %-12s %s' % (mark, name, (ver or '')[:96]))
|
||||
|
||||
print('\n--- java jars found near this skill ---')
|
||||
if jars:
|
||||
for k, v in sorted(jars.items()):
|
||||
print(' %s' % k)
|
||||
else:
|
||||
print(' (none) - a smali round-trip needs baksmali/smali/dexlib2 jars.')
|
||||
print(' Set APKREV_JARS=<dir-with-jars> to point this script at them.')
|
||||
|
||||
print('\n--- device ---')
|
||||
if not dev.get('available'):
|
||||
print(' unavailable: %s' % dev.get('reason'))
|
||||
elif not dev.get('devices'):
|
||||
print(' no device attached (adb works)')
|
||||
else:
|
||||
print(' target : %s' % dev.get('target'))
|
||||
for k, v in (dev.get('props') or {}).items():
|
||||
print(' %-9s: %s' % (k.replace('ro.', ''), v))
|
||||
print(' root : %s' % ('yes' if dev.get('root') else 'no'))
|
||||
skew = dev.get('clock_skew_s')
|
||||
if skew is not None:
|
||||
flag = ' <-- FIX THIS, clock drift poisons time-based checks' if skew > 30 else ''
|
||||
print(' clock skew: %ss%s' % (skew, flag))
|
||||
if dev.get('forwards'):
|
||||
print(' leftovers : adb forward entries still present: %s' % dev['forwards'])
|
||||
if dev.get('http_proxy', '').strip() not in ('', 'null', ':0'):
|
||||
print(' leftovers : device http_proxy = %s' % dev['http_proxy'])
|
||||
if dev.get('device_frida_processes'):
|
||||
print(' WARNING : a frida process is already running on device:')
|
||||
for p in dev['device_frida_processes']:
|
||||
print(' %s' % p)
|
||||
print(' If the target dies only while this is up, you are looking at')
|
||||
print(' a probe aimed at YOU. Stop it before concluding anything.')
|
||||
|
||||
if args.scripts:
|
||||
print('\n--- scripts ---')
|
||||
ready = [r for r in rows if r['runnable']]
|
||||
notready = [r for r in rows if not r['runnable']]
|
||||
for r in ready:
|
||||
print(' [OK ] %s' % r['script'])
|
||||
for r in notready:
|
||||
why = []
|
||||
if not r['present']:
|
||||
why.append('file missing')
|
||||
if r['missing']:
|
||||
why.append('missing: ' + ', '.join(r['missing']))
|
||||
print(' [-- ] %-24s %s' % (r['script'], '; '.join(why)))
|
||||
print('\n runnable: %d / %d' % (len(ready), len(rows)))
|
||||
else:
|
||||
ready = sum(1 for r in rows if r['runnable'])
|
||||
print('\n scripts runnable here: %d / %d (use --scripts for the table)'
|
||||
% (ready, len(rows)))
|
||||
|
||||
print('\nRead references/long-task-discipline.md before a long block:')
|
||||
print(' bound every wait, look at the screen while you wait, and record the '
|
||||
'time-to-death before patching.')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,275 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Redirect a native library load by rewriting an isolated string constant in place.
|
||||
|
||||
Why this exists
|
||||
---------------
|
||||
Protected targets frequently name a *checker* library at a single call site
|
||||
(``System.loadLibrary("X")`` reached from a JNI constant pool) rather than through
|
||||
``DT_NEEDED``. When that checker does the integrity validation, deleting the loader
|
||||
deadlocks you: keep it and validation kills the process, delete it and the load
|
||||
throws.
|
||||
|
||||
Rewriting the *name* is the way out. Point that call at a library that is guaranteed
|
||||
already mapped (on Android, ``android`` / ``libandroid.so``): the load succeeds, the
|
||||
checker is never mapped, and **no byte offsets move** because the replacement is
|
||||
exactly the same length. Nothing for an integrity check to notice about layout, and
|
||||
no death path to neutralize.
|
||||
|
||||
Usage
|
||||
-----
|
||||
# inspect: where does this name occur, and is it an isolated constant?
|
||||
python so_constpatch.py libfoo.so --find apkhuan
|
||||
|
||||
# patch a bare .so
|
||||
python so_constpatch.py libfoo.so --replace apkhuan=android -o libfoo.patched.so
|
||||
|
||||
# patch inside an APK (rebuilds the zip, entry order preserved)
|
||||
python so_constpatch.py app.apk --entry lib/arm64-v8a/libfoo.so \
|
||||
--replace apkhuan=android -o app.patched.apk
|
||||
|
||||
# only patch occurrences that sit in an ELF constant-pool section
|
||||
python so_constpatch.py libfoo.so --replace apkhuan=android --section-aware
|
||||
|
||||
Design notes
|
||||
------------
|
||||
* Equal length is mandatory and enforced; there is no padding mode that can be safe
|
||||
when the next constant lives immediately after the NUL.
|
||||
* ``--find`` reports whether each hit is isolated (NUL on both sides). Patching a
|
||||
substring of a longer identifier corrupts that identifier, so non-isolated hits are
|
||||
refused unless ``--allow-nonisolated`` is given explicitly.
|
||||
* The ELF section map is only used for *reporting* which constant pool a hit lives in.
|
||||
It never gates a patch on section validity, because hardened libraries ship forged
|
||||
section headers (see references/native-tamper-and-suicide.md) while the bytes you
|
||||
need are still exactly where the loader reads them.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- ELF helpers
|
||||
|
||||
def elf_sections(data: bytes):
|
||||
"""Return [(name, sh_type, addr, offset, size)] or [] if unusable.
|
||||
|
||||
Deliberately tolerant: forged/truncated section headers are common in this
|
||||
domain, and a failed parse must not stop the string scan.
|
||||
"""
|
||||
try:
|
||||
if data[:4] != b'\x7fELF':
|
||||
return []
|
||||
e_shoff = struct.unpack_from('<Q', data, 0x28)[0]
|
||||
e_shentsize = struct.unpack_from('<H', data, 0x3a)[0]
|
||||
e_shnum = struct.unpack_from('<H', data, 0x3c)[0]
|
||||
e_shstrndx = struct.unpack_from('<H', data, 0x3e)[0]
|
||||
if e_shoff == 0 or e_shnum == 0 or e_shoff >= len(data):
|
||||
return []
|
||||
raw = []
|
||||
for i in range(e_shnum):
|
||||
o = e_shoff + i * e_shentsize
|
||||
if o + 0x40 > len(data):
|
||||
break
|
||||
name, stype, _flags, addr, offset, size = struct.unpack_from('<IIQQQQ', data, o)
|
||||
raw.append([name, stype, addr, offset, size])
|
||||
if e_shstrndx >= len(raw):
|
||||
return []
|
||||
base = raw[e_shstrndx][3]
|
||||
out = []
|
||||
for name, stype, addr, offset, size in raw:
|
||||
try:
|
||||
end = data.index(b'\x00', base + name)
|
||||
nm = data[base + name:end].decode('ascii', 'replace')
|
||||
except Exception:
|
||||
nm = '?'
|
||||
out.append((nm, stype, addr, offset, size))
|
||||
return out
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def section_of(sections, off: int):
|
||||
for nm, stype, _addr, soff, ssize in sections:
|
||||
if stype != 8 and soff <= off < soff + ssize:
|
||||
return nm
|
||||
return None
|
||||
|
||||
|
||||
POOLS = ('.rodata', '.data', '.data.rel.ro', '.rodata.str1.1', '.dynstr', '.strtab')
|
||||
|
||||
|
||||
def occurrences(data: bytes, needle: bytes):
|
||||
"""Yield (offset, isolated, section_name, context)."""
|
||||
sections = elf_sections(data)
|
||||
start = 0
|
||||
while True:
|
||||
i = data.find(needle, start)
|
||||
if i < 0:
|
||||
return
|
||||
prev = data[i - 1:i]
|
||||
nxt = data[i + len(needle):i + len(needle) + 1]
|
||||
isolated = (prev == b'\x00' or i == 0) and (nxt == b'\x00')
|
||||
yield i, isolated, section_of(sections, i), data[max(0, i - 24):i + len(needle) + 24]
|
||||
start = i + 1
|
||||
|
||||
|
||||
def describe(ctx: bytes) -> str:
|
||||
return ''.join(chr(b) if 32 <= b < 127 else ('\\0' if b == 0 else '.') for b in ctx)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- operations
|
||||
|
||||
def do_find(data: bytes, needle: bytes) -> int:
|
||||
hits = list(occurrences(data, needle))
|
||||
if not hits:
|
||||
print('no occurrence of %r' % needle.decode('utf-8', 'replace'))
|
||||
return 1
|
||||
print('%d occurrence(s) of %r:' % (len(hits), needle.decode('utf-8', 'replace')))
|
||||
for off, iso, sec, ctx in hits:
|
||||
print(' 0x%-8x isolated=%-5s section=%-12s | %s' % (off, iso, sec or '-', describe(ctx)))
|
||||
n_iso = sum(1 for _, iso, _, _ in hits if iso)
|
||||
print('\nisolated (safe to rewrite in place): %d / %d' % (n_iso, len(hits)))
|
||||
return 0
|
||||
|
||||
|
||||
def do_replace(data: bytes, old: bytes, new: bytes, section_aware: bool,
|
||||
allow_nonisolated: bool, force: bool) -> tuple[bytes, list]:
|
||||
if len(old) != len(new):
|
||||
raise SystemExit(
|
||||
'REFUSING: %r (%d bytes) -> %r (%d bytes).\n'
|
||||
'Equal length is mandatory - a different length shifts every byte after it '
|
||||
'and invalidates the ELF.\nPass a same-length name (e.g. pad with a shorter '
|
||||
'already-loaded library name).' % (
|
||||
old.decode('utf-8', 'replace'), len(old),
|
||||
new.decode('utf-8', 'replace'), len(new)))
|
||||
|
||||
hits = list(occurrences(data, old))
|
||||
if not hits:
|
||||
raise SystemExit('REFUSING: %r not present' % old.decode('utf-8', 'replace'))
|
||||
|
||||
selected = []
|
||||
for off, iso, sec, ctx in hits:
|
||||
if not iso and not allow_nonisolated:
|
||||
print(' skip 0x%-8x not an isolated constant (would corrupt a neighbour)' % off)
|
||||
continue
|
||||
if section_aware and (sec not in POOLS):
|
||||
print(' skip 0x%-8x section=%s (not a constant pool; --section-aware)' % (off, sec))
|
||||
continue
|
||||
selected.append(off)
|
||||
|
||||
if not selected:
|
||||
raise SystemExit('REFUSING: no eligible occurrence (see remarks above)')
|
||||
|
||||
if len(selected) > 1 and not force:
|
||||
print('\n%d eligible occurrence(s). Re-run with --force to patch all of them, '
|
||||
'or narrow the search.' % len(selected))
|
||||
for off in selected:
|
||||
print(' 0x%x' % off)
|
||||
raise SystemExit(2)
|
||||
|
||||
out = bytearray(data)
|
||||
applied = []
|
||||
for off in selected:
|
||||
before = bytes(out[off:off + len(old)])
|
||||
out[off:off + len(old)] = new
|
||||
applied.append((off, before, bytes(new)))
|
||||
print(' patched 0x%-8x %r -> %r' % (off, before.decode('utf-8', 'replace'),
|
||||
new.decode('utf-8', 'replace')))
|
||||
|
||||
print('\n%d byte(s) changed, file length unchanged (%d).' % (len(applied) * len(old), len(data)))
|
||||
return bytes(out), applied
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- containers
|
||||
|
||||
def load_target(path: str, entry: str | None):
|
||||
if path.lower().endswith('.apk') or path.lower().endswith('.zip'):
|
||||
if not entry:
|
||||
raise SystemExit('--entry is required when patching an APK '
|
||||
'(e.g. --entry lib/arm64-v8a/libfoo.so)')
|
||||
z = zipfile.ZipFile(path)
|
||||
if entry not in z.namelist():
|
||||
cands = [n for n in z.namelist() if n.endswith('.so')]
|
||||
raise SystemExit('entry %r not in %s\navailable .so entries:\n %s'
|
||||
% (entry, path, '\n '.join(cands[:40])))
|
||||
return z.read(entry), 'apk'
|
||||
with open(path, 'rb') as f:
|
||||
return f.read(), 'file'
|
||||
|
||||
|
||||
def write_target(path: str, kind: str, src_path: str, entry: str | None,
|
||||
new_data: bytes, out_path: str):
|
||||
if kind == 'file':
|
||||
with open(out_path, 'wb') as f:
|
||||
f.write(new_data)
|
||||
return
|
||||
src = zipfile.ZipFile(src_path)
|
||||
total = 0
|
||||
with zipfile.ZipFile(out_path, 'w', zipfile.ZIP_DEFLATED) as dst:
|
||||
for item in src.infolist():
|
||||
blob = new_data if item.filename == entry else src.read(item.filename)
|
||||
dst.writestr(zipfile.ZipInfo(item.filename, date_time=item.date_time),
|
||||
blob, item.compress_type)
|
||||
total += 1
|
||||
print(' rebuilt %s (%d entries)' % (out_path, total))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- main
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(
|
||||
description='In-place same-length rewrite of a string constant (library-load redirection).',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog='examples:\n'
|
||||
' so_constpatch.py libfoo.so --find checkername\n'
|
||||
' so_constpatch.py libfoo.so --replace checkername=android -o libfoo.patched.so\n'
|
||||
' so_constpatch.py app.apk --entry lib/arm64-v8a/libfoo.so '
|
||||
'--replace checkername=android -o app.patched.apk\n')
|
||||
ap.add_argument('target', help='.so file or .apk')
|
||||
ap.add_argument('--entry', default=None, help='zip entry path when target is an APK')
|
||||
ap.add_argument('--find', default=None, metavar='STR', help='report occurrences and exit')
|
||||
ap.add_argument('--replace', default=None, metavar='OLD=NEW',
|
||||
help='same-length rewrite of OLD with NEW')
|
||||
ap.add_argument('-o', '--out', default=None, help='output path (required for --replace)')
|
||||
ap.add_argument('--section-aware', action='store_true',
|
||||
help='only patch hits inside known constant-pool sections')
|
||||
ap.add_argument('--allow-nonisolated', action='store_true',
|
||||
help='permit patching a substring of a longer identifier (dangerous)')
|
||||
ap.add_argument('--force', action='store_true', help='patch every eligible occurrence')
|
||||
args = ap.parse_args()
|
||||
|
||||
data, kind = load_target(args.target, args.entry)
|
||||
print('loaded %s (%s, %d bytes)\n' % (args.target, kind, len(data)))
|
||||
|
||||
if args.find:
|
||||
sys.exit(do_find(data, args.find.encode()))
|
||||
|
||||
if not args.replace:
|
||||
ap.error('one of --find or --replace is required')
|
||||
|
||||
if '=' not in args.replace:
|
||||
ap.error('--replace expects OLD=NEW')
|
||||
old_s, new_s = args.replace.split('=', 1)
|
||||
old, new = old_s.encode(), new_s.encode()
|
||||
|
||||
if not args.out and kind == 'file':
|
||||
args.out = args.target + '.patched'
|
||||
if not args.out:
|
||||
ap.error('-o/--out is required when patching an APK')
|
||||
if os.path.abspath(args.out) == os.path.abspath(args.target):
|
||||
ap.error('refusing to overwrite the input in place; choose a different -o')
|
||||
|
||||
patched, applied = do_replace(data, old, new, args.section_aware,
|
||||
args.allow_nonisolated, args.force)
|
||||
write_target(args.out, kind, args.target, args.entry, patched, args.out)
|
||||
print('\nwrote %s' % args.out)
|
||||
print('next: re-sign, then verify the loader\'s log tag count is 0 on a cold start '
|
||||
'(see references/code-virtualization-and-custom-linkers.md).')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user