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:
@@ -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.
|
||||
Reference in New Issue
Block a user