El contenedor ZIP de un APK
1. Qué es y por qué existe
Un APK es un ZIP. No «se parece a», no «usa una variante de»: es un archivo ZIP normal
que cualquier unzip abre. Lo que lo convierte en un APK es una convención sobre qué
entradas contiene y cómo están codificadas: AndroidManifest.xml en formato
AXML, el código en classes*.dex, la tabla de recursos en
resources.arsc, y el resto repartido entre res/, assets/, lib/<abi>/ y META-INF/.
Que sea un ZIP no es un detalle de empaquetado: es una decisión de diseño que se paga en
cada capa por encima. El ZIP tiene un índice al final —el Central Directory— y una copia
parcial de ese índice delante de cada fichero —el Local File Header—. Esa duplicación
permite descomprimir en streaming sin haber leído el final, y es también el origen de las dos
vulnerabilidades más conocidas de la historia de Android (sección 12). El ZIP permite además
guardar entradas sin comprimir, lo que hace posible que el sistema mapee resources.arsc y
los .so directamente en memoria en vez de copiarlos a RAM — de ahí las reglas de alineación
de la sección 10.
Este documento es el fundamento del resto del corpus: cualquier herramienta que toque un APK empieza por abrir su ZIP, y casi todos los fallos de Diagnóstico de fallos se explican en esta capa.
Endianness: todas las estructuras del ZIP son little-endian. Todos los offsets de las tablas de este documento son relativos al inicio de la estructura descrita.
2. Layout global
Un ZIP se lee de atrás hacia delante. El único punto de entrada fiable es el EOCD, que
está al final; de ahí se salta al Central Directory, y de cada entrada del
Central Directory se salta al Local File Header correspondiente.
offset 0
┌────────────────────────────────────────────────────────────┐
│ Local File Header (entrada 1) │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ datos de la entrada 1 (stored o deflate) │ │
│ └────────────────────────────────────────────────────────┘ │
│ [data descriptor (entrada 1)] ← solo si bit 3 │
├────────────────────────────────────────────────────────────┤
│ Local File Header (entrada 2) + datos + [data descriptor] │
├────────────────────────────────────────────────────────────┤
│ … │
├════════════════════════════════════════════════════════════┤
│ APK Signing Block ← no es ZIP: hueco propio de Android │
├────────────────────────────────────────────────────────────┤
│ Central Directory (una entrada por fichero, en orden) │
├────────────────────────────────────────────────────────────┤
│ [Zip64 EOCD record] [Zip64 EOCD locator] ← si zip64 │
├────────────────────────────────────────────────────────────┤
│ End Of Central Directory + [comentario] │
└────────────────────────────────────────────────────────────┘
fin de fichero
El APK Signing Block no forma parte de la especificación ZIP. Es un bloque que Android
inserta entre el último dato y el Central Directory, aprovechando que ningún lector de
ZIP mira ahí: el EOCD apunta al Central Directory por offset absoluto, así que el hueco
intermedio se ignora. La especificación de la firma v2 lo describe así:
«Contents of ZIP entries (from offset 0 until the start of APK signing block) / APK signing block / ZIP Central Directory / ZIP End of Central Directory»
Medido sobre el corpus, el hueco es exactamente eso:
| APK | Fin de datos | Offset del Central Directory |
Hueco |
|---|---|---|---|
com.termux_1002.apk |
113.824.459 | 113.831.936 | 7.477 |
com.x8bit.bitwarden_2026.5.0.apk |
78.765.228 | 78.781.612 | 16.384 |
org.fdroid.fdroid_1023052.apk |
12.349.411 | 12.353.536 | 4.125 |
En los tres, los 16 bytes que preceden al Central Directory son la cadena APK Sig Block 42.
Ver APK Signing Block.
3. Firmas mágicas
| Estructura | Firma | Bytes en el fichero |
|---|---|---|
Local File Header |
0x04034b50 |
50 4B 03 04 |
Central Directory (entrada) |
0x02014b50 |
50 4B 01 02 |
End Of Central Directory |
0x06054b50 |
50 4B 05 06 |
data descriptor (opcional) |
0x08074b50 |
50 4B 07 08 |
Zip64 EOCD record |
0x06064b50 |
50 4B 06 06 |
Zip64 EOCD locator |
0x07064b50 |
50 4B 07 06 |
Los dos primeros bytes son siempre PK, iniciales de Phil Katz. Como el valor numérico es
little-endian y los bytes son ASCII, en un volcado hexadecimal se leen al revés de como se
escriben en la especificación: 0x06054b50 aparece como 50 4b 05 06.
⚠️ Ojo con el uso de estas firmas como criterio de búsqueda: 50 4B 03 04 puede aparecer por
casualidad dentro de datos comprimidos. Solo el Central Directory dice dónde empieza de
verdad cada entrada.
4. Local File Header
Precede a los datos de cada entrada. Tamaño fijo de 30 bytes (0x1E) más el nombre y el
extra field.
| Campo | Offset | Tamaño | Tipo | Significado |
|---|---|---|---|---|
local file header signature |
0x00 | 4 | uint32_t |
0x04034b50 |
version needed to extract |
0x04 | 2 | uint16_t |
Versión mínima del lector, en décimas: 20 = 2.0 |
general purpose bit flag |
0x06 | 2 | uint16_t |
Bits de control. El bit 3 y el bit 11 son los que importan aquí (sección 7) |
compression method |
0x08 | 2 | uint16_t |
0 = stored, 8 = deflate (sección 9) |
last mod file time |
0x0A | 2 | uint16_t |
Hora en formato MS-DOS: hhhhhmmm mmmsssss, segundos en pasos de 2 |
last mod file date |
0x0C | 2 | uint16_t |
Fecha en formato MS-DOS: yyyyyyym mmmddddd, año desde 1980 |
crc-32 |
0x0E | 4 | uint32_t |
CRC-32 de los datos sin comprimir. Cero si el bit 3 está puesto |
compressed size |
0x12 | 4 | uint32_t |
Tamaño comprimido. Cero si bit 3. 0xFFFFFFFF si zip64 |
uncompressed size |
0x16 | 4 | uint32_t |
Tamaño original. Cero si bit 3. 0xFFFFFFFF si zip64 |
file name length |
0x1A | 2 | uint16_t |
Longitud en bytes del nombre |
extra field length |
0x1C | 2 | uint16_t |
Longitud en bytes del extra field |
file name |
0x1E | var | char[] |
Ruta con / como separador, sin / inicial |
extra field |
— | var | — | Cadena de registros id+size (sección 8) |
Los datos de la entrada empiezan justo después:
offset_datos = offset_LFH + 30 + file_name_length + extra_field_length
Esa fórmula es la que gobierna toda la alineación de Android: el único grado de libertad para
mover el inicio de los datos es extra_field_length.
Ejemplo real. Primeros 32 bytes de com.termux_1002.apk:
00000000: 504b 0304 0000 0000 0800 2108 2102 dca9 PK........!.!...
00000010: c923 3300 0000 3700 0000 3900 0000 4d45 .#3...7...9...ME
Firma 50 4b 03 04; version needed = 0; flags = 0x0000; method = 0x0008 (deflate);
time = 0x0821; date = 0x0221; crc-32 = 0x23C9A9DC; compressed size = 51;
uncompressed size = 55; file name length = 57; extra field length = 0.
0x0221 decodifica a 1981-01-01 y 0x0821 a 01:01:02. No es la fecha de compilación: es
la constante que el Android Gradle Plugin escribe en todas las entradas para que la salida
sea reproducible. Idéntica en los 58 APK standalone del corpus.
5. Entrada del Central Directory
Una por fichero, todas contiguas. Tamaño fijo de 46 bytes (0x2E) más nombre, extra field y
comentario.
| Campo | Offset | Tamaño | Tipo | Significado |
|---|---|---|---|---|
central file header signature |
0x00 | 4 | uint32_t |
0x02014b50 |
version made by |
0x04 | 2 | uint16_t |
Byte alto: sistema de origen (0 = FAT, 3 = Unix). Byte bajo: versión |
version needed to extract |
0x06 | 2 | uint16_t |
Igual que en el LFH |
general purpose bit flag |
0x08 | 2 | uint16_t |
Debe coincidir con el LFH; en la práctica no siempre coincide |
compression method |
0x0A | 2 | uint16_t |
0 o 8 |
last mod file time |
0x0C | 2 | uint16_t |
Formato MS-DOS |
last mod file date |
0x0E | 2 | uint16_t |
Formato MS-DOS |
crc-32 |
0x10 | 4 | uint32_t |
Siempre relleno, aunque el LFH lo lleve a cero |
compressed size |
0x14 | 4 | uint32_t |
0xFFFFFFFF si el valor real está en el extra field zip64 |
uncompressed size |
0x18 | 4 | uint32_t |
Igual |
file name length |
0x1C | 2 | uint16_t |
|
extra field length |
0x1E | 2 | uint16_t |
Independiente del extra field length del LFH |
file comment length |
0x20 | 2 | uint16_t |
Casi siempre 0 en un APK |
disk number start |
0x22 | 2 | uint16_t |
0 en archivos de un solo volumen |
internal file attributes |
0x24 | 2 | uint16_t |
Bit 0: fichero de texto. Irrelevante en Android |
external file attributes |
0x26 | 4 | uint32_t |
Permisos Unix en los 16 bits altos cuando version made by dice Unix |
relative offset of local header |
0x2A | 4 | uint32_t |
Offset absoluto del LFH. 0xFFFFFFFF si zip64 |
file name |
0x2E | var | char[] |
|
extra field |
— | var | — | |
file comment |
— | var | char[] |
El Central Directory es la autoridad. Un lector correcto enumera este índice y nunca
recorre los Local File Header secuencialmente. Las razones prácticas están en la sección 11
y las históricas en la 12.
6. EOCD y cómo se localiza
| Campo | Offset | Tamaño | Tipo | Significado |
|---|---|---|---|---|
end of central dir signature |
0x00 | 4 | uint32_t |
0x06054b50 |
number of this disk |
0x04 | 2 | uint16_t |
0 |
number of the disk with the start of the central directory |
0x06 | 2 | uint16_t |
0 |
total number of entries in the central directory on this disk |
0x08 | 2 | uint16_t |
0xFFFF si zip64 |
total number of entries in the central directory |
0x0A | 2 | uint16_t |
0xFFFF si zip64 |
size of the central directory |
0x0C | 4 | uint32_t |
En bytes. 0xFFFFFFFF si zip64 |
offset of start of central directory |
0x10 | 4 | uint32_t |
Absoluto desde el inicio del fichero. 0xFFFFFFFF si zip64 |
.ZIP file comment length |
0x14 | 2 | uint16_t |
0 a 65.535 |
.ZIP file comment |
0x16 | var | char[] |
6.1 El algoritmo de búsqueda
El EOCD es la última estructura del fichero salvo que haya comentario. Como el
comentario va detrás y su longitud solo se conoce leyendo el propio EOCD, no hay forma de
saltar directamente: hay que buscar la firma 0x06054b50 hacia atrás desde el final. El
comentario puede medir hasta 65.535 bytes, así que la ventana máxima es 65.535 + 22 = 65.557
bytes. Es lo que hace libziparchive, el lector de ZIP de AOSP, en
FindCentralDirectoryInfo() —«Scan backward for the EOCD magic. In an archive without a
trailing comment, we'll find it on the first try»— con
static const uint32_t kMaxCommentLen = 65535;.
6.2 Por qué el comentario complica la búsqueda
Tres problemas, en orden de gravedad:
- La firma puede aparecer dentro del comentario. Nada impide que un comentario contenga
los bytes
50 4B 05 06, y un lector que se quede con la primera coincidencia desde el final aterriza en unEOCDfalso. La defensa es validar queoffset_del_EOCD + 22 + comment_length == tamaño_del_fichero;zipinfola aplica, y por eso imprime «Actual» y «Expected end-cent-dir record offset». - La firma puede aparecer dentro de los datos de la última entrada, si el comentario es corto y la entrada termina cerca del final.
- Es un vector de ambigüedad deliberada. Dos lectores con criterios distintos —el primero
desde el final frente al último válido— eligen
EOCDdistintos, y por tantoCentral Directorydistintos, sobre el mismo fichero. Es la misma clase de divergencia que la sección 12.
Ninguno de los 58 APK standalone ni de los 28 contenedores del corpus lleva comentario
(.ZIP file comment length = 0 en todos). No es una garantía: la especificación lo permite y
un APK con comentario se instala igual.
6.3 El EOCD de un APK real
Últimos 22 bytes de com.termux_1002.apk:
06c9abed: 504b 0506 0000 0000 e202 e202 edbb 0000 PK..............
06c9abfd: 00f0 c806 0000 ......
Firma; disco 0; disco del Central Directory 0; e2 02 = 738 entradas en este disco y 738
en total; ed bb 00 00 = 48.109 bytes de Central Directory; 00 f0 c8 06 = 0x06C8F000 =
113.831.936 de offset; comentario de longitud 0. Ese offset es múltiplo de 4.096, y no por
casualidad: es consecuencia de la alineación del APK Signing Block.
7. data descriptor y el bit 3
7.1 Qué es
Cuando un escritor genera un ZIP en streaming —sin poder volver atrás a corregir la
cabecera— no conoce el CRC ni los tamaños al escribir el Local File Header. La
especificación lo resuelve poniendo el bit 3 del general purpose bit flag y escribiendo
un data descriptor detrás de los datos:
«If this bit is set, the fields crc-32, compressed size and uncompressed size are set to zero in the local header. The correct values are put in the data descriptor immediately following the compressed data.»
| Campo | Offset | Tamaño | Tipo | Significado |
|---|---|---|---|---|
data descriptor signature |
0x00 | 4 | uint32_t |
0x08074b50. Opcional según la spec |
crc-32 |
0x04 | 4 | uint32_t |
|
compressed size |
0x08 | 4 (8 si zip64) | uint32_t / uint64_t |
|
uncompressed size |
0x0C | 4 (8 si zip64) | uint32_t / uint64_t |
7.2 Por qué rompe lectores
La firma del data descriptor es opcional. Sin ella, la estructura son 12 bytes sin
marca distintiva, y para saber dónde acaban los datos de una entrada hay que descomprimir el
stream deflate entero y contar. Un lector que quiera avanzar secuencialmente por los
Local File Header sin descomprimir no puede hacerlo. De ahí la regla: leer siempre por
el Central Directory, que sí tiene los tamaños. libziparchive guarda para esto un error
literal, "gpb flag mismatch at bit 3", cuando el bit 3 del LFH y el del
Central Directory no coinciden.
7.3 Qué aparece de verdad en el corpus
El reparto es tajante:
| Universo | Con data descriptor |
|---|---|
| 58 APK standalone | 0 |
3 .xapk, 14 .apks, 8 .apkm, 1 SAI zip |
prácticamente todos |
Los APK que produce el Android Gradle Plugin nunca lo usan: el empaquetador conoce los
tamaños antes de escribir. Los contenedores de APKMirror, APKCombo y bundletool sí,
porque se generan en streaming. Ejemplo medido sobre
GoogleKeep_com.google.android.keep.xapk: 30 entradas, 30 con bit 3.
Y ahí aparece la desviación interesante. El Local File Header de la primera entrada:
00000000: 504b 0304 0a00 0808 0000 0b2b 075d 67f5 PK.........+.]g.
00000010: 14f0 2fae fe00 2fae fe00 1b00 0000 636f ../.../.......co
flags = 0x0808: bit 3 puesto (hay data descriptor) y bit 11 puesto (nombre en UTF-8).
Pero crc-32 = 0xF014F567 y los dos tamaños = 0x00FEAE2F no son cero, cuando la
especificación dice que deben serlo. Y el data descriptor está igualmente presente detrás
de los datos, con firma:
00feae68: 504b 0708 67f5 14f0 2fae fe00 2fae fe00 PK..g.../.../...
El escritor puso el bit, escribió el descriptor y rellenó la cabecera. Un lector que
ignore los tres campos del LFH funciona; uno que valide que son cero falla sobre un
fichero que Android acepta sin protestar. Es el caso límite que obliga a la lectura tolerante
de la sección 11.
8. Extra fields
Tanto el Local File Header como la entrada del Central Directory terminan en un campo de
longitud variable con la misma estructura: una secuencia de registros.
| Campo | Offset | Tamaño | Tipo | Significado |
|---|---|---|---|---|
header id |
0x00 | 2 | uint16_t |
Identificador del registro |
data size |
0x02 | 2 | uint16_t |
Longitud de los datos que siguen |
data |
0x04 | var | — | Contenido específico del header id |
Los registros se encadenan hasta agotar extra field length. Un lector que no reconozca un
header id salta 4 + data_size bytes y sigue: el mismo principio de «ignorar sin perder»
que gobierna los chunks de resources.arsc. El extra field del LFH y el del
Central Directory son campos distintos y no tienen por qué coincidir, y esa es la
propiedad que Android explota para alinear.
8.1 El extra field como relleno de alineación
zipalign no reordena nada ni toca los datos: cambia el tamaño del extra field del
Local File Header hasta que el offset de los datos cae donde debe. Los bytes de relleno
son ceros, lo que técnicamente forma un registro con header id = 0x0000 y
data size = 0x0000 seguido de más ceros — no un registro válido, sino relleno mudo que
todo lector salta porque solo mira la longitud total.
Medido sobre com.x8bit.bitwarden_2026.5.0.apk, entrada
lib/arm64-v8a/libandroidx.graphics.path.so (stored, LFH en 15.096.044, nombre de 42
bytes): el Local File Header declara extra field length = 9.932 y la entrada del
Central Directory declara 0. Los 9.932 bytes son todos cero, y el offset de los datos
resulta 15.096.044 + 30 + 42 + 9.932 = 15.106.048 = 16.384 × 922: alineado exactamente a
16 KiB.
Consecuencia de primer orden: zipinfo -v y cualquier herramienta que lea solo el
Central Directory informan de extra field length: 0 para esa entrada. Un parser que
copie entradas de un ZIP a otro leyendo únicamente el Central Directory destruye la
alineación sin dar ningún aviso.
zipalign decide la alineación por extensión de fichero, no por contenido: su
getAlignment() devuelve pageSize si el nombre acaba en .so y pageAlignSharedLibs está
activo, y defaultAlignment en cualquier otro caso. Y las entradas comprimidas no se
alinean nunca, porque no se mapean: alinear un stream deflate no sirve de nada.
8.2 El extra field zip64, 0x0001
| Campo | Offset | Tamaño | Tipo | Significado |
|---|---|---|---|---|
header id |
0x00 | 2 | uint16_t |
0x0001 |
data size |
0x02 | 2 | uint16_t |
Suma de los campos presentes |
Original Size |
0x04 | 8 | uint64_t |
Tamaño sin comprimir |
Compressed Size |
0x0C | 8 | uint64_t |
Tamaño comprimido |
Relative Header Offset |
0x14 | 8 | uint64_t |
Offset del Local File Header |
Disk Start Number |
0x1C | 4 | uint32_t |
Disco de inicio |
Solo están presentes los campos cuyo equivalente de 32 bits vale 0xFFFFFFFF, y en ese
orden. data size dice cuántos hay. Leer los cuatro siempre es un error clásico.
9. Métodos de compresión
De los métodos que define APPNOTE.TXT, en un APK solo aparecen dos:
| Método | Valor | Descripción de la spec | Uso en Android |
|---|---|---|---|
stored |
0 |
«The file is stored (no compression)» | resources.arsc desde targetSdk 30; .so con extractNativeLibs=false; ficheros ya comprimidos (.png, .webp, .ogg) |
deflate |
8 |
«The file is Deflated» | Todo lo demás: classes*.dex, AndroidManifest.xml, res/*.xml, assets/, META-INF/ |
stored no es solo «no comprimir»: es la condición necesaria para mapear en memoria la
entrada. Un fichero deflate hay que descomprimirlo a RAM; uno stored y alineado se mapea
directo desde el APK instalado, sin copia. Ese es el único motivo de la sección siguiente.
10. Reglas propias de Android
10.1 resources.arsc sin comprimir y alineado a 4 bytes
Desde targetSdkVersion 30. La formulación oficial:
«Apps that target Android 11 (API level 30) or higher can't be installed if they contain a compressed
resources.arscfile or if this file is not aligned on a 4-byte boundary. This file can't be memory-mapped by the system if either of these conditions is present. Resources tables that can't be memory-mapped must be read into a buffer in RAM, resulting in unnecessary memory pressure on the system and greatly-increased RAM usage on the device.»
El incumplimiento produce el error de instalación
INSTALL_PARSE_FAILED_RESOURCES_ARSC_COMPRESSED, el fallo de reempaquetado más frecuente de
las herramientas que reconstruyen el ZIP sin cuidado.
En el corpus, resources.arsc está stored tanto en com.termux_1002.apk (targetSdk 28)
como en com.x8bit.bitwarden_2026.5.0.apk (targetSdk 36). En termux el offset de los datos
es 112.951.716 + 30 + 14 + 0 = 112.951.760, múltiplo exacto de 4 sin relleno alguno.
10.2 .so sin comprimir cuando extractNativeLibs=false
El atributo del manifiesto decide si el instalador copia las bibliotecas nativas al sistema de ficheros:
«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.»
Desde AGP 3.6:
«When you build your app, the plugin now sets
extractNativeLibsto"false"by default. That is, your native libraries are page aligned and packaged uncompressed.»
Desde AGP 4.2 se configura por DSL con useLegacyPackaging en vez de escribirse en el
manifiesto. La documentación advierte además de que «the default value of extractNativeLibs
depends on minSdkVersion and the version of AGP you're using».
⚠️ sin verificar: no se ha encontrado en developer.android.com el umbral exacto de
minSdkVersion que cambia el valor por defecto.
El contraste está en el corpus:
| APK | targetSdk |
extractNativeLibs |
.so en el ZIP |
|---|---|---|---|
com.termux_1002.apk |
28 | true |
Defl:N (deflate) |
com.x8bit.bitwarden_2026.5.0.apk |
36 | false |
Stored + alineado a 16 KiB |
El detalle de lo que eso implica para el binario está en Código nativo y ELF.
10.3 Alineación de entradas
zipalign es la herramienta oficial:
«
zipalignis a zip archive alignment tool that helps ensure that all uncompressed files in the archive are aligned relative to the start of the file. This lets the files be accessed directly viammap(2), removing the need to copy this data in RAM and reducing your app's memory usage.»
Dos alineaciones, con dos motivos distintos:
| Alineación | A qué se aplica | Motivo |
|---|---|---|
| 4 bytes | Toda entrada stored |
Acceso alineado a la palabra. Obligatorio para resources.arsc desde targetSdk 30 |
16 KiB (-P 16) |
Entradas .so stored |
Página de memoria de 16 KB. Ver Código nativo y ELF, sección 6 |
«If your APK contains shared libraries (
.sofiles), use-P 16to ensure that they're aligned to a 16KiB page boundary suitable formmap(2)in both 16KiB and 4KiB devices.»
Y el orden respecto a la firma, que es la fuente número uno de APK rotos: «If you use
apksigner, zipalign must be used before the APK file has been signed» — porque la
firma v2 y superiores cubren todos los bytes del fichero, y mover un byte después de firmar
la invalida. Ver Alineación y zipalign.
11. Lectura tolerante frente a lectura estricta
Un lector estricto valida todo lo validable y aborta ante la primera discrepancia: es lo
correcto para un verificador de seguridad. Un lector tolerante acepta lo que Android
acepta, registra lo que ha perdonado y sigue: es lo que necesita una herramienta de análisis,
porque un APK que no se puede abrir tampoco se puede analizar. libziparchive es estricto, y
sus comprobaciones dicen dónde divergen en la práctica las dos copias de los metadatos:
| Comprobación | Mensaje de error de AOSP |
|---|---|
| Longitud del nombre | "lfh name length did not match central directory" |
| Contenido del nombre | "lfh name did not match central directory" |
| Tamaños y CRC sin descriptor | "size/crc32 mismatch. expected {…}, was {…}" |
| Bit 3 de los flags | "gpb flag mismatch at bit 3" |
| Coherencia zip64 | ambos tamaños deben valer UINT32_MAX a la vez |
Discrepancias reales observadas en el corpus, en orden de frecuencia:
extra field lengthdistinto entreLFHyCentral Directory. Universal en APK alineados. Bitwarden: 9.932 frente a 0. No es un error, es cómo funciona la alineación; un lector que asuma que coinciden calcula mal el offset de los datos.- Bit 3 puesto con los tamaños del
LFHrellenos. En las 30 entradas del.xapkde Google Keep y en el resto de contenedores. La spec dice que deben ser cero. - Firma del
data descriptorpresente aunque sea opcional. Los contenedores la escriben; la especificación no obliga.
La regla operativa, que no admite matices: el Central Directory es la fuente de verdad
sobre qué entradas existen, cómo se llaman, cuánto ocupan y dónde están. El
Local File Header solo aporta un dato que el Central Directory no tiene: su propio
extra field length, imprescindible para localizar el inicio de los datos. Todo lo demás que
diga el LFH es una copia, y cuando la copia contradice al índice, gana el índice.
12. Nota histórica: Master Key y Janus
Contexto de diseño, no guía. Las dos vulnerabilidades explotaban propiedades estructurales del contenedor, y entender cuáles explica por qué el ecosistema quedó como quedó.
Master Key (bug 8219321, 2013). Android verificaba la firma con código Java y cargaba el
código con una implementación en C++. Ambas leían el mismo ZIP y discrepaban ante nombres
duplicados: el verificador, sobre un LinkedHashMap, se quedaba con la última entrada —
«only the last entry with a given name is considered for signature verification: all previous
duplicates are discarded» — y el cargador nativo, sobre una tabla hash con sondeo lineal, con
la primera — «earlier entries are used instead of later ones». Con dos entradas llamadas
classes.dex, se verificaba la legítima y se ejecutaba la maliciosa, con la firma intacta.
Lo explotado: el ZIP no prohíbe nombres duplicados en el Central Directory, y dos
implementaciones pueden resolver el duplicado de forma distinta. El arreglo fue rechazar el
archivo entero ante un nombre repetido.
Janus (CVE-2017-13156, 2017).
«An APK file is a ZIP archive which can contain arbitrary bytes at the start, before and between its ZIP entries. The JAR signature scheme only takes into account the ZIP entries, ignoring any extra bytes when computing or verifying the application's signature.»
Y por el otro lado, un DEX admite bytes arbitrarios al final. Un mismo fichero podía ser
a la vez un DEX válido y un APK válido: se anteponía un DEX malicioso al APK original, el
verificador v1 ignoraba el prefijo y validaba las entradas ZIP intactas, y el runtime cargaba
el DEX del principio. Lo explotado: la firma v1 cubre entradas, no bytes. La firma v2
lo cierra por construcción, porque cubre todos los bytes del fichero.
Ambos casos dejan la misma lección: las propiedades laxas del ZIP no son bugs del formato, son el espacio donde dos implementaciones pueden discrepar, y la discrepancia es la vulnerabilidad.
13. Recetas
Herramientas usadas: unzip y zipinfo del sistema, xxd, python3 3.12, y zipalign de
build-tools 37.0.0. Todas las salidas de esta sección se han ejecutado el 12 de agosto de
2026 sobre el corpus.
13.1 Listado y método de compresión
unzip -l da nombres y tamaños; unzip -v añade el método en la columna Method.
CORPUS=~/corpus-apk
unzip -l $CORPUS/com.termux_1002.apk | head -6
unzip -v $CORPUS/com.x8bit.bitwarden_2026.5.0.apk \
| awk '$0 ~ /\.so$|resources.arsc/ {print $2, $8}' | sort -u | head -3
Archive: ~/corpus-apk/com.termux_1002.apk
Length Date Time Name
--------- ---------- ----- ----
55 01-01-1981 01:01 META-INF/com/android/build/gradle/app-metadata.properties
3138560 01-01-1981 01:01 classes.dex
29392992 01-01-1981 01:01 lib/arm64-v8a/libtermux-bootstrap.so
Stored lib/arm64-v8a/libandroidx.graphics.path.so
Stored lib/arm64-v8a/libbitwarden_uniffi.so
Stored lib/arm64-v8a/libimage_processing_util_jni.so
13.2 zipinfo -v: el EOCD y una entrada
zipinfo -v $CORPUS/com.termux_1002.apk | sed -n '8,9p;13,16p'
zipinfo -v $CORPUS/com.termux_1002.apk resources.arsc \
| grep -E "compression method|extended local header|length of extra|offset of local"
Actual end-cent-dir record offset: 113880045 (0000000006C9ABEDh)
Expected end-cent-dir record offset: 113880045 (0000000006C9ABEDh)
central directory contains 738 entries.
The central directory is 48109 (000000000000BBEDh) bytes long,
and its (expected) offset in bytes from the beginning of the zipfile
is 113831936 (0000000006C8F000h).
offset of local header from start of archive: 112951716
compression method: none (stored)
extended local header: no
length of extra field: 0 bytes
Que «Actual» y «Expected» coincidan es la validación de la sección 6.2; si difieren, hay algo
entre el Central Directory y el EOCD que no debería estar. extended local header: no es
como zipinfo llama al bit 3, y length of extra field es el del Central Directory, no el
del Local File Header — ver 13.4.
13.3 Localizar el EOCD a mano
Sin comentario, el EOCD son los últimos 22 bytes exactos:
tail -c 22 $CORPUS/com.termux_1002.apk | xxd
00000000: 504b 0506 0000 0000 e202 e202 edbb 0000 PK..............
00000010: 00f0 c806 0000 ......
Si empieza por 504b 0506, no hay comentario. Si no, hay que buscar hacia atrás en la
ventana entera. Volcarla con el xxd por defecto no sirve: la firma se parte entre dos
líneas de 16 bytes en cuanto el offset no es múltiplo de 16, que es justo lo que pasa aquí.
Hay que volcar hexadecimal plano en una sola línea.
tail -c 65557 $CORPUS/com.termux_1002.apk | xxd -p -c 65557 | tr -d '\n' \
| grep -bo '504b0506' | tail -1
python3 -c "print(113880067 - 65557 + 131070//2)"
131070:504b0506
113880045
grep -bo da el desplazamiento en caracteres hexadecimales: se divide entre dos y se
suma al inicio de la ventana. El resultado es el «Actual end-cent-dir record offset» de 13.2.
Con el campo de 0x10 del EOCD —00 f0 c8 06 = 0x06C8F000 = 113.831.936— se salta a la
primera entrada del Central Directory:
xxd -s 113831936 -l 48 $CORPUS/com.termux_1002.apk
06c8f000: 504b 0102 0003 0000 0000 0800 2108 2102 PK..........!.!.
06c8f010: dca9 c923 3300 0000 3700 0000 3900 0000 ...#3...7...9...
06c8f020: 0000 0000 0000 0000 b681 0000 0000 4d45 ..............ME
version made by = 0x0300, byte alto 0x03 = Unix; crc-32 = 0x23C9A9DC,
compressed size = 51 y uncompressed size = 55, idénticos a los del Local File Header de
la sección 4; external file attributes en 0x26 = 0x81B60000, cuyos 16 bits altos son el
modo Unix 0o100666; relative offset of local header en 0x2A = 0, porque es la primera
entrada; y desde 0x2E, el nombre, que empieza por ME.
13.4 Ver el relleno de alineación que zipinfo no enseña
xxd -s 15096044 -l 64 $CORPUS/com.x8bit.bitwarden_2026.5.0.apk
00e658ec: 504b 0304 0000 0000 0000 2108 2102 a0ba PK........!.!...
00e658fc: 3497 7027 0000 7027 0000 2a00 cc26 6c69 4.p'..p'..*..&li
00e6590c: 622f 6172 6d36 342d 7638 612f 6c69 6261 b/arm64-v8a/liba
00e6591c: 6e64 726f 6964 782e 6772 6170 6869 6373 ndroidx.graphics
file name length = 2a 00 = 42; extra field length = cc 26 = 9.932, todos ceros. La
entrada equivalente del Central Directory declara extra field length: 0 bytes.
13.5 Comprobar la alineación con zipalign
BT=~/Library/Android/sdk/build-tools/37.0.0
$BT/zipalign -c -P 16 -v 4 $CORPUS/com.x8bit.bitwarden_2026.5.0.apk | tail -5
72831560 res/zQ.xml (OK - compressed)
72832416 res/zV.9.png (OK)
72834919 res/zq.xml (OK - compressed)
72835232 resources.arsc (OK)
Verification successful
Los offsets que imprime son los de los datos, no los de los Local File Header. Las
entradas (OK - compressed) pasan por definición: no se alinean.
⚠️ Atención al interpretar el resultado: com.termux_1002.apk, cuyas .so están
comprimidas, también da Verification successful con -P 16. La comprobación no dice «este
APK cumple la regla de 16 KB», dice «las entradas que debían estar alineadas lo están».
Fuentes
- APPNOTE.TXT — .ZIP File Format Specification, versión 6.3.10 (1 de noviembre de 2022) — https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT Consultado el 12 de agosto de 2026. De aquí salen íntegras las tablas de campos de las secciones 4, 5, 6, 7.1 y 8.2, las firmas de la sección 3, la descripción del bit 3 y los valores de método de compresión de la sección 9.
- AOSP —
system/libziparchive/zip_archive.cc— https://android.googlesource.com/platform/system/libziparchive/+/refs/heads/main/zip_archive.cc Consultado el 12 de agosto de 2026. De aquí salen el algoritmo de búsqueda delEOCDde la sección 6.1, el comentario «Scan backward for the EOCD magic…» y los mensajes de error de la tabla de la sección 11. - AOSP —
system/libziparchive/zip_archive_common.h— https://android.googlesource.com/platform/system/libziparchive/+/refs/heads/main/zip_archive_common.h Consultado el 12 de agosto de 2026. De aquí salekMaxCommentLen = 65535de la sección 6.1 y la confirmación de que AOSP modela las estructuras zip64 (Zip64EocdRecord,Zip64EocdLocator) que se documentan en la sección 8.2. - Behavior changes: Apps targeting Android 11 — Compressed resource files — https://developer.android.com/about/versions/11/behavior-changes-11
Consultado el 12 de agosto de 2026. De aquí sale citada la regla de
resources.arscsin comprimir y alineado a 4 bytes de la sección 10.1. <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 10.2 y la advertencia de que su valor por defecto depende deminSdkVersiony de la versión de AGP.- Android Gradle Plugin 3.6.0 release notes — Native libraries packaged uncompressed by default — 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 10.2). - Android Gradle Plugin 4.2.0 release notes — Use the DSL to package compressed native libraries — https://developer.android.com/build/releases/past-releases/agp-4-2-0-release-notes
Consultado el 12 de agosto de 2026. De aquí sale que
useLegacyPackagingsustituye al atributo del manifiesto (sección 10.2). - zipalign — Android Studio — https://developer.android.com/tools/zipalign
Consultado el 12 de agosto de 2026. De aquí salen las citas sobre
mmap(2), los flags-p,-Py-c, y la regla de orden respecto aapksignerde la sección 10.3. - AOSP —
build/tools/zipalign/ZipAlign.cpp— https://github.com/aosp-mirror/platform_build/blob/master/tools/zipalign/ZipAlign.cpp Consultado el 12 de agosto de 2026. De aquí sale la funcióngetAlignment()citada en la sección 8.1 y la confirmación de que las entradas comprimidas se copian sin alinear. - APK signature scheme v2 — Android Open Source Project — https://source.android.com/docs/security/features/apksigning/v2
Consultado el 12 de agosto de 2026. De aquí sale la descripción de las cuatro secciones
del APK y la posición del
APK Signing Blockde la sección 2. - Exploit (& Fix) Android "Master Key" — Jay Freeman (saurik) — https://www.saurik.com/id/17 Consultado el 12 de agosto de 2026. De aquí salen las dos citas sobre el comportamiento divergente del verificador Java y del cargador C++ de la sección 12.1. Se usa esta fuente porque el bug 8219321 no tiene una descripción técnica equivalente en la documentación oficial de Android.
- Janus Vulnerability: Threats and Mitigation — Guardsquare — https://www.guardsquare.com/blog/new-android-vulnerability-allows-attackers-to-modify-apps-without-affecting-their-signatures-guardsquare Consultado el 12 de agosto de 2026. De aquí sale la cita sobre los bytes arbitrarios al inicio del ZIP y el alcance de la firma v1 de la sección 12.2. Es la fuente del descubridor de la vulnerabilidad.
- Corpus de verificación — 5,9 GB, 86 contenedores y 717 APK: 58 sueltos de F-Droid y 659 dentro de contenedores de splits. Medido el 12 de agosto de 2026. De aquí salen todas las cifras de las secciones 2, 4, 6.3, 7.3, 8.1, 10.1, 10.2 y todas las salidas de la sección 13.