XML binario de Android (AXML)

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

Todos los enteros de este documento son little-endian.

1. Qué es y por qué existe

Dentro de un APK no hay ni un solo fichero XML en texto. AndroidManifest.xml y todo lo que cuelga de res/layout/, res/xml/, res/anim/ y los .xml de res/drawable/ están en AXML, el formato binario que produce aapt2 al compilar. Hacer cat AndroidManifest.xml sobre un APK descomprimido devuelve basura: los tres primeros bytes son 03 00 08, no <?x.

La razón es el arranque. PackageManager lee el manifiesto de cada aplicación instalada en cada arranque, y AssetManager infla layouts en el camino crítico de cada Activity. AXML llega ya resuelto: un array plano de estructuras de tamaño fijo, con las cadenas deduplicadas en una tabla al principio y los valores ya convertidos a su tipo final. Un color escrito #ff0000 en el fuente llega al dispositivo como el entero 0xffff0000 etiquetado TYPE_INT_COLOR_ARGB8; nadie vuelve a analizar la cadena.

Se gana lectura rápida, memoria acotada y un fichero mapeable. Se pierde el fuente: AXML no guarda indentación, ni comentarios (reserva un hueco, casi siempre vacío), ni si un booleano se escribió true o TRUE. Toda herramienta que muestre el manifiesto en texto —aapt2 dump xmltree, apktool, jadx— está reconstruyendo una representación plausible, no recuperando el original. Dos manifiestos distintos pueden compilar al mismo AXML, y un AXML admite varias reconstrucciones igualmente válidas. Esa asimetría está detrás de buena parte de los fallos de reempaquetado que recoge Diagnóstico de fallos.

2. El sustrato de chunks

AXML y resources.arsc no son dos formatos: son dos dialectos del mismo sustrato binario. Se documenta aquí; el otro documento enlaza a esta sección en lugar de repetirla.

2.1 ResChunk_header

Toda unidad estructural —un chunk (glosario)— empieza por la misma cabecera de 8 bytes:

Campo Offset Tamaño Tipo Significado
type 0x00 2 uint16_t Identificador de tipo. Su significado depende del chunk contenedor
headerSize 0x02 2 uint16_t Tamaño de esta cabecera. Sumado a la dirección del chunk, apunta a sus datos
size 0x04 4 uint32_t Tamaño total, cabecera incluida. Sumado a la dirección del chunk, apunta al siguiente

La propiedad que hace viable todo lo demás está en el comentario de AOSP sobre size: «Adding this value to the chunk allows you to completely skip its contents (including any child chunks)». Es decir:

Un lector puede saltarse un chunk que no entiende sin perderlo y sin desalinearse.

Eso hace el formato extensible hacia delante y permite algo mejor a una herramienta de edición: leer un fichero, modelar tipadamente solo lo que se entiende, conservar en crudo el resto y reemitir el conjunto byte a byte. Es la razón de que el round-trip byte-exacto sea alcanzable sin implementar el cien por cien del formato.

Los dos tamaños son independientes. headerSize puede ser mayor que la estructura que el lector conoce: significa que una versión posterior de Android añadió campos, y hay que saltar hasta headerSize en vez de asumir el sizeof propio. Es el mismo mecanismo que hace crecer a ResTable_config (sección 7 de Tabla de recursos).

2.2 Los valores de type

Del enum anónimo de ResourceTypes.h:

Nombre simbólico Valor Dónde aparece
RES_NULL_TYPE 0x0000 Relleno; también lo que queda si se pisa una cabecera
RES_STRING_POOL_TYPE 0x0001 AXML y resources.arsc
RES_TABLE_TYPE 0x0002 Raíz de resources.arsc
RES_XML_TYPE 0x0003 Raíz de un fichero AXML
RES_XML_FIRST_CHUNK_TYPE 0x0100 Marca de rango, igual a RES_XML_START_NAMESPACE_TYPE
RES_XML_START_NAMESPACE_TYPE 0x0100 Dentro de RES_XML_TYPE
RES_XML_END_NAMESPACE_TYPE 0x0101 Dentro de RES_XML_TYPE
RES_XML_START_ELEMENT_TYPE 0x0102 Dentro de RES_XML_TYPE
RES_XML_END_ELEMENT_TYPE 0x0103 Dentro de RES_XML_TYPE
RES_XML_CDATA_TYPE 0x0104 Dentro de RES_XML_TYPE
RES_XML_LAST_CHUNK_TYPE 0x017f Marca de rango
RES_XML_RESOURCE_MAP_TYPE 0x0180 Dentro de RES_XML_TYPE. Opcional
RES_TABLE_PACKAGE_TYPE 0x0200 Dentro de RES_TABLE_TYPE
RES_TABLE_TYPE_TYPE 0x0201 Dentro de RES_TABLE_PACKAGE_TYPE
RES_TABLE_TYPE_SPEC_TYPE 0x0202 Dentro de RES_TABLE_PACKAGE_TYPE
RES_TABLE_LIBRARY_TYPE 0x0203 Dentro de RES_TABLE_PACKAGE_TYPE
RES_TABLE_OVERLAYABLE_TYPE 0x0204 Dentro de RES_TABLE_PACKAGE_TYPE
RES_TABLE_OVERLAYABLE_POLICY_TYPE 0x0205 Dentro de RES_TABLE_OVERLAYABLE_TYPE
RES_TABLE_STAGED_ALIAS_TYPE 0x0206 Dentro de RES_TABLE_PACKAGE_TYPE

