Reversing Native Libraries

Tip

Apprenez et pratiquez le hacking AWS :HackTricks Training AWS Red Team Expert (ARTE)
Apprenez et pratiquez le hacking GCP : HackTricks Training GCP Red Team Expert (GRTE) Apprenez et pratiquez le hacking Azure : HackTricks Training Azure Red Team Expert (AzRTE)

Soutenir HackTricks

Pour plus d’informations, voir : https://maddiestone.github.io/AndroidAppRE/reversing_native_libs.html

Les applications Android peuvent utiliser des bibliothèques natives, généralement écrites en C ou C++, pour des tâches critiques en termes de performance. Les créateurs de malware abusent aussi de ces bibliothèques car les ELF shared objects sont encore plus difficiles à décompiler que le byte-code DEX/OAT. Cette page se concentre sur des workflows pratiques et des améliorations récentes des outils (2023–2025) qui facilitent le reversing des fichiers .so Android.


Procédure de triage rapide pour un libfoo.so fraîchement récupéré

  1. Extraire la bibliothèque
# 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. Identifier l’architecture et les 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. Lister les symboles exportés et les liaisons JNI
readelf -s libfoo.so | grep ' Java_'     # dynamic-linked JNI
strings libfoo.so   | grep -i "RegisterNatives" -n   # static-registered JNI
  1. Charger dans un decompiler (Ghidra ≥ 11.0, IDA Pro, Binary Ninja, Hopper or Cutter/Rizin) et lancer l’auto-analysis. Les versions récentes de Ghidra ont introduit un décompilateur AArch64 qui reconnaît les stubs PAC/BTI et les tags MTE, améliorant grandement l’analyse des bibliothèques construites avec le Android 14 NDK.
  2. Décider entre reversing statique et dynamique : le code stripped/obfuscated nécessite souvent de l’instrumentation (Frida, ptrace/gdbserver, LLDB).

Instrumentation dynamique (Frida ≥ 16)

La série 16 de Frida a apporté plusieurs améliorations spécifiques à Android qui aident quand la cible utilise des optimisations modernes de Clang/LLD :

  • thumb-relocator peut maintenant hook tiny ARM/Thumb functions générées par l’alignement agressif de LLD (--icf=all).
  • L’énumération et la re-liason des ELF import slots fonctionne sur Android, permettant le patching par-module via dlopen()/dlsym() quand les inline hooks sont rejetés.
  • Le hooking Java a été corrigé pour le nouvel ART quick-entrypoint utilisé lorsque les apps sont compilées avec --enable-optimizations sur Android 14.

Exemple : énumérer toutes les fonctions enregistrées via RegisterNatives et dumper leurs adresses à l’exécution :

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 fonctionnera immédiatement sur les appareils compatibles PAC/BTI (Pixel 8/Android 14+) tant que vous utilisez frida-server 16.2 ou ultérieur – les versions antérieures échouaient à localiser le padding pour les inline hooks.

Extraction de bibliothèques natives décryptées à l’exécution depuis la mémoire (Frida soSaver)

Quand un APK protégé conserve du code natif chiffré ou ne le mappe qu’à l’exécution (packers, payloads téléchargés, libs générées), attachez Frida et dumppez l’ELF mappé directement depuis la mémoire du processus.

soSaver workflow (hôte Python + agent Frida TS/JS):

  • Intercepte dlopen et android_dlopen_ext pour détecter le mappage des bibliothèques au chargement et effectue un balayage initial des modules déjà chargés.
  • Scanne périodiquement les mappages mémoire du processus à la recherche d’en-têtes ELF pour attraper les modules chargés via des mappeurs non standard qui n’utilisent jamais les loader APIs.
  • Lit chaque module par blocs depuis la mémoire et stream les octets via des messages Frida vers l’hôte ; si une région ne peut être lue, il retombe sur la lecture depuis le chemin sur disque quand disponible.
  • Sauvegarde les fichiers .so reconstruits et affiche des stats d’extraction par module, fournissant des artefacts pour le static RE.

