Reverse-Engineering nativer Bibliotheken

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, weil ELF shared objects immer noch schwerer zu dekompilieren sind als DEX/OAT-Bytecode. Diese Seite konzentriert sich auf praktische Workflows und neue Werkzeugverbesserungen (2023–2025), die das Reverse-Engineering von Android .so-Dateien erleichtern.


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

  1. Bibliothek extrahieren
# 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
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
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 Auto-Analyse ausführen. Neuere Ghidra-Versionen enthalten einen AArch64-Decompiler, der PAC/BTI-Stubs und MTE-Tags erkennt und die Analyse von Bibliotheken, die mit dem Android 14 NDK erstellt wurden, deutlich verbessert.
  2. Entscheiden zwischen statischem und dynamischem Reversing: gestrippter, obfuskierter Code benötigt oft instrumentation (Frida, ptrace/gdbserver, LLDB).

Dynamische Instrumentierung (Frida ≥ 16)

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

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

Beispiel: Alle über RegisterNatives registrierten Funktionen auflisten und ihre Adressen zur Laufzeit dumpen:

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

Laufzeit-entschlüsselte native Bibliotheken aus dem Speicher dumpen (Frida soSaver)

Wenn eine geschützte APK nativen Code verschlüsselt hält oder ihn nur zur Laufzeit mapped (packers, downloaded payloads, generated libs), dann attach Frida und dump das gemappte ELF direkt aus dem Prozessspeicher.

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

  • Hookt dlopen und android_dlopen_ext, um Load-time-Library-Mappings zu erkennen, und führt einen ersten Sweep bereits geladener Module durch.
  • Scannt periodisch die Prozess-Memory-Mappings nach ELF-Headern, um Module zu erfassen, die über nicht-standardmäßige Mapper geladen wurden und nie die loader APIs aufgerufen haben.
  • Liest jedes Modul blockweise aus dem Speicher und streamt die Bytes über Frida-Messages an den Host; kann ein Bereich nicht gelesen werden, greift es stattdessen auf den on-disk path zu, falls verfügbar.
  • Speichert die rekonstruierten .so-Dateien und gibt pro Modul Extraktionsstatistiken aus, wodurch Artefakte für static RE bereitgestellt werden.

