Reversing Native Libraries

Tip

Impara e pratica il hacking AWS:HackTricks Training AWS Red Team Expert (ARTE)
Impara e pratica il hacking GCP: HackTricks Training GCP Red Team Expert (GRTE) Impara e pratica il hacking Azure: HackTricks Training Azure Red Team Expert (AzRTE)

Supporta HackTricks

Per ulteriori informazioni consulta: https://maddiestone.github.io/AndroidAppRE/reversing_native_libs.html

Le app Android possono usare librerie native, tipicamente scritte in C o C++, per compiti che richiedono alte prestazioni. I creatori di malware abusano anche di queste librerie perché ELF shared objects sono ancora più difficili da decompilare rispetto al byte-code DEX/OAT. Questa pagina si concentra su flussi di lavoro pratici e recenti miglioramenti degli strumenti (2023-2025) che rendono più semplice reversing dei file .so di Android.


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/
  1. 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)
  1. List exported symbols & JNI bindings
readelf -s libfoo.so | grep ' Java_'     # dynamic-linked JNI
strings libfoo.so   | grep -i "RegisterNatives" -n   # static-registered JNI
  1. 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.
  2. Decide on static vs dynamic reversing: stripped, obfuscated code often needs strumentazione (Frida, ptrace/gdbserver, LLDB).

Dynamic Instrumentation (Frida ≥ 16)

La serie 16 di Frida ha introdotto diversi miglioramenti specifici per Android che aiutano quando il bersaglio usa ottimizzazioni moderne di Clang/LLD:

  • 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.

Esempio: 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 funzionerà immediatamente su dispositivi abilitati PAC/BTI (Pixel 8/Android 14+) purché si utilizzi frida-server 16.2 o versione successiva – le versioni precedenti non riuscivano a individuare il padding per gli inline hooks.

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

Quando un APK protetto mantiene il codice nativo cifrato o lo mappa solo a runtime (packers, downloaded payloads, generated libs), attach Frida e dump l’ELF mappato direttamente dalla memoria del processo.

Flusso di lavoro di soSaver (Python host + TS/JS Frida agent):

  • Collega hook a dlopen e android_dlopen_ext per rilevare la mappatura delle librerie al load-time ed esegue una scansione iniziale dei moduli già caricati.
  • Scansiona periodicamente le mappature della memoria del processo alla ricerca di header ELF per intercettare moduli caricati tramite mapper non standard che non passano mai per le API del loader.
  • Legge ogni modulo a blocchi dalla memoria e streamma i byte attraverso messaggi Frida verso l’host; se una regione non può essere letta, ripiega sulla lettura dal percorso su disco quando disponibile.
  • Salva i file .so ricostruiti e stampa statistiche di estrazione per modulo, fornendo artefatti per la static RE.

