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.

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