Reversión de bibliotecas nativas

Tip

Aprende y practica Hacking en AWS:HackTricks Training AWS Red Team Expert (ARTE)
Aprende y practica Hacking en GCP: HackTricks Training GCP Red Team Expert (GRTE) Aprende y practica Hacking en Azure: HackTricks Training Azure Red Team Expert (AzRTE)

Apoya a HackTricks

For further information check: https://maddiestone.github.io/AndroidAppRE/reversing_native_libs.html

Android apps can use native libraries, typically written in C or C++, for performance-critical tasks. Malware creators also abuse these libraries because ELF shared objects are still harder to decompile than DEX/OAT byte-code. This page focuses on practical workflows and recent tooling improvements (2023-2025) that make reversing Android .so files easier.


Flujo rápido de triaje para una libfoo.so recién extraída

  1. Extraer la biblioteca
# 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. Identificar arquitectura y protecciones
file libfoo.so        # arm64 or arm32 / x86
readelf -h libfoo.so  # OS ABI, PIE, NX, RELRO, etc.
checksec --file libfoo.so  # (peda/pwntools)
  1. Listar símbolos exportados y bindings JNI
readelf -s libfoo.so | grep ' Java_'     # dynamic-linked JNI
strings libfoo.so   | grep -i "RegisterNatives" -n   # static-registered JNI
  1. Cargar en un decompiler (Ghidra ≥ 11.0, IDA Pro, Binary Ninja, Hopper or Cutter/Rizin) y ejecutar el análisis automático. Las versiones más recientes de Ghidra incluyen un decompiler AArch64 que reconoce PAC/BTI stubs y MTE tags, mejorando considerablemente el análisis de bibliotecas compiladas con el NDK de Android 14.
  2. Decidir entre reversing estático vs dinámico: el código stripped u ofuscado a menudo necesita instrumentation (Frida, ptrace/gdbserver, LLDB).

Dynamic Instrumentation (Frida ≥ 16)

Frida’s 16-series brought several Android-specific improvements that help when the target uses modern Clang/LLD optimisations:

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

Example: 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 will work out of the box on PAC/BTI-enabled devices (Pixel 8/Android 14+) as long as you use frida-server 16.2 or later – earlier versions failed to locate padding for inline hooks.

Volcado de bibliotecas nativas desencriptadas en tiempo de ejecución desde memoria (Frida soSaver)

When a protected APK keeps native code encrypted or only maps it at runtime (packers, downloaded payloads, generated libs), attach Frida and dump the mapped ELF directly from process memory.

Flujo de trabajo de soSaver (Python host + TS/JS Frida agent):

  • Hooks dlopen and android_dlopen_ext to detect load-time library mapping and performs an initial sweep of already loaded modules.
  • Periodically scans the process memory mappings for ELF headers to catch modules loaded through non-standard mappers that never hit the loader APIs.
  • Reads each module in blocks from memory and streams the bytes through Frida messages to the host; if a region cannot be read, it falls back to reading from the on-disk path when available.
  • Saves the reconstructed .so files and prints per-module extraction stats, providing artifacts for static RE.

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

Este enfoque elude las protecciones de “only decrypted in RAM” recuperando la imagen mapeada en vivo, lo que permite el análisis offline en IDA/Ghidra incluso si la copia en el sistema de archivos está ofuscada o ausente.

Telemetría JNI local al proceso mediante .so precargado (SoTap)

Cuando la instrumentación completa es exagerada o está bloqueada, aún puedes obtener visibilidad a nivel nativo precargando un pequeño logger dentro del proceso objetivo. SoTap es una biblioteca nativa ligera de Android (.so) que registra el comportamiento en tiempo de ejecución de otras bibliotecas JNI (.so) dentro del mismo proceso de la app (no root required).

Key properties:

  • Se inicializa temprano y observa las interacciones JNI/nativas dentro del proceso que lo carga.
  • Persiste logs usando varias rutas escribibles con retroceso a Logcat cuando el almacenamiento está restringido.
  • Personalizable en el código fuente: edita sotap.c para ampliar/ajustar lo que se registra y recompílalo por ABI.

