Analisi inversa delle librerie native

Reading time: 12 minutes

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 task critici in termini di prestazioni. Anche gli autori di malware abusano di queste librerie perché gli 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 sui miglioramenti degli strumenti recenti (2023-2025) che rendono più semplice l'analisi delle .so Android.


Flusso di triage rapido per una libfoo.so appena estratta

  1. Estrai la libreria
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. Identifica architettura e protezioni
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. Elenca simboli esportati e binding JNI
bash
readelf -s libfoo.so | grep ' Java_'     # dynamic-linked JNI
strings libfoo.so   | grep -i "RegisterNatives" -n   # static-registered JNI
  1. Carica in un decompilatore (Ghidra ≥ 11.0, IDA Pro, Binary Ninja, Hopper or Cutter/Rizin) e avvia l'analisi automatica. Le versioni più recenti di Ghidra hanno introdotto un decompilatore AArch64 che riconosce PAC/BTI stubs e tag MTE, migliorando notevolmente l'analisi delle librerie buildate con l'Android 14 NDK.
  2. Decidi tra reversing statico e dinamico: il codice stripped o offuscato spesso richiede strumentazione (Frida, ptrace/gdbserver, LLDB).

Strumentazione dinamica (Frida ≥ 16)

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

  • thumb-relocator può ora hook tiny ARM/Thumb functions generate dall'aggressivo allineamento di LLD (--icf=all).
  • L'enumerazione e il rebinding degli ELF import slots funziona su Android, permettendo patch per modulo con dlopen()/dlsym() quando gli inline hooks vengono rifiutati.
  • Java hooking è stato risolto per il nuovo ART quick-entrypoint usato quando le app sono compilate con --enable-optimizations su Android 14.

Esempio: enumerare tutte le funzioni registrate tramite RegisterNatives e dumpare i loro indirizzi a runtime:

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 funzionerà immediatamente sui 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 inline hooks.

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

Quando una 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 (.so) leggera che registra il comportamento a runtime di altre librerie JNI (.so) all'interno dello stesso processo dell'app (no root richiesto).

Key properties:

  • Si inizializza precocemente e osserva le interazioni JNI/native all'interno del processo che la carica.
  • Persiste i log usando più percorsi scrivibili con fallback elegante a Logcat quando lo storage è limitato.
  • Personalizzabile a livello di sorgente: modifica sotap.c per estendere/aggiustare ciò che viene registrato e ricompila 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)
  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. Ricompila/firma/installa, avvia l'app e poi 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

Notes and troubleshooting:

  • L'allineamento ABI è obbligatorio. Un disallineamento causerà 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 a emettere output via Logcat.
  • Comportamento/verbosità sono pensati per essere personalizzati; ricompila dalla sorgente dopo aver modificato sotap.c.

Questo approccio è utile per il triage di malware e il debugging JNI quando è cruciale osservare i flussi di chiamate native fin dall'avvio del processo ma non sono disponibili hook a livello di root/sistema.


See also: in‑memory native code execution via JNI

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

In Memory Jni Shellcode Execution


Recent vulnerabilities worth hunting for in APKs

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

Quando individui file .so di terze parti dentro un APK, controlla sempre l'hash rispetto agli advisory upstream. SCA (Software Composition Analysis) è poco comune su mobile, quindi build vulnerabili e datate sono diffuse.


  • Pointer Authentication (PAC) & Branch Target Identification (BTI): Android 14 abilita PAC/BTI nelle librerie di sistema su siliconi ARMv8.3+ supportati. I decompilatori 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 dove necessario.
  • MTE & Scudo hardened allocator: memory-tagging è opzionale ma molte app che usano Play-Integrity sono buildate con -fsanitize=memtag; usa setprop arm64.memtag.dump 1 più adb shell am start ... per catturare fault di tag.
  • LLVM Obfuscator (opaque predicates, control-flow flattening): i commercial packer (es. Bangcle, SecNeo) proteggono sempre più codice nativo, non solo Java; aspettati controllo-flusso fasullo e blob di stringhe criptate in .rodata.

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

App altamente protette spesso collocano controlli di root/emulatore/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 di 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 vengono spesso popolate al load time da relocazioni R_AARCH64_RELATIVE il cui addend è l'indirizzo della funzione target dentro .text.
  • I byte di .init_array possono apparire vuoti staticamente; il dynamic linker scrive l'indirizzo risolto durante il processing delle relocazioni.

Identify the constructor target

  • Usa l'NDK toolchain Android per un parsing ELF accurato su 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
  • Trova la relocazione che atterra all'interno del range di indirizzi virtuali di .init_array; l'addend di quella R_AARCH64_RELATIVE è il constructor (es., 0xA34, 0x954).
  • Disassembla intorno a quell'indirizzo per un controllo di sanità:
bash
objdump -D ./libnativestaticinit.so --start-address=0xA34 | head -n 40

Patch plan

  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 così può essere chiamato manualmente.
  3. Rinominare JNI_OnLoadJNI_OnLoad0 per impedire ad ART di invocarlo implicitamente.

Validation after 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'

Modifica con LIEF (Python)

Script: rimuovere INIT_ARRAY/INIT_ARRAYSZ, esportare INIT0, rinominare 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')

Note e approcci falliti (per portabilità)

  • Azzerare i byte di .init_array o impostare la lunghezza della sezione a 0 non aiuta: il dynamic linker la ripopola via relocations.
  • Impostare INIT_ARRAY/INIT_ARRAYSZ a 0 può rompere il loader a causa di tag inconsistenti. La rimozione pulita di quelle voci DYNAMIC è la leva affidabile.
  • Eliminare completamente la sezione .init_array tende a far crashare il loader.
  • Dopo il patching, gli indirizzi di funzione/layout possono spostarsi; ricomputare sempre il costruttore dagli addendi in .rela.dyn sul file patchato se devi 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 di destinazione nel classpath in modo che qualsiasi RegisterNatives trovi le sue classi Java.
Harness minimale (CMake e C) per chiamare INIT0 → JNI_OnLoad0 → metodo Java
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

Errori 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 includa ogni classe usata dalle chiamate RegisterNatives.
  • Il comportamento può variare con le versioni di NDK/loader; il passaggio 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