From 0fc2a304083a7455642f63b0cfeafcb42dd8c013 Mon Sep 17 00:00:00 2001 From: inliver233 Date: Sun, 20 Sep 2026 00:51:58 +0800 Subject: [PATCH] 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 --- README.md | 30 +- SKILL.md | 8 +- references/ad-removal.md | 1 + .../code-virtualization-and-custom-linkers.md | 220 +++++++++ references/packers.md | 6 + references/pitfalls.md | 11 +- references/repack-and-sign.md | 20 + scripts/doctor.py | 428 ++++++++++++++++++ scripts/so_constpatch.py | 275 +++++++++++ 9 files changed, 993 insertions(+), 6 deletions(-) create mode 100644 references/code-virtualization-and-custom-linkers.md create mode 100644 scripts/doctor.py create mode 100644 scripts/so_constpatch.py diff --git a/README.md b/README.md index 8bb1524..83b726a 100644 --- a/README.md +++ b/README.md @@ -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 | |---|---| diff --git a/SKILL.md b/SKILL.md index f7d1046..4424945 100644 --- a/SKILL.md +++ b/SKILL.md @@ -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. | diff --git a/references/ad-removal.md b/references/ad-removal.md index 3bfa09f..1611fa5 100644 --- a/references/ad-removal.md +++ b/references/ad-removal.md @@ -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//files/'" diff --git a/references/code-virtualization-and-custom-linkers.md b/references/code-virtualization-and-custom-linkers.md new file mode 100644 index 0000000..fab8733 --- /dev/null +++ b/references/code-virtualization-and-custom-linkers.md @@ -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 ()V + invoke-static {v0, v1}, L/DtcLoader;->registerNativesForClass(ILjava/lang/Class;)V + invoke-static {v1}, L/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.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.so" not found + at java.lang.System.loadLibrary(System.java) + at .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; … +… X … +``` + +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/: Loading embedded protected image from carrier (self) +I/: Found embedded protected image at offset 0x…, logical size 0x… +E/: Invalid ELF magic <-- appears ONLY on a modified build +D/: ELF Header: type=3, machine=183, entry=0x0, phnum=N +D/: 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. diff --git a/references/packers.md b/references/packers.md index 1b4ffb9..5fc5362 100644 --- a/references/packers.md +++ b/references/packers.md @@ -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 diff --git a/references/pitfalls.md b/references/pitfalls.md index cb2df46..dd3775e 100644 --- a/references/pitfalls.md +++ b/references/pitfalls.md @@ -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/. -> +META-INF/services/. -> ``` +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. diff --git a/references/repack-and-sign.md b/references/repack-and-sign.md index cb795a2..4f42f0d 100644 --- a/references/repack-and-sign.md +++ b/references/repack-and-sign.md @@ -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/.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 diff --git a/scripts/doctor.py b/scripts/doctor.py new file mode 100644 index 0000000..3722525 --- /dev/null +++ b/scripts/doctor.py @@ -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 # 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= 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()) diff --git a/scripts/so_constpatch.py b/scripts/so_constpatch.py new file mode 100644 index 0000000..5ed7c49 --- /dev/null +++ b/scripts/so_constpatch.py @@ -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('= 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('= 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()