Análisis inverso de bibliotecas nativas

Reading time: 13 minutes

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

Las apps de Android pueden usar bibliotecas nativas, típicamente escritas en C o C++, para tareas críticas de rendimiento. Los creadores de malware también abusan de estas bibliotecas porque los objetos compartidos ELF siguen siendo más difíciles de descompilar que el byte-code DEX/OAT. Esta página se centra en flujos de trabajo prácticos y en mejoras recientes de herramientas (2023-2025) que facilitan el reversing de archivos .so en Android.


Flujo de triage rápido para un libfoo.so recién extraído

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

Instrumentación dinámica (Frida ≥ 16)

La serie 16 de Frida trajo varias mejoras específicas para Android que ayudan cuando el objetivo usa optimizaciones modernas de Clang/LLD:

  • thumb-relocator ahora puede hook tiny ARM/Thumb functions generadas por la alineación agresiva de LLD (--icf=all).
  • La enumeración y rebinding de ELF import slots funciona en Android, permitiendo parchear por módulo con dlopen()/dlsym() cuando los inline hooks son rechazados.
  • Se corrigió el Java hooking para el nuevo ART quick-entrypoint usado cuando las apps se compilan con --enable-optimizations en Android 14.

Example: enumerating all functions registered through RegisterNatives and dumping their addresses at 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 funcionará de forma inmediata en dispositivos con PAC/BTI (Pixel 8/Android 14+) siempre que uses frida-server 16.2 o posterior — versiones anteriores no podían localizar padding para inline hooks.

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 librería nativa ligera de Android (.so) que registra el comportamiento en tiempo de ejecución de otras librerías JNI (.so) dentro del mismo proceso de la app (no requiere root).

Propiedades clave:

  • Se inicializa temprano y observa las interacciones JNI/native dentro del proceso que la carga.
  • Persiste logs usando múltiples rutas escribibles con fallback elegante a Logcat cuando el almacenamiento está restringido.
  • Personalizable desde el código fuente: edita sotap.c para extender/ajustar lo que se registra y recompila por ABI.

Configuración (reempacar el APK):

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

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

Notas y resolución de problemas:

  • El alineamiento de ABI es obligatorio. Un desajuste lanzará UnsatisfiedLinkError y el logger no se cargará.
  • Las restricciones de almacenamiento son comunes en Android moderno; si las escrituras a archivo fallan, SoTap seguirá emitiendo vía Logcat.
  • El comportamiento/verbosidad está pensado para personalizarse; recompila desde la fuente después de editar sotap.c.

Este enfoque es útil para triage de malware y depuración JNI donde observar los flujos de llamadas nativas desde el inicio del proceso es crítico pero no hay hooks a nivel 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 snippet 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.1Heap buffer overflow reachable from native code that decodes WebP images. Varias apps Android integran 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 verifica su hash contra 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 y Hardenización (Android 13-15)

  • Pointer Authentication (PAC) & Branch Target Identification (BTI): Android 14 habilita PAC/BTI en las librerías del sistema en silicio ARMv8.3+ con soporte. Los decompiladores ahora muestran pseudo-instrucciones relacionadas con PAC; para análisis dinámico Frida inyecta trampolines después de quitar 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 conscientes de Play-Integrity compilan con -fsanitize=memtag; usa setprop arm64.memtag.dump 1 junto con adb shell am start ... para capturar tag faults.
  • 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 control-flow falso y blobs de strings encriptados en .rodata.

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

Las apps altamente protegidas a menudo colocan comprobaciones de root/emulator/debug en constructores nativos que se ejecutan extremadamente pronto vía .init_array, antes de JNI_OnLoad y mucho antes de que se ejecute cualquier código Java. Puedes hacer esos inicializadores implícitos explícitos y recuperar el control mediante:

  • Eliminar INIT_ARRAY/INIT_ARRAYSZ de la tabla DYNAMIC para que el loader no ejecute automáticamente las entradas de .init_array.
  • Resolver la dirección del constructor desde relocations RELATIVE y exportarla como un símbolo de función regular (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 relocations 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 relocations.

Identificar el objetivo del constructor

  • Usa el toolchain del Android NDK para un parseo ELF preciso en 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
  • Encuentra la relocation que cae dentro del rango de direcciones virtuales de .init_array; el addend de ese R_AARCH64_RELATIVE es el constructor (p. ej., 0xA34, 0x954).
  • Desensambla alrededor de esa dirección para comprobar:
bash
objdump -D ./libnativestaticinit.so --start-address=0xA34 | head -n 40

Plan de parche

  1. Elimina las tags DYNAMIC INIT_ARRAY y INIT_ARRAYSZ. No borres secciones.
  2. Añade un símbolo GLOBAL DEFAULT FUNC INIT0 en la dirección del constructor para que pueda ser llamado manualmente.
  3. Renombra JNI_OnLoadJNI_OnLoad0 para impedir que ART lo invoque implícitamente.

Validación después del parche

bash
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')

Notas y enfoques fallidos (por portabilidad)

  • Poner a cero los bytes de .init_array o establecer la longitud de la sección a 0 no ayuda: el dynamic linker los repuebla vía relocations.
  • Establecer INIT_ARRAY/INIT_ARRAYSZ a 0 puede romper el loader debido a tags inconsistentes. La eliminación limpia de esas DYNAMIC entries es la palanca fiable.
  • Eliminar por completo la sección .init_array suele provocar un crash del loader.
  • Tras el parcheo, las direcciones de funciones/layout pueden desplazarse; siempre recalcula el constructor a partir de los addends en .rela.dyn del archivo parcheado si necesitas volver a aplicar el patch.

Bootstrapping a minimal ART/JNI to invoke INIT0 and JNI_OnLoad0

  • Usa JNIInvocation para levantar 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/clases objetivo en el classpath para que cualquier RegisterNatives encuentre sus clases Java.
Entorno mínimo (CMake y C) para llamar a INIT0 → JNI_OnLoad0 → método 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

Errores comunes:

  • Las direcciones de los constructores cambian después de parchear debido al re-layout; siempre recalcula desde .rela.dyn en el binario final.
  • Asegúrate de que -Djava.class.path incluya todas las clases usadas por las llamadas RegisterNatives.
  • El comportamiento puede variar con 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