Exécution (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

Cette approche contourne les protections « only decrypted in RAM » en récupérant l’image mappée en mémoire, permettant une analyse hors-ligne dans IDA/Ghidra même si la copie sur le système de fichiers est obfusquée ou absente.

Télémétrie JNI locale au processus via .so préchargée (SoTap)

Lorsque l’instrumentation complète est excessive ou bloquée, vous pouvez quand même obtenir une visibilité au niveau natif en préchargeant un petit logger dans le processus ciblé. SoTap est une bibliothèque native Android légère (.so) qui enregistre le comportement d’exécution des autres bibliothèques JNI (.so) dans le même processus d’application (sans root requis).

Propriétés clés :

  • S’initialise tôt et observe les interactions JNI/native à l’intérieur du processus qui le charge.
  • Persiste les logs en utilisant plusieurs chemins accessibles en écriture, avec repli sur Logcat lorsque le stockage est restreint.
  • Personnalisable côté source : éditez sotap.c pour étendre/ajuster ce qui est journalisé et recompilez pour chaque ABI.

Configuration (repackager l’APK) :

  1. Déposez le build correspondant à l’ABI dans l’APK pour que le loader puisse résoudre libsotap.so :
  • lib/arm64-v8a/libsotap.so (for arm64)
  • lib/armeabi-v7a/libsotap.so (for arm32)
  1. Assurez-vous que SoTap se charge avant les autres libs JNI. Injectez un appel tôt (par exemple, dans l’initialiseur static d’une sous-classe Application ou dans onCreate) afin que le logger soit initialisé en premier. Exemple de snippet Smali :
const-string v0, "sotap"
invoke-static {v0}, Ljava/lang/System;->loadLibrary(Ljava/lang/String;)V
  1. Reconstruire/signer/installer l’APK, lancer l’app, puis collecter les logs.

Emplacements des logs (vérifiés dans l’ordre) :

/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 et dépannage:

  • 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


Vulnérabilités récentes à rechercher dans les APK

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.

Lorsque vous trouvez des fichiers .so tiers dans un APK, comparez toujours leur hash avec les avis en amont. SCA (Software Composition Analysis) est peu répandue sur mobile, donc des builds vulnérables obsolètes sont fréquents.


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

Les applications fortement protégées placent souvent des vérifications root/emulator/debug dans des constructeurs natifs qui s’exécutent très tôt via .init_array, avant JNI_OnLoad et bien avant que tout code Java ne s’exécute. Vous pouvez rendre ces initialisateurs implicites explicites et reprendre le contrôle en :

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

Pourquoi cela fonctionne sur 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.

Identifier la cible du constructeur

  • 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 après le patch

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

Patch avec LIEF (Python)

Script : supprimer INIT_ARRAY/INIT_ARRAYSZ, exporter INIT0, renommer 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>

Remarques et approches échouées (pour la portabilité)
- Mettre à zéro les octets de `.init_array` ou définir la longueur de la section à 0 n'aide pas : le dynamic linker la repopule via les relocations.
- Définir `INIT_ARRAY`/`INIT_ARRAYSZ` à 0 peut casser le loader en raison de tags incohérents. La suppression propre de ces entrées DYNAMIC est le levier fiable.
- Supprimer entièrement la section `.init_array` a tendance à faire planter le loader.
- Après le patch, les adresses des fonctions/du layout peuvent se décaler ; recalculer toujours le constructeur à partir des addends de `.rela.dyn` sur le fichier patché si vous devez relancer le patch.

Démarrage d'un ART/JNI minimal pour invoquer INIT0 et JNI_OnLoad0
- Utilisez JNIInvocation pour démarrer un petit contexte ART VM dans un binaire autonome. Appelez ensuite `INIT0()` et `JNI_OnLoad0(vm)` manuellement avant tout code Java.
- Incluez l'APK cible / ses classes sur le classpath afin que `RegisterNatives` trouve ses classes Java.

<details>
<summary>Environnement minimal (CMake et C) pour appeler INIT0 → JNI_OnLoad0 → méthode Java</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

Pièges courants :

  • Les adresses des constructeurs changent après le patch à cause du re-layout ; recalculer toujours à partir de .rela.dyn sur le binaire final.
  • S’assurer que -Djava.class.path couvre chaque classe utilisée par les appels RegisterNatives.
  • Le comportement peut varier selon les versions du NDK/loader ; l’étape systématiquement fiable a été de supprimer les tags DYNAMIC INIT_ARRAY/INIT_ARRAYSZ.

Références

Tip

Apprenez et pratiquez le hacking AWS :HackTricks Training AWS Red Team Expert (ARTE)
Apprenez et pratiquez le hacking GCP : HackTricks Training GCP Red Team Expert (GRTE) Apprenez et pratiquez le hacking Azure : HackTricks Training Azure Red Team Expert (AzRTE)

Soutenir HackTricks