Ausführen (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

Dieser Ansatz umgeht “only decrypted in RAM”-Schutzmechanismen, indem er das live gemappte Image wiederherstellt und so eine Offline-Analyse in IDA/Ghidra ermöglicht, selbst wenn die Dateisystemkopie obfuskiert oder nicht vorhanden ist.

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

Wenn umfassende Instrumentierung übertrieben oder blockiert ist, kann man trotzdem native Sichtbarkeit gewinnen, indem man einen kleinen Logger im Zielprozess vorlädt. SoTap ist eine leichtgewichtige Android native (.so) Bibliothek, die das Laufzeitverhalten anderer JNI (.so) Bibliotheken innerhalb desselben App-Prozesses protokolliert (no root required).

Wesentliche Eigenschaften:

  • Initialisiert früh und beobachtet JNI/native Interaktionen innerhalb des Prozesses, der es lädt.
  • Speichert Logs über mehrere beschreibbare Pfade mit Fallback auf Logcat, wenn der Speicher eingeschränkt ist.
  • An den Quellcode anpassbar: bearbeite sotap.c, um zu erweitern/anzupassen, was protokolliert wird, und baue für jede ABI neu.

Setup (APK neu verpacken):

  1. Lege den Build für die passende ABI in die APK, damit der Loader libsotap.so auflösen kann:
  • lib/arm64-v8a/libsotap.so (für arm64)
  • lib/armeabi-v7a/libsotap.so (für arm32)
  1. Stelle sicher, dass SoTap vor anderen JNI-Libs geladen wird. Füge früh einen Aufruf ein (z. B. im static initializer einer Application-Subklasse oder in onCreate), damit der Logger zuerst initialisiert wird. Smali-Snippet-Beispiel:
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-Pfade (in dieser Reihenfolge geprüft):

/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 Alignment ist obligatorisch. Ein Mismatch führt zu UnsatisfiedLinkError und der Logger wird nicht geladen.
  • Storage-Beschränkungen sind auf modernen Android-Geräten üblich; wenn Dateischreibvorgänge fehlschlagen, gibt SoTap weiterhin via Logcat aus.
  • Verhalten/Verbosity ist zur Anpassung vorgesehen; nach dem Editieren von sotap.c aus dem Source neu bauen.

Dieser Ansatz ist nützlich für Malware-Triage und JNI-Debugging, wenn es wichtig ist, native Call-Flows vom Prozessstart an zu beobachten, aber root-/systemweite Hooks nicht verfügbar sind.


See also: in‑memory native code execution via JNI

Ein gängiges Angriffs-Pattern ist es, zur Laufzeit ein rohes shellcode-Blob herunterzuladen und es direkt aus dem Speicher über eine JNI-Bridge auszuführen (kein On‑Disk ELF). Details und einsatzbereites JNI-Snippet hier:

In Memory Jni Shellcode Execution


Recent vulnerabilities worth hunting for in APKs

JahrCVEBetroffene BibliothekAnmerkungen
2023CVE-2023-4863libwebp ≤ 1.3.1Heap buffer overflow, erreichbar aus native code, der WebP-Bilder decodiert. Mehrere Android-Apps bündeln verwundbare Versionen. Wenn du eine libwebp.so in einem APK siehst, prüfe die Version und versuche exploitation oder patching.
2024MultipleOpenSSL 3.x seriesMehrere memory-safety- und padding-oracle-Probleme. Viele Flutter & ReactNative-Bundles bringen ihr eigenes libcrypto.so mit.

Wenn du third-party .so-Dateien in einem APK entdeckst, überprüfe deren Hash stets gegen upstream advisories. SCA (Software Composition Analysis) ist auf Mobile unüblich, daher sind veraltete verwundbare Builds weit verbreitet.


  • Pointer Authentication (PAC) & Branch Target Identification (BTI): Android 14 aktiviert PAC/BTI in Systembibliotheken auf unterstütztem ARMv8.3+-Silicon. Decompiler zeigen jetzt PAC‑bezogene Pseudo‑Instruktionen; für dynamische Analyse injiziert Frida Trampoline nach dem Entfernen von PAC, aber eigene Trampoline sollten bei Bedarf pacda/autibsp aufrufen.
  • MTE & Scudo hardened allocator: Memory-tagging ist opt-in, aber viele Play-Integrity-aware Apps bauen mit -fsanitize=memtag; nutze setprop arm64.memtag.dump 1 plus adb shell am start ..., um Tag-Faults aufzuzeichnen.
  • LLVM Obfuscator (opaque predicates, control-flow flattening): Kommerzielle Packer (z. B. Bangcle, SecNeo) schützen zunehmend native code, nicht nur Java; erwarte bogus control-flow 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 häufig root/emulator/debug-Checks in nativen Konstruktoren, die extrem früh via .init_array laufen, vor JNI_OnLoad und lange bevor irgendein Java-Code ausgeführt wird. Du kannst diese impliziten Initializer explizit machen und die Kontrolle zurückgewinnen, indem du:

  • INIT_ARRAY/INIT_ARRAYSZ aus der DYNAMIC-Tabelle entfernst, sodass der Loader die .init_array-Einträge nicht automatisch ausführt.
  • Die Constructor-Adresse aus RELATIVE-Relocations auflöst und sie als reguläres Funktionssymbol exportierst (z. B. INIT0).
  • JNI_OnLoad in JNI_OnLoad0 umbenennst, damit ART es nicht implizit aufruft.

Warum das auf Android/arm64 funktioniert

  • Auf AArch64 werden .init_array-Einträge oft zur Ladezeit durch R_AARCH64_RELATIVE-Relocations befüllt, deren Addend die Ziel-Funktionsadresse innerhalb von .text ist.
  • Die Bytes der .init_array können statisch leer aussehen; der dynamic linker schreibt die aufgelöste Adresse während der Relocation-Verarbeitung.

Identifiziere das Ziel des Konstruktors

  • Verwende die Android NDK-Toolchain für präzises ELF-Parsing auf 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
  • Finde die Relocation, die in den virtuellen Adressbereich von .init_array fällt; das addend jener R_AARCH64_RELATIVE ist der Konstruktor (z. B. 0xA34, 0x954).
  • Disassembliere um diese Adresse herum zur Plausibilitätsprüfung:
objdump -D ./libnativestaticinit.so --start-address=0xA34 | head -n 40

Patch-Plan

  1. Entferne die DYNAMIC-Tags INIT_ARRAY und INIT_ARRAYSZ. Lösche nicht die Sections.
  2. Füge ein GLOBAL DEFAULT FUNC-Symbol INIT0 an der Konstruktor-Adresse hinzu, sodass es manuell aufgerufen werden kann.
  3. Benenne JNI_OnLoadJNI_OnLoad0 um, um zu verhindern, dass ART es implizit aufruft.

Validierung nach dem 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 mit LIEF (Python)

Skript: Entfernen von INIT_ARRAY/INIT_ARRAYSZ, Export von INIT0, Umbenennen von JNI_OnLoad→JNI_OnLoad0 ```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’)

</details>

Hinweise und fehlgeschlagene Ansätze (für Portabilität)
- Das Nullsetzen der `.init_array`-Bytes oder das Setzen der Abschnittslänge auf 0 hilft nicht: der dynamische Linker befüllt sie über Relocations erneut.
- Das Setzen von `INIT_ARRAY`/`INIT_ARRAYSZ` auf 0 kann den Loader aufgrund inkonsistenter Tags beschädigen. Sauberes Entfernen dieser DYNAMIC-Einträge ist der verlässliche Hebel.
- Das komplette Entfernen des `.init_array`-Abschnitts führt tendenziell zum Absturz des Loaders.
- Nach dem Patchen können sich Funktions-/Layout-Adressen verschieben; berechne in diesem Fall immer den Konstruktor aus den `.rela.dyn`-Addends in der gepatchten Datei neu, wenn du den Patch erneut ausführen musst.

Bootstrapping eines minimalen ART/JNI, um INIT0 und JNI_OnLoad0 aufzurufen
- Verwende JNIInvocation, um in einer eigenständigen 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/classes in den Classpath ein, damit `RegisterNatives` seine Java-Klassen findet.

<details>
<summary>Minimaler Harness (CMake und C) zum Aufrufen von INIT0 → JNI_OnLoad0 → Java-Methode</summary>
```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)
// 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

Häufige Fallstricke:

  • Constructor-Adressen ändern sich nach dem Patchen durch Neuanordnung; immer von .rela.dyn im finalen Binary neu berechnen.
  • Stellen Sie 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