Código nativo y ELF

Antes conviene leer «Contenedor ZIP»

referencia técnica · Actualizado el 25 de septiembre de 2026

1. Qué es y por qué existe

No todo el código de una aplicación Android es DEX. Bajo lib/<abi>/ viven bibliotecas compartidas en formato ELF —el mismo formato de los binarios de Linux— compiladas con el NDK y llamadas desde Java o Kotlin a través de JNI. Existen por rendimiento, por reutilización de bibliotecas en C y C++ que nadie va a reescribir (ffmpeg, sqlite, motores de juego), y por ocultación.

Ese tercer motivo es el que importa aquí. Un método movido a .so desaparece del «bytecode Dalvik»: lo que queda en el DEX es una declaración native sin cuerpo. Un decompilador a Java lo enseña vacío y el análisis se para ahí, salvo que se cambie de herramienta y de nivel.

Este documento cubre lo que hace falta para inventariar, identificar y decidir: qué ABIs trae un APK, cómo se enlaza cada método nativo, qué exige la regla de las páginas de 16 KB y qué se ha perdido al hacer stripping. El análisis del código nativo en sí —Ghidra, radare2, IDA— está en «Análisis binario y estático».

Endianness: el ELF la declara en su propia cabecera, en el byte EI_DATA. Las cuatro ABIs vigentes de Android son little-endian (ELFDATA2LSB), y todos los offsets de las tablas de este documento son relativos al inicio de la estructura descrita.

2. lib/<abi>/ y las ABIs vigentes

El empaquetado es una convención sobre rutas del ZIP: un directorio por ABI bajo lib/, y dentro los .so planos, sin subdirectorios. El instalador elige el directorio que corresponde a la arquitectura del dispositivo e ignora el resto.

2.1 Las cuatro vigentes

ABI CPU Nota de la documentación del NDK
arm64-v8a ARM de 64 bits «Armv8.0 only.» long double de 128 bits
armeabi-v7a ARM de 32 bits «Incompatible with ARMv5/v6 devices.» Incluye Thumb-2 y Neon
x86_64 x86 de 64 bits «Full x86-64-v2»
x86 x86 de 32 bits «No support for MOVBE or SSE4.»

2.2 Las eliminadas

«Historically the NDK supported ARMv5 (armeabi), and 32-bit and 64-bit MIPS, but support for these ABIs was removed in NDK r17.»

Es decir, armeabi, mips y mips64 ya no se pueden compilar con un NDK moderno. Pero siguen apareciendo en APK de 2026, porque una biblioteca Java de terceros puede traer sus .so precompilados de hace diez años dentro del .jar y el empaquetador los copia sin preguntar. En com.x8bit.bitwarden_2026.5.0.apk del «muestrario», con targetSdk 36:

Directorio Entradas Bytes
arm64-v8a 5 14.544.672
armeabi-v7a 5 9.437.328
x86_64 5 15.964.120
x86 5 15.481.932
armeabi 1 126.980
mips 1 130.556
mips64 1 150.256

Los tres huérfanos son el mismo fichero, libjnidispatch.so, de JNA, y aapt2 dump badging los declara como ABIs soportadas: native-code: 'arm64-v8a' 'armeabi' 'armeabi-v7a' 'mips' 'mips64' 'x86' 'x86_64'.

Consecuencia para un inventario: contar directorios bajo lib/ no da las ABIs que la aplicación soporta de verdad, da las que alguien empaquetó.

riscv64 está admitido para los dispositivos y no para las aplicaciones. Desde el CDD de Android 15, la lista cerrada de ABIs que un dispositivo compatible puede declarar en Build.SUPPORTED_ABIS incluye riscv64 (§3.3.1, [C-0-6]; el CDD de Android 14 no lo menciona). El NDK, en cambio, no lo soporta: el changelog de r27 dice que su sysroot «is not supported. It is present to aid bringup for OS vendors, but it's not yet a supported Android ABI», los de r28 a r30 no lo mencionan, y la página de ABIs del NDK (actualizada el 15 de mayo de 2026) tampoco. Ningún APK del muestrario disponible trae lib/riscv64/.

3. Estructura mínima de un ELF compartido

Un .so de Android es un ELF de tipo ET_DYN (3, «Shared object file»). Interesan tres capas: la cabecera, la tabla de program headers —lo que el cargador usa— y la tabla de secciones —lo que el analista usa—.

┌─────────────────────────────────────────┐  0x00
│ Elf64_Ehdr   (64 bytes)                 │
├─────────────────────────────────────────┤  e_phoff  (típicamente 0x40)
│ Elf64_Phdr[e_phnum]  (56 bytes cada uno)│  ← lo que mapea el cargador
├─────────────────────────────────────────┤
│ .dynsym .dynstr .text .rodata .init_array│  segmentos PT_LOAD
│ .data.rel.ro .dynamic .got               │
├─────────────────────────────────────────┤  e_shoff
│ Elf64_Shdr[e_shnum]  (64 bytes cada uno)│  ← opcional para ejecutar
└─────────────────────────────────────────┘

3.1 Cabecera ELF64

