Reversing Native Libraries

Tip

Learn & practice AWS Hacking:HackTricks Training AWS Red Team Expert (ARTE)
Learn & practice GCP Hacking: HackTricks Training GCP Red Team Expert (GRTE)
Learn & practice Az Hacking: HackTricks Training Azure Red Team Expert (AzRTE)

Support HackTricks

For further information check: https://maddiestone.github.io/AndroidAppRE/reversing_native_libs.html

Android apps can use native libraries, typically written in C or C++, for performance-critical tasks. Malware creators also abuse these libraries because ELF shared objects are still harder to decompile than DEX/OAT byte-code.
This page focuses on practical workflows and recent tooling improvements (2023-2025) that make reversing Android .so files easier.


Quick triage-workflow for a freshly pulled libfoo.so

  1. Extract the library
    # From an installed application
    adb shell "run-as <pkg> cat lib/arm64-v8a/libfoo.so" > libfoo.so
    # Or from the APK (zip)
    unzip -j target.apk "lib/*/libfoo.so" -d extracted_libs/
    
  2. Identify architecture & protections
    file libfoo.so        # arm64 or arm32 / x86
    readelf -h libfoo.so  # OS ABI, PIE, NX, RELRO, etc.
    checksec --file libfoo.so  # (peda/pwntools)
    
  3. List exported symbols & JNI bindings
    readelf -s libfoo.so | grep ' Java_'     # dynamic-linked JNI
    strings libfoo.so   | grep -i "RegisterNatives" -n   # static-registered JNI
    
  4. Load in a decompiler (Ghidra ≥ 11.0, IDA Pro, Binary Ninja, Hopper or Cutter/Rizin) and run auto-analysis.
    Newer Ghidra versions introduced an AArch64 decompiler that recognises PAC/BTI stubs and MTE tags, greatly improving analysis of libraries built with the Android 14 NDK.
  5. Decide on static vs dynamic reversing: stripped, obfuscated code often needs instrumentation (Frida, ptrace/gdbserver, LLDB).

Dynamic Instrumentation (Frida ≥ 16)

Frida’s 16-series brought several Android-specific improvements that help when the target uses modern Clang/LLD optimisations:

  • thumb-relocator can now hook tiny ARM/Thumb functions generated by LLD’s aggressive alignment (--icf=all).
  • Enumerating and rebinding ELF import slots works on Android, enabling per-module dlopen()/dlsym() patching when inline hooks are rejected.
  • Java hooking was fixed for the new ART quick-entrypoint used when apps are compiled with --enable-optimizations on Android 14.

Example: enumerating all functions registered through RegisterNatives and dumping their addresses at runtime:

Java.perform(function () {
  var Runtime = Java.use('java.lang.Runtime');
  var register = Module.findExportByName(null, 'RegisterNatives');
  Interceptor.attach(register, {
    onEnter(args) {
      var envPtr  = args[0];
      var clazz   = Java.cast(args[1], Java.use('java.lang.Class'));
      var methods = args[2];
      var count   = args[3].toInt32();
      console.log('[+] RegisterNatives on ' + clazz.getName() + ' -> ' + count + ' methods');
      // iterate & dump (JNI nativeMethod struct: name, sig, fnPtr)
    }
  });
});

Frida will work out of the box on PAC/BTI-enabled devices (Pixel 8/Android 14+) as long as you use frida-server 16.2 or later – earlier versions failed to locate padding for inline hooks.

Dumping runtime-decrypted native libraries from memory (Frida soSaver)

When a protected APK keeps native code encrypted or only maps it at runtime (packers, downloaded payloads, generated libs), attach Frida and dump the mapped ELF directly from process memory.

soSaver workflow (Python host + TS/JS Frida agent):

  • Hooks dlopen and android_dlopen_ext to detect load-time library mapping and performs an initial sweep of already loaded modules.
  • Periodically scans the process memory mappings for ELF headers to catch modules loaded through non-standard mappers that never hit the loader APIs.
  • Reads each module in blocks from memory and streams the bytes through Frida messages to the host; if a region cannot be read, it falls back to reading from the on-disk path when available.
  • Saves the reconstructed .so files and prints per-module extraction stats, providing artifacts for static RE.