Setup (repack the APK):

  1. Coloca la build correspondiente al ABI dentro del APK para que el loader pueda resolver libsotap.so:
  • lib/arm64-v8a/libsotap.so (para arm64)
  • lib/armeabi-v7a/libsotap.so (para arm32)
  1. Asegúrate de que SoTap se cargue antes que otras libs JNI. Inyecta una llamada temprano (p. ej., en el inicializador estático de la subclase Application o en onCreate) para que el logger se inicialice primero. Ejemplo de snippet Smali:
const-string v0, "sotap"
invoke-static {v0}, Ljava/lang/System;->loadLibrary(Ljava/lang/String;)V
  1. Reconstruir/firmar/instalar, ejecutar la app y luego recopilar logs.

Rutas de logs (comprobadas en este orden):

/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

Notas y solución de problemas:

  • La alineación del ABI es obligatoria. Un desajuste provocará UnsatisfiedLinkError y el logger no se cargará.
  • Las limitaciones de almacenamiento son comunes en Android moderno; si las escrituras de archivo fallan, SoTap seguirá emitiendo a través de Logcat.
  • El comportamiento/verbosidad está pensado para personalizarse; recompila desde la fuente tras editar sotap.c.

Este enfoque es útil para el triage de malware y la depuración JNI cuando observar los flujos de llamadas nativas desde el inicio del proceso es crítico pero no hay hooks a nivel de root/sistema disponibles.


Ver también: ejecución de código nativo en memoria vía JNI

Un patrón de ataque común es descargar un blob de shellcode en bruto en tiempo de ejecución y ejecutarlo directamente desde la memoria a través de un puente JNI (sin ELF en disco). Detalles y un fragmento JNI listo para usar aquí:

In Memory Jni Shellcode Execution


Vulnerabilidades recientes que vale la pena buscar en APKs

AñoCVELibrería afectadaNotas
2023CVE-2023-4863libwebp ≤ 1.3.1Desbordamiento de heap accesible desde código nativo que decodifica imágenes WebP. Varias apps Android incluyen versiones vulnerables. Cuando veas un libwebp.so dentro de un APK, comprueba su versión e intenta explotarlo o parchearlo.
2024MultipleOpenSSL 3.x seriesVarios problemas de seguridad de memoria y padding-oracle. Muchos bundles de Flutter & ReactNative incluyen su propio libcrypto.so.

Cuando encuentres archivos .so de terceros dentro de un APK, siempre compara su hash con los avisos upstream. SCA (Software Composition Analysis) es poco común en móvil, por lo que las compilaciones vulnerables y desactualizadas son frecuentes.


Tendencias de Anti-Reversing & Hardening (Android 13-15)

  • Pointer Authentication (PAC) & Branch Target Identification (BTI): Android 14 habilita PAC/BTI en las librerías del sistema en siliconas ARMv8.3+ compatibles. Los decompiladores ahora muestran pseudo-instrucciones relacionadas con PAC; para análisis dinámico, Frida inyecta trampolines después de eliminar PAC, pero tus trampolines personalizados deberían llamar a pacda/autibsp cuando sea necesario.
  • MTE & Scudo hardened allocator: El memory-tagging es opt-in pero muchas apps concienciadas con Play-Integrity se compilan con -fsanitize=memtag; usa setprop arm64.memtag.dump 1 más adb shell am start ... para capturar fallos de tags.
  • LLVM Obfuscator (opaque predicates, control-flow flattening): Los packers comerciales (p. ej., Bangcle, SecNeo) protegen cada vez más el código nativo, no solo Java; espera flujos de control falsos y blobs de cadenas encriptadas en .rodata.

Neutralizar inicializadores nativos tempranos (.init_array) y JNI_OnLoad para instrumentación temprana (ARM64 ELF)

Apps altamente protegidas a menudo colocan comprobaciones de root/emulador/debug en constructores nativos que se ejecutan muy temprano vía .init_array, antes de JNI_OnLoad y mucho antes de que cualquier código Java se ejecute. Puedes convertir esos inicializadores implícitos en explícitos y recuperar el control mediante:

  • Eliminar INIT_ARRAY/INIT_ARRAYSZ de la tabla DYNAMIC para que el loader no auto-ejecute las entradas de .init_array.
  • Resolver la dirección del constructor a partir de relocaciones RELATIVE y exportarla como un símbolo de función normal (p. ej., INIT0).
  • Renombrar JNI_OnLoad a JNI_OnLoad0 para evitar que ART lo invoque implícitamente.