Campo Offset Tamaño Tipo Significado
e_ident 0x00 16 unsigned char[16] Identificación. Ver 3.2
e_type 0x10 2 Elf64_Half ET_DYN = 3 en toda biblioteca compartida
e_machine 0x12 2 Elf64_Half Arquitectura. Ver 3.3
e_version 0x14 4 Elf64_Word 1
e_entry 0x18 8 Elf64_Addr Punto de entrada. 0 o irrelevante en un .so
e_phoff 0x20 8 Elf64_Off Offset de la tabla de program headers. 0x40
e_shoff 0x28 8 Elf64_Off Offset de la tabla de secciones. 0 si se han eliminado
e_flags 0x30 4 Elf64_Word Banderas específicas de la arquitectura
e_ehsize 0x34 2 Elf64_Half Tamaño de esta cabecera: 0x40 = 64
e_phentsize 0x36 2 Elf64_Half Tamaño de un program header: 0x38 = 56
e_phnum 0x38 2 Elf64_Half Número de program headers
e_shentsize 0x3A 2 Elf64_Half Tamaño de una cabecera de sección: 0x40 = 64
e_shnum 0x3C 2 Elf64_Half Número de secciones
e_shstrndx 0x3E 2 Elf64_Half Índice de la sección con los nombres de sección

En ELF32 la cabecera mide 52 bytes (0x34) y los campos de dirección son de 4 bytes; el resto del esquema es idéntico.

3.2 e_ident

Índice Nombre Tamaño Valor en un .so de Android
0–3 EI_MAG0–EI_MAG3 4 7F 45 4C 46 (\x7fELF)
4 EI_CLASS 1 2 = ELFCLASS64, 1 = ELFCLASS32
5 EI_DATA 1 1 = ELFDATA2LSB
6 EI_VERSION 1 1
7 EI_OSABI 1 0
8 EI_ABIVERSION 1 0
9–15 EI_PAD 7 ceros

3.3 e_machine por ABI

ABI e_machine Constante
arm64-v8a 183 (0xB7) EM_AARCH64
armeabi-v7a 40 (0x28) EM_ARM
x86_64 62 (0x3E) EM_X86_64
x86 3 (0x03) EM_386

La ABI se deduce del binario, no de la ruta. Un .so colocado en el directorio equivocado es un error que solo se detecta leyendo e_machine.

3.4 program header

Campo Offset Tamaño Tipo Significado
p_type 0x00 4 Elf64_Word PT_LOAD = 1, PT_DYNAMIC = 2, PT_NOTE = 4, PT_PHDR = 6
p_flags 0x04 4 Elf64_Word Permisos: r, w, x
p_offset 0x08 8 Elf64_Off Offset dentro del fichero
p_vaddr 0x10 8 Elf64_Addr Dirección virtual
p_paddr 0x18 8 Elf64_Addr Dirección física. Irrelevante
p_filesz 0x20 8 Elf64_Xword Bytes en el fichero
p_memsz 0x28 8 Elf64_Xword Bytes en memoria. Mayor que p_filesz si hay .bss
p_align 0x30 8 Elf64_Xword El campo de la regla de 16 KB («sección 6 · Páginas de 16 KB»)

Es el Elf64_Phdr, de 56 bytes. En ELF32 (armeabi-v7a, x86) el Elf32_Phdr mide 32 bytes y cambia el orden: p_type en 0x00, p_offset en 0x04, p_vaddr en 0x08, p_paddr en 0x0C, p_filesz en 0x10, p_memsz en 0x14, p_flags en 0x18 y p_align en 0x1C, todos de 4 bytes.

«This member gives the value to which the segments are aligned in memory and in the file. Values 0 and 1 mean no alignment is required.»

y para los segmentos cargables, «p_vaddr should equal p_offset, modulo p_align».

3.5 Secciones que importan al analizar

Sección Contenido Para qué sirve al analista
.dynsym Tabla de símbolos dinámicos La única que sobrevive al stripping. De ahí salen los Java_* y JNI_OnLoad
.dynstr Nombres de .dynsym Los strings de los símbolos exportados e importados
.text Código máquina Lo que se desensambla
.rodata Datos de solo lectura Cadenas literales, tablas, claves embebidas
.init_array Punteros a constructores Se ejecutan antes que JNI_OnLoad: el sitio clásico del descifrado inicial y de las comprobaciones anti-análisis
.fini_array Punteros a destructores Simétrico del anterior
.dynamic NEEDED, SONAME, tablas de relocalización El grafo de dependencias entre .so
.symtab Tabla de símbolos completa Ausente en release. Ver «sección 7 · Stripping de símbolos»
.note.android.ident Versión del NDK y minSdk de compilación Huella de la cadena de build

4. JNI: cómo se enlaza un método nativo

Un método declarado native en Java no tiene cuerpo en el DEX. En el momento de la primera llamada, ART tiene que encontrar la función en C que le corresponde. Hay dos vías, y la diferencia entre ellas decide si el análisis estático es trivial o es un problema.

4.1 Cargar la biblioteca

Desde un inicializador estático de la clase, con System.loadLibrary("fubar"): «the argument is the "undecorated" library name, so to load libfubar.so you would pass in "fubar"». El .so sale de lib/<abi>/ del APK instalado, o del directorio al que el instalador lo extrajo («sección 5 · extractNativeLibs y lo que implica para el ZIP»).