Run (root + frida-server, Python ≥3.8, uv):

git clone https://github.com/TheQmaks/sosaver.git
cd sosaver && uv sync
source .venv/bin/activate    # .venv\Scripts\activate on Windows

# target by package or PID; choose output/verbosity
sosaver com.example.app
sosaver 1234 -o /tmp/so-dumps --debug

This approach bypasses “only decrypted in RAM” protections by recovering the live mapped image, allowing offline analysis in IDA/Ghidra even if the filesystem copy is obfuscated or absent.

Process-local JNI telemetry via preloaded .so (SoTap)

When full-featured instrumentation is overkill or blocked, you can still gain native-level visibility by preloading a small logger inside the target process. SoTap is a lightweight Android native (.so) library that logs the runtime behavior of other JNI (.so) libraries within the same app process (no root required).

Key properties:

  • Initializes early and observes JNI/native interactions inside the process that loads it.
  • Persists logs using multiple writable paths with graceful fallback to Logcat when storage is restricted.
  • Source-customizable: edit sotap.c to extend/adjust what gets logged and rebuild per ABI.

Setup (repack the APK):

  1. Drop the proper ABI build into the APK so the loader can resolve libsotap.so:
    • lib/arm64-v8a/libsotap.so (for arm64)
    • lib/armeabi-v7a/libsotap.so (for arm32)
  2. Ensure SoTap loads before other JNI libs. Inject a call early (e.g., Application subclass static initializer or onCreate) so the logger is initialized first. Smali snippet example:
    const-string v0, "sotap"
    invoke-static {v0}, Ljava/lang/System;->loadLibrary(Ljava/lang/String;)V
    
  3. Rebuild/sign/install, run the app, then collect logs.

Log paths (checked in order):

/data/user/0/%s/files/sotap.log
/data/data/%s/files/sotap.log
/sdcard/Android/data/%s/files/sotap.log
/sdcard/Download/sotap-%s.log
# If all fail: fallback to Logcat only

Notes and troubleshooting:

  • ABI alignment is mandatory. A mismatch will raise UnsatisfiedLinkError and the logger won’t load.
  • Storage constraints are common on modern Android; if file writes fail, SoTap will still emit via Logcat.
  • Behavior/verbosity is intended to be customized; rebuild from source after editing sotap.c.

This approach is useful for malware triage and JNI debugging where observing native call flows from process start is critical but root/system-wide hooks aren’t available.


See also: in‑memory native code execution via JNI

A common attack pattern is to download a raw shellcode blob at runtime and execute it directly from memory through a JNI bridge (no on‑disk ELF). Details and ready‑to‑use JNI snippet here:

In Memory Jni Shellcode Execution


Recent vulnerabilities worth hunting for in APKs

YearCVEAffected libraryNotes
2023CVE-2023-4863libwebp ≤ 1.3.1Heap buffer overflow reachable from native code that decodes WebP images. Several Android apps bundle vulnerable versions. When you see a libwebp.so inside an APK, check its version and attempt exploitation or patching.
2024MultipleOpenSSL 3.x seriesSeveral memory-safety and padding-oracle issues. Many Flutter & ReactNative bundles ship their own libcrypto.so.

