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
11 KiB
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
-
Whole classes whose methods are
nativedeclarations 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 fullynativeis the signal.Two
nativemethods is normal (JNI). Sixty is virtualization. -
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;)VThe
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. -
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_SONAMEfrom.dynamic, not the file name —probe_deps-style parsing ofPT_DYNAMIC(seenative-and-so.md§forged headers) gives you the truth. Alib<something>.soclaimingsoname = liblinkerloader.sois 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
UnsatisfiedLinkErrorand 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.mdrule set (make it return, don't make it not return) never comes into play. There is nothing to neutralize.
Requirements and hazards:
- 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.
- 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. - 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.
- 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:
- Install a build you signed normally, so PMS records your certificate.
- Root-overwrite that installed
base.apkwith your candidate (one whose internal signature block is the original), leaving PMS's record untouched. - 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
Applicationinheritance 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:
- Loader's log tag count in a full cold-start capture = 0 (previously non-zero).
Invalid ELF magic= 0.SIGSEGV= 0 andSIGSYS/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.- Process survives past the previously measured time-to-death, on this machine, with nothing attached; then survives real interaction.
- 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.