Back to Blog

Protecting Native Libraries: SO Packing and Anti-Dump

8 min read·2026-08-22

Why the .so layer is both your strongest hiding place and your most fragile dependency, and how to harden it without breaking ABIs.

Moving logic into a .so is the classic answer to 'my Java is too easy to read', and it works — up to a point. A native library forces the attacker from jadx into IDA or Ghidra, and that is a genuine cliff in required skill. But the .so is not a vault: it is an ELF file that the linker must eventually load, which means at some moment the real code exists in readable, executable memory. Everything in native protection is about controlling that moment.

Packing exploits it in the other direction. The real .text is encrypted on disk; a stub .init_proc or .init_array constructor decrypts it into memory and fixes up relocations before JNI_OnLoad ever runs. Static analysis of the file shows the stub and high-entropy blobs. The catch is that the decryption is deterministic — attach Frida, hook dlopen or android_dlopen_ext, wait for the constructor to finish, and dump the mapped region. Memory dumping is the standard counter to packing, and no packer wins that race indefinitely.

Source-level obfuscation is what makes the dump expensive to read. Rename symbols, flatten control flow, encrypt string constants. The last one matters more than people expect: detection code that stores 'gum-js-loop' or 'frida' as plaintext is trivially greppable, which is why real shells decode those names arithmetically at runtime (the sample we analysed shifts each byte by a cycling offset). Strip the export table too — if readelf shows only JNI_OnLoad, an attacker has lost their map.

Runtime checks are where native earns its keep, because they can see things Java cannot. Read /proc/self/task/*/status and look for Frida's gum-js-loop, gmain and gdbus threads. Walk /proc/self/fd and readlink for injector artefacts. Compare the first sixteen bytes of pthread_create against the original instructions to catch an inline hook. Check TracerPid for a debugger. VALLUM's RASP layer (Abdal-DroidGuard) implements this family of checks at the system-call level.

The catch is compatibility, and it is not theoretical. Detection logic placed in .init_array runs before anything else, so a false positive crashes the app before the user sees a frame — which is why some commercial shells are notorious for suiciding on benign environments. More importantly for us: a native shell is bound to the ABIs it ships. Exclude x86 and x86_64 and every emulator, Chrome OS device and Intel tablet dies with UnsatisfiedLinkError the moment System.load runs. Ship every ABI; a few hundred kilobytes is a trivial price.

Be honest about the ceiling. Security researchers break commercial shells routinely, usually by hooking the linker or dumping at dlopen. The point of native protection is not to be unbreakable, it is to move the attack from a script to a project — and to make the dumped material still obfuscated, still checksummed, and still watched by runtime checks. Layered with DEX-level protection, that is what turns reverse engineering into a business decision rather than an afternoon.