4.2 Vía A: descubrimiento por nombre (dlsym)

Si el símbolo exportado sigue una convención de nombres, el runtime lo encuentra solo:

«A native method name is concatenated from the following components: the prefix Java_; a mangled fully-qualified class name; an underscore ("_") separator; a mangled method name; for overloaded native methods, two underscores ("__") followed by the mangled argument signature.»

La barra del nombre cualificado se sustituye por _, y como ni un nombre ni un descriptor empiezan por número, los dígitos quedan libres para escapes: _0XXXX es el carácter Unicode XXXX en minúsculas (_0abcd, no _0ABCD), _1 es _, _2 es ; y _3 es [. La signatura solo se añade cuando el método está sobrecargado: «The VM looks first for the short name… It then looks for the long name».

Esto es un regalo para el análisis: el nombre del símbolo contiene el paquete, la clase y el método Java a los que corresponde. En libtermux.so del muestrario, .dynsym trae Java_com_termux_terminal_JNI_close, …_createSubprocess, …_setPtyUTF8Mode, …_setPtyWindowSize y …_waitFor: sin abrir el desensamblador ya se sabe que com.termux.terminal.JNI tiene cinco métodos nativos y cuál es cada uno.

4.3 Vía B: RegisterNatives desde JNI_OnLoad

La otra vía es registrar la correspondencia en tiempo de ejecución, con una tabla de punteros a función:

JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) {
    // … obtener JNIEnv* env y jclass c = env->FindClass("com/example/MyClass") …
    static const JNINativeMethod methods[] = {
        {"nativeFoo", "()V", reinterpret_cast<void*>(nativeFoo)},
        {"nativeBar", "(Ljava/lang/String;I)Z", reinterpret_cast<void*>(nativeBar)},
    };
    env->RegisterNatives(c, methods, sizeof(methods)/sizeof(JNINativeMethod));
    return JNI_VERSION_1_6;
}

Y es lo que Google recomienda, por motivos ajenos a la ofuscación: «Most apps should call RegisterNatives() from JNI_OnLoad(). […] Mistakes are caught early. An incorrectly named function will cause an error during library load. The only symbol that typically must be exported is JNI_OnLoad()», y «build with a version script (preferred) or use -fvisibility=hidden so that only your JNI_OnLoad is exported from your library».

4.4 Por qué RegisterNatives complica el análisis estático

Con la vía A la correspondencia entre método Java y función nativa está en la tabla de símbolos, que es un dato estático. Con la vía B está en el argumento de una llamada que solo ocurre en tiempo de ejecución, y recuperarla exige localizar JNI_OnLoad en .text, seguir el flujo hasta la llamada a RegisterNatives —que es indirecta, a través de la tabla de funciones de JNIEnv—, y reconstruir el array JNINativeMethod[], normalmente en .rodata, con sus tres campos: nombre, descriptor de firma y puntero a la implementación. Si el nombre y el descriptor se construyen o se descifran en ejecución, no queda nada estático que reconstruir.

El resultado se ve de un vistazo. libandroidx.graphics.path.so de com.x8bit.bitwarden_2026.5.0.apk exporta un solo símbolo propio:

00000000000016c8 g    DF .text  00000000000001c0  LIBANDROIDX.GRAPHICS.PATH JNI_OnLoad

Ni un Java_*. Todo lo demás en .dynsym son importaciones de libc.

El criterio de triaje: si .dynsym trae símbolos Java_*, el mapa está servido; si solo trae JNI_OnLoad, hay que entrar al desensamblador; y si no trae ninguno de los dos, o la biblioteca no es un puente JNI, o la carga se hace por otra vía y conviene sospechar de un packer («Packers y protectores»).

5. extractNativeLibs y lo que implica para el ZIP

«This attribute indicates whether the package installer extracts native libraries from the APK to the file system. If set to "false", your native libraries are stored uncompressed in the APK. Although your APK might be larger, your application loads faster because the libraries load directly from the APK at runtime.»

Valor En el ZIP En el dispositivo
true .so en deflate, sin restricción de alineación El instalador los descomprime a disco: dos copias
false .so en stored y alineados Se mapean con mmap(2) desde el APK: una copia. En un dispositivo de 16 KB con la .so a 4 KiB, Android 16 la extrae —dos copias— y, si el ELF es de 4 KB, la copia entera a memoria anónima («sección 6.4 · Qué pasa en el dispositivo»)

Desde AGP 3.6 el valor por defecto es "false" —«the plugin now sets extractNativeLibs to "false" by default. That is, your native libraries are page aligned and packaged uncompressed»— y desde AGP 4.2 se configura por DSL con useLegacyPackaging. La guía dice que «the default value of extractNativeLibs depends on minSdkVersion and the version of AGP you're using», y el umbral está en la referencia de la DSL: JniLibsPackaging.useLegacyPackaging —«If null, .so files will be uncompressed and page-aligned when minSdk >= 23»—.

Contraste medido en el muestrario con aapt2 dump xmltree y unzip -v:

