Reverse Engineering nativer Bibliotheken

Reading time: 12 minutes

tip

Lernen & üben Sie AWS Hacking:HackTricks Training AWS Red Team Expert (ARTE)
Lernen & üben Sie GCP Hacking: HackTricks Training GCP Red Team Expert (GRTE) Lernen & üben Sie Azure Hacking: HackTricks Training Azure Red Team Expert (AzRTE)

Unterstützen Sie HackTricks

Für weitere Informationen siehe: https://maddiestone.github.io/AndroidAppRE/reversing_native_libs.html

Android-Apps können native Bibliotheken verwenden, typischerweise in C oder C++ geschrieben, für leistungskritische Aufgaben. Malware-Autoren missbrauchen diese Bibliotheken ebenfalls, da ELF shared objects noch immer schwerer zu dekompilieren sind als DEX/OAT-Bytecode. Diese Seite konzentriert sich auf praktische Workflows und kürzliche Verbesserungen von Tools (2023–2025), die das Reverse Engineering von Android .so-Dateien erleichtern.


Schneller Triage-Workflow für eine frisch extrahierte libfoo.so

  1. Bibliothek extrahieren
bash
# 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/
  1. Architektur & Schutzmechanismen identifizieren
bash
file libfoo.so        # arm64 or arm32 / x86
readelf -h libfoo.so  # OS ABI, PIE, NX, RELRO, etc.
checksec --file libfoo.so  # (peda/pwntools)
  1. Exportierte Symbole & JNI-Bindings auflisten
bash
readelf -s libfoo.so | grep ' Java_'     # dynamic-linked JNI
strings libfoo.so   | grep -i "RegisterNatives" -n   # static-registered JNI
  1. In einen Decompiler laden (Ghidra ≥ 11.0, IDA Pro, Binary Ninja, Hopper or Cutter/Rizin) und die Auto-Analyse ausführen. Neuere Ghidra-Versionen haben einen AArch64-Decompiler eingeführt, der PAC/BTI-Stubs und MTE-Tags erkennt und damit die Analyse von Bibliotheken, die mit dem Android 14 NDK gebaut wurden, deutlich verbessert.
  2. Entscheidung zwischen statischem und dynamischem Reverse Engineering: gestrippten, obfuskierten Code benötigt häufig Instrumentierung (Frida, ptrace/gdbserver, LLDB).

Dynamische Instrumentierung (Frida ≥ 16)

Die 16er-Serie von Frida brachte mehrere Android-spezifische Verbesserungen, die helfen, wenn das Ziel moderne Clang/LLD-Optimierungen verwendet:

  • thumb-relocator kann jetzt winzige ARM/Thumb-Funktionen hooken, die durch LLDs aggressive Alignment (--icf=all) erzeugt werden.
  • Das Auflisten und Neuverbinden von ELF import slots funktioniert auf Android, was pro Modul dlopen()/dlsym()-Patching ermöglicht, wenn Inline-Hooks abgelehnt werden.
  • Java-Hooking wurde für den neuen ART quick-entrypoint behoben, der verwendet wird, wenn Apps mit --enable-optimizations auf Android 14 kompiliert werden.

Beispiel: Auflisten aller Funktionen, die über RegisterNatives registriert wurden, und Dumpen ihrer Adressen zur Laufzeit:

javascript
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 funktioniert auf PAC/BTI-aktivierten Geräten (Pixel 8/Android 14+) sofort, solange du frida-server 16.2 oder neuer verwendest – frühere Versionen konnten das Padding für inline hooks nicht finden.

Prozess-lokale JNI-Telemetrie über vorab geladene .so (SoTap)

Wenn voll ausgestattete Instrumentierung übertrieben ist oder blockiert wird, kannst du trotzdem native Sichtbarkeit gewinnen, indem du einen kleinen Logger im Zielprozess vorlädst. SoTap ist eine leichte Android native (.so) Bibliothek, die das Laufzeitverhalten anderer JNI (.so) Bibliotheken innerhalb desselben App-Prozesses protokolliert (kein root erforderlich).

