Files
apk-reverse/references/code-virtualization-and-custom-linkers.md
T
inliver233 0fc2a30408 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
2026-09-20 00:51:58 +08:00

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

  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.