APK targetSdk extractNativeLibs Método ZIP de los .so
com.termux_1002.apk 28 true Defl:N
com.x8bit.bitwarden_2026.5.0.apk 36 false Stored, alineado a 16 KiB

Consecuencia al modificar un APK: si extractNativeLibs es false, reempaquetar un .so comprimiéndolo impide instalar el APK. El instalador lo rechaza con INSTALL_FAILED_INVALID_APK: Failed to extract native libraries, res=-2, y en logcat queda «Library '…' is compressed - will not be able to open it directly from apk». Dejarlo sin comprimir pero con un offset que no sea múltiplo de la página del dispositivo lo rechaza igual, con el mismo código. En un dispositivo de 16 KB depende de la versión: el Android 15 de salida rechaza la .so que no esté a 16 KiB, y Android 16 —y 15 desde QPR2, según el código— la extrae a disco si al menos está a 4 KiB: «16kB AppCompat: Library '…' is not PAGE(16384)-aligned

6. Páginas de 16 KB

6.1 Qué es el requisito

Android ha usado siempre páginas de memoria de 4 KB. A partir de Android 15 hay dispositivos con páginas de 16 KB, y en ellos un binario cuyos segmentos PT_LOAD estén alineados a 4 KB no se puede mapear tal cual. Qué pasa entonces depende de la versión. El Android 15 de salida no lo comprueba y lo mapea mal: el fallo llega después, con un mensaje que no habla de alineación. Desde Android 15 QPR1, según el código, el cargador lo rechaza con «"…" program alignment (4096) cannot be smaller than system page size (16384)». Y Android 16 añade un modo de compatibilidad: el instalador detecta los PT_LOAD con p_align de 4 KB, marca la aplicación, y el cargador, en vez de mapear la .so, la copia a memoria anónima. Cuesta RAM —ninguna página se comparte— y deja ejecutables los datos de solo lectura. El usuario ve un aviso al abrir la aplicación. El atributo android:pageSizeCompat del manifiesto, con los valores "enabled" o "disabled", fuerza el modo en un sentido u otro; en Android 16 fijarlo quita el aviso, y en Android 17, según el código, el aviso sale igual si hay .so desalineadas. Todo esto está ejecutado en la «sección 6.4 · Qué pasa en el dispositivo». La condición se comprueba sobre p_align:

«Check the output lines to ensure that the load segments don't have values less than 2**14. If any load segments are 2**13, 2**12, or lower values, you'll need to update the packaging»

2**14 = 16.384 = 0x4000. Son dos alineaciones distintas y ambas obligatorias:

Dónde Qué se alinea Cómo se comprueba
Dentro del .so p_align de cada segmento PT_LOAD ≥ 0x4000 objdump -p / readelf -l
Dentro del APK Offset de los datos de cada entrada .so stored zipalign -c -P 16 -v 4
Dentro del .so Fin de PT_GNU_RELRO (p_vaddr + p_memsz) múltiplo de 0x4000 llvm-readelf -Wl

La tercera fila la pide la guía para las .so enlazadas con NDK r27 o anterior sin los flags de abajo. El cargador de Android 16 QPR1 en adelante la aplica a su manera, según el código: si RELRO acaba fuera de 16 KB dentro de un segmento RW más largo, carga la .so en modo de compatibilidad aunque todos sus p_align valgan 0x4000 (FixMinAlignFor16KiB, en linker_phdr_16kib_compat.cpp). La alineación del ZIP, además, solo cuenta para las .so sin comprimir: con extractNativeLibs="true" no hay nada que alinear.

Con NDK r27 o anterior hay que pedirlo al enlazador:

-Wl,-z,max-page-size=16384
-Wl,-z,common-page-size=16384

«NDK version r28 and higher compile 16 KB-aligned by default.»

6.2 Fecha y alcance en Google Play

«To ensure your app works correctly on the latest versions of Android, all apps targeting Android 15 (API level 35) and higher must support 16 KB memory page sizes on 64-bit devices on Google Play. Starting February 1, 2027, if your app updates don't support 16 KB memory page sizes, you won't be able to release these updates.»

Es decir: targetSdk 35 o superior y dispositivos de 64 bits, y la fecha que da hoy la guía, el 1 de febrero de 2027, es la de las actualizaciones. No es el primer plazo: el blog de Android Developers anunció el 8 de mayo de 2025 que «Starting November 1st, 2025, all new apps and updates to existing apps submitted to Google Play and targeting Android 15+ devices must support 16 KB page sizes». Ninguna página oficial de las consultadas concilia las dos fechas.

6.3 Qué se ve en el muestrario

Alineación de los segmentos PT_LOAD de los .so de arm64-v8a:

APK p_align observado Cumple
com.termux_1002.apk 0x1000 (4 KB) ❌
com.x8bit.bitwarden_2026.5.0.apk 0x4000 ✅
org.fdroid.fdroid_1023052.apk 0x4000 ✅
de.westnordost.streetcomplete_6304.apk 0x4000 ✅
chat.simplex.app_354.apk 0x4000, y 0x10000 en libsimplex.so ✅

libtermux.so está compilado con NDK r22b —dato que sale de su propia .note.android.ident— y es anterior a todo esto. libsimplex.so usa 64 KB, que también cumple: el requisito es un mínimo, no un valor exacto.