When you spot third-party .so files inside an APK, always cross-check their hash against upstream advisories. SCA (Software Composition Analysis) is uncommon on mobile, so outdated vulnerable builds are rampant.


  • Pointer Authentication (PAC) & Branch Target Identification (BTI): Android 14 enables PAC/BTI in system libraries on supported ARMv8.3+ silicon. Decompilers now display PAC‐related pseudo-instructions; for dynamic analysis Frida injects trampolines after stripping PAC, but your custom trampolines should call pacda/autibsp where necessary.
  • MTE & Scudo hardened allocator: memory-tagging is opt-in but many Play-Integrity aware apps build with -fsanitize=memtag; use setprop arm64.memtag.dump 1 plus adb shell am start ... to capture tag faults.
  • LLVM Obfuscator (opaque predicates, control-flow flattening): commercial packers (e.g., Bangcle, SecNeo) increasingly protect native code, not only Java; expect bogus control-flow and encrypted string blobs in .rodata.

Neutralizing early native initializers (.init_array) and JNI_OnLoad for early instrumentation (ARM64 ELF)

Highly protected apps often place root/emulator/debug checks in native constructors that run extremely early via .init_array, before JNI_OnLoad and long before any Java code executes. You can make those implicit initializers explicit and regain control by:

  • Removing INIT_ARRAY/INIT_ARRAYSZ from the DYNAMIC table so the loader does not auto-execute .init_array entries.
  • Resolving the constructor address from RELATIVE relocations and exporting it as a regular function symbol (e.g., INIT0).
  • Renaming JNI_OnLoad to JNI_OnLoad0 to prevent ART from calling it implicitly.

Why this works on Android/arm64

  • On AArch64, .init_array entries are often populated at load time by R_AARCH64_RELATIVE relocations whose addend is the target function address inside .text.
  • The bytes of .init_array may look empty statically; the dynamic linker writes the resolved address during relocation processing.

Identify the constructor target

  • Use the Android NDK toolchain for accurate ELF parsing on AArch64:
    # Adjust paths to your NDK; use the aarch64-linux-android-* variants
    readelf -W -a ./libnativestaticinit.so | grep -n "INIT_ARRAY" -C 4
    readelf -W --relocs ./libnativestaticinit.so
    
  • Find the relocation that lands inside the .init_array virtual address range; the addend of that R_AARCH64_RELATIVE is the constructor (e.g., 0xA34, 0x954).
  • Disassemble around that address to sanity check:
    objdump -D ./libnativestaticinit.so --start-address=0xA34 | head -n 40
    

Patch plan

  1. Remove INIT_ARRAY and INIT_ARRAYSZ DYNAMIC tags. Do not delete sections.
  2. Add a GLOBAL DEFAULT FUNC symbol INIT0 at the constructor address so it can be called manually.
  3. Rename JNI_OnLoadJNI_OnLoad0 to stop ART from invoking it implicitly.

Validation after patch

readelf -W -d libnativestaticinit.so.patched | egrep -i 'init_array|fini_array|flags'
readelf -W -s libnativestaticinit.so.patched | egrep 'INIT0|JNI_OnLoad0'

Patching with LIEF (Python)

Script: remove INIT_ARRAY/INIT_ARRAYSZ, export INIT0, rename JNI_OnLoad→JNI_OnLoad0
import lief

b = lief.parse("libnativestaticinit.so")

# Locate .init_array VA range
init = b.get_section('.init_array')
va, sz = init.virtual_address, init.size

# Compute constructor address from RELATIVE relocation landing in .init_array
ctor = None
for r in b.dynamic_relocations:
    if va <= r.address < va + sz:
        ctor = r.addend
        break
if ctor is None:
    raise RuntimeError("No R_*_RELATIVE relocation found inside .init_array")

# Remove auto-run tags so loader skips .init_array
for tag in (lief.ELF.DYNAMIC_TAGS.INIT_ARRAYSZ, lief.ELF.DYNAMIC_TAGS.INIT_ARRAY):
    try:
        b.remove(b[tag])
    except Exception:
        pass

# Add exported FUNC symbol INIT0 at constructor address
sym = lief.ELF.Symbol()
sym.name = 'INIT0'
sym.value = ctor
sym.size = 0
sym.binding = lief.ELF.SYMBOL_BINDINGS.GLOBAL
sym.type = lief.ELF.SYMBOL_TYPES.FUNC
sym.visibility = lief.ELF.SYMBOL_VISIBILITY.DEFAULT