Esegui (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

Questo approccio bypassa le protezioni “only decrypted in RAM” recuperando l’immagine mappata in memoria attiva, permettendo l’analisi offline in IDA/Ghidra anche se la copia su filesystem è offuscata o assente.

Telemetria JNI locale al processo tramite .so precaricato (SoTap)

Quando la strumentazione completa è eccessiva o bloccata, puoi comunque ottenere visibilità a livello nativo precaricando un piccolo logger all’interno del processo target. SoTap è una libreria nativa Android leggera (.so) che registra il comportamento a runtime di altre librerie JNI (.so) all’interno dello stesso processo dell’app (non è richiesto root).

Caratteristiche principali:

  • Si inizializza precocemente e osserva le interazioni JNI/native all’interno del processo che lo carica.
  • Persiste i log usando più percorsi scrivibili con fallback a Logcat quando lo storage è limitato.
  • Personalizzabile dal sorgente: modifica sotap.c per estendere/aggiustare ciò che viene registrato e ricompila per ABI.

Setup (ricostruisci l’APK):

  1. Inserisci la build per ABI corretta nell’APK in modo che il loader possa risolvere libsotap.so:
  • lib/arm64-v8a/libsotap.so (per arm64)
  • lib/armeabi-v7a/libsotap.so (per arm32)
  1. Assicurati che SoTap venga caricato prima delle altre librerie JNI. Inietta una chiamata presto (es. static initializer della sottoclasse Application o onCreate) in modo che il logger sia inizializzato per primo. Esempio di snippet Smali:
const-string v0, "sotap"
invoke-static {v0}, Ljava/lang/System;->loadLibrary(Ljava/lang/String;)V
  1. Ricostruisci/firma/installa, esegui l’app, quindi raccogli i log.

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

Note e risoluzione dei problemi:

  • L’allineamento ABI è obbligatorio. Un mismatch solleverà UnsatisfiedLinkError e il logger non verrà caricato.
  • I vincoli di storage sono comuni su Android moderno; se le scritture su file falliscono, SoTap continuerà comunque ad emettere tramite Logcat.
  • Il comportamento/la verbosità sono pensati per essere personalizzati; ricompila dal sorgente dopo aver modificato sotap.c.

Questo approccio è utile per il triage di malware e il debug JNI dove osservare i flussi di chiamata native fin dall’avvio del processo è critico ma non sono disponibili hook root/system-wide.


Vedi anche: esecuzione di codice nativo in memoria via JNI

Un pattern d’attacco comune è scaricare un blob di shellcode raw a runtime ed eseguirlo direttamente dalla memoria tramite un bridge JNI (nessun ELF su disco). Dettagli e snippet JNI pronti all’uso qui:

In Memory Jni Shellcode Execution


Vulnerabilità recenti da cercare negli APK

AnnoCVELibreria interessataNote
2023CVE-2023-4863libwebp ≤ 1.3.1Heap buffer overflow raggiungibile dal codice nativo che decodifica immagini WebP. Diverse app Android includono versioni vulnerabili. Quando trovi un libwebp.so dentro un APK, verifica la sua versione e prova a sfruttarla o a patcharla.
2024MultipleOpenSSL 3.x seriesDiverse vulnerabilità di memory-safety e padding-oracle. Molti bundle Flutter & ReactNative includono il proprio libcrypto.so.

Quando noti dei file .so third-party dentro un APK, controlla sempre il loro hash rispetto agli advisories upstream. SCA (Software Composition Analysis) è poco comune su mobile, quindi build vulnerabili e obsolete sono diffuse.


  • Pointer Authentication (PAC) & Branch Target Identification (BTI): Android 14 abilita PAC/BTI nelle librerie di sistema sui silici ARMv8.3+ supportati. I decompiler ora mostrano pseudo-istruzioni correlate a PAC; per l’analisi dinamica Frida inietta trampolini dopo aver rimosso PAC, ma i tuoi trampolini custom dovrebbero chiamare pacda/autibsp quando necessario.
  • MTE & Scudo hardened allocator: memory-tagging è opt-in ma molte app attente al Play-Integrity compilano con -fsanitize=memtag; usa setprop arm64.memtag.dump 1 più adb shell am start ... per catturare i fault di tag.
  • LLVM Obfuscator (opaque predicates, control-flow flattening): packer commerciali (es., Bangcle, SecNeo) proteggono sempre più il codice native, non solo Java; aspettati control-flow fasullo e blob di stringhe crittate in .rodata.

Neutralizzare gli initializer nativi precoci (.init_array) e JNI_OnLoad per strumenti di instrumentation precoce (ARM64 ELF)

Le app altamente protette spesso inseriscono controlli per root/emulator/debug nei constructor nativi che vengono eseguiti molto presto tramite .init_array, prima di JNI_OnLoad e molto prima che qualsiasi codice Java venga eseguito. Puoi rendere quegli initializer impliciti espliciti e riprendere il controllo:

  • Rimuovere INIT_ARRAY/INIT_ARRAYSZ dalla tabella DYNAMIC in modo che il loader non esegua automaticamente le entry di .init_array.
  • Risolvere l’indirizzo del constructor dalle relocazioni RELATIVE ed esportarlo come simbolo funzione regolare (es., INIT0).
  • Rinominare JNI_OnLoad in JNI_OnLoad0 per impedire ad ART di chiamarlo implicitamente.

Perché questo funziona su Android/arm64

  • Su AArch64, le entry di .init_array sono spesso compilate a load time tramite relocazioni R_AARCH64_RELATIVE il cui addend è l’indirizzo della funzione target dentro .text.
  • I byte di .init_array possono sembrare vuoti staticamente; il dynamic linker scrive l’indirizzo risolto durante la fase di processing delle relocazioni.

Identificare il target del constructor

  • Usa toolchain Android NDK per un’accurata parsing ELF su 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
  • Trova la relocation che ricade nell’intervallo di indirizzi virtuali di .init_array; l’addend di quel R_AARCH64_RELATIVE è il constructor (es., 0xA34, 0x954).
  • Disassembla intorno a quell’indirizzo per un controllo di sanità:
objdump -D ./libnativestaticinit.so --start-address=0xA34 | head -n 40

Piano di patch

  1. Rimuovere i tag DYNAMIC INIT_ARRAY e INIT_ARRAYSZ. Non cancellare sezioni.
  2. Aggiungere un simbolo GLOBAL DEFAULT FUNC INIT0 all’indirizzo del constructor in modo che possa essere chiamato manualmente.
  3. Rinominare JNI_OnLoadJNI_OnLoad0 per impedire che ART lo invochi implicitamente.

Validazione dopo la 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 con LIEF (Python)

Script: remove INIT_ARRAY/INIT_ARRAYSZ, export INIT0, rename 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>

Note e approcci falliti (per portabilità)
- Azzerare i byte di `.init_array` o impostare la lunghezza della sezione a 0 non aiuta: the dynamic linker la ripopola tramite relocations.
- Impostare `INIT_ARRAY`/`INIT_ARRAYSZ` a 0 può rompere il loader a causa di tag incoerenti. La rimozione pulita di quelle entry DYNAMIC è la leva affidabile.
- Cancellare completamente la sezione `.init_array` tende a far crashare il loader.
- Dopo il patching, gli indirizzi di funzione/layout possono spostarsi; ricalcola sempre il constructor dagli addendi in `.rela.dyn` sul file patchato se hai bisogno di rieseguire la patch.

Bootstrap di un ART/JNI minimale per invocare INIT0 e JNI_OnLoad0
- Usa JNIInvocation per avviare un piccolo contesto ART VM in un binario standalone. Poi chiama `INIT0()` e `JNI_OnLoad0(vm)` manualmente prima di qualsiasi codice Java.
- Includi l'APK/classes target nel classpath così che qualsiasi `RegisterNatives` trovi le sue classi Java.

<details>
<summary>Minimal harness (CMake and C) to call INIT0 → JNI_OnLoad0 → Java method</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

Insidie comuni:

  • Gli indirizzi dei Constructor cambiano dopo il patching a causa del re-layout; ricalcolare sempre da .rela.dyn sul binario finale.
  • Assicurarsi che -Djava.class.path copra ogni classe usata dalle chiamate RegisterNatives.
  • Il comportamento può variare con le versioni di NDK/loader; il passo costantemente affidabile è stato rimuovere i tag DYNAMIC INIT_ARRAY/INIT_ARRAYSZ.

Riferimenti

Tip

Impara e pratica il hacking AWS:HackTricks Training AWS Red Team Expert (ARTE)
Impara e pratica il hacking GCP: HackTricks Training GCP Red Team Expert (GRTE) Impara e pratica il hacking Azure: HackTricks Training Azure Red Team Expert (AzRTE)

Supporta HackTricks