6.4 Qué pasa en el dispositivo

Ejecutado el 25 de septiembre de 2026 en los emuladores de 16 KB de Android 15 —compilación AE3A.240806.043, la de salida, equivalente a android-15.0.0_r1— y Android 16 (BE2A.250530.026.F3, android-16.0.0_r1), con una biblioteca de prueba sin libc cuya única función devuelve 143 si sus segmentos se cargaron bien. La columna de Android 17 sale del código de android-17.0.0_r1: su imagen de 16 KB no arranca en la máquina de referencia («El muestrario → §8 · La máquina de referencia»).

ELF ZIP extractNativeLibs Android 15 de salida Android 16 Android 17, según el código
16 KiB 16 KiB false Carga desde el APK Igual. pageSizeCompat=0 Igual
16 KiB 16 KiB true Carga la copia extraída Igual Igual
16 KiB 4 KiB false No instala: res=-2 Instala extrayendo la .so y carga normal. pageSizeCompat=2, aviso «APK alignment check failed» Igual, con el aviso nuevo
16 KiB 4 KiB true Carga Carga. pageSizeCompat=0 Igual
4 KiB 16 KiB false Instala y falla la carga: empty/missing DT_HASH/DT_GNU_HASH Carga en modo de compatibilidad. pageSizeCompat=4, aviso «ELF alignment check failed» Igual, con el aviso nuevo
4 KiB 16 KiB true Igual que la anterior Igual que la anterior Igual
4 KiB 4 KiB false No instala: res=-2 Instala extrayendo y carga en compatibilidad. pageSizeCompat=6, aviso «APK and ELF alignment checks failed» Igual
4 KiB 4 KiB true Instala y falla la carga (DT_HASH) Compatibilidad. pageSizeCompat=4 Igual

Lo que se ve, literal:

  • El rechazo de Android 15: adb install responde Failure [INSTALL_FAILED_INVALID_APK: INSTALL_FAILED_INVALID_APK: Failed to extract native libraries, res=-2] —con el código repetido—, y en logcat queda E/NativeLibraryHelper: Library 'libp16k.so' is not PAGE(16384)-aligned - will not be able to open it directly from apk.
  • El fallo de carga de Android 15: dlopen failed: empty/missing DT_HASH/DT_GNU_HASH in "…/base.apk!/lib/arm64-v8a/libp16k.so" (new hash type from the future?). No habla de alineación: el cargador de salida mapea los segmentos a 16 KB sin comprobar p_align, lee la tabla dinámica de un sitio donde solo hay ceros y se queja de otra cosa.
  • La extracción de Android 16: I/NativeLibraryHelper: 16kB AppCompat: Library 'libp16k.so' is not PAGE(16384)-aligned - falling back to extraction from apk.
  • Las marcas: adb shell dumpsys package <paquete> escribe pageSizeCompat=N. El 2 es la .so desalineada en el ZIP; el 4, un PT_LOAD con p_align de 4 KB; el 6, los dos; el 32 y el 64, el atributo del manifiesto. En Android 15 esa línea no existe.
  • El modo de compatibilidad: en /proc/<pid>/maps el código de la .so aparece como memoria anónima, r-xp 00000000 00:00 0 sin nombre de fichero; sin él, mapeado desde base.apk o desde lib/arm64/libp16k.so. Cuando funciona, no deja ninguna línea en logcat.
  • El aviso de Android 16: la etiqueta de la aplicación como título, «This app isn’t 16 KB compatible. ELF alignment check failed. This app will be run using page size compatible mode…» y un botón «OK».

Y las variantes que separan la teoría de la práctica:

Variante Android 15 de salida Android 16
pageSizeCompat="enabled", ELF de 4 KiB Falla la carga (DT_HASH) Compatibilidad, pageSizeCompat=32 y sin aviso
pageSizeCompat="disabled", ELF de 4 KiB Falla la carga (DT_HASH) Falla la carga: "…/libp16k.so" program alignment (4096) cannot be smaller than system page size (16384). pageSizeCompat=64
pageSizeCompat="disabled", ZIP de 4 KiB No instala No instala: pageSizeCompat=disabled library 'libp16k.so' is not PAGE(16384)-aligned within apk (APK alignment, not ELF alignment) -and will not be extracted.
ELF con p_align de 8 KiB Carga, y muere en la primera llamada: Fatal signal 4 (SIGILL) Falla la carga: program alignment (8192) cannot be smaller…. La compatibilidad de Android 16 solo cubre los 4 KiB exactos
Actualización de un ELF de 4 KiB a uno de 16 KiB La primera falla, la segunda carga La segunda carga normal, pero conserva pageSizeCompat=4 y vuelve a mostrar el aviso

Tres lecturas:

  1. El instalador nunca rechaza por la alineación ELF; solo por la del ZIP, y Android 16 ni eso: extrae.
  2. El Android 15 de salida es el peor caso: instala lo que luego no puede cargar, y el error no dice por qué. Si una aplicación con código nativo falla en un dispositivo de 16 KB con DT_HASH o con SIGILL, lo primero es mirar p_align.
  3. El modo de compatibilidad no es gratis ni silencioso para el usuario, pero sí para logcat. Lo que cambia en Android 17, según el código: el aviso pasa a titularse «Android App Compatibility» y lista las .so afectadas, el atributo del manifiesto ya no lo oculta, las marcas se recalculan al actualizar, cubre cualquier p_align menor de 16 KiB y la propiedad bionic.linker.16kb.app_compat.enabled admite el valor fatal.