Wesentliche Eigenschaften:

  • Initialisiert früh und beobachtet JNI/native-Interaktionen innerhalb des Prozesses, der es lädt.
  • Persistiert Logs mit mehreren beschreibbaren Pfaden und fällt bei eingeschränktem Speicher sanft auf Logcat zurück.
  • Quellcode-anpassbar: Bearbeite sotap.c, um zu erweitern/anpassen, was protokolliert wird, und baue pro ABI neu.

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 (für arm64)
  • lib/armeabi-v7a/libsotap.so (für arm32)
  1. 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:
smali
const-string v0, "sotap"
invoke-static {v0}, Ljava/lang/System;->loadLibrary(Ljava/lang/String;)V
  1. 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

Hinweise und Fehlerbehebung:

  • ABI-Ausrichtung ist zwingend erforderlich. Ein Mismatch führt zu UnsatisfiedLinkError und der Logger wird nicht geladen.
  • Speicherbeschränkungen sind bei modernen Android-Geräten üblich; falls Dateischreibvorgänge fehlschlagen, gibt SoTap weiterhin über Logcat aus.
  • Verhalten und Ausführlichkeit sind zur Anpassung vorgesehen; nach dem Bearbeiten von sotap.c aus dem Quellcode neu bauen.

Dieser Ansatz ist nützlich für malware triage und JNI debugging, wenn das Beobachten nativer Aufrufabläufe vom Prozessstart an kritisch ist, aber keine root-/systemweiten Hooks verfügbar sind.


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

JahrCVEBetroffene BibliothekAnmerkungen
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.

Wenn Sie third-party .so-Dateien in einem APK entdecken, gleichen Sie deren Hash immer mit upstream advisories ab. SCA (Software Composition Analysis) ist auf Mobile selten, daher sind veraltete, verwundbare Builds weit verbreitet.


  • Pointer Authentication (PAC) & Branch Target Identification (BTI): Android 14 aktiviert PAC/BTI in Systembibliotheken auf unterstützter ARMv8.3+ Hardware. Decompiler zeigen nun PAC‑bezogene Pseudo‑Instruktionen; für dynamische Analyse injiziert Frida Trampolines nach dem Entfernen von PAC, aber eigene Trampolines sollten bei Bedarf pacda/autibsp aufrufen.
  • MTE & Scudo hardened allocator: memory-tagging ist optional, aber viele Play-Integrity-bewusste Apps werden mit -fsanitize=memtag gebaut; verwenden Sie setprop arm64.memtag.dump 1 plus adb shell am start ..., um Tag-Fehler zu erfassen.
  • LLVM Obfuscator (opaque predicates, control-flow flattening): kommerzielle packer (z. B. Bangcle, SecNeo) schützen zunehmend native code, nicht nur Java; erwarten Sie gefälschte Kontrollflüsse und verschlüsselte String‑Blobs in .rodata.

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

Stark geschützte Apps platzieren oft root-/emulator-/debug-Prüfungen in nativen Konstruktoren, die sehr früh über .init_array ausgeführt werden, vor JNI_OnLoad und lange bevor Java-Code läuft. Sie können diese impliziten Initialisierer explizit machen und Kontrolle zurückgewinnen durch:

  • Entfernen von INIT_ARRAY/INIT_ARRAYSZ aus der DYNAMIC-Tabelle, damit der Loader .init_array-Einträge nicht automatisch ausführt.
  • Auflösen der Konstruktoradresse aus RELATIVE-Relokationen und Exportieren als reguläres Funktionssymbol (z. B. INIT0).
  • Umbenennen von JNI_OnLoad in JNI_OnLoad0, um zu verhindern, dass ART es implizit aufruft.