El rango 0x01000x017f está reservado a nodos del árbol XML: AOSP comprueba type >= RES_XML_FIRST_CHUNK_TYPE && type <= RES_XML_LAST_CHUNK_TYPE para decidir si un chunk es un nodo, así que un tipo desconocido dentro de ese rango llega a nextNode(), que lo ignora y sigue.

2.3 La validación que aplica AOSP

validate_chunk() es el guardián común de los dos formatos. Rechaza un chunk si headerSize es menor que la estructura que se espera leer, si headerSize > size, si (headerSize | size) & 0x3 != 0ambos tamaños múltiplos de 4— o si size excede el final del bloque de datos. Es el suelo de cualquier lector correcto y la base de la sección 7.

3. ResStringPool: la tabla de cadenas

Segundo chunk de todo fichero AXML y primero de resources.arsc. Todas las cadenas —nombres de elemento y de atributo, URIs de namespace, valores de texto— viven aquí una sola vez, y el resto del fichero las referencia por índice mediante un ResStringPool_ref, que es un uint32_t con el índice (0xFFFFFFFF significa «ninguna»).

3.1 ResStringPool_header

Campo Offset Tamaño Tipo Significado
header 0x00 8 ResChunk_header type = RES_STRING_POOL_TYPE = 0x0001
stringCount 0x08 4 uint32_t Número de cadenas
styleCount 0x0c 4 uint32_t Número de arrays de estilo
flags 0x10 4 uint32_t SORTED_FLAG = 1<<0, UTF8_FLAG = 1<<8
stringsStart 0x14 4 uint32_t Offset desde el inicio del chunk a los datos de las cadenas
stylesStart 0x18 4 uint32_t Offset desde el inicio del chunk a los datos de estilo. 0 si styleCount == 0

headerSize vale 28 (0x1c) en todo el corpus, exactamente sizeof(ResStringPool_header). SORTED_FLAG indica que el índice está ordenado por el valor de las cadenas (strcmp16()), lo que permite búsqueda binaria; UTF8_FLAG que están en UTF-8 y, si falta, en UTF-16.

┌───────────────────────────────────────────────────────────────┐
│ ResStringPool_header              (headerSize = 28)           │
├───────────────────────────────────────────────────────────────┤
│ uint32_t offsets[stringCount]     relativos a stringsStart    │
├───────────────────────────────────────────────────────────────┤
│ uint32_t styleOffsets[styleCount] relativos a stylesStart     │
├───────────────────────────────────────────────────────────────┤
│ datos de las cadenas              (en stringsStart)           │
├───────────────────────────────────────────────────────────────┤
│ datos de estilo                   (en stylesStart)            │
└───────────────────────────────────────────────────────────────┘

3.2 Codificación UTF-16

Cada cadena empieza por su longitud en caracteres UTF-16, seguida de los datos y de un terminador 0x0000. Se lee un uint16_t; si el bit 15 está a cero, ese es la longitud; si está a uno, se lee un segundo uint16_t y la longitud es ((primero & 0x7FFF) << 16) | segundo. El máximo es 0x7FFFFFFF. AOSP rechaza la cadena si el carácter en la posición longitud no es 0x0000string #%d is not null-terminated»).

3.3 Codificación UTF-8 y la trampa del doble campo

Aquí está el error clásico. En un pool UTF-8 cada cadena lleva dos longitudes: primero la que tendría en caracteres UTF-16 una vez convertida, y luego la longitud en bytes UTF-8 de los datos que siguen. Cada una se codifica igual entre sí pero distinto que en UTF-16: se lee un uint8_t; si el bit 7 está a uno, se lee un segundo uint8_t y la longitud es ((primero & 0x7F) << 8) | segundo. El máximo es 0x7FFF, o sea 32.767.

Las dos trampas, por frecuencia con que se cae en ellas:

  • Leer una sola longitud. Funciona con cualquier cadena ASCII, porque entonces los dos valores coinciden y el lector se come el segundo byte creyéndolo el primer carácter. Con la primera cadena no ASCII se desalinea.
  • Asumir que las dos longitudes ocupan el mismo número de bytes. Cada campo decide su forma por separado.

Ambas se comprueban en el corpus. Sobre el pool global de org.fdroid.fdroid_1023052.apk (44.157 cadenas, UTF-8), con el volcador propio de la sección 8.2:

primer string con utf16len != utf8len:
  idx=1090  utf16len=11  utf8len=13
  bytes = 0b 0d 25 31 24 73 20 e2 80 93 20 25 32 24 73   →  '%1$s – %2$s'

primer string con longitud en forma larga (bit alto):
  idx=1119  utf16len=1732  utf8len=1732
  primeros bytes = 86 c4 86 c4 33 30 38 32   → '3082035e30820246a0…'