7. Stripping de símbolos

«By default, native code libraries are stripped in release builds of your app. This stripping consists of removing the symbol table and debugging information contained in any native libraries used by your app.»

7.1 Qué se pierde y qué queda

Se pierde Sobrevive
.symtab: nombres de todas las funciones, incluidas las internas .dynsym: solo los símbolos exportados e importados
.debug_*: ficheros fuente, números de línea, tipos .dynstr, .dynamic, NEEDED, SONAME
Nombres de variables locales .rodata: los strings literales siguen ahí

En un .so sin stripping el desensamblador etiqueta cada función con su nombre real; con stripping, todo lo no exportado aparece como sub_1A40 o FUN_00101a40 y reconstruirlo es trabajo manual. Combinado con RegisterNatives («sección 4.4 · Por qué RegisterNatives complica el análisis estático») es el escenario habitual de una aplicación comercial. Comprobado en el muestrario: libtermux.so y libandroidx.graphics.path.so responden no symbols a nm, que solo lee .symtab, y conservan .dynsym intacta.

7.2 Los símbolos de depuración de Play Console

El desarrollador conserva los símbolos y los sube por separado, para que Play pueda simbolicar los informes de fallo. Se configura en Gradle:

android.buildTypes.release.ndk.debugSymbolLevel = { SYMBOL_TABLE | FULL }
Valor Contenido
NONE Por defecto. Sin símbolos
SYMBOL_TABLE Nombres de función. Fichero más pequeño
FULL Nombres de función, ficheros y números de línea

El artefacto sale en app/build/outputs/native-debug-symbols/<variant-name>/native-debug-symbols.zip, con un límite de 1,6 GB.

Para el análisis, esto significa que los símbolos existen pero no están en el APK. No hay vía legítima de recuperarlos desde el binario distribuido: es el equivalente nativo del mapping.txt de «R8 y ProGuard», con la diferencia de que Play nunca lo publica.

8. Recetas

objdump de esta máquina es Apple LLVM 21.0.0, que sabe leer ELF pese a ser el de macOS. Comprobado el 12 de agosto de 2026: en ~/Library/Android/sdk/build-tools/37.0.0/lld-bin/ solo hay lld, el enlazador, y no hay NDK instalado, así que no existen llvm-readelf ni llvm-objdump del SDK. readelf tampoco está en el PATH. Todo lo de abajo usa /usr/bin/objdump, /usr/bin/nm y xxd.

8.1 Inventariar las ABIs presentes

MUESTRAS=~/muestras-apk
unzip -l $MUESTRAS/com.x8bit.bitwarden_2026.5.0.apk \
  | awk '$4 ~ /^lib\// {split($4,a,"/"); c[a[2]]++; s[a[2]]+=$1}
         END {for (k in c) printf "%-14s %3d entradas %10d bytes\n", k, c[k], s[k]}' | sort
arm64-v8a        5 entradas   14544672 bytes
armeabi          1 entradas     126980 bytes
armeabi-v7a      5 entradas    9437328 bytes
mips             1 entradas     130556 bytes
mips64           1 entradas     150256 bytes
x86              5 entradas   15481932 bytes
x86_64           5 entradas   15964120 bytes

Sin agregación, unzip -l … | grep '^lib/' | cut -d/ -f2 | sort -u basta para la lista de directorios.

8.2 Extraer un .so y leer su cabecera con xxd

unzip -o -q $MUESTRAS/com.termux_1002.apk "lib/arm64-v8a/libtermux.so" -d /tmp/so/
xxd -l 64 /tmp/so/lib/arm64-v8a/libtermux.so
00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000  .ELF............
00000010: 0300 b700 0100 0000 f40e 0000 0000 0000  ................
00000020: 4000 0000 0000 0000 b01d 0000 0000 0000  @...............
00000030: 0000 0000 4000 3800 0800 4000 1600 1500  ....@.8...@.....

Contra las tablas 3.1 y 3.2: magic \x7fELF; EI_CLASS = 02 = ELFCLASS64; EI_DATA = 01 = ELFDATA2LSB; e_type = 0x0003 = ET_DYN; e_machine = 0x00B7 = 183 = EM_AARCH64; e_phoff = 0x40; e_shoff = 0x1DB0, distinto de cero, así que la tabla de secciones sigue ahí; y e_ehsize = 64, e_phentsize = 56, e_phnum = 8, e_shentsize = 64, e_shnum = 22, e_shstrndx = 21.

La misma biblioteca en lib/armeabi-v7a/ empieza por 7f45 4c46 0101 0100 … 0300 2800 0100 0000 300a 0000 3400 0000: EI_CLASS = 01 = ELFCLASS32, e_machine = 0x0028 = 40 = EM_ARM, y e_phoff = 0x34 en vez de 0x40 porque la cabecera de 32 bits mide 52 bytes.

