Pipeline de análisis
Antes conviene leer «Anatomía de un APK» · «Glosario»
1. Qué es y por qué existe
Los demás documentos explican piezas: el formato DEX, el APK Signing Block, qué
hace jadx. Este explica una secuencia, y la secuencia importa porque el orden de los pasos
determina cuánto tiempo se pierde.
El error caro del análisis de un APK no es equivocarse leyendo bytecode. Es decompilar antes
de mirar el manifiesto: invertir cuarenta minutos y ocho gigabytes de RAM en producir un árbol
de fuentes de una aplicación protegida con un packer, o que ni siquiera era un APK sino un ZIP
con veintisiete splits dentro. Los seis pasos están ordenados por coste creciente y valor
decreciente: los cuatro primeros son casi instantáneos y muchas veces suficientes; el quinto
—decompilar— es el que puede fallar, tardar y mentir.
coste ─────────────────────────────────────────────────────────────────▶
0 1 2 3 4 5 6
identificar inventariar manifiesto firma protecciones decompilar navegar
~15 ms ~25 ms ~0,2 s ~0,2 s ~0,2 s 10 s-10 min horas
◀───────────────────────────────────────────────────── valor por segundo
La regla que resume el documento: el manifiesto, la firma y el inventario del ZIP dan un perfil completo del comportamiento observable de la aplicación sin decompilar una sola línea («sección 5 de Anatomía de un APK · Qué sobrevive siempre a la ofuscación»). El paso 5 solo se ejecuta cuando queda una pregunta que los cuatro anteriores no han respondido.
Aquí no se repite el uso de las herramientas —eso está en «30-herramientas»—, sino el encadenado: el orden, su porqué y dónde se rompe cada eslabón.
Sobre las recetas. Las de
aapt2,apksigner,zipalignydexdumpestán ejecutadas en la máquina de referencia el 12 de agosto de 2026 y la salida pegada es la real. Las que no se han reproducido llevan aviso explícito en su sección.
2. Paso 0 — Identificar qué tienes
2.1 Por qué nunca por extensión
La extensión de un contenedor Android es un rótulo que pone quien lo distribuye, y miente con
frecuencia medible: en el «muestrario», 14 de los 16 ficheros con extensión .apks no son un
APK Set, son .xapk de APKCombo con la extensión cambiada. La tabla que diferencia todos
los formatos —qué es cada uno, cómo se reconoce y qué se puede hacer con él— está en
«Formatos y extensiones»; la estructura interna
del AAB y del APK Set, en
«AAB, splits y contenedores → §6 · Detección por estructura, nunca por extensión».
La detección correcta lee el Central Directory del ZIP y busca evidencias estructurales. No
descomprime nada salvo, como mucho, un manifest.json de dos kilobytes.
2.2 El algoritmo, en orden de precedencia
El orden no es una optimización: las evidencias se solapan. Un .apkm tiene entradas *.apk en
la raíz, así que también cumple la regla del zip plano, y comprobarla antes clasificaría mal ocho
contenedores del muestrario.
abrir como ZIP ── falla ──▶ no es un contenedor Android
│
¿toc.pb en la raíz? ────────── sí ──▶ APK Set genuino de bundletool
│ no
¿info.json + META-INF/*.RSA? ─ sí ──▶ .apkm de APKMirror
│ no
¿manifest.json con xapk_version? sí ▶ .xapk de APKCombo o APKPure
│ no
¿varias entradas *.apk? ────── sí ──▶ zip plano de splits (SAI y similares)
│ no
¿AndroidManifest.xml en raíz? ─ sí ──▶ APK suelto ← el único analizable directamente
│ no
¿BundleConfig.pb + base/manifest/? ─▶ .aab ← hay que pasar por bundletool
│ no
DESCONOCIDO — se informa, no se adivina
Traducido a código son doce comparaciones sobre zipfile.ZipFile(ruta).namelist(), con una única
sutileza: raiz = [x for x in n if "/" not in x.rstrip("/")], porque las evidencias de los pasos
1 a 3 y 5 tienen que estar en la raíz del ZIP y no en cualquier subdirectorio. El paso 3
además abre el manifest.json y comprueba que la clave xapk_version existe; que el fichero se
llame así no basta.
Ejecutado sobre los contenedores del muestrario, comparando el veredicto con lo que promete la extensión (salida real, recortada):
APK-SET 43 entradas com.ejemplo.fixture.apks
APKM 18 entradas tv.twitch.android.app.apkm
XAPK 30 entradas GoogleKeep_com.google.android.keep.xapk
XAPK 17 entradas com.whatsapp.apks ← la extensión .apks dice APK-SET
XAPK 30 entradas com.google.android.keep.apks ← la extensión .apks dice APK-SET
XAPK 39 entradas com.google.android.gm.apks ← la extensión .apks dice APK-SET
ZIP-SPLITS 39 entradas com.netflix.mediaclient.sai.zip
AAB 12 entradas com.ejemplo.fixture.aab
Catorce desajustes, todos del mismo tipo. El desajuste no es un error: es un dato del informe. Se continúa con el formato detectado y se advierte.
2.3 Qué hacer con cada tipo
| Tipo detectado | Siguiente paso |
|---|---|
APK suelto |
Directo al paso 1 |
XAPK, APKM, APK-SET, ZIP-SPLITS |
Extraer, hallar el base («§2.4 · Encontrar el base dentro de un contenedor») y analizarlo. Si hace falta la aplicación como unidad, «fusionar» |
AAB |
No se analiza como APK: no lleva resources.arsc sino resources.pb. Pasar por bundletool build-apks |
NO-ES-ZIP / DESCONOCIDO |
Parar y decirlo. Un APK corrupto no se analiza a medias |
2.4 Encontrar el base dentro de un contenedor
El base es el único split sin atributo split en su <manifest>. El nombre del fichero no
sirve: en los .xapk se llama <paquete>.apk, en los .apkm base.apk, y en un APK Set
splits/base-master.apk.
for f in *.apk; do
s=$($BT/aapt2 dump badging "$f" 2>/dev/null | head -1 | grep -o "split='[^']*'")
echo "${s:-BASE} $f"
done
Los .xapk y los .apkm traen además un JSON descriptivo —manifest.json con split_apks,
info.json con pname— que ahorra el barrido, pero lo escribe el sitio de descargas y no es
autoridad sobre el contenido. Sirve para orientarse; el veredicto lo da el manifiesto.
3. Paso 1 — Inventariar
3.1 Qué se aprende sin abrir nada
El Central Directory da gratis: cuántas entradas hay, cuánto ocupa cada una sin comprimir, con
qué método está comprimida, cuántos DEX hay, qué ABIs se empaquetan y si existe resources.arsc.
Eso responde ya a preguntas caras: si hay código nativo, si es multidex, si los recursos van
mapeables, si trae baseline profile.
El script reutilizable recorre zipfile.ZipFile(apk).infolist() y acumula tres cosas por
categoría de ruta: número de entradas, file_size —el tamaño sin comprimir— y el reparto de
compress_type. Las nueve categorías son las de la «sección 3.2 de
Anatomía de un APK · Las categorías», con una distinción que hay que
hacer a mano: dentro de META-INF/, separar las entradas de firma v1 —MANIFEST.MF y los
*.SF / *.RSA / *.DSA / *.EC— del resto, porque son cosas sin relación.
Ejecutado sobre com.aurora.store_75.apk del muestrario:
categoria n bytes sin compr. metodo
1 manifiesto 1 21.336 1 deflate
2 dex 2 12.555.168 2 deflate
3 resources.arsc 1 2.551.720 1 stored
4 res/ 984 1.253.449 224 stored · 760 deflate
5 lib/ 4 37.392 4 stored
6 assets/ 4 760.550 2 stored · 2 deflate
7 META-INF firma v1 3 217.583 3 deflate
8 META-INF resto 129 83.781 129 deflate
9 raiz y otros 21 171.049 21 deflate
ABIs : ['arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64']
DEX : 2 → ['classes.dex', 'classes2.dex']
resources.arsc: 2551720 bytes, stored
3.2 Cómo se lee ese inventario
| Observación | Qué significa |
|---|---|
resources.arsc stored |
Correcto. Desde targetSdk 30 es obligatorio; si va deflate la instalación falla («§10.1 de Contenedor ZIP · resources.arsc sin comprimir y alineado a 4 bytes») |
resources.arsc ausente |
No es un APK completo: es un split de solo código o de solo .so, o el contenedor se identificó mal |
Varios DEX |
Multidex. La numeración no tiene huecos y el orden es semántico: gana la primera definición |
| 4 ABIs | APK universal: no viene de Play. Cuatro directorios no implican cuatro arquitecturas realmente soportadas |
lib/ stored |
extractNativeLibs="false": las .so se cargan del APK y tienen que ir alineadas |
assets/ grande y opaco |
Modelos, bases de datos precargadas… y el payload de un packer («§6.4 · Comprobación 3 — la forma del DEX») |
META-INF resto con *.version |
La huella del stack: cada biblioteca AndroidX deja su fichero |
Ese último punto es el más infravalorado: el listado de META-INF/ y de la raíz identifica el
stack tecnológico completo sin abrir un solo DEX («sección 3.3 de
Anatomía de un APK · Categoría por categoría»).
4. Paso 2 — Leer el manifiesto
4.1 Las dos vistas, y hacen falta las dos
aapt2 dump badging es la vista interpretada: resume lo que el instalador y Play leen,
con los permisos implícitos ya resueltos. Es la primera orden de cualquier análisis.
$BT/aapt2 dump badging $MUESTRAS/com.aurora.store_75.apk | head -6
package: name='com.aurora.store' versionCode='75' versionName='4.8.3' platformBuildVersionName='17' platformBuildVersionCode='37' compileSdkVersion='37' compileSdkVersionCodename='17'
minSdkVersion:'23'
targetSdkVersion:'37'
uses-permission: name='android.permission.INTERNET'
uses-permission: name='android.permission.ACCESS_NETWORK_STATE'
uses-permission: name='android.permission.FOREGROUND_SERVICE'
aapt2 dump xmltree es la vista cruda: el árbol AXML con los identificadores de atributo
tal como están en el binario. Hace falta para todo lo que badging no resume —y del
<application> badging solo resume la etiqueta, el icono y las marcas testOnly,
application-isGame y application-debuggable.
$BT/aapt2 dump xmltree $MUESTRAS/com.aurora.store_75.apk --file AndroidManifest.xml \
| grep -E "E: application|debuggable|usesCleartextTraffic|networkSecurityConfig|allowBackup|extractNativeLibs"
E: application (line=72)
A: http://schemas.android.com/apk/res/android:allowBackup(0x01010280)=true
A: http://schemas.android.com/apk/res/android:extractNativeLibs(0x010104ea)=false
Lo que no aparece, no está declarado, y eso también es información: debuggable,
usesCleartextTraffic y networkSecurityConfig tienen valor por defecto y hay que conocerlo
antes de concluir nada.
4.2 Los diez campos que se miran
| Campo | Dónde | Qué dice |
|---|---|---|
package |
<manifest> |
La identidad real. No es el nombre del fichero: en el muestrario, org.fossify.calculator_1.4.0.apk declara org.fossify.math |
versionCode / versionName |
<manifest> |
El primero es el que compara el instalador; el segundo es texto para humanos |
minSdkVersion |
<uses-sdk> |
Suelo de compatibilidad. Cambia el veredicto de apksigner verify («§5.2 · La trampa: verify no informa de los esquemas presentes») |
targetSdkVersion |
<uses-sdk> |
Qué comportamientos del sistema se aplican. ≥ 30 obliga a resources.arsc sin comprimir; fija el esquema de firma mínimo |
uses-permission |
<manifest> |
La superficie de capacidades. Un permiso no declarado no se obtiene: es un límite duro, no una pista |
exported |
componentes | Qué puede invocar otra aplicación: la superficie de ataque local («§4.3 · Componentes exportados, con la regla del valor implícito») |
android:debuggable |
<application> |
true en un APK de distribución permite acoplar un depurador. Por defecto false |
android:usesCleartextTraffic |
<application> |
Tráfico HTTP en claro. Por defecto false desde targetSdk 28 |
android:networkSecurityConfig |
<application> |
Apunta a un recurso XML que puede permitir HTTP, fijar CA propias o confiar en el almacén del usuario, que es lo que habilita interceptar TLS |
android:extractNativeLibs |
<application> |
false ⇒ las .so se cargan del APK y tienen que ir stored y alineadas |
networkSecurityConfig merece el rodeo extra: el atributo solo da un resource ID, y el contenido
está en un AXML bajo res/. Se resuelve en dos pasos —de identificador a ruta con
dump resources, y de ruta a árbol con dump xmltree --file— y es lo que separa «declara una
configuración de red» de «confía en las CA que instale el usuario».
4.3 Componentes exportados, con la regla del valor implícito
exported no declarado no significa false. La regla: si el componente tiene al menos un
<intent-filter>, el valor implícito es true; si no, false. Desde targetSdk 31 declararlo es
obligatorio cuando hay intent-filter, pero el muestrario está lleno de aplicaciones anteriores.
El extractor recorre la salida de xmltree con tres variables de estado —elemento actual, nombre
del componente y valor explícito de exported— más un contador de intent-filter vistos dentro
del componente. Cada línea E: <elemento> (line=…) con elemento en
{activity, activity-alias, service, receiver, provider} cierra el componente anterior y abre uno
nuevo; el primer android:name(0x01010003)= que aparezca dentro es su nombre, y
android:exported(0x01010010)= su valor explícito si lo hay. Al cerrar se aplica la regla del
párrafo anterior.
Sobre Aurora Store, salida real (recortada; 21 componentes, 6 exportados):
activity exported=true com.aurora.store.MainActivity
activity exported=false com.aurora.store.ComposeActivity
provider exported=true rikka.shizuku.ShizukuProvider
receiver exported=true com.aurora.store.data.receiver.DeviceOwnerReceiver
service exported=true androidx.work.impl.background.systemjob.SystemJobService
receiver exported=true androidx.profileinstaller.ProfileInstallReceiver
Esa lista es el mapa de entrada a la aplicación, y es lo que alimenta el paso 6.
5. Paso 3 — Verificar la firma
5.1 La orden
Sobre el base.apk de Google Keep del muestrario (salida real, sin los digests SHA-1 y MD5):
$BT/apksigner verify --verbose --print-certs keep/com.google.android.keep.apk
Verifies
Verified using v1 scheme (JAR signing): false
Verified using v2 scheme (APK Signature Scheme v2): false
Verified using v3 scheme (APK Signature Scheme v3): true
Verified using v3.1 scheme (APK Signature Scheme v3.1): true
Verified for SourceStamp: true
Number of signers: 1
V3.1 Signer: (minSdkVersion=33, maxSdkVersion=2147483647) certificate DN: CN=Android, OU=Android, O=Google Inc., …
V3.1 Signer: (minSdkVersion=33, maxSdkVersion=2147483647) certificate SHA-256 digest: 7ce83c1b…2905053
V3.0 Signer: (minSdkVersion=24, maxSdkVersion=32) certificate SHA-256 digest: f0fd6c5b…2d60db83
Source Stamp Signer: certificate SHA-256 digest: 3257d599…733bbd6d
Dos firmantes v3 con rangos de SDK disjuntos, claves distintas y un source stamp: es rotación de
clave desplegada en producción, y se lee de un vistazo.
5.2 La trampa: verify no informa de los esquemas presentes
apksigner verify no responde «¿está presente este esquema?» sino «¿se usó este esquema para
verificar en el rango de API que me has dado?». Es la fuente de confusión número uno del
ecosistema y se demuestra en dos órdenes. Sobre una copia del muestrario firmada con v1+v2+v3:
$BT/apksigner verify --verbose --min-sdk-version 24 todos.apk | head -3
Verifies
Verified using v1 scheme (JAR signing): false
Verified using v2 scheme (APK Signature Scheme v2): true
$BT/apksigner verify --verbose --min-sdk-version 21 todos.apk | head -3
Verifies
Verified using v1 scheme (JAR signing): true
Verified using v2 scheme (APK Signature Scheme v2): true
El mismo fichero, dos veredictos. Y los ficheros de v1 están físicamente dentro en los dos casos:
156212 01-01-1981 01:01 META-INF/PROC.SF
1265 01-01-1981 01:01 META-INF/PROC.RSA
156085 01-01-1981 01:01 META-INF/MANIFEST.MF
La regla de lectura. Para saber qué esquemas hay, se mira el
ZIP(META-INF/*.SF) y elAPK Signing Block(«APK Signing Block → §9 · El volcador»). Para saber si se instalará, se ejecutaverifycon elminSdkVersionreal de la aplicación, que se leyó en el paso 2. Un informe que colapse las dos preguntas en un booleano miente la mitad de las veces.
Consecuencia recíproca: v1 sola ya no es firma. El mismo APK firmado únicamente con v1:
DOES NOT VERIFY
ERROR: Target SDK version 36 requires a minimum of signature scheme v2; the APK is not signed with this or a later signature scheme
Criptográficamente correcta, y aun así no se instala.
5.3 Qué dice la firma sobre la procedencia
| Observación | Lectura |
|---|---|
Un firmante con DN autofirmado del desarrollador |
Distribución directa: F-Droid, sitio propio, release manual |
CN=Android, O=Google Inc. |
Play App Signing: el APK lo re-firmó Play. La clave del desarrollador no aparece |
| Dos firmantes v3 con rangos de SDK disjuntos | Rotación de clave desplegada (v3.1): los dispositivos antiguos validan con la clave vieja |
Verified for SourceStamp: true |
Bloque 0x6dff800d: acredita el origen aparte de la firma de distribución |
CN genérico y validez de 30 años |
Nada concluyente: es la convención de keytool |
La evidencia más fuerte de procedencia es el frosting de Play, el bloque 0x2146444e
que Play inserta en el APK Signing Block al distribuir. apksigner no lo reporta —no está
en apksig— así que hay que buscarlo en el binario; el volcador de la «sección 9 de
APK Signing Block · El volcador» lo hace, y su «sección 11 · El bloque desconocido de Google Keep: 0x2146444e» documenta
qué contiene.
Aparece en 25 de los 717 APK del muestrario, todos base.apk de aplicaciones de Play —
incluidos los que van dentro de los .apkm de APKMirror, lo que demuestra que ese sitio
redistribuye binarios de Play sin recompilarlos. Ninguno de F-Droid lo lleva, pero tampoco los
split de esas mismas aplicaciones ni el APK suelto que llegó por APKPure: su presencia
indica Play; su ausencia no indica nada.
El esquema .proto del frosting no es público, pero su estructura exterior se ha reproducido
sobre los tres base.apk de Play de las muestras locales: la firma ECDSA del bloque verifica con
la clave pública de Play, dos campos del protobuf coinciden con el versionCode y el
minSdkVersion del manifiesto, y el campo 4, que se ha leído como marca de tiempo de la firma,
no puede serlo. El detalle está en la «sección 11 de
APK Signing Block · El bloque desconocido de Google Keep: 0x2146444e». El source stamp (0x6dff800d) sí lo
reporta apksigner por su nombre.
5.4 Firma y splits
Un conjunto de splits tiene que estar firmado entero con la misma clave: PackageManager los
trata como una sola aplicación. Comprobarlo antes de intentar nada es un bucle sobre
apksigner verify --print-certs quedándose con el certificate SHA-256 digest de cada uno; más
de un digest distinto significa que el conjunto no instalará
(INSTALL_FAILED_INVALID_APK: … signatures are inconsistent con install-multiple o cualquier
otra sesión de instalación, catalogado en «Diagnóstico de fallos → §2.3 · Los cinco que hay que conocer al detalle»).
Un caso que despista: el META-INF/APKMIRRO.RSA de un .apkm es la firma de APKMirror sobre el
contenedor, no la de la aplicación. Darla como veredicto sobre la aplicación es un error de
análisis frecuente.
6. Paso 4 — Detectar protecciones antes de perder la tarde
6.1 Qué se busca
Este paso responde a una sola pregunta: ¿merece la pena decompilar? Cuatro comprobaciones,
todas sobre el Central Directory y el primer DEX, todas en menos de dos segundos.
Lo que sigue es detección. Cómo funciona cada familia está en «40-ofuscacion», de «R8 y ProGuard» a «Anti-análisis y hardening». Neutralizar cualquiera de ellas está fuera del alcance de esta documentación; aquí se detecta y se informa.
6.2 Comprobación 1 — la métrica de nombres
La señal más barata de ofuscación de identificadores: la fracción de clases cuyo nombre simple
tiene uno o dos caracteres. Se lee del type_ids del primer DEX, sin desensamblar nada —
bastan dos campos del header_item:
ss, so = struct.unpack_from("<II", dex, 56) # string_ids_size / string_ids_off
ts, to = struct.unpack_from("<II", dex, 64) # type_ids_size / type_ids_off
# cada type_id es un índice al string pool; cada string es uleb128(longitud) + MUTF-8 + 0x00
# descartar los descriptores del framework (Ljava/, Landroid/, Lkotlin/…) y medir
# len(nombre_simple) <= 2 sobre las clases restantes
La estructura exacta de esas tablas está en «Formato DEX».
6.2.1 El umbral de dos caracteres es el instrumento equivocado
Es la trampa de esta comprobación y conviene verla antes de usarla. Medido sobre el primer
DEX de cada base.apk, con las clases del framework descartadas y el nombre simple sin
sufijo de clase interna:
| Aplicación | Clases | ≤2 car. | ≤3 car. | Mediana |
|---|---|---|---|---|
| Twitch | 6.990 | 78,4 % | 78,9 % | 1 |
| Fossify Calculator | 5.280 | 85,1 % | 85,1 % | 1 |
| Google Keep | 7.482 | 2,3 % | 97,0 % | 3 |
| 6.228 | 0,0 % | 93,7 % | 3 | |
| Netflix | 8.920 | 9,9 % | 20,0 % | 15 |
| Termux | 2.696 | 5,0 % | 5,2 % | 16 |
| F-Droid | 9.793 | 0,7 % | 1,0 % | 17 |
Keep y WhatsApp dan 2,3 % y 0,0 % con el umbral de dos caracteres, y 97,0 % y 93,7 %
con el de tres. Un lector que se quede en la primera columna los clasifica como «sin ofuscar»
cuando están renombrados de arriba abajo: sus clases se llaman LX/000;, exactamente tres
caracteres, porque R8 alarga los nombres a medida que se llena el ámbito.
El indicador robusto es la mediana de la longitud del nombre simple. La distribución es netamente bimodal —1 a 3 en el grupo renombrado, 15 a 17 en el que no— y no hay ninguna aplicación del muestrario en la zona intermedia. Un umbral fijo cae del lado equivocado en cuanto la aplicación es grande; la mediana no.
6.2.2 Las dos lecturas que sí valen
- Mediana ≤ 3: renombrado por
R8o equivalente. El código sale comoa.b.cy sinmapping.txtno vuelve. Enlaza a «R8 y ProGuard». - La métrica mide renombrado, no protección. Netflix tiene mediana 15 —no está renombrado— y es la muestra más protegida del muestrario: usa DexGuard, que cifra cadenas y añade comprobaciones de integridad sin necesidad de acortar nombres de clase. Y la calculadora de Fossify tiene mediana 1 siendo software libre sin ninguna intención de ocultarse. Una métrica alta predice incomodidad; una métrica baja no predice nada.
⚠️ sin verificar: esta métrica no reproduce el gradiente de ofuscación que se tomó como referencia (Twitch 99,9 %, Shazam 100 %, WhatsApp 50 %, «las de Google entre 0,1 % y 2,5 %»), y esa referencia no tiene fuente: está en el documento, sin cita, desde su primera versión en el repositorio. La parte de Google depende del instrumento: con el umbral de dos caracteres, Keep da un 2,3 %, dentro de ese rango, y es la mediana —o el umbral de tres— lo que la coloca entre las más renombradas. Twitch y WhatsApp no casan con ningún umbral de la tabla, y ni ellas ni Shazam siguen en la máquina de referencia para volver a medirlas. La «sección 10 de R8 y ProGuard · Medición propia: la fracción de identificadores cortos» llega a la misma conclusión midiendo sobre identificadores definidos en lugar de sobre clases.
6.3 Comprobación 2 — la ofuscación de recursos
Ortogonal a la anterior y visible en un dump resources: el nombre lógico se conserva y el
fichero se llama de otra forma.
$BT/aapt2 dump resources $MUESTRAS/org.fossify.calculator_1.4.0.apk | head -6
Binary APK
Package name=org.fossify.math id=7f
type anim id=01 entryCount=40
resource 0x7f010002 anim/abc_grow_fade_in_from_bottom
() (file) res/aM.xml type=XML
res/aM.xml en lugar de res/anim/abc_grow_fade_in_from_bottom.xml. En el muestrario también lo hace
Google Keep, cuyos splits de densidad traen res/-B.png y res/0x.9.png. Es lo que deshace
APKEditor refactor; ver
«Deofuscación».
6.4 Comprobación 3 — la forma del DEX
Un packer sustituye el DEX original por un stub que descifra y carga el código real en ejecución.
La huella de forma es característica: un solo DEX pequeño junto a bibliotecas nativas grandes y
un assets/ opaco.
| Señal | Umbral orientativo |
|---|---|
Un solo classes.dex de menos de ~400 KB en una aplicación con .so |
Muy sospechoso |
Entradas assets/*.dat, *.jar, *.dex, *.bin grandes |
Candidato a payload |
.so con nombre de familia conocida (libjiagu, libSecShell, libDexHelper, libtprt…) |
Huella directa |
lib/*/libpairipcore.so |
PairIP de Google Play |
Barriendo el muestrario entero —717 APK, contando los internos de los contenedores— con los patrones
de nombre de la última fila, el resultado es 0 APK con huella de packer sobre 717 APK inspeccionados. Es coherente con un hueco conocido del material: el muestrario no contiene
ninguna muestra de PairIP, y su único protector comercial es DexGuard en
Netflix, que no es un packer con stub nativo.
Y la comprobación de assets/ sí da falsos positivos, que conviene ver antes de fiarse:
com.whatsapp.apk DEX=11 (82.278.164 B) .so=0 entradas=9206
⚑ payload en assets/: ['assets/ReferenceFaceShapeConstants/v01_high_end_face_compressed.bin', …]
Once DEX y ochenta megas de código: no hay packer, son datos de modelos. Estas comprobaciones
producen señales, no veredictos, y un informe que las presente como conclusión miente.
6.5 Comprobación 4 — qué hay en nativo
unzip -l app.apk | awk '$4 ~ /^lib\//{print $4}' | sed 's|lib/[^/]*/||' | sort -u
Si la lista incluye bibliotecas con nombre propio de la aplicación —y no solo dependencias
reconocibles como libcronet, libsqlite, libc++_shared—, hay lógica en nativo y el análisis a
nivel DEX va a tener un agujero. Ver
«Código nativo y ELF» y
«Análisis binario y estático».
6.6 El veredicto del paso 4
| Resultado | Qué hacer |
|---|---|
| Sin ofuscación, sin nativo propio | Decompilar: va a salir bien |
| Nombres muy ofuscados, sin packer | Decompilar contando con nombres inventados. Buscar mapping.txt antes |
| Recursos ofuscados | Decompilar, y pasar refactor antes de leer |
| Lógica en nativo | Decompilar el DEX y abrir los .so: el DEX solo no responde |
| Huella de packer | Parar. classes.dex es el stub. Se informa y se acaba |
7. Paso 5 — Decompilar
7.1 La cifra que hay que tener presente antes de empezar
Solo el 21,02 % de las aplicaciones de Google Play decompilan enteras, sin un solo método fallido, con jadx. Es la tabla VII del estudio SANER 2021 de Mauthe, Kargén y Shahmehri, que lo atribuye, «probablemente en parte», a que una aplicación de Play tiene 63.748 métodos de media. La aritmética sola no lo explica: con una tasa de fallo por método del 0,019 % y fallos independientes, la probabilidad de que ninguno falle sería del 0,0006 %, no del 21 %. Los fallos se concentran en unas aplicaciones y no en otras. Combinar los cuatro decompiladores del estudio sube ese 21,02 % al 21,03 %.
Corolario operativo: hay que contar los marcadores de fallo en la salida. Nadie los cuenta por ti, y jadx no devuelve error por métodos fallidos — la decompilación parcial es su comportamiento normal. El desglose, con las tablas del estudio, está en «Decompiladores a Java → §6 · La comparación honesta: qué dicen las mediciones».
7.2 Qué ruta según qué pregunta
| Pregunta | Nivel («§4 de Anatomía · Los cuatro niveles de “decompilar”») | Herramienta |
|---|---|---|
| «Quiero leer la lógica» | 4 · decompilar | jadx |
| «jadx ha fallado justo en el método que me importa» | 4 · decompilar | dex2jar + Vineflower o CFR |
| «Necesito citar lo que hace de verdad» | 3 · desensamblar | baksmali, o la vista lateral de jadx-gui |
| «Voy a modificar y reempaquetar» | 3 · desensamblar | apktool o baksmali → «modificación» |
| «Solo quiero los recursos» | 2 · decodificar | aapt2 dump, apktool -s, APKEditor |
| «Solo quiero los ficheros» | 1 · desempaquetar | unzip |
Los cuatro niveles no son etapas de una escalera: son fuentes de información distintas, y el
análisis serio las cruza. El nivel 4 solo puede escribir getString(R.string.app_name) porque
alguien le ha dado la tabla del nivel 2.
Los tiempos de la escala de la «sección 1 · Qué es y por qué existe» están medidos sobre las 61 muestras del
«muestrario» que siguen en la máquina de referencia,
con las órdenes de la checklist de la «sección 10 · Checklist ejecutable» y una pasada por muestra (25 de septiembre de
2026). Medianas: 14 ms para identificar, 25 ms para inventariar, 0,24 s para el manifiesto,
0,23 s para la firma y 0,18 s para las protecciones, contando la métrica de nombres y la forma
del DEX; ningún paso de 0 a 4 llegó a 3 s en ninguna muestra. Decompilar con
jadx --show-bad-code llevó 15,6 s de mediana, más de un minuto en 8 de las 61 y 9 min
43 s en la peor, SimpleX Chat. Las aplicaciones grandes de Play no están en local y no entran
en estas cifras.
7.3 Las recetas de jadx
⚠️ Salida no reproducida en esta sección. La sintaxis procede del README oficial del proyecto, consultado el 12 de agosto de 2026, y no se ha pegado aquí ninguna salida real. Sí hay recetas de jadx ejecutadas con la versión 1.5.6, con tiempos y códigos de salida, en «Decompiladores a Java → §7 · Recetas de jadx CLI».
# exploración: solo código, sin recursos — mucho más rápido
jadx -d salida/ --no-res app.apk
# análisis serio: con los métodos incoherentes visibles para poder auditarlos
jadx -d salida/ --show-bad-code app.apk
# con mapping.txt real, que es lo único que recupera nombres de verdad.
# OJO al -P: sin él no renombra nada y no avisa. Ver más abajo.
jadx -d salida/ -Prename-mappings.invert=yes --mappings-path mapping.txt app.apk
# aplicación grande: subir el heap y bajar los hilos
JAVA_OPTS="-Xmx8g" jadx -j 4 -d salida/ app.apk
--deobf y --mappings-path no son lo mismo, y confundirlos produce informes falsos.
--deobf es heurístico —cambia a.b.c por algo pronunciable y no recupera nada— y está
desactivado por defecto en la CLI; --mappings-path es determinista y devuelve los nombres reales
(«Decompiladores a Java → §3.6 · --deobf y mapping.txt: dos cosas distintas»).
⚠️ Y hay un segundo modo de producir el mismo informe falso, más traicionero: pasar el
mapping.txtsin invertirlo.R8lo escribe en el sentidooriginal -> ofuscado, y jadx aplica el mapeo de izquierda a derecha: busca en elDEXclases llamadascom.ejemplo.Real, que no existen, y no renombra nada. Sale conexit=0y sin un solo aviso — igual que si el fichero no existiera, caso en el que también respondedoney0. El interruptor es una opción de plugin que no aparece en la lista principal de--help:-Prename-mappings.invert=yes # default: noReproducido con jadx 1.5.6 sobre un
DEXcon la clasea.by unmapping.txtconcom.ejemplo.Real -> a.b:. Sin el-Psalesources/a/b.java; con él,sources/com/ejemplo/Real.java, encabezado por/* JADX INFO: renamed from: a.b */. La ayuda de la herramienta refuerza la trampa:--mappings-pathdeclara aceptar «Tiny y Tiny v2, Enigma o directorio Enigma», sin mencionar el formato de ProGuard y R8, que sí parsea.
7.4 Medir la cobertura, que es el paso que nadie da
Después de decompilar y antes de leer hay que contar cuántos métodos quedaron fuera. Los
marcadores están verificados contra el código fuente de jadx (commit e738a26, 5 de agosto
de 2026); el catálogo completo con su significado está en
«Diagnóstico de fallos → §6 · jadx».
grep -rc "Code decompiled incorrectly" salida/sources/ | awk -F: '{s+=$2} END{print s" incoherentes"}'
grep -rc "JADX ERROR:" salida/sources/ | awk -F: '{s+=$2} END{print s" errores duros"}'
grep -rc "Method not decompiled:" salida/sources/ | awk -F: '{s+=$2} END{print s" no decompilados"}'
Y el código de salida sirve para esto y solo para esto: 3 significa «decompiló, pero con
errores», con el mensaje finished with errors, count: N. Un 0 no garantiza cobertura completa
—los avisos no cuentan como error—, pero un 3 garantiza que hay huecos.
La distinción que el informe tiene que preservar: JADX ERROR: es un hueco visible —el método
sale como volcado de instrucciones y se ve que falta—, mientras que
Code decompiled incorrectly marca Java plausible que puede no corresponder al comportamiento
real. Tratarlos como la misma categoría desperdicia justo la información útil.
⚠️ Corrección respecto a otra parte de esta documentación: la «sección 3.5 de Decompiladores a Java · Los modos de fallo, y qué significa cada uno» cita los marcadores como
// decompilation failedy// JADX WARNING: inconsistent code. Ninguna de las dos cadenas existe en el código de jadx; ver la «sección 6.1 de Diagnóstico de fallos · Corrección previa: dos cadenas que circulan y no existen» para el detalle de qué se emite realmente y desde qué fichero.
8. Paso 6 — Navegar el resultado
Con un árbol de veinte mil ficheros delante, la pregunta no es qué leer sino por dónde empezar. Cuatro anclas, en orden.
1 · Los componentes del manifiesto son los puntos de entrada reales. La lista del paso 2 («§4.3 · Componentes exportados, con la regla del valor implícito»)
es literalmente el conjunto de clases por las que el sistema entra en la aplicación: onCreate de
la Activity lanzable, onReceive de cada receiver exportado, onStartCommand de cada
service, query/insert de cada provider. Todo lo demás cuelga de ahí. Y sobrevive a la
ofuscación: el manifiesto tiene que nombrar esas clases para que el sistema las resuelva, así que
aunque el resto del código sea a.b.c esos nombres concretos están en claro.
2 · Los strings dan la superficie de red y de almacenamiento.
$BT/dexdump -d "$APK" | grep -oE 'https?://[a-zA-Z0-9./_-]+' | sort -u | head
Sobre el APK y no sobre classes.dex: así dexdump recorre todos los classes*.dex. Con
solo el primero, en una aplicación multidex faltan cadenas y se concluye en falso lo del
párrafo siguiente.
Si no sale casi nada y la aplicación evidentemente habla por red, los strings están cifrados: señal de ofuscador comercial, y remite a «Ofuscadores comerciales».
3 · Las llamadas al framework no se pueden renombrar. Landroid/... lo define el sistema, no la
aplicación. Buscar Landroid/telephony/TelephonyManager, Ljavax/crypto/Cipher,
Ldalvik/system/DexClassLoader o Ljava/lang/Runtime;->exec localiza el comportamiento sensible en
código completamente ofuscado. Es el suelo del análisis.
4 · Los recursos nombran lo que el código ya no nombra. Un layout se llama
activity_login.xml aunque la clase que lo infla se llame f.a. La tabla de recursos y los AXML
de res/ son un diccionario semántico gratis — y por eso la ofuscación de recursos («§6.3 · Comprobación 2 — la ofuscación de recursos») existe:
justamente para quitarlo.
9. El árbol de decisión completo
fichero de entrada
▼
PASO 0 ┌── detección estructural (§2.2) ──────────────────────┐
│ APK ──┐ XAPK/APKM/SET/SPLITS ─┐ AAB ──┐ ¿? ──┐ │
└───────┼────────────┬───────────┼────────┼───────┼────┘
│ ▼ │ ▼ ▼
│ extraer + hallar el │ bundletool PARAR
│ base (§2.4) ─────────┘ build-apks e informar
▼◀───────────┘
PASO 1 ── inventario del Central Directory (§3) ──────────────
│ ¿resources.arsc? ¿stored? ¿nº DEX? ¿ABIs?
▼
PASO 2 ── aapt2 dump badging + dump xmltree (§4) ─────────────
│ paquete · versiones · SDK · permisos
│ componentes exportados · flags de seguridad
▼
PASO 3 ── apksigner verify --print-certs (§5) ────────────────
│ CON el minSdkVersion leído en el paso 2
▼
PASO 4 ── nombres · recursos · forma del DEX · nativo (§6) ───
│ │ │
│packer │ofuscado │limpio
▼ ▼ │
PARAR: buscar │
informar mapping.txt ──────────┤
y no ▼
decompilar ┌────────────────────────────────────┐
PASO 5 │ ¿qué pregunta hay que responder? │
│ leer lógica ──────▶ jadx │
│ citar comportamiento ▶ smali │
│ modificar ────────▶ doc. 02 │
│ solo recursos ────▶ aapt2 dump │
└──────────────┬─────────────────────┘
▼
medir la cobertura (§7.4) ─¿baja?─▶ 2.º decompilador
▼ sobre esos métodos
PASO 6 · entrar por los componentes del manifiesto;
strings · Landroid/* · recursos (§8)
10. Checklist ejecutable
De arriba abajo. Cada línea o produce un dato del informe o corta el análisis.
#!/usr/bin/env bash
set -u
BT=~/Library/Android/sdk/build-tools/37.0.0
APK="$1"
# 0 · qué es: sin AndroidManifest.xml en la raíz no es un APK suelto; clasificar el
# contenedor con el algoritmo de §2.2, sacar el base (§2.4) y repetir con él
unzip -Z1 "$APK" | grep -qx AndroidManifest.xml || { echo "no es un APK suelto: §2.2"; exit 2; }
# 1 · inventario (§3): categorías, DEX, ABIs y cómo va resources.arsc
unzip -Z1 "$APK" | cut -d/ -f1 | sort | uniq -c | sort -rn
unzip -Z1 "$APK" | grep -cE '^classes[0-9]*\.dex$'
unzip -Z1 "$APK" | sed -n 's|^lib/\([^/]*\)/.*|\1|p' | sort -u
unzip -Zv "$APK" resources.arsc | grep -E 'compression method|offset of local header'
# 2 · manifiesto — y guardar el minSdk, que hace falta en el paso 3
MIN=$($BT/aapt2 dump badging "$APK" | sed -n "s/^minSdkVersion:'\(.*\)'/\1/p")
$BT/aapt2 dump badging "$APK" | grep -E "^(package|targetSdkVersion|uses-permission)"
$BT/aapt2 dump xmltree "$APK" --file AndroidManifest.xml > /tmp/tree.txt
grep -E "debuggable|usesCleartextTraffic|networkSecurityConfig" /tmp/tree.txt
# componentes exportados: recorrer /tmp/tree.txt con la regla del valor implícito de §4.3
# 3 · firma, CON el minSdk real, y el inventario físico de v1 aparte
$BT/apksigner verify --verbose --print-certs --min-sdk-version "${MIN:-21}" "$APK" 2>&1 \
| grep -E "^(Verifies|DOES NOT|Verified using|ERROR)|SHA-256 digest"
# el 2>&1 hace falta: DOES NOT VERIFY y los ERROR salen por stderr
unzip -l "$APK" | grep -E "META-INF/(MANIFEST\.MF|[A-Z0-9_-]+\.(SF|RSA))" \
|| echo " (sin firma v1 física)"
# 3b · alineación: también es procedencia
$BT/zipalign -c 4 "$APK" >/dev/null 2>&1 && echo "align 4 OK" || echo "align 4 FALLA"
$BT/zipalign -c -P 16 4 "$APK" >/dev/null 2>&1 && echo "align 16K OK" || echo "align 16K FALLA"
# 4 · protecciones: la métrica de nombres (§6.2) y la forma del DEX (§6.4) no tienen orden
# propia; lo que sí la tiene es la ofuscación de recursos (§6.3) y lo nativo (§6.5).
# El veredicto, con la tabla de §6.6
$BT/aapt2 dump resources "$APK" 2>/dev/null | grep -m3 "(file) res/"
unzip -l "$APK" | awk '$4 ~ /^lib\//{print $4}' | sed 's|lib/[^/]*/||' | sort -u
# 5 · solo si los pasos 0-4 no han respondido:
# jadx -d salida/ --show-bad-code "$APK" (ejecutado con jadx 1.5.6: 30-herramientas/01, §7)
# y contar los marcadores de §7.4 ANTES de leer nada
# 6 · entrar por los componentes exportados del paso 2
Fuentes
Todas las URL se consultaron el 12 de agosto de 2026.
- aapt2 — https://developer.android.com/tools/aapt2 · lista de subcomandos de
dumpy opciones--filey--no-values(«§4.1», «§6.3», «§10»). - apksigner — https://developer.android.com/tools/apksigner · sintaxis de
verify,--print-certsy--min-sdk-version(«§5 · Paso 3 — Verificar la firma»). - zipalign — https://developer.android.com/tools/zipalign ·
-c,-P 16y la regla de orden respecto aapksigner(«§10 · Checklist ejecutable», paso 3b). <application>— https://developer.android.com/guide/topics/manifest/application-element · valores por defecto deandroid:debuggable,allowBackupyextractNativeLibs(«§4.2 · Los diez campos que se miran»).<activity>— https://developer.android.com/guide/topics/manifest/activity-element · la regla del valor implícito deandroid:exportedy su obligatoriedad desdetargetSdk31 («§4.3 · Componentes exportados, con la regla del valor implícito»).- Network security configuration —
https://developer.android.com/privacy-and-security/security-config · comportamiento por defecto
de
usesCleartextTrafficdesdetargetSdk28 y efecto de confiar en el almacén del usuario («§4.2 · Los diez campos que se miran»). - A Large-Scale Empirical Study of Android App Decompilation — Mauthe, Kargén y Shahmehri, SANER 2021 · https://www.ida.liu.se/~ulfka17/papers/SANER2021.pdf · el 21,02 % de aplicaciones de Play que decompilan enteras, los 63.748 métodos de media y el 0,019 % de fallo por método («§7.1 · La cifra que hay que tener presente antes de empezar»).
- jadx — README oficial — https://github.com/skylot/jadx#readme · la sintaxis de
--no-res,--show-bad-code,--mappings-path,--deobf,-jyJAVA_OPTS(«§7.3 · Las recetas de jadx»). - jadx —
JadxCLI.javayMethodGen.java, commite738a26del 5 de agosto de 2026 — https://github.com/skylot/jadx/blob/master/jadx-cli/src/main/java/jadx/cli/JadxCLI.java y https://github.com/skylot/jadx/blob/master/jadx-core/src/main/java/jadx/core/codegen/MethodGen.java · los literales de «§7.4 · Medir la cobertura, que es el paso que nadie da» y el código de salida 3 confinished with errors, count: {}. Repositorio clonado y leído directamente, no vía buscador. - Ejecuciones locales sobre el muestrario — 12 de agosto de 2026,
build-tools37.0.0 (aapt22.20-15087165,apksigner0.9,zipalign,dexdump), OpenJDK 21.0.10 y Python 3.12.10 sobre macOS Darwin 25.5.0. De aquí salen todas las salidas pegadas de «§2.2», «§3.1», «§4.1», «§4.3», «§5.1», «§5.2», «§6.2», «§6.3» y «§6.4», contracom.aurora.store_75.apk,org.fossify.calculator_1.4.0.apk,org.fdroid.fdroid_1023052.apk,com.x8bit.bitwarden_2026.5.0.apk,GoogleKeep_com.google.android.keep.xapk,com.netflix.mediaclient.sai.zip,tv.twitch.android.app.apkmy los contenedores desplits/. - aapt2 —
DumpManifest.cpp, etiquetaandroid-16.0.0_r1— https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-16.0.0_r1/tools/aapt2/dump/DumpManifest.cpp · Consultado el 25 de septiembre de 2026. De aquí sale lo quebadgingimprime del<application>:application-label,application-icon,application:,testOnly,application-isGameyapplication-debuggable(«sección 4 · Paso 2 — Leer el manifiesto»).