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:
inliver233
2026-09-20 00:51:58 +08:00
parent 02c832f847
commit 0fc2a30408
9 changed files with 993 additions and 6 deletions
+29 -1
View File
@@ -54,6 +54,14 @@ references/ loaded on demand, one topic each
recon.md identify packer, SDKs, code location, tamper checks; unpacking recon.md identify packer, SDKs, code location, tamper checks; unpacking
packers.md hardened targets: rejection signals, measuring the validation packers.md hardened targets: rejection signals, measuring the validation
boundary with single-variable tests, choosing a native host boundary with single-variable tests, choosing a native host
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 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) find logic when there are no symbols (string encoding traps)
dart-aot.md Dart AOT in depth: version pinning and building a matching decompiler, 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 verification.md the claim ladder; what "done" means
pitfalls.md the failure catalogue -- read before building pitfalls.md the failure catalogue -- read before building
scripts/ parameterized, path-agnostic scripts/ parameterized, path-agnostic
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 smtool.py baksmali/smali wrapper with a configurable classpath
dexpatch/ dexlib2 method-level surgical rewriter (preferred tool) dexpatch/ dexlib2 method-level surgical rewriter (preferred tool)
patch_smali.py method-body replacement in a smali tree 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 ## 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 | | Tool | Used for |
|---|---| |---|---|
+6 -2
View File
@@ -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. 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. 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. 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 ## 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. **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`. 2. **Extract strings and endpoints** — build a picture of the app's API surface and SDK inventory from the dex string tables. No decompiler needed for this, and it is fast. Scripts: `scripts/dex_strings.py`.
3. **Trace to the owning class** — find the class that wraps the behavior (the app almost always wraps third-party SDKs in one helper). Reverse-lookup instructions: `references/dex-patching.md` §finding-the-call-site. 3. **Trace to the owning class** — find the class that wraps the behavior (the app almost always wraps third-party SDKs in one helper). Reverse-lookup instructions: `references/dex-patching.md` §finding-the-call-site.
@@ -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/recon.md` | Starting any new sample; identifying packer, SDKs, code location, ABI |
| `references/packers.md` | The app is packed/hardened, or an edit makes it die before your code runs. Also load before discarding any route as "blocked by the shell" | | `references/packers.md` | The app is packed/hardened, or an edit makes it die before your code runs. Also load before discarding any route as "blocked by the shell" |
| `references/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/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/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** | | `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 | | 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/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/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** | | `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/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/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/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/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/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. | | `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. |
+1
View File
@@ -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). 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. 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**: 3. **Filesystem, the strongest signal** — the app's private directory contains **no ad SDK working directory at all**:
```bash ```bash
adb shell "su -c 'ls -la /data/data/<app.package>/files/'" 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.
+6
View File
@@ -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 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. "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 ## What a packer actually does
A shell (jiagu / weapon / leggu / 360 / ijiami / bangcle class) replaces the manifest's A shell (jiagu / weapon / leggu / 360 / ijiami / bangcle class) replaces the manifest's
+8 -3
View File
@@ -18,15 +18,20 @@ App dies immediately on launch. Sometimes a different `ClassNotFoundException` f
**Root cause** **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. `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/kotlinx.coroutines.internal.MainDispatcherFactory -> kc
META-INF/services/io.ktor.client.engine.HttpClientEngineContainer -> OkHttpEngineContainer META-INF/services/io.ktor.client.engine.HttpClientEngineContainer -> OkHttpEngineContainer
META-INF/services/io.ktor.serialization.kotlinx.KotlinxSerializationExtensionProvider META-INF/services/io.ktor.serialization.kotlinx.KotlinxSerializationExtensionProvider
META-INF/services/kotlinx.coroutines.CoroutineExceptionHandler -> wc META-INF/services/kotlinx.coroutines.CoroutineExceptionHandler -> wc
META-INF/services/com.arialyy.aria.core.inf.IUtil META-INF/services/<third-party-package>.<SomeInterface> -> <impl class>
META-INF/services/com.arialyy.aria.core.listener.IEventListener 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** **Why it is hard to see**
The error message names Kotlin coroutines, not your repack. You will spend an hour blaming the dex. The error message names Kotlin coroutines, not your repack. You will spend an hour blaming the dex.
+20
View File
@@ -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/`. **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**. **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 ## Repacking an unpacked (de-shelled) app
+428
View File
@@ -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())
+275
View File
@@ -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()