En el primero, 0x0b = 11 caracteres UTF-16 y 0x0d = 13 bytes UTF-8: el guion largo ocupa tres bytes (e2 80 93) y un solo carácter. En el segundo, 0x86 tiene el bit 7 a uno, así que la longitud es ((0x86 & 0x7F) << 8) | 0xc4 = 0x6c4 = 1.732; los dos campos están en forma larga y ocupan cuatro bytes en total. Un lector que asuma un byte por campo empieza a leer en c4 33 30 38 y produce basura.

3.4 Estilos: ResStringPool_span

Si styleCount > 0, tras los offsets de cadena viene un segundo array de uint32_t, uno por estilo, con offsets relativos a stylesStart. Cada entrada apunta a un array de ResStringPool_span terminado por el centinela 0xFFFFFFFF:

Campo Offset Tamaño Tipo Significado
name 0x00 4 ResStringPool_ref Índice en el propio pool de la etiqueta que abrió el span (b, i, u, font…). END = 0xFFFFFFFF cierra el array
firstChar 0x04 4 uint32_t Primer carácter al que se aplica, inclusive
lastChar 0x08 4 uint32_t Último carácter al que se aplica, inclusive

Los índices cuentan caracteres, no bytes, incluso en un pool UTF-8: otra razón por la que la longitud UTF-16 se guarda aparte. Los estilos se asocian a las primeras styleCount cadenas del pool. De app.pachli_50.apk del corpus, cuyo pool global tiene 46.829 cadenas y 137 estilos:

estilo del string [1] = 'A rather recent emoji pack featuring a minimalistic style.\nAll
emojis designed by OpenMoji – the open-source emoji and icon project…'
   ResStringPool_span  name=3118('i')  firstChar=59  lastChar=188

3.5 Por qué el string pool es la pieza delicada del round-trip

Reescribir un pool y obtener los mismos bytes exige reproducir decisiones que el formato no obliga a tomar de una forma concreta: el orden de las cadenas, que no sigue ninguna regla y cuyo cambio reindexa todo el fichero; la deduplicación, porque dos cadenas iguales pueden compartir offset o no y en el corpus se dan los dos casos dentro del mismo fichero; la forma de la longitud, ya que un valor de 100 se puede escribir 0x64 o, sin violar ninguna regla del lector, 0x80 0x64; el relleno hasta el múltiplo de 4 que exige validate_chunk; y stylesStart cuando styleCount == 0, que vale 0 en todo el corpus pero que AOSP ni exige ni lee en ese caso.

Ninguna afecta a la semántica; todas afectan a los bytes. Y ninguna se reconstruye a partir de la lista de cadenas, así que el modelo de un lector que aspire al round-trip no puede ser un Vec<String>: tiene que guardar la codificación observada.

4. El árbol XML

4.1 RES_XML_TYPE y su contenido

El chunk raíz es un ResXMLTree_header, que no añade ningún campo a ResChunk_header: 8 bytes, headerSize = 8. Dentro vienen, en este orden habitual pero no garantizado:

┌──────────────────────────────────────────────────────────────────┐
│ RES_XML_TYPE           headerSize=8   size = fichero completo    │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ RES_STRING_POOL_TYPE                                         │ │
│ ├──────────────────────────────────────────────────────────────┤ │
│ │ RES_XML_RESOURCE_MAP_TYPE          (opcional)                │ │
│ ├──────────────────────────────────────────────────────────────┤ │
│ │ RES_XML_START_NAMESPACE_TYPE                                 │ │
│ │ RES_XML_START_ELEMENT_TYPE   ┐                               │ │
│ │ RES_XML_CDATA_TYPE           │ array plano: la jerarquía la  │ │
│ │ RES_XML_END_ELEMENT_TYPE     │ da el orden START/END         │ │
│ │ …                            ┘                               │ │
│ │ RES_XML_END_NAMESPACE_TYPE                                   │ │
│ └──────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘

No hay árbol. Hay una lista de nodos, y la anidación se deduce del emparejamiento de START_ELEMENT con END_ELEMENT, como en un XmlPullParser. Nada en el formato obliga a que estén bien emparejados; ver sección 7.

4.2 El nodo común: ResXMLTree_node

Los cinco tipos de nodo comparten esta cabecera de 16 bytes:

Campo Offset Tamaño Tipo Significado
header 0x00 8 ResChunk_header headerSize = 16 en todo lo observado
lineNumber 0x08 4 uint32_t Línea del fichero fuente. Informativo
comment 0x0c 4 ResStringPool_ref Comentario asociado. 0xFFFFFFFF si no hay

La extensión propia de cada tipo empieza en nodo + header.headerSize, no en nodo + 16. Es la diferencia entre un lector correcto y uno que funciona por casualidad.

4.3 Las extensiones de cada tipo de nodo

ResXMLTree_namespaceExt, para los tipos 0x0100 y 0x0101. 8 bytes; chunk total 24.

