El contenedor ZIP de un APK

referencia técnica · Revisado el 12 de agosto de 2026

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:

  1. 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 un EOCD falso. La defensa es validar que offset_del_EOCD + 22 + comment_length == tamaño_del_fichero; zipinfo la aplica, y por eso imprime «Actual» y «Expected end-cent-dir record offset».
  2. La firma puede aparecer dentro de los datos de la última entrada, si el comentario es corto y la entrada termina cerca del final.
  3. Es un vector de ambigüedad deliberada. Dos lectores con criterios distintos —el primero desde el final frente al último válido— eligen EOCD distintos, y por tanto Central Directory distintos, 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.arsc file 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 extractNativeLibs to "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:

«zipalign is 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 via mmap(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 (.so files), use -P 16 to ensure that they're aligned to a 16KiB page boundary suitable for mmap(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:

  1. extra field length distinto entre LFH y Central 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.
  2. Bit 3 puesto con los tamaños del LFH rellenos. En las 30 entradas del .xapk de Google Keep y en el resto de contenedores. La spec dice que deben ser cero.
  3. Firma del data descriptor presente 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 EOCD00 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

  1. 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.
  2. 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 del EOCD de 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.
  3. 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í sale kMaxCommentLen = 65535 de 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.
  4. 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.arsc sin comprimir y alineado a 4 bytes de la sección 10.1.
  5. <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 de minSdkVersion y de la versión de AGP.
  6. 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 extractNativeLibs a "false" por defecto (sección 10.2).
  7. 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 useLegacyPackaging sustituye al atributo del manifiesto (sección 10.2).
  8. 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, -P y -c, y la regla de orden respecto a apksigner de la sección 10.3.
  9. 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ón getAlignment() citada en la sección 8.1 y la confirmación de que las entradas comprimidas se copian sin alinear.
  10. 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 Block de la sección 2.
  11. 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.
  12. 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.
  13. 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.