Código nativo y ELF
Antes conviene leer «Contenedor ZIP»
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
ELFla declara en su propia cabecera, en el byteEI_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
- falling back to extraction from apk». Lo impiden
android:pageSizeCompat="disabled"en el manifiesto o la propiedadpm.16kb.app_compat.disabledatrue(copyFileIfChangedenNativeLibraryHelper.cpp). La aplicación sigue declarandoextractNativeLibs="false", pero carga la copia delib/arm64/, que va antes quebase.apk!/lib/arm64-v8aen la ruta de búsqueda. Todo está medido en la «sección 6.4 · Qué pasa en el dispositivo». El mecanismo de alineación —relleno en elextra fielddelLocal File Header— está en «Contenedor ZIP → §8.1 · El extra field como relleno de alineación».
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 are2**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 installrespondeFailure [INSTALL_FAILED_INVALID_APK: INSTALL_FAILED_INVALID_APK: Failed to extract native libraries, res=-2]—con el código repetido—, y enlogcatquedaE/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 comprobarp_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>escribepageSizeCompat=N. El 2 es la.sodesalineada en el ZIP; el 4, unPT_LOADconp_alignde 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>/mapsel código de la.soaparece como memoria anónima,r-xp 00000000 00:00 0sin nombre de fichero; sin él, mapeado desdebase.apko desdelib/arm64/libp16k.so. Cuando funciona, no deja ninguna línea enlogcat. - 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:
- El instalador nunca rechaza por la alineación ELF; solo por la del ZIP, y Android 16 ni eso: extrae.
- 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_HASHo conSIGILL, lo primero es mirarp_align. - 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.soafectadas, el atributo del manifiesto ya no lo oculta, las marcas se recalculan al actualizar, cubre cualquierp_alignmenor de 16 KiB y la propiedadbionic.linker.16kb.app_compat.enabledadmite el valorfatal.
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
- 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,mipsymips64en NDK r17 de la «sección 2.2 · Las eliminadas». - 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_Ehdrde la «sección 3.1 · Cabecera ELF64», los índices dee_identde la 3.2 y los valoresELFCLASS64,ELFDATA2LSB,ET_DYN,EM_386,EM_ARMyEM_X86_64. - 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_Phdrde la «sección 3.4 · program header», la definición dep_aligny los valoresPT_LOAD,PT_DYNAMIC,PT_NOTEyPT_PHDR. - 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. - 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 deJNI_OnLoadconRegisterNatives, la recomendación de Google y la recomendación de-fvisibility=hiddende las secciones «4.1», «4.3» y «4.4». - 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–_3y la regla del nombre corto frente al largo de la «sección 4.2 · Vía A: descubrimiento por nombre (dlsym)». - 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**14sobre los segmentosLOADy 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». <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.- 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
extractNativeLibsa"false"por defecto («sección 5 · extractNativeLibs y lo que implica para el ZIP»). - 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_TABLEyFULL, la ruta del artefacto y el límite de 1,6 GB de la «sección 7 · Stripping de símbolos». - 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»). - 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»).
libc/kernel/uapi/linux/elf.hde bionic (etiquetaandroid-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 estructuraElf32_Phdrde la «sección 3.4 · program header».- AOSP — el cargador de bionic en las etiquetas
android-15.0.0_r1,android-15.0.0_r6,android-16.0.0_r1yandroid-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.cppylinker.cpp. Consultado el 25 de septiembre de 2026. De aquí salen la falta de comprobación dep_alignen 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»). - AOSP —
core/jni/com_android_internal_content_NativeLibraryHelper.cppyservices/core/java/com/android/server/pm/ScanPackageUtils.javaen las etiquetasandroid-15.0.0_r1,android-16.0.0_r1yandroid-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 marcaspageSizeCompat(secciones «5» y «6.4»). - Emuladores de 16 KB de Android 15 y 16 — imágenes
google_apis_ps16karm64-v8a, compilacionesAE3A.240806.043yBE2A.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». - 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
riscv64y en qué versión entra («sección 2 · lib/<abi>/ y las ABIs vigentes»). - 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
riscv64no está soportado para aplicaciones («sección 2 · lib/<abi>/ y las ABIs vigentes»).