Warum das auf Android/arm64 funktioniert

  • Auf AArch64 werden .init_array-Einträge häufig zur Ladezeit durch R_AARCH64_RELATIVE-Relokationen befüllt, deren addend die Ziel-Funktionsadresse im .text ist.
  • Die Bytes von .init_array können statisch leer aussehen; der dynamic linker schreibt die aufgelöste Adresse während der Relokationsverarbeitung hinein.

Konstruktorziel identifizieren

  • Verwenden Sie die Android NDK-Toolchain für genaue ELF-Parsing auf AArch64:
bash
# 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
  • Finden Sie die Relokation, die in den virtuellen Adressbereich von .init_array fällt; das addend der R_AARCH64_RELATIVE ist der Konstruktor (z. B. 0xA34, 0x954).
  • Disassemblieren Sie um diese Adresse herum zur Plausibilitätsprüfung:
bash
objdump -D ./libnativestaticinit.so --start-address=0xA34 | head -n 40

Patch-Plan

  1. Entfernen Sie die DYNAMIC-Tags INIT_ARRAY und INIT_ARRAYSZ. Löschen Sie dabei nicht die Sections.
  2. Fügen Sie ein GLOBAL DEFAULT FUNC-Symbol INIT0 an der Konstruktoradresse hinzu, damit es manuell aufgerufen werden kann.
  3. Benennen Sie JNI_OnLoadJNI_OnLoad0 um, damit ART es nicht mehr implizit aufruft.

Validierung nach dem Patch

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

Patchen mit LIEF (Python)

Skript: entferne INIT_ARRAY/INIT_ARRAYSZ, exportiere INIT0, benenne JNI_OnLoad→JNI_OnLoad0 um
python
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')

Anmerkungen und fehlgeschlagene Ansätze (zur Portabilität)

  • Das Nullsetzen der .init_array-Bytes oder das Setzen der Sektionslänge auf 0 hilft nicht: der dynamic linker füllt sie über relocations wieder auf.
  • Das Setzen von INIT_ARRAY/INIT_ARRAYSZ auf 0 kann den loader durch inkonsistente Tags beschädigen. Sauberes Entfernen dieser DYNAMIC-Einträge ist der zuverlässige Hebel.
  • Das vollständige Löschen der .init_array-Sektion führt häufig zum Absturz des loaders.
  • Nach dem Patchen können sich Funktions-/Layout-Adressen verschieben; berechne bei Bedarf den Konstruktor immer neu aus den .rela.dyn-addends in der gepatchten Datei, wenn du den Patch erneut ausführen musst.

Bootstrapping eines minimalen ART/JNI, um INIT0 und JNI_OnLoad0 aufzurufen

  • Verwende JNIInvocation, um in einem standalone Binary einen kleinen ART-VM-Kontext zu starten. Rufe dann INIT0() und JNI_OnLoad0(vm) manuell auf, bevor irgendein Java-Code ausgeführt wird.
  • Füge das Ziel-APK/die Klassen in den classpath ein, damit RegisterNatives die Java-Klassen findet.
Minimales Testgerüst (CMake und C), um INIT0 → JNI_OnLoad0 → Java-Methode aufzurufen
cmake
# 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)
c
// 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);
}
bash
# 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

Häufige Fallstricke:

  • Konstruktoradressen ändern sich nach dem Patchen aufgrund einer Neu-Anordnung; immer aus .rela.dyn der finalen Binärdatei neu berechnen.
  • Stelle sicher, dass -Djava.class.path jede Klasse abdeckt, die von RegisterNatives-Aufrufen verwendet wird.
  • Das Verhalten kann je nach NDK/Loader-Version variieren; der durchgehend zuverlässige Schritt war das Entfernen der INIT_ARRAY/INIT_ARRAYSZ DYNAMIC-Tags.

Referenzen

tip

Lernen & üben Sie AWS Hacking:HackTricks Training AWS Red Team Expert (ARTE)
Lernen & üben Sie GCP Hacking: HackTricks Training GCP Red Team Expert (GRTE) Lernen & üben Sie Azure Hacking: HackTricks Training Azure Red Team Expert (AzRTE)

Unterstützen Sie HackTricks