8.3 Comprobar la alineación de 16 KB con objdump -p

objdump -p /tmp/so/lib/arm64-v8a/libtermux.so | grep "LOAD"
unzip -o -q $MUESTRAS/com.x8bit.bitwarden_2026.5.0.apk \
  "lib/arm64-v8a/libandroidx.graphics.path.so" -d /tmp/bw/
objdump -p /tmp/bw/lib/arm64-v8a/libandroidx.graphics.path.so | grep "LOAD"
    LOAD off    0x0000000000000000 vaddr 0x0000000000000000 paddr 0x0000000000000000 align 2**12
    LOAD off    0x00000000000018e0 vaddr 0x00000000000028e0 paddr 0x00000000000028e0 align 2**12
    LOAD off    0x0000000000000000 vaddr 0x0000000000000000 paddr 0x0000000000000000 align 2**14
    LOAD off    0x0000000000001c40 vaddr 0x0000000000005c40 paddr 0x0000000000005c40 align 2**14
    LOAD off    0x0000000000001fd0 vaddr 0x0000000000009fd0 paddr 0x0000000000009fd0 align 2**14

2**12 = 4.096: libtermux.so no cumple. 2**14 = 16.384: el .so de 2026 sí.

8.4 Listar los símbolos JNI

objdump -T /tmp/so/lib/arm64-v8a/libtermux.so | grep -E "Java_|JNI_OnLoad"
objdump -T /tmp/bw/lib/arm64-v8a/libandroidx.graphics.path.so | grep -E "Java_|JNI_OnLoad"
00000000000016b8 g    DF .text	0000000000000008              Java_com_termux_terminal_JNI_close
0000000000000f3c g    DF .text	00000000000006a0              Java_com_termux_terminal_JNI_createSubprocess
000000000000161c g    DF .text	0000000000000054              Java_com_termux_terminal_JNI_setPtyUTF8Mode
00000000000015dc g    DF .text	0000000000000040              Java_com_termux_terminal_JNI_setPtyWindowSize
0000000000001670 g    DF .text	0000000000000048              Java_com_termux_terminal_JNI_waitFor
00000000000016c8 g    DF .text	00000000000001c0  LIBANDROIDX.GRAPHICS.PATH JNI_OnLoad

Las cinco primeras líneas son la vía A; la última, un binario entero cuya única exportación propia es JNI_OnLoad: vía B.

8.5 Confirmar el stripping y leer la huella del NDK

nm /tmp/so/lib/arm64-v8a/libtermux.so
objdump -s -j .note.android.ident /tmp/so/lib/arm64-v8a/libtermux.so | sed -n '3,5p'
/tmp/so/lib/arm64-v8a/libtermux.so: no symbols
Contents of section .note.android.ident:
 0200 08000000 84000000 01000000 416e6472  ............Andr
 0210 6f696400 18000000 72323262 00000000  oid.....r22b....

nm lee .symtab; «no symbols» confirma el stripping, mientras objdump -T, que lee .dynsym, sí devuelve símbolos. La nota dice Android, 0x18 = 24 —el nivel de API con el que se compiló— y r22b, la versión del NDK: una huella útil para fechar una biblioteca.

