# Code Virtualization & Custom Linkers Load this when recon says **there is no packer** (the manifest's `application` is the app's own class, dex is readable) and yet a re-signed build dies before your code runs — especially when the death is `SIGSEGV fault addr 0x0` with all registers zero. This is the layer between "packed" and "clean". `packers.md` covers a shell that owns `Application`; this file covers protection that leaves `Application` alone and instead **turns individual methods into native code** and hosts a private loader next to them. It is now common enough that treating it as "no packer, therefore easy" is the most expensive recon error in this skill's history. ## Identification — three cheap tells 1. **Whole classes whose methods are `native` declarations with no implementation.** Read the smali for the entry Activity / engine host class. Under code virtualization the *entire class* is converted — lifecycle callbacks, getters, everything — each becoming a bare `.method public native ...` with no body. A framework class you *know* ships a Java implementation (a Flutter/Unity/RN host class is the usual victim) sitting there fully `native` is the signal. Two `native` methods is normal (JNI). **Sixty is virtualization.** 2. **A registration call in the class's static initializer.** Someone binds those native declarations to an implementation at class-load time: ``` .method static constructor ()V invoke-static {v0, v1}, L/DtcLoader;->registerNativesForClass(ILjava/lang/Class;)V invoke-static {v1}, L/hidden/Hidden0;->special_clinit_0_00(Ljava/lang/Class;)V ``` The `Hidden0.special_clinit_*` family is the virtualizer's hook for running its own version of the class initializer. When you see this, the Java in that class **is not what executes**. 3. **A shipping library whose SONAME does not match its filename.** A private loader is often named for its role while sitting under an innocuous filename. Read `DT_SONAME` from `.dynamic`, not the file name — `probe_deps`-style parsing of `PT_DYNAMIC` (see `native-and-so.md` §forged headers) gives you the truth. A `lib.so` claiming `soname = liblinkerloader.so` is a private ELF loader. Supporting tells: `assets/` carrying a small text descriptor naming the protector and its version; native libraries woven into `DT_NEEDED` chains of other libraries (the virtualized code has to be reachable); log lines tagged with the loader's own name appearing during startup. ## The deadlock that eats hours — and the way out A virtualizer that also does integrity checking produces this shape: | Action | Result | |---|---| | Keep the private loader in the APK | its embedded validation runs → deliberate crash on a re-signed build | | Delete the private loader | the virtualized class has **no implementation at all** → `UnsatisfiedLinkError: dlopen failed: library "…" not found` | Both routes die, and the two failures look nothing alike, so it is easy to conclude the target is impossible. It is not. The way out is one question: > **Is that library the checker, or the implementation — and who asks for it?** ## Separate the checker from the implementation Two distinct roles hide behind "a protected library": - **The implementation** — the native code that *is* the virtualized class. It must be present and loaded, or the class cannot work. - **The checker** — a validation payload (a signature/integrity payload, a second-stage self-decrypting image) that something *explicitly loads*. They are frequently **different libraries**, and the load of the checker is frequently a **single, findable call site** — not a link-time dependency. Test which is which before designing a fix: ``` # 1. Does anything name it in DT_NEEDED? Parse PT_DYNAMIC of every shipped .so. # No DT_NEEDED referrer => it is dlopen()ed, i.e. named at some call site. # 2. Delete it and read the exact error: which class/method was executing when the # load failed? That is the caller. ``` In the observed case the error was not "the class is missing" but: ``` java.lang.UnsatisfiedLinkError: dlopen failed: library "lib.so" not found at java.lang.System.loadLibrary(System.java) at .onCreate(Native Method) ``` Read that stack carefully: the **virtualized** `onCreate` — the very code you feared deleting the library would break — is running fine. It is the *checker* that is absent. The implementation lives elsewhere and was never the problem. **That single stack frame converts an impossible target into a trivial one.** ## Technique: redirect the load by rewriting a string constant Once you know the checker is named at a call site, you do not need to patch code, hook anything, or neutralize a crash. **Change what that call site loads.** JNI-heavy native code keeps its class/method/string operands in a constant pool inside the library. A `loadLibrary("X")` becomes three facts sitting in `.rodata`/`.data`: ``` … onCreate (Landroid/os/Bundle;)V loadLibrary (Ljava/lang/String;)Ljava/lang/String; … … X … ``` Find the name as an **isolated NUL-terminated string** and rewrite it **in place, same length** to the name of a library that is *guaranteed already loaded*. On Android, `android` (`libandroid.so`) is reliably mapped — the protected libraries themselves depend on it. ``` apkhuan -> android (7 chars -> 7 chars) ``` Why this is disproportionately effective: - **Equal length means zero structural change.** No offsets move, no section resizes, the ELF stays exactly as valid as it was. Nothing for an integrity check to notice about *layout*. - **The load succeeds**, so no `UnsatisfiedLinkError` and no error branch anywhere. - **The checker never gets mapped**, so none of its validation runs — the signature payload, the deliberate-crash payload, all of it becomes dead weight in the APK. - **You never patch a death path**, so the whole `native-tamper-and-suicide.md` rule set (make it *return*, don't make it *not return*) never comes into play. There is nothing to neutralize. Requirements and hazards: 1. **Same byte length is not optional.** Replacing with a shorter/longer name shifts everything after it. Pad only within the same NUL-terminated slot; never spill into the next constant. 2. **Verify the string is an isolated constant** — the byte before and after must be `\x00`. A substring of a longer identifier will corrupt that identifier. 3. **The replacement must be loadable and already-loaded.** A nonexistent name throws and puts you back in the deadlock; a heavy library changes startup cost. 4. **Scan the whole file** for the name, then confirm which occurrence sits in the constant pool. Report the offset in the patch record. `scripts/so_constpatch.py` implements this: finds isolated constants, enforces equal length, writes a patched copy, and prints the byte-diff. This is one member of a general family — **redirect a reference instead of defeating a check** — and the same shape recurs for class names, method names and file paths in a constant pool. ## Expected log shapes A private loader announces itself. These strings come from the loader, not from the app, and their presence/absence across builds is a usable signal: ``` I/: Loading embedded protected image from carrier (self) I/: Found embedded protected image at offset 0x…, logical size 0x… E/: Invalid ELF magic <-- appears ONLY on a modified build D/: ELF Header: type=3, machine=183, entry=0x0, phnum=N D/: Starting PrelinkImage for embedded_protected ``` A custom container magic at the tail of the library (a four-byte tag, not `\x7fELF`) marks the embedded image; `entry=0x0` plus `SIGSEGV fault addr 0x0` is the loader failing to decrypt its payload and jumping to a zero entry. Treat "`Invalid ELF magic` appears on my build and not on the original" as **proof the loader is validating the build**, which is exactly what makes the string-redirect route worth trying. ## What the native check actually reads — measure it, don't guess A validation payload that reads "the signature" can be reading several different things, and the answer decides whether *any* repack route exists: | It reads | Consequence | |---|---| | the certificate material **inside the APK** | keeping the original signature block while swapping code can pass | | the signature **PackageManager reports** | that trick fails; you must remove the check (this file's technique) or rebuild | | an entry **hash** of the whole file | only the string-redirect / check-removal route survives | **Single-variable experiment to tell them apart.** Same APK content both times; flip only the PMS-recorded signature: 1. Install a build **you signed** normally, so PMS records *your* certificate. 2. Root-overwrite that installed `base.apk` with your candidate (one whose *internal* signature block is the original), leaving PMS's record untouched. 3. Launch. Dies ⇒ the check reads the PMS-reported signature, and preserving in-APK certificate material cannot save you. Survives ⇒ it reads in-APK material, and a "keep the original signature block" route is worth investing in. Do this **before** investing in elaborate signature-block plumbing. It is two installs. ## A Java-layer "signature killer" is a decoy A protection bundle may ship a Java class that hooks `PackageManager` so the framework reports a hardcoded certificate, and log `Signature consistency ok` forever — including on your modified build. This is an **anti-tamper feature aimed at other patchers**, not a gate you must satisfy. Its corollary is what matters operationally: **`Signature consistency ok` in the log proves nothing.** Do not let a green Java-layer message talk you out of the fact that the process is dying, or send you hunting for a Java signature check. If the death is `SIGSEGV` with a zero program counter, the check that killed you is native and the Java one is irrelevant. Two practical notes: - **Do not delete such a class to "clean up".** It usually participates in the `Application` inheritance chain; removing it breaks startup and trips unrelated class-hierarchy checks. - Its presence is a strong hint that **the code that matters is virtualized**, because it exists to survive exactly the kind of repack you are attempting. ## Verification A string-redirect fix is verified by the **absence of the loader**, not by the absence of an error: 1. Loader's log tag count in a full cold-start capture = **0** (previously non-zero). 2. `Invalid ELF magic` = 0. 3. `SIGSEGV` = 0 **and** `SIGSYS`/seccomp kills = 0 — the secondary kill paths the payload carried disappear with it. A drop from 1 to 0 here is a real result worth recording. 4. Process survives past the previously measured time-to-death, on this machine, with nothing attached; then survives real interaction. 5. The features that class implements still work — the virtualized code is the *implementation*, so exercise it (start playback, navigate, whatever it owns). A build that starts but whose virtualized feature is dead is not a result. Record the original and patched bytes, the offset, and the loader log counts before/after in the patch record — the same audit standard as any other byte-level edit.