Por qué esto funciona en Android/arm64

  • En AArch64, las entradas de .init_array a menudo se rellenan en tiempo de carga por relocaciones R_AARCH64_RELATIVE cuyo addend es la dirección de la función objetivo dentro de .text.
  • Los bytes de .init_array pueden parecer vacíos estáticamente; el linker dinámico escribe la dirección resuelta durante el procesamiento de relocaciones.

Identificar el objetivo del constructor

  • Usa la toolchain del Android NDK para un parseo preciso de ELF en AArch64:
# Ajusta las rutas a tu NDK; usa las variantes aarch64-linux-android-*
readelf -W -a ./libnativestaticinit.so | grep -n "INIT_ARRAY" -C 4
readelf -W --relocs ./libnativestaticinit.so
  • Encuentra la relocación que cae dentro del rango de direcciones virtuales de .init_array; el addend de esa R_AARCH64_RELATIVE es el constructor (p. ej., 0xA34, 0x954).
  • Desensamblar alrededor de esa dirección para comprobar:
objdump -D ./libnativestaticinit.so --start-address=0xA34 | head -n 40

Plan de parche

  1. Eliminar los tags DYNAMIC INIT_ARRAY y INIT_ARRAYSZ. No borres secciones.
  2. Añadir un símbolo GLOBAL DEFAULT FUNC INIT0 en la dirección del constructor para que pueda llamarse manualmente.
  3. Renombrar JNI_OnLoadJNI_OnLoad0 para evitar que ART lo invoque implícitamente.

Validación después del parche

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

Parcheo con LIEF (Python)

Script: eliminar INIT_ARRAY/INIT_ARRAYSZ, exportar INIT0, renombrar 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>

Notas y enfoques fallidos (para portabilidad)
- Poner a cero los bytes de `.init_array` o ajustar la longitud de la sección a 0 no ayuda: el enlazador dinámico la repuebla mediante relocations.
- Establecer `INIT_ARRAY`/`INIT_ARRAYSZ` a 0 puede romper el cargador debido a etiquetas inconsistentes. La eliminación limpia de esas entradas DYNAMIC es la palanca fiable.
- Eliminar completamente la sección `.init_array` suele provocar que el cargador se bloquee.
- Después de parchear, las direcciones de funciones/layout pueden desplazarse; siempre recalcula el constructor a partir de los addends de `.rela.dyn` en el archivo parcheado si necesitas volver a aplicar el parche.

Arranque de un ART/JNI mínimo para invocar INIT0 y JNI_OnLoad0
- Usa JNIInvocation para iniciar un pequeño contexto ART VM en un binario independiente. Luego llama a `INIT0()` y `JNI_OnLoad0(vm)` manualmente antes de cualquier código Java.
- Incluye el APK/las clases objetivo en el classpath para que cualquier `RegisterNatives` encuentre sus clases Java.

<details>
<summary>Arnés mínimo (CMake and C) para llamar a INIT0 → JNI_OnLoad0 → método 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

Errores comunes:

  • Las direcciones de los constructores cambian después del parcheo debido al re-layout; siempre recalcula desde .rela.dyn en el binario final.
  • Asegúrate de que -Djava.class.path cubra todas las clases utilizadas por las llamadas a RegisterNatives.
  • El comportamiento puede variar según las versiones de NDK/loader; el paso consistentemente fiable fue eliminar las etiquetas DYNAMIC INIT_ARRAY/INIT_ARRAYSZ.

Referencias

Tip

Aprende y practica Hacking en AWS:HackTricks Training AWS Red Team Expert (ARTE)
Aprende y practica Hacking en GCP: HackTricks Training GCP Red Team Expert (GRTE) Aprende y practica Hacking en Azure: HackTricks Training Azure Red Team Expert (AzRTE)

Apoya a HackTricks