Campo Offset Tamaño Tipo Significado
prefix 0x00 4 ResStringPool_ref Prefijo (android, app, tools, dist)
uri 0x04 4 ResStringPool_ref URI (http://schemas.android.com/apk/res/android)

ResXMLTree_endElementExt, para el tipo 0x0103. 8 bytes; chunk total 24.

Campo Offset Tamaño Tipo Significado
ns 0x00 4 ResStringPool_ref Namespace del elemento que se cierra
name 0x04 4 ResStringPool_ref Nombre del elemento que se cierra

ResXMLTree_cdataExt, para el tipo 0x0104. 12 bytes; chunk total 28.

Campo Offset Tamaño Tipo Significado
data 0x00 4 ResStringPool_ref El texto en crudo
typedData 0x04 8 Res_value El mismo texto ya tipado, si se pudo

AndroidManifest.xml no suele llevar CDATA; los recursos XML sí. Localizado en res/8G.xml de org.fdroid.fdroid_1023052.apk: 19 START_ELEMENT, 19 END_ELEMENT, 7 CDATA y ningún chunk de namespace, lo que es perfectamente legal.

4.4 ResXMLTree_attrExt — tipo 0x0102, START_ELEMENT

20 bytes de extensión, seguidos del array de atributos.

Campo Offset Tamaño Tipo Significado
ns 0x00 4 ResStringPool_ref Namespace del elemento. 0xFFFFFFFF si ninguno
name 0x04 4 ResStringPool_ref Nombre del elemento
attributeStart 0x08 2 uint16_t Offset desde el inicio de esta extensión al primer atributo. Vale 0x14
attributeSize 0x0a 2 uint16_t Tamaño de cada ResXMLTree_attribute. Vale 0x14
attributeCount 0x0c 2 uint16_t Número de atributos
idIndex 0x0e 2 uint16_t Índice base 1 del atributo android:id. 0 si no hay
classIndex 0x10 2 uint16_t Índice base 1 del atributo class. 0 si no hay
styleIndex 0x12 2 uint16_t Índice base 1 del atributo style. 0 si no hay

El atributo i está en extensión + attributeStart + attributeSize * i. Los dos campos deben leerse del fichero, no darse por supuestos: son precisamente los que manipula la ofuscación de manifiestos (sección 7.3).

4.5 ResXMLTree_attribute

20 bytes, y son los 20 bytes que más se leen de todo el APK.

Campo Offset Tamaño Tipo Significado
ns 0x00 4 ResStringPool_ref Namespace del atributo. 0xFFFFFFFF si ninguno
name 0x04 4 ResStringPool_ref Índice en el string pool, no un resource ID. Ver sección 6
rawValue 0x08 4 ResStringPool_ref Valor textual original. 0xFFFFFFFF si aapt2 no lo conservó
typedValue 0x0c 8 Res_value El valor ya tipado

rawValue solo se conserva cuando el valor es una cadena; para un entero o un booleano vale 0xFFFFFFFF. Por eso aapt2 dump xmltree imprime (Raw: "…") solo en algunos atributos.

5. Res_value

Los 8 bytes que representan cualquier valor tipado. Aparecen en AXML (en cada atributo y cada CDATA) y en resources.arsc: son el punto de contacto entre los dos formatos.

Campo Offset Tamaño Tipo Significado
size 0x00 2 uint16_t Tamaño de la estructura. Vale 8
res0 0x02 1 uint8_t Reservado. Siempre 0
dataType 0x03 1 uint8_t Tipo del dato
data 0x04 4 uint32_t El dato, interpretado según dataType

5.1 Los TYPE_*

Nombre Valor data contiene
TYPE_NULL 0x00 0 = DATA_NULL_UNDEFINED, 1 = DATA_NULL_EMPTY
TYPE_REFERENCE 0x01 Un resource ID (0xPPTTEEEE)
TYPE_ATTRIBUTE 0x02 El resource ID de un atributo (?attr/…)
TYPE_STRING 0x03 Índice en el string pool del contenedor
TYPE_FLOAT 0x04 Un float IEEE-754 de precisión simple, reinterpretado
TYPE_DIMENSION 0x05 Número complejo; ver 5.2
TYPE_FRACTION 0x06 Número complejo; ver 5.2
TYPE_DYNAMIC_REFERENCE 0x07 Referencia cuyo PP debe traducirse en tiempo de ejecución
TYPE_DYNAMIC_ATTRIBUTE 0x08 Ídem para atributos
TYPE_FIRST_INT = TYPE_INT_DEC 0x10 Entero, escrito en decimal en el fuente
TYPE_INT_HEX 0x11 Entero, escrito en hexadecimal en el fuente
TYPE_INT_BOOLEAN 0x12 0 = false, distinto de 0 = true
TYPE_FIRST_COLOR_INT = TYPE_INT_COLOR_ARGB8 0x1c #aarrggbb
TYPE_INT_COLOR_RGB8 0x1d #rrggbb
TYPE_INT_COLOR_ARGB4 0x1e #argb
TYPE_INT_COLOR_RGB4 = TYPE_LAST_COLOR_INT = TYPE_LAST_INT 0x1f #rgb

La distinción entre 0x10 y 0x11, y entre las cuatro variantes de color, solo existe para poder reimprimir el valor como se escribió. El sistema los trata igual. Un reconstructor a texto que ignore dataType produce XML válido pero distinto del original.

5.2 Números complejos: COMPLEX_*

TYPE_DIMENSION y TYPE_FRACTION empaquetan mantisa, radix y unidad en los 32 bits de data:

 31                                8   7     6 5 4   3 2 1 0
┌────────────────────────────────────┬───────┬───────┬───────┐
│ mantisa con signo (24 bits)        │ radix │  ---  │ unit  │
└────────────────────────────────────┴───────┴───────┴───────┘
  MANTISSA_SHIFT = 8                  RADIX_SHIFT=4   UNIT_SHIFT=0
  MANTISSA_MASK = 0xffffff            MASK = 0x3      MASK = 0xf
Constante Valor Significado
COMPLEX_UNIT_PX 0 TYPE_DIMENSION: píxeles
COMPLEX_UNIT_DIP 1 dp
COMPLEX_UNIT_SP 2 sp
COMPLEX_UNIT_PT 3 puntos
COMPLEX_UNIT_IN 4 pulgadas
COMPLEX_UNIT_MM 5 milímetros
COMPLEX_UNIT_FRACTION 0 TYPE_FRACTION: fracción del tamaño propio (%)
COMPLEX_UNIT_FRACTION_PARENT 1 Fracción del tamaño del padre (%p)
COMPLEX_RADIX_23p0 0 Mantisa entera, 0xnnnnnn.0
COMPLEX_RADIX_16p7 1 0xnnnn.nn
COMPLEX_RADIX_8p15 2 0xnn.nnnn
COMPLEX_RADIX_0p23 3 0x0.nnnnnn

El valor real es mantisa * 2^(-tabla[radix]) con tabla = {0, 7, 15, 23}, multiplicado después por el factor de la unidad. Los bits 6 y 7 no están asignados por ningún enum de ResourceTypes.h. ⚠️ sin verificar: la cabecera no documenta esos dos bits; se han observado a cero en los valores complejos leídos del corpus, pero la especificación no lo garantiza.

6. Cómo se resuelve el nombre de un atributo

Este es el punto que más confunde.

El campo name de ResXMLTree_attribute es un índice en el string pool, no un resource ID. Para android:versionCode la cadena guardada es simplemente "versionCode", sin prefijo, y el nombre es ambiguo: podría ser el atributo del framework 0x0101021b o uno propio de la aplicación que se llame igual. La desambiguación no está en la cadena: está en el resource map chunk.

6.1 El mecanismo

RES_XML_RESOURCE_MAP_TYPE (0x0180) es un chunk cuya carga útil es un array plano de uint32_t de longitud (size - headerSize) / 4. La regla es:

El elemento i del array es el resource ID de la cadena i del string pool.

El array es paralelo al principio del string pool. Si un atributo referencia la cadena de índice 6 y el resource map tiene al menos 7 entradas, su resource ID es resIds[6]. Si el índice cae fuera del array, el atributo no tiene resource ID: es propio de la aplicación o uno de los pseudoatributos que aapt2 inventa (package, platformBuildVersionCode). Literalmente, de ResXMLParser::getAttributeNameResID:

int32_t id = getAttributeNameID(idx);
if (id >= 0 && (size_t)id < mTree.mNumResIds) {
    uint32_t resId = dtohl(mTree.mResIds[id]);
    …
    return resId;
}
return 0;

Dos consecuencias. Primera: las primeras N cadenas del pool de un manifiesto no son arbitrarias; son los nombres de los atributos del framework que el fichero usa, en el mismo orden que el resource map. Reordenar el pool sin reordenar el resource map rompe el fichero. Segunda: el resource ID es la autoridad, no el nombre.

6.2 Un ejemplo real, byte a byte

Sobre com.termux_1002.apk. El chunk del resource map está en +0x1810:

00001810: 8001 0800 a000 0000 0000 0101 0100 0101  ................
00001820: 0200 0101 0300 0101 0600 0101 0900 0101  ................

type = 0x0180, headerSize = 0x0008, size = 0x000000a0 = 160. El array tiene (160 - 8) / 4 = 38 entradas: 0x01010000, 0x01010001, 0x01010002… Y el principio del string pool, en paralelo:

Índice Resource map Cadena del pool
0 0x01010000 theme
1 0x01010001 label
2 0x01010002 icon
3 0x01010003 name
6 0x0101000b sharedUserId
20 0x0101021b versionCode
35 0x01010572 compileSdkVersion
37 0x0101057a appComponentFactory

Los resource IDs van en orden creciente, que es como aapt2 los emite; de la cadena 38 en adelante ya no hay cobertura. El elemento <manifest> está en +0x18c8:

000018c8: 0201 1000 ec00 0000 0200 0000 ffff ffff  ................
000018d8: ffff ffff 6900 0000 1400 1400 0a00 0000  ....i...........
000018e8: 0000 0000 6600 0000 0600 0000 5200 0000  ....f.......R...
000018f8: 0800 0003 5200 0000 6600 0000 1400 0000  ....R...f.......

type = 0x0102, headerSize = 16, size = 0xec = 236, lineNumber = 2, comment = 0xFFFFFFFF. La extensión empieza en +0x18d8: ns = 0xFFFFFFFF, name = 0x69 = 105 ("manifest"), attributeStart = 0x14, attributeSize = 0x14, attributeCount = 10. Y 16 + 20 + 20 × 10 = 236, que es exactamente size.

El primer atributo empieza en 0x18d8 + 0x14 = 0x18ec: ns = 102 (el URI de android), name = 6, rawValue = 82, typedValue = {size:8, res0:0, dataType:0x03, data:82}. Se resuelve resIds[6] = 0x0101000b = android:sharedUserId, de tipo TYPE_STRING apuntando a la cadena 82, "com.termux". Que es justo lo que imprime aapt2 en la receta 8.1.

Los diez atributos, resueltos con el script de la sección 8.2:

  #   name (pool)                  resmap     dataType data
  0   'sharedUserId'               0x0101000b 0x03     0x00000052  raw='com.termux'
  1   'versionCode'                0x0101021b 0x10     0x000003ea  raw=—
  2   'versionName'                0x0101021c 0x03     0x00000026  raw='0.118.3'
  3   'sharedUserLabel'            0x01010261 0x01     0x7f1000c5  raw=—
  4   'installLocation'            0x010102b7 0x10     0x00000001  raw=—
  5   'compileSdkVersion'          0x01010572 0x10     0x0000001e  raw=—
  6   'compileSdkVersionCodename'  0x01010573 0x03     0x00000027  raw='11'
  7   'package'                    —          0x03     0x00000052  raw='com.termux'
  8   'platformBuildVersionCode'   —          0x10     0x0000001e  raw=—
  9   'platformBuildVersionName'   —          0x10     0x0000000b  raw=—

Los tres últimos no tienen resource ID: sus índices caen fuera de las 38 entradas. El atributo 3 tiene dataType = 0x01 (TYPE_REFERENCE) y data = 0x7f1000c5, un resource ID del paquete de la aplicación: para saber qué texto es hay que ir a resources.arsc. Los dos formatos no se entienden por separado.

6.3 Consecuencia para quien edita

Añadir una cadena al pool obliga a reindexar todos los nodos, todos los atributos y —si entra antes de la posición 38 del ejemplo— también el resource map. Por eso una edición segura añade al final del pool y nunca inserta en medio, aunque rompa el orden que traía aapt2.

7. Manifiestos malformados: ofuscación y evasión de análisis

Un AXML puede ser rechazado por aapt2, apktool y jadx y seguir instalándose. Esa asimetría es una técnica de evasión documentada y presente en familias de malware reales. El corpus de verificación es de aplicaciones legítimas y no contiene ninguna muestra malformada; lo que sigue procede de AOSP y de literatura publicada, y está marcado como tal.

7.1 De dónde nace la asimetría

El parser del sistema es permisivo por diseño. ResXMLTree::setTo recorre los chunks de nivel superior y, para cualquier tipo que no sea string pool, resource map o nodo XML, hace literalmente Skipping unknown chunk! y sigue. ResXMLParser::nextNode hace lo mismo con los tipos de nodo desconocidos: continue. Una herramienta que aborte donde el sistema continúa se queda sin datos que el dispositivo sí verá.

7.2 Lo que AOSP sí rechaza

Condición Reacción de AOSP
headerSize menor que la estructura esperada BAD_TYPE, fichero rechazado
headerSize > size BAD_TYPE
headerSize o size no múltiplo de 4 BAD_TYPE
size del chunk raíz mayor que el fichero BAD_TYPE
stringCount * 4 + headerSize > size del pool BAD_TYPE, pool entero descartado
stringsStart >= size - 2 BAD_TYPE
stylesStart <= stringsStart con styleCount > 0 BAD_TYPE
Última cadena del pool no terminada en cero BAD_TYPE
Cadena concreta no terminada en 0x0000 Esa cadena devuelve nulo; el resto sigue
size - headerSize menor que la extensión mínima del nodo BAD_DOCUMENT
En START_ELEMENT, attributeStart + attributeSize * attributeCount > size - headerSize BAD_TYPE
No se encuentra ningún nodo raíz BAD_TYPE

Todo lo que no está en esta lista pasa.

7.3 Técnicas observadas en la literatura

Técnica Campo manipulado Efecto en el analizador Qué hace Android
Magia alterada type del chunk raíz, 0x0003 → otro Herramientas que validan el tipo rechazan el fichero Depende de la versión: ver 7.4
stringCount inflado ResStringPool_header.stringCount Lecturas fuera del array de offsets, y en algunas herramientas fuera del fichero Rechaza si desborda size; si cabe, las cadenas sobrantes salen basura y el fichero se lee
Offsets duplicados o fuera de rango array de offsets del pool Cadenas irresolubles, bucles, caídas La cadena concreta devuelve nulo; el resto sigue
headerSize mentiroso en START_ELEMENT ResChunk_header.headerSize El analizador lee la extensión en el sitio equivocado Lee en nodo + headerSize, que es lo que el atacante quiso
Datos basura entre nodos ResChunk_header.size inflado Herramientas que avanzan por sizeof se desalinean Avanza por size y salta la basura
attributeSize distinto de 0x14 ResXMLTree_attrExt.attributeSize Herramientas que asumen 20 bytes leen atributos desplazados Usa el campo y lee bien
attributeStart arbitrario ResXMLTree_attrExt.attributeStart Ídem Ídem
Chunk de tamaño cero ResChunk_header.size = 0 Bucle infinito en cualquier lector que haga off += size sin comprobar validate_chunk lo rechaza: size < headerSize
Namespaces sin cerrar, END_ELEMENT de más ninguno; el orden de los nodos Los reconstructores a texto producen XML no balanceado o abortan El parser es de eventos: no comprueba emparejamiento

Las cuatro filas centrales comparten forma: el campo existe en el formato, el atacante lo usa como el formato manda, y quien se rompe es el analizador que dio por supuesto un valor constante. No es corrupción: es leer la especificación con más cuidado que el defensor.

7.4 El caso de la magia, con fecha

La afirmación «Android no valida el tipo del chunk raíz» circula en artículos de análisis de malware. Es cierta hasta Android 14 e incorrecta desde Android 15. Verificado descargando libs/androidfw/ResourceTypes.cpp de seis etiquetas de AOSP y buscando la comprobación en ResXMLTree::setTo:

Etiqueta de AOSP ¿Existe type != RES_XML_TYPE?
android-7.1.2_r36, android-11.0.0_r1, android-12.0.0_r1 No
android-13.0.0_r1, android-14.0.0_r1 No
android-15.0.0_r1
main (12 de agosto de 2026)

Un lector de terceros tiene que decidir qué versión de Android imita. Para una herramienta de análisis solo hay una respuesta segura: leer lo que el dispositivo más permisivo aceptaría y decirlo en el informe.

⚠️ sin verificar: ninguna de las técnicas de 7.3 se ha podido reproducir sobre una muestra real, porque el corpus no contiene malware. Lo verificado leyendo código es la columna «qué hace Android» y la tabla entera de 7.2; la columna «efecto en el analizador» procede de las fuentes 6 y 7.

8. Recetas

Máquina de referencia: macOS, aapt2 2.20-15087165 de las build-tools 37.0.0.

8.1 Volcado del árbol y hexdump del manifiesto

BT=~/Library/Android/sdk/build-tools/37.0.0
CORPUS=~/corpus-apk
$BT/aapt2 dump xmltree --file AndroidManifest.xml $CORPUS/com.termux_1002.apk | head -6
N: android=http://schemas.android.com/apk/res/android (line=2)
  E: manifest (line=2)
    A: http://schemas.android.com/apk/res/android:sharedUserId(0x0101000b)="com.termux" (Raw: "com.termux")
    A: http://schemas.android.com/apk/res/android:versionCode(0x0101021b)=1002
    A: http://schemas.android.com/apk/res/android:versionName(0x0101021c)="0.118.3" (Raw: "0.118.3")
    A: http://schemas.android.com/apk/res/android:sharedUserLabel(0x01010261)=@0x7f1000c5

Los mismos bytes, en crudo:

unzip -o -q $CORPUS/com.termux_1002.apk AndroidManifest.xml -d /tmp/t
xxd -l 32 /tmp/t/AndroidManifest.xml
00000000: 0300 0800 e833 0000 0100 1c00 0818 0000  .....3..........
00000010: 7a00 0000 0000 0000 0000 0000 0402 0000  z...............

Anotado a mano:

Bytes Campo Valor
03 00 header.type 0x0003 = RES_XML_TYPE
08 00 header.headerSize 8
e8 33 00 00 header.size 0x33e8 = 13.288, el fichero entero
01 00 type del primer subchunk 0x0001 = RES_STRING_POOL_TYPE
1c 00 headerSize 28
08 18 00 00 size 0x1808 = 6.152
7a 00 00 00 stringCount 122
00 00 00 00 styleCount 0
00 00 00 00 flags 0 → UTF-16, no ordenado
04 02 00 00 stringsStart 0x204, relativo al inicio del pool (+0x08)

Y la primera cadena, en 0x08 + 0x204 = 0x20c:

xxd -s 0x20c -l 16 /tmp/t/AndroidManifest.xml
0000020c: 0500 7400 6800 6500 6d00 6500 0000 0500  ..t.h.e.m.e.....

05 00 = 5 caracteres, 74 00 68 00 65 00 6d 00 65 00 = theme en UTF-16LE, 00 00 = terminador. La cadena siguiente empieza acto seguido con su propio 05 00.

8.2 Volcado de chunks propio

El volcador que produce las salidas de 3.3, 3.4 y 6.2 son unas 140 líneas de python3 sin dependencias. Su núcleo:

def walk(b, off, end, depth):
    while off + 8 <= end:
        t  = u16(b, off)
        hs = u16(b, off + 2)
        size = u32(b, off + 4)
        print("%s+0x%06x %-30s headerSize=%d size=%d"
              % ("  " * depth, off, NOMBRES.get(t, "?"), hs, size))
        if size == 0:            # defensa contra el bucle infinito de 7.3
            return
        if t in (RES_XML_TYPE, RES_TABLE_TYPE, RES_TABLE_PACKAGE_TYPE):
            walk(b, off + hs, off + size, depth + 1)   # descender: off + hs, no off + 8
        off += size              # avanzar: off + size, no off + sizeof(struct)

Las dos líneas que importan son las dos últimas: descender por headerSize y avanzar por size. Un lector que use sizeof en cualquiera de las dos se rompe con el primer fichero producido por una versión de aapt2 distinta de la suya, y con todos los de la sección 7. Sobre el manifiesto de Termux:

fichero: AndroidManifest.xml  (13288 bytes)
+0x000000 RES_XML_TYPE                    headerSize=8   size=13288
  +0x000008 RES_STRING_POOL_TYPE            headerSize=28  size=6152 stringCount=122 styleCount=0 UTF-16
  +0x001810 RES_XML_RESOURCE_MAP_TYPE       headerSize=8   size=160 ids=38
  +0x0018b0 RES_XML_START_NAMESPACE_TYPE    headerSize=16  size=24
  +0x0018c8 RES_XML_START_ELEMENT_TYPE      headerSize=16  size=236
  +0x0019b4 RES_XML_START_ELEMENT_TYPE      headerSize=16  size=76
  +0x001a00 RES_XML_END_ELEMENT_TYPE        headerSize=16  size=24
  …

8.3 apktool

apktool d -f -o salida app.apk
# el manifiesto reconstruido queda en salida/AndroidManifest.xml, ya en texto

⚠️ no ejecutado localmente: apktool no está instalado en la máquina de referencia. -f fuerza el borrado del directorio de destino y -o fija su nombre, según la documentación oficial de parámetros de la CLI (fuente 8).

Fuentes

  1. frameworks/base/libs/androidfw/include/androidfw/ResourceTypes.h (rama main) — https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/libs/androidfw/include/androidfw/ResourceTypes.h Consultado el 12 de agosto de 2026, descargado con ?format=TEXT y decodificado. De aquí salen íntegras las tablas de campos de las secciones 2.1, 2.2, 3.1, 3.4, 4.2 a 4.5 y 5, y las constantes RES_*, SORTED_FLAG, UTF8_FLAG, TYPE_* y COMPLEX_*.
  2. frameworks/base/libs/androidfw/ResourceTypes.cpp (rama main) — https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/libs/androidfw/ResourceTypes.cpp Consultado el 12 de agosto de 2026. De aquí salen validate_chunk (sección 2.3), las dos funciones decodeLength (secciones 3.2 y 3.3), la validación de ResStringPool::setTo (tabla 7.2), ResXMLTree::setTo, ResXMLTree::validateNode, ResXMLParser::nextNode y ResXMLParser::getAttributeNameResID (sección 6.1).
  3. frameworks/base/libs/androidfw/ResourceTypes.cpp en las etiquetas android-7.1.2_r36, android-11.0.0_r1, android-12.0.0_r1, android-13.0.0_r1, android-14.0.0_r1 y android-15.0.0_r1 — https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-15.0.0_r1/libs/androidfw/ResourceTypes.cpp Consultado el 12 de agosto de 2026, las seis etiquetas. De aquí sale la tabla 7.4 sobre cuándo se introdujo la validación del tipo del chunk raíz.
  4. frameworks/base/tools/aapt2/Resource.h (rama main) — https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/tools/aapt2/Resource.h Consultado el 12 de agosto de 2026. De aquí sale la estructura 0xPPTTEEEE citada en 5.1 y las constantes kAppPackageId = 0x7f y kFrameworkPackageId = 0x01.
  5. 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 con aapt2 2.20-15087165 y un volcador propio en python3. De aquí salen los volcados y hexdumps de las secciones 3.3, 3.4, 4.3, 6.2 y 8, sobre com.termux_1002.apk, org.fdroid.fdroid_1023052.apk y app.pachli_50.apk.
  6. Unpacking the Unpackable: Malformed APKs as an Anti-Analysis Technique — Cleafy Labs — https://www.cleafy.com/cleafy-labs/malformed-apks-as-an-anti-analysis-technique-malfixer-tool Consultado el 12 de agosto de 2026. De aquí salen las filas de la tabla 7.3 sobre magia alterada, stringCount inflado, offsets duplicados, attributeSize = 0x18 frente al 0x14 estándar y attributeStart arbitrario.
  7. Technical Analysis of Multi-layered Obfuscation Techniques in AndroidManifest.xml Aimed at Evading Static Analysis — Lian Security — https://www.liansecurity.com/news/article/H_NoQIoBE2npFSfF-iQ5 Consultado el 12 de agosto de 2026. De aquí salen las filas de la tabla 7.3 sobre headerSize mentiroso en START_ELEMENT y datos basura entre nodos. Su afirmación de que Android no valida el tipo del chunk raíz se ha contrastado contra AOSP y corregido con fecha en 7.4.
  8. apktool — parámetros de la CLI — https://apktool.org/docs/cli-parameters Consultado el 12 de agosto de 2026. De aquí sale la sintaxis de la receta 8.3, que no se ha podido ejecutar.