XML binario de Android (AXML)
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 0x0100–0x017f 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 != 0 —ambos 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
0x0000 («string #%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
idel array es elresource IDde la cadenaidel 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 |
Sí |
main (12 de agosto de 2026) |
Sí |
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
frameworks/base/libs/androidfw/include/androidfw/ResourceTypes.h(ramamain) — 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=TEXTy 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 constantesRES_*,SORTED_FLAG,UTF8_FLAG,TYPE_*yCOMPLEX_*.frameworks/base/libs/androidfw/ResourceTypes.cpp(ramamain) — https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/libs/androidfw/ResourceTypes.cpp Consultado el 12 de agosto de 2026. De aquí salenvalidate_chunk(sección 2.3), las dos funcionesdecodeLength(secciones 3.2 y 3.3), la validación deResStringPool::setTo(tabla 7.2),ResXMLTree::setTo,ResXMLTree::validateNode,ResXMLParser::nextNodeyResXMLParser::getAttributeNameResID(sección 6.1).frameworks/base/libs/androidfw/ResourceTypes.cppen las etiquetasandroid-7.1.2_r36,android-11.0.0_r1,android-12.0.0_r1,android-13.0.0_r1,android-14.0.0_r1yandroid-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.frameworks/base/tools/aapt2/Resource.h(ramamain) — 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 estructura0xPPTTEEEEcitada en 5.1 y las constanteskAppPackageId = 0x7fykFrameworkPackageId = 0x01.- 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
aapt22.20-15087165 y un volcador propio enpython3. De aquí salen los volcados y hexdumps de las secciones 3.3, 3.4, 4.3, 6.2 y 8, sobrecom.termux_1002.apk,org.fdroid.fdroid_1023052.apkyapp.pachli_50.apk. - 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,
stringCountinflado, offsets duplicados,attributeSize = 0x18frente al0x14estándar yattributeStartarbitrario. - 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
headerSizementiroso enSTART_ELEMENTy 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. - 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.