Fuentes

  1. Android ABIs — NDK — https://developer.android.com/ndk/guides/abis Consultado el 12 de agosto de 2026. De aquí sale la tabla de ABIs vigentes de la «sección 2.1 · Las cuatro vigentes», sus notas literales, y la frase sobre la eliminación de armeabi, mips y mips64 en NDK r17 de la «sección 2.2 · Las eliminadas».
  2. System V ABI (gABI) — ELF Header — https://refspecs.linuxfoundation.org/elf/gabi4+/ch4.eheader.html Consultado el 12 de agosto de 2026. De aquí salen la estructura Elf64_Ehdr de la «sección 3.1 · Cabecera ELF64», los índices de e_ident de la 3.2 y los valores ELFCLASS64, ELFDATA2LSB, ET_DYN, EM_386, EM_ARM y EM_X86_64.
  3. System V ABI (gABI) — Program Header — https://refspecs.linuxfoundation.org/elf/gabi4+/ch5.pheader.html Consultado el 12 de agosto de 2026. De aquí salen la estructura Elf64_Phdr de la «sección 3.4 · program header», la definición de p_align y los valores PT_LOAD, PT_DYNAMIC, PT_NOTE y PT_PHDR.
  4. ELF for the Arm 64-bit Architecture (AAELF64) — https://github.com/ARM-software/abi-aa/blob/main/aaelf64/aaelf64.rst Consultado el 12 de agosto de 2026. De aquí sale EM_AARCH64 (183, 0xB7) de la «sección 3.3 · e_machine por ABI», que la especificación gABI no lista.
  5. JNI Tips — Android Developers — https://developer.android.com/training/articles/perf-jni Consultado el 12 de agosto de 2026. De aquí salen las citas sobre System.loadLibrary, el ejemplo de JNI_OnLoad con RegisterNatives, la recomendación de Google y la recomendación de -fvisibility=hidden de las secciones «4.1», «4.3» y «4.4».
  6. Java Native Interface Specification — Design Overview — https://docs.oracle.com/javase/8/docs/technotes/guides/jni/spec/design.html Consultado el 12 de agosto de 2026. De aquí salen la regla de composición del nombre mangled, la tabla de escapes _0–_3 y la regla del nombre corto frente al largo de la «sección 4.2 · Vía A: descubrimiento por nombre (dlsym)».
  7. Support 16 KB page sizes — Android Developers — https://developer.android.com/guide/practices/page-sizes Consultado el 12 de agosto y el 25 de septiembre de 2026; la página se actualizó el 16 de septiembre. De aquí salen la comprobación de 2**14 sobre los segmentos LOAD y la del fin de RELRO de la «sección 6.1 · Qué es el requisito», los flags de enlazado, la afirmación de que NDK r28 alinea por defecto y la fecha y el alcance del requisito de Google Play de la «sección 6.2 · Fecha y alcance en Google Play».
  8. <application> — android:extractNativeLibs — https://developer.android.com/guide/topics/manifest/application-element Consultado el 12 de agosto de 2026. De aquí sale la descripción del atributo de la «sección 5 · extractNativeLibs y lo que implica para el ZIP» y la advertencia sobre su valor por defecto.
  9. Android Gradle Plugin 3.6.0 release notes — https://developer.android.com/build/releases/past-releases/agp-3-6-0-release-notes Consultado el 12 de agosto de 2026. De aquí sale que AGP fija extractNativeLibs a "false" por defecto («sección 5 · extractNativeLibs y lo que implica para el ZIP»).
  10. Include native symbols in your release build — https://developer.android.com/build/include-native-symbols Consultado el 12 de agosto de 2026. De aquí salen la cita sobre el stripping por defecto, los valores NONE, SYMBOL_TABLE y FULL, la ruta del artefacto y el límite de 1,6 GB de la «sección 7 · Stripping de símbolos».
  11. Android 16 — cambios de comportamiento para todas las aplicaciones — https://developer.android.com/about/versions/16/behavior-changes-all Consultado el 25 de septiembre de 2026; la página se actualizó el 16 de septiembre. De aquí salen el modo de compatibilidad de 16 KB y el atributo android:pageSizeCompat («sección 6.1 · Qué es el requisito»).
  12. Prepare your apps for Google Play's 16 KB page size compatibility requirement — Android Developers Blog — https://android-developers.googleblog.com/2025/05/prepare-play-apps-for-devices-with-16kb-page-size.html Consultado el 25 de septiembre de 2026. De aquí sale el plazo del 1 de noviembre de 2025 para las aplicaciones nuevas y las actualizaciones («sección 6.2 · Fecha y alcance en Google Play»).
  13. libc/kernel/uapi/linux/elf.h de bionic (etiqueta android-16.0.0_r1) — https://android.googlesource.com/platform/bionic/+/refs/tags/android-16.0.0_r1/libc/kernel/uapi/linux/elf.h Consultado el 25 de septiembre de 2026. De aquí sale la estructura Elf32_Phdr de la «sección 3.4 · program header».
  14. AOSP — el cargador de bionic en las etiquetas android-15.0.0_r1, android-15.0.0_r6, android-16.0.0_r1 y android-17.0.0_r1 — https://android.googlesource.com/platform/bionic/+/refs/tags/android-16.0.0_r1/linker/linker_phdr.cpp y, en el mismo directorio, linker_phdr_16kib_compat.cpp y linker.cpp. Consultado el 25 de septiembre de 2026. De aquí salen la falta de comprobación de p_align en el Android 15 de salida, el error de QPR1, el modo de compatibilidad y lo que cambia en Android 17 (secciones «6.1» y «6.4»).
  15. AOSP — core/jni/com_android_internal_content_NativeLibraryHelper.cpp y services/core/java/com/android/server/pm/ScanPackageUtils.java en las etiquetas android-15.0.0_r1, android-16.0.0_r1 y android-17.0.0_r1 — https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-16.0.0_r1/core/jni/com_android_internal_content_NativeLibraryHelper.cpp Consultado el 25 de septiembre de 2026. De aquí salen el rechazo por offset, la extracción de rescate y las marcas pageSizeCompat (secciones «5» y «6.4»).
  16. Emuladores de 16 KB de Android 15 y 16 — imágenes google_apis_ps16k arm64-v8a, compilaciones AE3A.240806.043 y BE2A.250530.026.F3, con el emulador 37.1.11. Ejecutado el 25 de septiembre de 2026. De aquí sale la matriz de la «sección 6.4 · Qué pasa en el dispositivo».
  17. Android 15 Compatibility Definition (CDD), §3.3.1 — https://source.android.com/docs/compatibility/15/android-15-cdd Consultado el 25 de septiembre de 2026, con los de Android 14, 16 y 17. De aquí sale la lista cerrada de ABIs con riscv64 y en qué versión entra («sección 2 · lib/<abi>/ y las ABIs vigentes»).
  18. Android NDK — Changelog r27 — https://github.com/android/ndk/wiki/Changelog-r27 Consultado el 25 de septiembre de 2026, con los de r28 a r30. De aquí sale que el sysroot de riscv64 no está soportado para aplicaciones («sección 2 · lib/<abi>/ y las ABIs vigentes»).