# Place symbol in .text index
text = b.get_section('.text')
for idx, sec in enumerate(b.sections):
    if sec == text:
        sym.shndx = idx
        break
b.add_dynamic_symbol(sym)

# Rename JNI_OnLoad -> JNI_OnLoad0 to block implicit ART init
j = b.get_symbol('JNI_OnLoad')
if j:
    j.name = 'JNI_OnLoad0'

b.write('libnativestaticinit.so.patched')

Notes and failed approaches (for portability)

  • Zeroing .init_array bytes or setting the section length to 0 does not help: the dynamic linker repopulates it via relocations.
  • Setting INIT_ARRAY/INIT_ARRAYSZ to 0 can break the loader due to inconsistent tags. Clean removal of those DYNAMIC entries is the reliable lever.
  • Deleting the .init_array section entirely tends to crash the loader.
  • After patching, function/layout addresses might shift; always recompute the constructor from .rela.dyn addends on the patched file if you need to re-run the patch.

Bootstrapping a minimal ART/JNI to invoke INIT0 and JNI_OnLoad0

  • Use JNIInvocation to spin up a tiny ART VM context in a standalone binary. Then call INIT0() and JNI_OnLoad0(vm) manually before any Java code.
  • Include the target APK/classes on the classpath so any RegisterNatives finds its Java classes.
Minimal harness (CMake and C) to call INIT0 → JNI_OnLoad0 → Java method
# CMakeLists.txt
project(caller)
cmake_minimum_required(VERSION 3.8)
include_directories(AFTER ${CMAKE_SOURCE_DIR}/include)
link_directories(${CMAKE_SOURCE_DIR}/lib)
find_library(log-lib log REQUIRED)
add_executable(caller "caller.c")
add_library(jenv SHARED "jnihelper.c")
target_link_libraries(caller jenv nativestaticinit)
// caller.c
#include <jni.h>
#include "jenv.h"
JavaCTX ctx;
void INIT0();
void JNI_OnLoad0(JavaVM* vm);
int main(){
  char *jvmopt = "-Djava.class.path=/data/local/tmp/base.apk"; // include app classes
  if (initialize_java_environment(&ctx,&jvmopt,1)!=0) return -1;
  INIT0();                   // manual constructor
  JNI_OnLoad0(ctx.vm);       // manual JNI init
  jclass c = (*ctx.env)->FindClass(ctx.env, "eu/nviso/nativestaticinit/MainActivity");
  jmethodID m = (*ctx.env)->GetStaticMethodID(ctx.env,c,"stringFromJNI","()Ljava/lang/String;");
  jstring s = (jstring)(*ctx.env)->CallStaticObjectMethod(ctx.env,c,m);
  const char* p = (*ctx.env)->GetStringUTFChars(ctx.env,s,NULL);
  printf("Native string: %s\n", p);
  cleanup_java_env(&ctx);
}
# Build (adjust NDK/ABI)
cmake -DANDROID_PLATFORM=31 \
  -DCMAKE_TOOLCHAIN_FILE=$HOME/Android/Sdk/ndk/26.1.10909125/build/cmake/android.toolchain.cmake \
  -DANDROID_ABI=arm64-v8a ..
make

Common Pitfalls:

  • Constructor addresses change after patching due to re-layout; always recompute from .rela.dyn on the final binary.
  • Ensure -Djava.class.path covers every class used by RegisterNatives calls.
  • Behavior may vary with NDK/loader versions; the consistently reliable step was removing INIT_ARRAY/INIT_ARRAYSZ DYNAMIC tags.

References

Tip

Learn & practice AWS Hacking:HackTricks Training AWS Red Team Expert (ARTE)
Learn & practice GCP Hacking: HackTricks Training GCP Red Team Expert (GRTE)
Learn & practice Az Hacking: HackTricks Training Azure Red Team Expert (AzRTE)

Support HackTricks