R8 y ProGuard
Antes conviene leer «Anatomía de un APK», «Formato DEX»
1. Qué es y por qué existe
Casi todo el código ofuscado que se encuentra dentro de un APK no lo ofuscó nadie a
propósito: lo ofuscó la cadena de compilación oficial de Android, porque el desarrollador
puso minifyEnabled = true para que la aplicación ocupase menos. R8 es la herramienta de
Google que hace ese trabajo, y desde el plugin de Gradle 3.4.0 viene activada por defecto.
Eso convierte a R8 en el punto de partida obligatorio de este bloque. No es un producto de
seguridad ni pretende serlo: es un reductor de tamaño cuyo efecto secundario —renombrar
todo lo que puede a a, b, c— resulta ser la barrera de análisis más extendida del
ecosistema. Un ofuscador comercial como DexGuard
(«Ofuscadores comerciales») empieza donde R8 acaba.
La pieza que hace que todo esto sea reversible se llama mapping.txt. R8 la emite en cada
compilación y contiene la correspondencia exacta entre el nombre original y el ofuscado. Con
ella, deshacer la ofuscación es una sustitución determinista sin riesgo; sin ella, hay que
inferir, y ahí el techo es bajo («Deofuscación»). Casi todo el valor
práctico de este documento está en la «sección 6 · mapping.txt: la gramática exacta», que fija la gramática de ese fichero con
suficiente detalle para implementar un lector.
Este documento se queda en el lado de la identificación: qué hace R8, qué formato produce y
cómo se reconoce, desde un APK ya construido, que pasó por ahí. La «sección 10 · Medición propia: la fracción de identificadores cortos» publica
una medición propia sobre el «muestrario».
Versiones de referencia: R8 9.2.4-dev (build-tools 37.0.0), Retrace 8.9.27, ProGuard 7.9.1 y AGP hasta la 9.3, consultadas en agosto y septiembre de 2026.
2. Historia y relevo
ProGuard lo escribió Eric Lafortune; su primer commit es del 1 de mayo de 2002 y lo
describe como un proyecto personal. En diciembre de 2010 rondaba el medio millón de
descargas y ya venía integrado en el SDK de Android: la decisión de Google de incluirlo en
Android 2.3 Gingerbread es la que le dio escala. En 2012 nace DexGuard, su hermano
comercial, y en 2014 se funda Guardsquare, la empresa belga que hoy mantiene los dos.
ProGuard sigue vivo y es software libre bajo GPL-2.0 con excepciones; la versión más
reciente es la v7.10.0, del 24 de agosto de 2026, y la de las ejecuciones de este documento,
la v7.9.1, del 9 de abril. Sus cuatro pasos, según su propio manual, son
shrinking, optimization, obfuscation y preverification.
ProGuard opera sobre bytecode de la JVM: entra .class, sale .class, lo que en la
cadena antigua de Android obligaba a encadenar javac → ProGuard → dx. Google lo colapsó en
dos herramientas nuevas que comparten repositorio y código:
| Herramienta | Entrada | Salida | Qué hace |
|---|---|---|---|
D8 |
.class |
DEX |
Traduce y desugariza. Sustituye a dx. No renombra nada |
R8 |
.class |
DEX |
Todo lo de D8 más shrinking, optimización y ofuscación. Sustituye a ProGuard + dx |
L8 |
.class |
DEX/.class |
Compila la biblioteca del núcleo reescrita del core library desugaring |
R8 se introdujo en el plugin de Gradle 3.3.0 y quedó activado por defecto en la
3.4.0, para módulos de aplicación y de biblioteca. La documentación de esa versión lo
resume sin rodeos: antes, «ProGuard era un paso de compilación distinto del dexing y del
desugaring»; ahora es uno solo.
Qué queda de ProGuard hoy, en la práctica:
- El formato de configuración. Las keep rules de
R8son sintaxis deProGuard, y los ficheros se siguen llamandoproguard-rules.pro. - El formato de
mapping.txt.R8produce un superconjunto compatible («sección 6 · mapping.txt: la gramática exacta»). - La marca
PGen el atributoSourceFile(«sección 9.2 · El atributo SourceFile»), queR8sigue reconociendo como valor centinela heredado. - Proyectos antiguos y builds no-Android que siguen invocando
ProGuarddirectamente, yDexGuard, que es «compatible hacia atrás conProGuard» y reutiliza su configuración.
En el muestrario, 69 de las 86 aplicaciones llevan un marcador ~~R8 dentro del DEX
(«sección 9.1 · El marcador ~~R8{…} dentro del DEX»). ProGuard puro no aparece en ninguno.
3. Los tres trabajos, que no son el mismo
minifyEnabled = true enciende tres transformaciones distintas a la vez. Se confunden
constantemente, y separarlas importa porque solo una de las tres es la que mapping.txt
deshace.
┌──────────────────────────────────────────────┐
.class ────► │ 1. SHRINKING elimina lo inalcanzable │
librerías │ (tree shaking) desde los entry points │
keep rules ├──────────────────────────────────────────────┤
│ 2. OPTIMIZACIÓN inlining, fusión de clases │
│ propagación de constantes │
├──────────────────────────────────────────────┤
│ 3. OFUSCACIÓN renombra lo que queda │
│ (minification) a a, b, c… │
└──────────────────────────────────────────────┘
│ │
▼ ▼
classes.dex mapping.txt
1. Shrinking. «R8 identifica y elimina el código inalcanzable de la aplicación y de sus
dependencias. Analizando los entry points de la aplicación (como las Activities o los
Services declarados en el manifiesto), R8 construye un grafo del código referenciado y
elimina todo lo que quede sin referenciar». Lo eliminado se lista en usage.txt.
2. Optimización. Las dos técnicas que la documentación nombra son method inlining
—«R8 sustituye el punto de llamada por el cuerpo del método llamado»— y class merging
—«R8 combina conjuntos de clases e interfaces en una sola clase»—. A eso se añaden
propagación de constantes, eliminación de argumentos sin uso y outlining («sección 6.6 · Los comentarios de metadatos por elemento»).
Desde AGP 8.12.0, R8 optimiza además los recursos, y desde AGP 9.0 lo hace por defecto
(android.r8.optimizedResourceShrinking pasa a true).
3. Ofuscación. «Para reducir el tamaño del fichero DEX, R8 acorta los nombres de
clases, campos y métodos (por ejemplo, com.example.MyActivity podría convertirse en
a.b.a)». Es un efecto de tamaño, no de seguridad: los nombres viven en string_ids
(«Formato DEX → §6 · Las tablas de índices») y acortarlos ahorra bytes.
Por qué la optimización estorba al decompilador tanto o más que el renombrado. Un nombre renombrado sigue siendo un nombre: el decompilador produce código correcto y feo, y el lector puede renombrar mentalmente. La optimización destruye la correspondencia entre el fuente y el binario: un método inlineado ya no existe como método, y su cuerpo aparece repetido dentro de cada llamador; dos clases fusionadas aparecen como una, con métodos que solo tienen sentido para la mitad del objeto; una constante propagada borra el parámetro que la transportaba; y el outlining hace lo contrario, inventa un método que no estaba en el fuente y lo llama desde sitios que no tenían relación.
mapping.txt recupera los nombres y, gracias a los rangos de línea, también la pila de
llamadas original de un método inlineado («sección 6.5 · Métodos inlineados»). Lo que no recupera es la forma del
código: el DEX sigue teniendo el cuerpo repetido. Esa asimetría es el motivo de que
retrace funcione perfectamente sobre una traza de pila y de que deofuscar un proyecto
decompilado sea otro problema.
Cada pieza se puede pedir por separado: -dontobfuscate deja shrinking y optimización,
-dontoptimize deja shrinking y renombrado, -dontshrink deja los otros dos. Varias
aplicaciones del muestrario están en ese estado intermedio: Signal y Candy Crush llevan marcador
de R8 y SourceFile de clases sintéticas de R8, pero 2.580 y 2.486 nombres de fichero
fuente reales y medianas de longitud de identificador de 13 y 15. Es R8 sin ofuscación.
4. Cómo se enciende y dónde viven las reglas
La forma clásica, todavía la mayoritaria, y la DSL nueva de AGP 9.3, que enciende las dos
cosas de golpe y ya incluye las reglas por defecto de la plataforma:
buildTypes { release { buildTypes { release {
isMinifyEnabled = true optimization { enable = true }
isShrinkResources = true } }
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro")
} }
Con la DSL nueva, las reglas van en ficheros con sufijo .keep dentro del source set
src/<variant>/keepRules, por ejemplo src/main/keepRules/custom-rules.keep.
Dónde caen los ficheros de salida. mapping.txt se escribe en
app/build/outputs/mapping/<variante>/mapping.txt y, además, AGP lo empaqueta
automáticamente dentro del AAB. La documentación advierte de lo importante: «el fichero
de mapping se sobrescribe cada vez que se compila el proyecto, así que hay que guardar una
copia cada vez que se publica una release». Junto a él aparecen seeds.txt —lo que las
reglas impidieron eliminar—, usage.txt —lo eliminado— y configuration.txt —«todas las
reglas fusionadas de cada biblioteca y módulo que se usaron para configurar R8»—.
La ruta real lleva segmento de variante: app/build/outputs/mapping/<variante>/configuration.txt,
en el mismo directorio que seeds.txt y usage.txt. Lo dicen las notas de AGP 9.0.0
(«typically in a path like <app_module>/build/outputs/mapping/<build_variant>/configuration.txt»)
y lo confirma el código de AGP: InternalArtifactType.R8_MAPPING_CONFIGURATION se declara con
categoría OUTPUTS, carpeta mapping y fichero configuration.txt, y getOutputPath()
intercala la variante entre la carpeta y el fichero (platform/tools/base, etiqueta
studio-2026.1.2). Las otras dos páginas oficiales fallan cada una en un detalle: la de
diagnóstico omite la variante (./app/build/outputs/mapping/configuration.txt) y la del
APK Analyzer escribe mappings en plural.
5. Keep rules
Una keep rule le dice a R8 qué no puede tocar. Las opciones se dividen en tres ejes que
se combinan: qué se conserva (la clase, sus miembros, o ambos), si además se conserva el
nombre, y si se permite optimizar por dentro.
| Opción | Conserva la clase | Conserva los miembros | Se aplica cuando |
|---|---|---|---|
-keep |
Sí | Los que se especifiquen | Siempre |
-keepclassmembers |
No por sí sola | Sí | Si la clase se conserva por otra vía |
-keepclasseswithmembers |
Sí | Sí | Si la clase tiene esos miembros |
-keepnames |
Solo el nombre | — | La clase puede eliminarse; si sobrevive, no se renombra |
-keepclassmembernames |
— | Solo el nombre | Ídem para miembros |
-keepclasseswithmembernames |
Solo el nombre | Solo el nombre | La usa la regla estándar de métodos nativos |
Modificadores entre corchetes tras la opción: allowshrinking, allowoptimization,
allowobfuscation, allowaccessmodification, allowrepackage, includedescriptorclasses.
Comodines: * (un segmento), ** (varios, incluidos puntos), ? (un carácter), ***
(cualquier tipo), ... (cualquier lista de argumentos), % (cualquier primitivo). Existen
reglas condicionales -if … -keep … con retrorreferencias <1>, <2>.
Un detalle que sorprende, y que depende de la versión: si una regla -keep no especifica
miembros, R8 la convierte implícitamente en una regla que conserva el constructor por
defecto. Así funciona R8 cuando se usa solo y con AGP hasta la 8.x. Desde AGP 9.0,
android.r8.strictFullModeForKeepRules vale true y «-keep class A no longer implies
-keep class A { <init>(); }» (notas de AGP 9.0.0): el constructor hay que pedirlo
expresamente.
5.1 Los ficheros y su procedencia
| Fichero | Quién lo pone | Qué contiene |
|---|---|---|
proguard-android-optimize.txt |
AGP, vía getDefaultProguardFile() |
Reglas base de la plataforma |
proguard-rules.pro |
El desarrollador | Las reglas propias del módulo |
consumer-rules.pro |
La biblioteca, vía consumerProguardFiles |
Reglas que la biblioteca impone a quien la usa |
META-INF/proguard/* |
Un JAR de biblioteca |
Equivalente al anterior para artefactos JAR |
| (generado) | AAPT2, o R8 con la reducción optimizada de recursos |
Reglas derivadas de los componentes del manifiesto |
La distinción entre proguardFiles y consumerProguardFiles está bien enunciada en la
documentación de Google: los primeros «se usan en tiempo de compilación para definir qué
parte de tu biblioteca debe conservarse durante la compilación de la biblioteca — como
mínimo, su API pública»; los segundos «se empaquetan dentro de la biblioteca para afectar a
qué optimizaciones ocurren después, durante la compilación de la aplicación que la consume».
Consecuencia práctica para quien analiza: buena parte de las reglas que gobiernan una
aplicación no las escribió su autor, vinieron dentro de sus dependencias.
Desde AGP 9.0, publicar una biblioteca falla si sus consumer rules llevan opciones
globales como -dontoptimize o -dontobfuscate, y la aplicación ignora en silencio las que
lleguen dentro de un JAR o AAR ya compilado (notas de AGP 9.0.0,
android.r8.globalOptionsInConsumerRules.disallowed).
El mecanismo tiene dos formas, según cómo se reduzcan los recursos. En la clásica, AAPT2
escribe las reglas al enlazar (aapt2 link --proguard) y AGP se las pasa a R8: es el
aapt_rules.txt de build/intermediates/aapt_proguard_file/, que la página oficial de
diagnóstico cita entre los orígenes de reglas («rules generated by AAPT») con una
MainActivity conservada desde …/processReleaseResources/aapt_rules.txt:4:1. Reproducido
con el aapt2 de build-tools 37.0.0 sobre un manifiesto mínimo, sale una regla por componente con su línea de
origen: # Referenced at AndroidManifest.xml:5 seguido de
-keep class com.ejemplo.demo.MainActivity { <init>(); }. Con la reducción optimizada de
recursos —por defecto desde AGP 9.0 cuando isShrinkResources = true— esas reglas no
llegan a R8: AGP deja de pasarlas («R8's optimized shrinking does not need AAPT2-generated
Proguard rules», ProguardConfigurableTask.kt) y es R8 quien lee el manifiesto compilado y
traza los android:name de activity, service, receiver, provider, instrumentation,
process y application (ResourceShrinkerState.java).
5.2 Las reglas que AGP empaqueta de verdad
Desde AGP 2.2 los ficheros de $ANDROID_HOME «ya no se mantienen y serán ignorados»; lo
que getDefaultProguardFile() desempaqueta son tres ficheros que viven en el propio
plugin: proguard-header.txt (solo comentarios), proguard-optimizations.txt (una única
línea, -allowaccessmodification) y proguard-common.txt, que es el interesante:
-keepattributes AnnotationDefault, EnclosingMethod, InnerClasses,
RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations,
RuntimeVisibleTypeAnnotations, Signature
-keepclasseswithmembernames,includedescriptorclasses class * { native <methods>; }
-keepclassmembers public class * extends android.view.View { void set*(***); *** get*(); }
-keepclassmembers class * extends android.app.Activity { public void *(android.view.View); }
-keepclassmembers enum * { public static **[] values(); public static ** valueOf(java.lang.String); }
-keepclassmembers class * implements android.os.Parcelable { public static final ** CREATOR; }
-keepclassmembers class * { @android.webkit.JavascriptInterface <methods>; }
-keep class android.support.annotation.Keep
-keep @android.support.annotation.Keep class * {*;}
Cada línea es una pista de análisis: que los set*/get* de las subclases de View
conserven su nombre, que los values()/valueOf() de los enum sobrevivan, que el campo
CREATOR de todo Parcelable se siga llamando CREATOR y que los métodos anotados con
@JavascriptInterface mantengan el suyo son anclas fijas en un DEX por lo demás
ilegible, y la base de las heurísticas de «Deofuscación».
Nota verificable: el fichero de AGP solo entiende la anotación legacy
android.support.annotation.Keep, no androidx.annotation.Keep, y sigue así en el
proguard-common.txt de AGP 9.4.1. La versión AndroidX trae sus reglas dentro de la propia
biblioteca: androidx.annotation:annotation-jvm:1.11.0 lleva
META-INF/proguard/androidx-annotations.pro, con
-keep,allowobfuscation @interface androidx.annotation.Keep,
-keep @androidx.annotation.Keep class * {*;} y tres -keepclasseswithmembers para métodos,
campos y constructores anotados. El @Keep de AndroidX funciona como regla de biblioteca,
no porque lo sepa AGP.
5.3 Qué se conserva siempre, y qué rompe
Siempre: los componentes declarados en el manifiesto, que el framework instancia por
nombre; los métodos native, por la regla de arriba, «para que se puedan seguir
enlazando con la biblioteca nativa»; y lo que digan las reglas de las dependencias.
Lo de los componentes vale con la configuración por defecto. R8 trae ya una poda de los
componentes con android:exported="false" y sin intent-filter —no los trata como raíz—,
pero apagada: solo se enciende con la propiedad com.android.tools.r8.enableManifestPruning
(InternalOptions.java y ResourceShrinkerState.java, etiqueta 9.4.23).
Lo que no se conserva y rompe en ejecución es todo lo alcanzado por reflexión: «R8 no
puede identificar cuándo se accede a clases, campos o métodos mediante reflexión… Podría
renombrarlos o eliminarlos por completo, lo que produce una ClassNotFoundException o una
NoSuchMethodException en tiempo de ejecución». Y lo mismo con JNI: «cuando código nativo
llama a un método Java o Kotlin… la llamada se basa en una búsqueda dinámica por cadena del
nombre del método».
6. mapping.txt: la gramática exacta
Todo lo que sigue está contrastado contra tres fuentes: la gramática publicada en el manual
de ProGuard, el código de R8 (ProguardMapReader.java, ClassNamingForNameMapper.java,
Range.java, ProguardMapMarkerInfo.java) y doc/retrace.md del repositorio de R8. Los
ejemplos son salida real de R8 9.2.4-dev, el que viene en
build-tools/37.0.0/lib/d8.jar de esta máquina; el experimento está en la «sección 11.2 · Generar un mapping.txt real (ejecutada)».
Es un fichero de texto plano en UTF-8, orientado a líneas, con indentación significativa de cuatro espacios.
6.1 El preámbulo de metadatos
Salida real:
# compiler: R8
# compiler_version: 9.2.4-dev
# min_api: 21
# compiler_hash: 084a89126ae0596c599143df57a654646af28313
# common_typos_disable
# {"id":"com.android.tools.r8.mapping","version":"2.2"}
# pg_map_id: 5d9ebd539f55ab9c5999893268740017c8d4b9b6acfe65239fb4fbcdfc906ac5
# pg_map_hash: SHA-256 5d9ebd539f55ab9c5999893268740017c8d4b9b6acfe65239fb4fbcdfc906ac5
El orden y la condicionalidad los fija ProguardMapMarkerInfo.toPreamble():
| Línea | ¿Siempre? | Significado |
|---|---|---|
# compiler: <Tool> |
Sí | D8, L8, R8, R8Partial, Relocator, TraceReferences… |
# compiler_version: <v> |
Sí | Versión de R8 |
# min_api: <n> |
Solo si genera DEX |
minSdkVersion numérico |
# compiler_hash: <sha> |
Solo en builds de desarrollo | SHA del build de R8 |
# common_typos_disable |
Sí | Desactiva el linting del fichero en algunos sistemas de build |
# {"id":"com.android.tools.r8.mapping","version":"…"} |
Si la versión > none |
Versión del formato |
# pg_map_id: <id> |
Sí | Identificador del mapping |
# pg_map_hash: SHA-256 <hash> |
Sí | Hash del contenido |
Dos trampas para un parser. min_api va antes de common_typos_disable, no al final. Y
compiler_hash no aparece en releases estables: quien lo dé por obligatorio falla contra
mapping.txt de producción. El prefijo SHA-256 de pg_map_hash es literal y R8 lo
valida como tal (line.startsWith("# pg_map_hash: SHA-256 ")).
La versión estable del formato en R8 9.2.4-dev, y en main en agosto de 2026, es la
2.2 (MapVersion.STABLE). Existe una 2.3 en
el enumerado, pero no es la estable. La regla de compatibilidad está en doc/retrace.md y es
la que hace que el formato pueda crecer: «la información de versión se aplica al contenido
del fichero que sigue a la posición de la entrada de versión hasta el final del fichero o
hasta otra entrada de versión», y «al interpretar el fichero, cualquier información adicional
correspondiente a una versión posterior debe ignorarse; dicho de otro modo, tratarse como un
comentario normal». Un lector correcto avisa cuando ve una versión superior a la que
soporta, y sigue.
6.2 Mapeo de clase
Empieza en columna 0 y termina en dos puntos (originalclassname -> obfuscatedclassname:).
Ese : final es su discriminador. Salida real:
com.ejemplo.Calculadora -> com.ejemplo.b:
com.ejemplo.Calculadora$Auxiliar -> com.ejemplo.a:
com.ejemplo.Main -> com.ejemplo.Main:
com.ejemplo.Punto -> a:
com.ejemplo.Lambdas -> R8$$REMOVED$$CLASS$$0:
Cinco cosas de la vida real en cinco líneas: los nombres van con puntos, no con barras ni
en formato descriptor; las clases internas se nombran con $ como en el DEX; una clase
puede mapearse a sí misma (Main -> Main, porque una keep rule la conservó); R8 puede
sacar una clase de su paquete y ponerla en el paquete raíz (Punto -> a), que es
-repackageclasses y lo que explica los La;, Laai; de las aplicaciones de Google; y una
clase eliminada cuyo código sobrevive inlineado en otra recibe el nombre centinela
R8$$REMOVED$$CLASS$$<n>, que sostiene sus marcos inlineados. Una clase eliminada por
inalcanzable no aparece en mapping.txt: solo en usage.txt.
6.3 Mapeo de campo
Cuatro espacios de indentación, sin rango de líneas:
originalfieldtype originalfieldname -> obfuscatedfieldname. Salida real:
com.ejemplo.Calculadora -> com.ejemplo.b:
int acumulador -> a
java.lang.String etiqueta -> b
Dos campos de tipos distintos pueden mapearse al mismo nombre ofuscado, porque en el
DEX un field_id lleva class_idx, type_idx y name_idx: a de tipo int y a de
tipo String son campos distintos. El mapeo no es biyectivo por el nombre solo.
6.4 Mapeo de método y los rangos de línea
La forma completa, tal como la publica el manual de ProGuard:
[startline:endline:]originalreturntype [originalclassname.]originalmethodname(args)[:origstart[:origend]] -> obfuscatedmethodname
Y así es como R8 la serializa, en ClassNamingForNameMapper.MappedRange.toString():
if (minifiedRange != null) builder.append(minifiedRange).append(':');
builder.append(signature);
if (originalRange != null && !originalRange.equals(minifiedRange))
builder.append(":").append(originalRange);
builder.append(" -> ").append(renamedName);
Descompuesto sobre el ejemplo del enunciado clásico, 23:25:void foo():42:44 -> a:
| Trozo | Nombre en el código de R8 |
Qué es |
|---|---|---|
23:25 |
minifiedRange |
Rango de líneas en el binario producido. Es lo que aparecerá en la traza de pila del APK |
void foo() |
signature |
Firma original: tipo de retorno, nombre y argumentos originales |
42:44 |
originalRange |
Rango de líneas en el fichero fuente |
a |
renamedName |
Nombre ofuscado del método |
Tres reglas que un parser tiene que respetar y que casi ninguna descripción informal recoge:
R8omiteoriginalRangecuando es igual aminifiedRange. La condición literal es!originalRange.equals(minifiedRange). Así que1:3:void <init>(java.lang.String):7:7y1:7:void main(java.lang.String[]):5:11conviven con líneas sin la segunda mitad.- Un
Range«cardinal» se serializa como un solo número, no comofrom:to. De ahí la tercera forma que documenta el javadoc deProguardMapReader:range COLON signature COLON number ARROW name. - Puede no haber rango en absoluto, por ejemplo en un método abstracto, que no tiene
código. Salida real:
double area() -> a.
Salida real completa de una clase con métodos renombrados:
com.ejemplo.Calculadora -> com.ejemplo.b:
int acumulador -> a
java.lang.String etiqueta -> b
1:3:void <init>(java.lang.String):7:7 -> <init>
4:6:void <init>(java.lang.String):8:8 -> <init>
7:9:void <init>(java.lang.String):9:9 -> <init>
1:3:int cuentaDeOperaciones():30:30 -> a
1:1:void registrar(java.lang.String):23:23 -> b
2:2:void registrar(java.lang.String):25:25 -> b
1:7:int resta(int,int):18:18 -> c
1:7:int suma(int,int):13:13 -> d
Obsérvese que cuatro métodos originales se llaman ahora a, b, c y d y que los
rangos de minifiedRange se solapan entre métodos distintos: los dos empiezan en 1. No
es un error. El rango solo desambigua dentro del mismo nombre ofuscado: 1:1 -> b y
2:2 -> b son dos líneas del mismo método b, mientras que 1:7 -> c y 1:7 -> d son
métodos distintos. La clave de búsqueda es el par (nombre ofuscado, línea).
El rango catch-all. Cuando basta una sola posición, R8 emite el rango completo:
0:65535:void foo():33:33 -> a
doc/retrace.md lo explica: «para asegurar la compatibilidad, R8 emite un rango catch-all
0:65535… Los rangos catch-all nunca deben usarse para sobrecargas».
6.5 Métodos inlineados
Aquí es donde el formato deja de ser una tabla y se convierte en algo con estructura. El
javadoc de ProguardMapReader lo enuncia: la forma
range COLON signature COLON range ARROW name significa «que signature fue inlineado desde
el segundo rango a los números de línea nuevos del primero», y a continuación vienen las
entradas «con la información de la traza de llamada hasta donde el miembro fue inlineado».
Salida real de una compilación con optimización, sobre el mismo código:
com.ejemplo.Main -> com.ejemplo.Main:
1:1:void com.ejemplo.Calculadora.registrar(java.lang.String):24:24 -> main
1:1:int com.ejemplo.Calculadora.suma(int,int):13 -> main
1:1:void main(java.lang.String[]):6 -> main
2:2:double com.ejemplo.Punto.distancia(com.ejemplo.Punto):8:8 -> main
2:2:void main(java.lang.String[]):9 -> main
La lectura correcta del bloque 1:1, de arriba abajo, es de marco más interno a marco más
externo:
línea 1 del método `main` del APK ──► Calculadora.registrar(String) línea 24 ← el fallo
llamado desde
Calculadora.suma(int,int) línea 13
llamado desde
Main.main(String[]) línea 6
Una sola línea de la traza de pila real se expande en tres marcos. Eso es lo que hace
retrace, y es la razón por la que una traza deofuscada puede ser más larga que la
original. La señal sintáctica de que una entrada es un marco inlineado venido de otra clase
es que la firma lleva el nombre de clase cualificado delante
(void com.ejemplo.Calculadora.registrar(...) en vez de void registrar(...)).
6.6 Los comentarios de metadatos por elemento
Además del preámbulo, R8 emite comentarios JSON pegados al elemento al que se refieren.
El conjunto exacto de identificadores, extraído de las constantes ID del paquete
com.android.tools.r8.naming.mappinginformation:
id |
Desde | Claves | Qué dice |
|---|---|---|---|
com.android.tools.r8.mapping |
1.0 | version |
Versión del formato (solo en el preámbulo) |
sourceFile |
0 | fileName |
Nombre del fichero fuente de la clase |
com.android.tools.r8.synthesized |
1.0 | — | Este elemento lo generó el compilador, no estaba en el fuente |
com.android.tools.r8.residualsignature |
2.2 | signature |
Firma residual (la del binario) cuando difiere de la original |
com.android.tools.r8.rewriteFrame |
2.0 | conditions, actions |
Reescritura condicional de marcos |
com.android.tools.r8.outline |
2.0 | — | Este método es un outline |
com.android.tools.r8.outlineCallsite |
2.0 | positions, outline |
Correspondencia posición-en-el-outline → posición-en-el-llamador |
com.android.tools.r8.mergedClasses |
— | — | Clases fusionadas |
partitionSourceFiles |
— | fileNameMappings |
Ficheros fuente en mappings particionados |
Dos correcciones a lo que circula por ahí, verificadas en el código: no existe
com.android.tools.r8.compilerSynthesized —la clase Java se llama
CompilerSynthesizedMappingInformation pero su ID es com.android.tools.r8.synthesized— y
no existe com.android.tools.r8.sourceFile: el identificador es el literal sourceFile,
sin cualificar, porque, según doc/retrace.md, «es el único identificador sin cualificar
permitido, ya que se introdujo antes del esquema de versionado». Los dos últimos de la tabla
existen en el código pero no están documentados en doc/retrace.md.
Salida real con clases sintéticas de lambda y firma residual:
com.ejemplo.Lambdas$1 -> b:
# {"id":"sourceFile","fileName":"R8$$SyntheticClass"}
# {"id":"com.android.tools.r8.synthesized"}
int com.ejemplo.Lambdas$$InternalSyntheticLambda$1$5e2a…$2.f$0 -> a
# {"id":"com.android.tools.r8.synthesized"}
1:1:boolean com.ejemplo.Lambdas.lambda$run$2(int,int,java.lang.String):19:19 -> test
# {"id":"com.android.tools.r8.residualsignature","signature":"(Ljava/lang/Object;)Z"}
Tres cosas que enseña ese fragmento. El fileName de una clase sintética es la cadena
literal R8$$SyntheticClass, que aparece tal cual dentro del DEX y es una huella
excelente («sección 9.2 · El atributo SourceFile»). El nombre original de una lambda desugarizada es
…$$InternalSyntheticLambda$<n>$<sha256>$<m>, con un hash de 64 hexadecimales dentro. Y
residualsignature existe porque la firma del binario no se puede deducir de la
original: aquí el parámetro String se convirtió en Object al implementar Predicate.
Según doc/retrace.md, «no tiene efecto sobre el retrace de trazas de pila, pero es
necesaria al interactuar con firmas residuales a través de la API de Retrace o al componer
ficheros de mapping».
Reglas de colocación, verbatim: la información de fichero fuente «debe colocarse directamente
debajo del mapeo de clase al que se refiere»; la de synthesized, «directamente debajo del
mapeo de clase, campo o método al que se refiere», y «nunca debe colocarse sobre marcos
inlineados».
rewriteFrame es el más raro y merece nota: su condición throws(<descriptor>) «será cierta
si la excepción lanzada arriba es <descriptor>» —varias condiciones se combinan con AND,
y para hacer un OR hay que duplicar la información— y su acción removeInnerFrames(<n>)
«elimina ese número de marcos empezando por el más interno», y solo se aplica «si la línea
que se está deofuscando está directamente debajo de la línea de la excepción». Sirve para que
los NullPointerException sintéticos que R8 introduce al inlinear no salgan en la traza.
⚠️ Los ejemplos de doc/retrace.md usan comillas simples en el JSON; el fichero real que
emite R8 usa comillas dobles, como se ve arriba. Un parser tolerante acepta ambas.
6.7 El algoritmo de lectura
estado: clase_actual = ninguna
para cada línea:
1. empieza por '#', con o sin sangría
→ metadato
· preámbulo → compilador, versión, min_api, map id
· JSON con "id" → asociarlo al último elemento emitido
· versión desconocida → avisar y tratar como comentario
2. no empieza por espacio, acaba en ':'
→ clase: partir por " -> ", clase_actual = esta
3. empieza por espacios → miembro de clase_actual, partir por " -> "
· empieza por dígito y ':' → método con rango
· contiene '(' → método sin rango
· en otro caso → campo
La operación inversa —la de retrace— está desarrollada en
«Deofuscación → §3.1 · El algoritmo», con sus tres casos límite: entradas sin
minifiedRange, rangos solapados, y la no inyectividad del mapping, que hace la
deofuscación ambigua cuando la traza no trae número de línea.
7. retrace
retrace es la herramienta que aplica un mapping.txt a una traza de pila. La definición
oficial: «R8 retrace es una herramienta para obtener la traza de pila original a partir de
una traza ofuscada. La traza se reconstruye emparejando los nombres de clase y método de un
fichero de mapping con sus definiciones originales».
retrace <fichero-de-mapping> [<fichero-de-traza>] [opciones]
Se instala con el SDK Manager en cmdline-tools/<version>/bin/. Sin fichero de traza lee de
la entrada estándar. Opciones, las mismas que lista retrace --help en Retrace 8.9.27
(cmdline-tools 19.0): --verbose («imprime más información, como los parámetros y
el tipo de retorno del método»), --info, --quiet, --regex <expresión> y
--verify-mapping-file-hash. El retrace.jar de ProGuard acepta la misma idea con una
sintaxis de comodines propia (%c clase, %m método, %l línea, %s fichero fuente…) y
exige que las expresiones usen solo grupos sin captura, (?:...).
Ejecutado de verdad sobre el mapping.txt generado en la «sección 11.2 · Generar un mapping.txt real (ejecutada)», con
java -cp $BT/lib/d8.jar com.android.tools.r8.retrace.Retrace mapping2.txt traza.txt:
java.lang.IllegalStateException: demo:suma java.lang.IllegalStateException: demo:suma
at com.ejemplo.b.b(SourceFile:2) → at com.ejemplo.Calculadora.registrar(Calculadora.java:25)
at com.ejemplo.b.d(SourceFile:1) at com.ejemplo.Calculadora.suma(Calculadora.java:13)
at com.ejemplo.Main.main(SourceFile:6) at com.ejemplo.Main.main(Main.java:10)
Y sobre el mapping.txt con optimización, una sola línea de entrada produce tres:
java.lang.NullPointerException java.lang.NullPointerException
at com.ejemplo.Main2.main(SourceFile:4) → at com.ejemplo.Lambdas.filtra(Lambdas.java:12)
at com.ejemplo.Lambdas.run(Lambdas.java:19)
at com.ejemplo.Main2.main(Main2.java:5)
Por qué es deofuscación determinista y sin riesgo. Porque no infiere nada. Es una
búsqueda en una tabla que produjo el mismo compilador que produjo el binario: no hay
heurística, no hay confianza que declarar, no hay falsos positivos posibles. O el
mapping.txt corresponde al APK —y el hash pg_map_id permite comprobarlo, «sección 9.1 · El marcador ~~R8{…} dentro del DEX»—
o no corresponde y la respuesta es «no lo sé». Es la razón por la que retrace es la única
deofuscación que se puede ofrecer sin reservas.
8. R8 full mode
R8 tiene dos modos. El modo compatibilidad imita a ProGuard; el modo completo
(full mode) hace suposiciones más fuertes sobre el código y optimiza más. android.enableR8.fullMode
pasó a valer true por defecto en AGP 8.0.
Las siete diferencias, tomadas literalmente de compatibility-faq.md del repositorio de R8:
- El constructor por defecto
<init>()no se conserva implícitamente al conservar una clase. - Tampoco se conserva para tipos que solo se usan con
ldc,instanceofocheckcast. - Las clases que contienen campos o métodos casados por una
-keepclassmembersno se consideran implícitamente instanciadas. - Los métodos
defaultno se conservan implícitamente como métodos abstractos. - Los atributos (como
Signature) y las anotaciones solo se conservan para las clases, métodos y campos casados por una keep rule, aunque se haya puesto-keepattributes. - Al optimizar o minificar, el atributo
SourceFilesiempre se reescribe aSourceFilesalvo que se use-renamesourcefileattribute. En elR89.2.4-dev de build-tools 37 el valor por defecto en full mode esr8-map-id-<hash>(«sección 9.2 · El atributo SourceFile»). - La modificación de acceso (
-allowaccessmodification) está activada por defecto al optimizar.
Los puntos 1 a 4 son la razón de que el modo completo «rompa más código»: cada uno elimina
una conservación implícita en la que muchos proyectos se apoyaban sin saberlo, y el síntoma
es una ClassNotFoundException o una NoSuchMethodException en ejecución.
El punto 5 es el que más duele al analizar, no al compilar: sin Signature desaparecen
los genéricos, que en el DEX viven en la anotación de sistema dalvik.annotation.Signature
(«Formato DEX → §9 · La sección de datos»). Un List<String> decompila
como List y ya no hay forma de recuperar el parámetro de tipo.
⚠️ Discrepancia observada entre fuentes oficiales: el mismo compatibility-faq.md afirma que
«el modo compatibilidad de R8 es el predeterminado en Android Studio», lo que contradice
las notas de AGP 8.0. El texto de la FAQ parece desactualizado. Los datos del muestrario apoyan
las notas de AGP: de los 90 marcadores con campo r8-mode encontrados, 64 dicen full y
26 dicen compatibility, y los compatibility se concentran en bibliotecas compiladas con
versiones antiguas de R8 que el AAR traía ya dexificadas.
9. Cómo se detecta que una app pasó por R8
En orden de fiabilidad, de la certeza a la estadística.
9.1 El marcador ~~R8{…} dentro del DEX
R8, D8 y L8 escriben su propia firma como una cadena del DEX. Es un objeto JSON
precedido de dos virgulillas, y es la huella más fuerte que existe porque no es una
inferencia: es una declaración. Salida real de la compilación de la «sección 11.2 · Generar un mapping.txt real (ejecutada)»:
~~R8{"backend":"dex","compilation-mode":"release","has-checksums":false,"min-api":21,
"pg-map-id":"5d9ebd539f55ab9c5999893268740017c8d4b9b6acfe65239fb4fbcdfc906ac5",
"r8-mode":"full","sha-1":"084a89126ae0596c599143df57a654646af28313","version":"9.2.4-dev"}
(partido en tres líneas aquí; en el fichero es una sola cadena).
Qué se saca de ahí, de un APK cualquiera y sin herramientas: qué herramienta lo
procesó, qué versión exacta, si se compiló en release o en debug, contra qué
minSdkVersion, si estaba en modo full o compatibility, y el pg-map-id, que es
literalmente el mismo valor que la línea # pg_map_id: del mapping.txt. Es decir: se
puede verificar que un mapping.txt dado corresponde a un APK dado sin ejecutar nada.
Buscarlo es trivial:
MUESTRAS=~/muestras-apk
unzip -p $MUESTRAS/com.looker.droidify_710.apk classes.dex | strings | grep '~~'
Medido sobre el muestrario completo, extrayendo el marcador de los 323 classes*.dex de los 86
contenedores:
| Observación | Valor |
|---|---|
Contenedores con al menos un marcador ~~R8 |
69 de 86 |
Contenedores sin ningún marcador ~~D8/~~R8/~~L8 |
5 |
Versiones distintas de R8/D8/L8 observadas |
45 |
Marcadores con r8-mode |
90: 64 full, 26 compatibility |
Los cinco sin marcador son com.whatsapp.apks, com.supercell.clashroyale.apks,
com.moonactive.coinmaster.apks, org.gnucash.android_2.4.0.apk y
io.github.muntashirakon.AppManager_445.apk. La ausencia del marcador es en sí una señal:
en WhatsApp, que sí está claramente ofuscado («sección 10 · Medición propia: la fracción de identificadores cortos»), significa que algo posterior a
R8 reescribió el DEX y se llevó la cadena por delante.
Un matiz que evita conclusiones erróneas: una aplicación trae muchos marcadores, no uno.
Twitch trae 12 con 8 pg-map-id distintos y versiones desde la 2.2.66 hasta la 8.12.x; los
AAR de terceros vienen ya dexificados y con su propio marcador. El marcador del DEX que
contiene el código de la aplicación es el que importa; los demás describen sus dependencias
—y, de paso, delatan qué bibliotecas usa.
9.2 El atributo SourceFile
Cada class_def_item tiene un source_file_idx
(«Formato DEX → §10 · class_def_item y class_data_item»). En un binario compilado sin
ofuscar hay un nombre de fichero distinto por clase; tras pasar por R8 hay uno solo, y
su valor identifica el camino recorrido:
Valor de SourceFile |
Qué significa | Origen |
|---|---|---|
Muchos valores distintos (Foo.java, Bar.kt…) |
Sin renombrar | — |
SourceFile |
Valor por defecto de R8 |
DexItemFactory.defaultSourceFileAttributeString |
PG |
Valor centinela heredado de ProGuard |
DexItemFactory.pgSourceFileAttributeString |
R8$$SyntheticClass / D8$$SyntheticClass |
Clase sintética generada por el compilador | mapping.txt, {"id":"sourceFile"} |
r8-map-id-<64 hex> |
El pg_map_id incrustado en el SourceFile |
SourceFileRewriter.computeSourceFileProvider |
| Cadena vacía | Reescrito a vacío por algo posterior | — |
La lógica exacta está en SourceFileRewriter.java: -renamesourcefileattribute solo se
aplica si SourceFile está en -keepattributes; en modo compatibilidad, además, solo
cuando se están minificando nombres; y si no está, R8 reescribe al valor por defecto, que
en el R8 9.2.4-dev de build-tools 37 es "SourceFile" en modo compatibilidad y
r8-map-id-<hash> en full mode, el caso que sigue.
El caso r8-map-id-… merece una nota, porque el nombre del interruptor engaña. En el
fuente de R8 la rama vive en SourceFileRewriter.computeSourceFileProvider y está
condicionada a options.getTestingOptions().enableMapIdInSourceFile, lo que hace pensar en
una opción experimental. No lo es: el campo se declara
public boolean enableMapIdInSourceFile =
SystemPropertyUtils.parseSystemPropertyOrDefault(
"com.android.tools.r8.enableMapIdInSourceFile", true);
es decir, activa por defecto en cualquier R8 estándar, salvo que se desactive con esa
propiedad de sistema. Comprobado con el R8 9.2.4-dev de build-tools 37: en full mode
escribe r8-map-id-<hash> con -keepattributes SourceFile y sin ella, y con
-Dcom.android.tools.r8.enableMapIdInSourceFile=false vuelve a SourceFile. Vivir en TestingOptions no la hace experimental. Eso explica sin
misterio que aparezca en 19 de las 85 aplicaciones medidas —Twitch, Reddit, K-9 Mail,
NewPipe, Wikipedia, AnkiDroid y siete aplicaciones de Fossify—. Es además la huella más útil de
todas, porque el valor contiene
el identificador del mapping.txt correspondiente.
Distribución del valor mayoritario de SourceFile sobre las 85 aplicaciones con DEX:
| Valor mayoritario | Contenedores |
|---|---|
SourceFile |
31 |
r8-map-id-<sha256> |
19 |
D8$$SyntheticClass |
11 |
R8$$SyntheticClass |
9 |
PG |
9 |
| Cadena vacía | 2 |
Otros (R.java, lambda, nombre de artefacto Maven) |
4 |
Las nueve aplicaciones con PG son las nueve de Google del muestrario: Gmail, Maps, YouTube,
Photos, Drive, Keep —en sus dos empaquetados— más Authenticator y Calculator. Es una huella
de la cadena de compilación interna de Google, no de AGP.
Y 59 de los 85 contenedores tienen un único valor de SourceFile para todas sus clases,
que es el indicador binario más barato de «esto pasó por un reescritor de atributos».
9.3 Otras huellas
com.google.android.gmssin renombrar junto a clasesLa;,Lb;: es el patrón normal de una aplicación ofuscada con dependencias de Google, cuyas consumer rules conservan buena parte de su superficie. Netflix, con DexGuard, tiene 475 clases encom/google/android/gms/internal/castsin ofuscar al lado de 17.329 clases en un paquete llamadoo.- Clases sintéticas con nombres reconocibles:
$$ExternalSyntheticLambda<n>,$$InternalSyntheticLambda$<n>$<sha256>$<m>,$$ExternalSyntheticApiModelOutline.R8yD8los generan y no los renombran; su sola presencia identifica la herramienta. - El paquete raíz poblado, resultado de
-repackageclasses/-flattenpackagehierarchy. Elclasses.dexde Gmail empieza literalmente porLa;,Laai;,Laaj;. META-INF/*.kotlin_moduleintacto: conservan el nombre del módulo Gradle original yR8no los toca. En Shazam, ofuscado al máximo, siguen ahíAMSKit_release.kotlin_moduleyapp_googleRelease.kotlin_module; en Reddit,account_impl.kotlin_module,awards_public.kotlin_module,deeplinkdispatch_release.kotlin_module.- Pocos
debug_info_itempara muchos métodos:R8codifica las líneas por PC y hace que muchoscode_itemcompartan el mismodebug_info_item. No es ausencia de información de depuración: en una compilación de prueba con elR89.2.4 de build-tools 37, los 48code_itemtienendebug_info_off != 0y apuntan a solo 3debug_info_itemdistintos, con-keepattributes LineNumberTabley sin ella. En elclasses.dexdecom.looker.droidify_710.apk, del muestrario, 28.006code_itemcomparten 1.113 items y solo 21 tienendebug_info_off == 0. Lo que hay que contar son esoscode_item, no los items; ver «Formato DEX → §12 · debug_info_item».
10. Medición propia: la fracción de identificadores cortos
10.1 Definición de la métrica
Una forma habitual de caracterizar la ofuscación es la «fracción de identificadores de uno o
dos caracteres en el primer DEX». Esa métrica se ha
reimplementado desde cero —un lector de DEX en Python que abre el base.apk del
contenedor, lee su classes.dex y recorre class_defs, class_data_item, field_ids,
method_ids y string_ids— con esta definición precisa:
- Universo: los identificadores definidos en el primer
DEX, no los referenciados. Es decir, para los miembros se recorre elclass_data_itemde cadaclass_def_itemy se siguen losfield_idx_diff/method_idx_diff, no se toman las tablas enteras: las tablas incluyen referencias ajava.lang.String.lengthy a medio framework, que no ofusca nadie. - Clases: el nombre simple de la clase de nivel superior, deduplicado. De
Lcom/foo/Bar$Baz$1;se tomaBar. Los sufijos$1,$2de clases anónimas los pone el compilador esté ofuscado el código o no, y contarlos como identificadores cortos infla la cifra en un 20 % incluso en aplicaciones limpias. - Métodos: se excluyen
<init>y<clinit>, que nunca se renombran. - Longitud: en unidades UTF-16, leída del
utf16_sizedelstring_data_item, lo que evita tener que decodificarMUTF-8para contar.
10.2 El resultado, y por qué el umbral de dos caracteres es el instrumento equivocado
| App | Identificadores | ≤ 1 | ≤ 2 | ≤ 3 | Mediana | SourceFile |
|---|---|---|---|---|---|---|
| YouTube | 70.622 | 62,7 % | 85,4 % | 92,2 % | 1 | PG |
| Maps | 60.397 | 64,2 % | 85,4 % | 91,0 % | 1 | PG |
| Gmail | 61.604 | 69,4 % | 83,1 % | 91,3 % | 1 | PG |
| Photos | 63.919 | 63,1 % | 81,4 % | 90,7 % | 1 | PG |
| Snapchat | 97.035 | 51,2 % | 81,2 % | 89,7 % | 1 | SourceFile |
| Keep | 63.545 | 64,2 % | 77,6 % | 89,2 % | 1 | PG |
| Drive | 87.393 | 72,3 % | 76,4 % | 87,1 % | 1 | PG |
| VLC Remote | 72.826 | 74,5 % | 82,1 % | 82,7 % | 1 | SourceFile |
| 67.796 | 0,0 % | 0,3 % | 79,8 % | 3 | (vacío) | |
| Spotify | 82.402 | 61,4 % | 69,3 % | 73,8 % | 1 | SourceFile |
| Shazam | 88.457 | 50,1 % | 54,3 % | 70,0 % | 1 | SourceFile |
| Duolingo | 90.728 | 45,8 % | 55,8 % | 64,9 % | 2 | SourceFile |
| Twitch | 59.406 | 54,5 % | 61,8 % | 62,6 % | 1 | r8-map-id-… |
| 97.480 | 48,2 % | 52,8 % | 61,2 % | 2 | r8-map-id-… |
|
| My Talking Tom | 85.947 | 51,6 % | 55,4 % | 56,5 % | 1 | SourceFile |
| Telegram | 86.187 | 1,1 % | 1,3 % | 29,9 % | 8 | SourceFile |
| Netflix | 80.238 | 16,7 % | 19,2 % | 22,0 % | 10 | (vacío) |
| Clash Royale | 105.944 | 1,7 % | 2,0 % | 19,6 % | 11 | SourceFile |
| Coin Master | 96.598 | 8,2 % | 9,2 % | 14,5 % | 17 | SourceFile (810 distintos) |
| Signal | 83.604 | 0,3 % | 0,6 % | 7,4 % | 13 | R8$$SyntheticClass (2.580) |
| LinkedIn Learning | 81.305 | 0,7 % | 0,9 % | 7,1 % | 13 | SourceFile |
| Candy Crush | 95.783 | 0,7 % | 0,8 % | 4,0 % | 15 | R8$$SyntheticClass (2.486) |
La fila de WhatsApp es la que enseña algo. Con el umbral de dos caracteres da 0,3 %:
«sin ofuscar». Con tres da 79,8 %. Mirando los nombres se ve por qué: sus clases se llaman
LX/000;, LX/001;, LX/00A; — exactamente tres caracteres, dentro de un paquete de
uno. Es de las aplicaciones más ofuscadas del muestrario y el umbral de dos la declara limpia.
La causa es estructural: R8 genera nombres por orden lexicográfico creciente, a, b,
…, z, aa, ab, …, aaa, así que cuando el espacio de nombres de un ámbito se llena, los
nombres crecen. Una aplicación con 60.000 identificadores en el paquete raíz
necesariamente tiene miles de nombres de tres y cuatro caracteres. El umbral fijo no mide
«cuánto está ofuscada la app», mide «cuántos identificadores caben en el ámbito antes de
desbordar», que depende del tamaño y del esquema de repaquetado.
El indicador robusto es la mediana de la longitud. La distribución es netamente bimodal: en el grupo ofuscado la mediana vale 1 o 2 —3 en WhatsApp—, y en el no ofuscado, entre 8 y 17. No hay nada en medio salvo cuatro aplicaciones con mediana 4 o 6, las de ofuscación parcial.
10.3 El grupo de control de F-Droid
Medidos 57 de los 58 APK sueltos —todos menos com.termux.styling_1000.apk, que solo
define 155 identificadores—, 37 caen por debajo del 20 % de identificadores de tres o menos
caracteres (mediana de la métrica: 4,4 %; medianas de longitud entre 10 y 17) y 20 caen por
encima (mediana: 72,3 %; medianas de longitud de 1 a 6). De esos 57, 53 vienen de F-Droid,
tres de la publicación de su propio proyecto —Bitwarden, GnuCash y Orbot— y uno de APKPure; la
procedencia de cada uno está en la «sección 5 de El muestrario · De dónde salen y cuándo».
Es decir: la premisa de que los sueltos de F-Droid son un grupo de control «mayoritariamente
sin ofuscar» solo es cierta para dos tercios. F-Droid compila desde el fuente con la
configuración Gradle del propio proyecto, y si el proyecto pone minifyEnabled = true,
entrega un APK ofuscado. Las doce aplicaciones de Fossify, AnkiDroid, Aurora Store,
Bitwarden, My Expenses, Pachli, Material Files, net.typeblog.shelter e
io.github.subhamtyagi.ocr —los 20 del grupo alto— están tan ofuscadas como Twitch. Como grupo
de control real quedan los 37 del extremo bajo: Termux (2,3 %, mediana 14), osmdroid (2,6 %,
15), AntennaPod (2,8 %, 17), F-Droid mismo, VLC, NewPipe, Briar y SimpleX Chat, entre otros.
Eso no es un defecto del muestrario: es mejor de lo previsto, porque esos 20 ofuscados son los únicos casos en los que el código fuente está disponible, y por tanto los únicos donde se puede construir la verdad de terreno de un evaluador de deofuscación.
10.4 Qué indicador usar, y la sorpresa de Google
De la medición salen dos conclusiones que conviene dejar dichas, porque ninguna se ve de un vistazo en la tabla.
El indicador principal es la mediana de longitud, no un umbral fijo. Ya se ha visto con WhatsApp, que pasa del 0,3 % al 79,8 % entre el umbral de dos caracteres y el de tres; Telegram hace lo mismo del 1,3 % al 29,9 %. Un umbral no ordena las aplicaciones, las baraja, y cuál elegir cambia el resultado más que la ofuscación que pretende medir. La mediana las separa limpiamente —1 en las ofuscadas al máximo, de 8 a 17 en las que no lo están— y no depende del tamaño del ámbito de nombres.
Las aplicaciones de Google están entre las más ofuscadas del muestrario, no entre las menos.
Es contraintuitivo, y la tabla de la «sección 10.2 · El resultado, y por qué el umbral de dos caracteres es el instrumento equivocado» lo dice sin ambigüedad: YouTube, Maps, Gmail,
Photos, Keep y Drive ocupan seis de las siete primeras filas. La evidencia directa lo confirma:
la primera clase de classes.dex de Gmail es La;, seguida de Laai;, Laaj;, Laax;; su
SourceFile es PG para las 5.452 clases; su ≤ 3 es del 91,3 % y su mediana de longitud de
identificador, 1.
Y el muestrario sirve como gradiente: va del 4,0 % de Candy Crush al 92,2 % de YouTube, con casos intermedios y con casos de ofuscación parcial. Hay dónde elegir para probar una herramienta contra grados distintos de ofuscación.
11. Recetas
11.1 Sobre un APK cualquiera (ejecutadas)
BT=~/Library/Android/sdk/build-tools/37.0.0
MUESTRAS=~/muestras-apk
| Objetivo | Orden |
|---|---|
| Marcador de compilador | unzip -p $MUESTRAS/com.termux_1002.apk classes.dex | strings | grep '~~' |
Valores de SourceFile |
$BT/dexdump -h classes.dex | grep source_file_idx | sort | uniq -c |
| Nombres de clase definidos | $BT/dexdump -h classes.dex | grep 'Class descriptor' |
| Módulos Kotlin supervivientes | unzip -l app.apk 'META-INF/*.kotlin_module' |
11.2 Generar un mapping.txt real (ejecutada)
R8 y retrace están disponibles en esta máquina dentro de
build-tools/37.0.0/lib/d8.jar, aunque no aparezcan como binarios sueltos. Es la forma de
obtener salida real del formato sin instalar nada:
BT=~/Library/Android/sdk/build-tools/37.0.0
java -cp $BT/lib/d8.jar com.android.tools.r8.R8 --version
# R8 9.2.4-dev (build 084a89126ae0596c599143df57a654646af28313 from go/r8bot …)
javac -g -d cls $(find src -name '*.java')
printf -- '-keep class com.ejemplo.Main { public static void main(java.lang.String[]); }\n-keepattributes SourceFile,LineNumberTable\n-dontoptimize\n-dontshrink\n' > reglas.pro
mkdir -p out
java -cp $BT/lib/d8.jar com.android.tools.r8.R8 --release --min-api 21 \
--lib ~/Library/Android/sdk/platforms/android-34/android.jar \
--pg-conf reglas.pro --pg-map-output mapping.txt --output out cls/com/ejemplo/*.class
--output debe ser un directorio que ya exista o un .zip/.jar; si no, R8 falla con
Invalid output. --pg-compat selecciona el modo compatibilidad; sin él, R8 compila en
full mode, que es lo que confirma la «sección 8 · R8 full mode». Todo el mapping.txt de las «secciones
6.1 · El preámbulo de metadatos» a 6.6 y la ejecución de retrace de la «sección 7 · retrace» salen de este experimento.
11.3 retrace, y los dos programas que se llaman igual
Hay dos retrace y hacen lo mismo con implementaciones distintas: el de Guardsquare, que
viene con ProGuard, y el de Google, que viene con cmdline-tools y entiende el formato
extendido de R8. Si están los dos instalados colisionan en el PATH, así que conviene
invocarlos por ruta absoluta. Verificado el 13 de agosto de 2026 con ProGuard 7.9.1 (Homebrew)
y Retrace 8.9.27 (cmdline-tools).
Partiendo del mapping.txt del experimento de 11.2 y de esta traza ofuscada:
java.lang.ArithmeticException: divide by zero
at com.ejemplo.Main.a(SourceFile:3)
at com.ejemplo.Main.main(SourceFile:4)
~/Library/Android/sdk/cmdline-tools/latest/bin/retrace mapping.txt traza.txt
java.lang.ArithmeticException: divide by zero
at com.ejemplo.Main.calcula(Main.java:3)
at com.ejemplo.Main.main(Main.java:4)
Deshace las dos cosas a la vez: el nombre del método —a vuelve a ser calcula— y el del
fichero fuente, porque R8 escribió el nombre real en el comentario
# {"id":"sourceFile","fileName":"Main.java"} de la «sección 6.2 · Mapeo de clase». Para eso no hace falta
-keepattributes SourceFile: desde R8 8.2 el nombre original va siempre al mapping
(compatibility FAQ), y con el R8 9.2.4 de build-tools 37 el comentario sale igual con la
regla y sin ella.
ProGuard independiente también se ejecuta, con la sintaxis de fichero de configuración:
proguard @config.pro
ProGuard, version 7.9.1
El mapping.txt tiene que ser el del binario del que salió la traza. Aplicando el mapa
que produjo ProGuard en su propia compilación a una traza generada por el binario de R8,
el retrace de Guardsquare no falla: devuelve la traza medio traducida, con el fichero
resuelto y el método sin resolver.
at com.ejemplo.Main.a(Main.java:3)
Ese a que sobrevive es la única señal de que el mapa no correspondía. No hay error, no hay
código de salida distinto de cero, y una automatización que no compare el pg_map_hash de la
«sección 6.1 · El preámbulo de metadatos» se lo traga entero.
Fuentes
- Enable app optimization with R8 — https://developer.android.com/build/shrink-code
Consultado el 12 de agosto de 2026. De aquí salen la separación entre shrinking,
optimización y ofuscación («sección 3 · Los tres trabajos, que no son el mismo»), las dos DSL y el source set
keepRules(«sección 4 · Cómo se enciende y dónde viven las reglas»), y el estado de full mode por versión deAGP(«sección 8 · R8 full mode»). - Troubleshoot app optimization —
https://developer.android.com/topic/performance/app-optimization/troubleshoot-the-optimization
Consultado el 12 de agosto de 2026. De aquí salen la ruta de
mapping.txt, su empaquetado en elAAB,configuration.txty la invocación deretrace(secciones «4» y «7»). Consultado de nuevo el 25 de septiembre de 2026: sigue dando la ruta deconfiguration.txtsin la variante («sección 4 · Cómo se enciende y dónde viven las reglas»). - Add keep rules / Keep rules overview / Optimize your library —
https://developer.android.com/topic/performance/app-optimization/add-keep-rules,
.../keep-rules-overviewy.../library-optimizationConsultados el 12 de agosto de 2026. Del primero, la tabla de opciones, modificadores y comodines de la «sección 5 · Keep rules» y la regla del constructor por defecto implícito; del segundo, las citas sobre reflexión yJNIde la «sección 5.3 · Qué se conserva siempre, y qué rompe»; del tercero,consumerProguardFiles,META-INF/proguard/y la distinción de la «sección 5.1 · Los ficheros y su procedencia». - Analyze your build with APK Analyzer — https://developer.android.com/studio/debug/apk-analyzer
Consultado el 12 de agosto de 2026. De aquí salen
seeds.txtyusage.txty la discrepancia de ruta señalada en la «sección 4 · Cómo se enciende y dónde viven las reglas». Consultado de nuevo el 25 de septiembre de 2026: sigue escribiendomappings, en plural. - Retrace — https://developer.android.com/tools/retrace
Consultado el 12 de agosto de 2026. De aquí salen la definición, la sintaxis y las
opciones de
retracede la «sección 7 · retrace». - Android Gradle plugin 8.0.0 release notes —
https://developer.android.com/build/releases/past-releases/agp-8-0-0-release-notes
Consultado el 12 de agosto de 2026. De aquí sale que
android.enableR8.fullModepasa atruepor defecto («sección 8 · R8 full mode»). - Android Gradle plugin 3.4.0 release notes —
https://developer.android.com/build/releases/past-releases/agp-3-4-0-release-notes
Consultado el 12 de agosto de 2026. De aquí sale el relevo de
ProGuardporR8(«sección 2 · Historia y relevo»). - R8 — compatibility FAQ — https://r8.googlesource.com/r8/+/refs/heads/main/compatibility-faq.md
Consultado el 12 de agosto de 2026. De aquí salen las siete diferencias del full mode y
la discrepancia señalada al final de la «sección 8 · R8 full mode».
Consultado de nuevo el 25 de septiembre de 2026: de aquí sale también que desde
R88.2 no hace falta-keepattributes SourceFilepara conservar el nombre del fichero fuente en el mapping («sección 11.3 · retrace, y los dos programas que se llaman igual»). - R8 —
doc/retrace.md— https://r8.googlesource.com/r8/+/refs/heads/main/doc/retrace.md Consultado el 12 de agosto de 2026. De aquí salen el versionado del formato, el rango catch-all0:65535, y la semántica desourceFile,synthesized,rewriteFrame,outline,outlineCallsiteyresidualsignature(secciones «6.1», «6.4» y «6.6»). - R8 — código fuente — https://r8.googlesource.com/r8/
Consultado el 12 de agosto de 2026. Ficheros leídos:
src/main/java/com/android/tools/r8/naming/ProguardMapReader.java(gramática del javadoc),ClassNamingForNameMapper.javayRange.java(serialización exacta de la «sección 6.4 · Mapeo de método y los rangos de línea»),ProguardMapMarkerInfo.javayMapVersion.java(preámbulo y versión estable de la «sección 6.1 · El preámbulo de metadatos»),SourceFileRewriter.javaygraph/DexItemFactory.java(valores centinelaSourceFile,PGyr8-map-id-de la «sección 9.2 · El atributo SourceFile»),dex/Marker.java(valores de# compiler:) ynaming/mappinginformation/*.java(losidde la «sección 6.6 · Los comentarios de metadatos por elemento»). - Reglas por defecto que empaqueta AGP —
https://android.googlesource.com/platform/tools/base/+/refs/heads/mirror-goog-studio-main/build-system/gradle-core/src/main/resources/com/android/build/gradle/
Consultado el 12 de agosto de 2026. De aquí salen íntegros
proguard-header.txt,proguard-common.txtyproguard-optimizations.txtde la «sección 5.2 · Las reglas que AGP empaqueta de verdad». - ProGuard manual — Retrace — https://www.guardsquare.com/manual/tools/retrace
Consultado el 12 de agosto de 2026. De aquí sale la gramática formal de la «sección 6 · mapping.txt: la gramática exacta» y la
sintaxis de comodines de
retrace.jarde la «sección 7 · retrace». - ProGuard manual — Usage / Home — https://www.guardsquare.com/manual/configuration/usage
y https://www.guardsquare.com/manual/home
Consultados el 12 de agosto de 2026. De aquí salen
-renamesourcefileattribute,-keepattributes,-printmapping,-applymapping,-printseeds,-printusage, y los cuatro pasos deProGuardde la «sección 2 · Historia y relevo». - The ProGuard story: 20 years of innovation — https://www.guardsquare.com/blog/the-proguard-story-20-years-of-innovation-in-java-optimization-guardsquare Consultado el 12 de agosto de 2026. De aquí salen Eric Lafortune, el primer commit del 1 de mayo de 2002, Gingerbread, 2012 y 2014 («sección 2 · Historia y relevo»).
- Guardsquare/proguard — https://github.com/Guardsquare/proguard
Consultado el 12 de agosto de 2026. De aquí salen la licencia GPL-2.0 con excepciones y
el tag
v7.9.1(«sección 2 · Historia y relevo»). ⚠️ El año de esa release no es visible en la interfaz. - Dalvik executable format — https://source.android.com/docs/core/runtime/dex-format
Consultado el 12 de agosto de 2026. De aquí sale
source_file_idxdelclass_def_itemy el papel dedalvik.annotation.Signature(secciones «8» y «9.2»). - ProGuard 7.9.1 y Retrace 8.9.27 ejecutados localmente — ProGuard instalado con
Homebrew y
retracedecmdline-tools, el 13 de agosto de 2026. De aquí salen las tres ejecuciones de la «sección 11.3 · retrace, y los dos programas que se llaman igual», incluida la traducción a medias que produce aplicar unmapping.txtque no corresponde al binario. - Android Gradle Plugin 9.0.0 release notes — https://developer.android.com/build/releases/agp-9-0-0-release-notes
Consultado el 24 de septiembre de 2026. De aquí sale
android.r8.strictFullModeForKeepRulesy el fin del constructor implícito de las reglas-keep(«sección 5 · Keep rules»). Consultado de nuevo el 25 de septiembre de 2026: de aquí salen también la optimización de recursos por defecto («sección 3 · Los tres trabajos, que no son el mismo»), el rechazo de opciones globales en las consumer rules («sección 5.1 · Los ficheros y su procedencia») y la ruta deconfiguration.txtcon la variante («sección 4 · Cómo se enciende y dónde viven las reglas»). - R8 9.2.4-dev de build-tools 37 ejecutado localmente —
java -cp build-tools/37.0.0/lib/d8.jar com.android.tools.r8.R8, condexdumpy un lector decode_itemen Python Consultado el 25 de septiembre de 2026. De aquí salen el método sin rango de la «sección 6.4 · Mapeo de método y los rangos de línea», la clase inlineadaR8$$REMOVED$$CLASS$$0frente a la inalcanzable, que solo va ausage.txt(«sección 6.2 · Mapeo de clase»), losdebug_info_itemcompartidos de la «sección 9.3 · Otras huellas» y el comentariosourceFilesin-keepattributes SourceFilede la «sección 11.3 · retrace, y los dos programas que se llaman igual». - API de GitHub,
repos/Guardsquare/proguard/releases— https://api.github.com/repos/Guardsquare/proguard/releases Consultado el 25 de septiembre de 2026. De aquí salen lav7.10.0, del 24 de agosto de 2026, y la fecha de lav7.9.1, el 9 de abril de 2026 («sección 2 · Historia y relevo»). retrace --helpdecmdline-tools19.0, ejecutado localmente —~/Library/Android/sdk/cmdline-tools/latest/bin/retrace --helpConsultado el 25 de septiembre de 2026. De aquí sale que Retrace 8.9.27 lista exactamente--regex,--verbose,--info,--quiety--verify-mapping-file-hash(«sección 7 · retrace»).- R8 9.2.4-dev de build-tools 37,
SourceFilepor modo y por regla, ejecutado localmente —java -cp build-tools/37.0.0/lib/d8.jar com.android.tools.r8.R8, con y sin--pg-compat,-keepattributes SourceFile,-renamesourcefileattribute SourceFiley-Dcom.android.tools.r8.enableMapIdInSourceFile=false, leído condexdumpConsultado el 25 de septiembre de 2026. De aquí sale que en full mode el valor por defecto esr8-map-id-<hash>y en modo compatibilidadSourceFile, y que la propiedad de sistema lo devuelve aSourceFile(secciones «8» y «9.2»). - Use rules to troubleshoot optimization —
https://developer.android.com/topic/performance/app-optimization/troubleshooting-rules
Consultado el 25 de septiembre de 2026 (actualizada el 19 de mayo de 2026). De aquí salen
las «rules generated by AAPT» entre los orígenes de reglas, el ejemplo de
-whyareyoukeepingconaapt_rules.txt:4:1y el aviso de que esas reglas no existen con la reducción optimizada de recursos («sección 5.1 · Los ficheros y su procedencia»). AGP— código fuente, etiquetastudio-2026.1.2— https://android.googlesource.com/platform/tools/base/+/refs/tags/studio-2026.1.2/build-system/gradle-core/ Consultado el 25 de septiembre de 2026. Ficheros leídos:internal/scope/InternalArtifactType.kt,ArtifactTypeUtil.ktyinternal/tasks/R8Task.kt(la ruta deconfiguration.txt, «sección 4 · Cómo se enciende y dónde viven las reglas»);LinkApplicationAndroidResourcesTask.kt,ProguardConfigurableTask.kt,R8ResourceShrinkingParameters.ktyBooleanOption.kt(cuándo llegaaapt_rules.txtaR8, «sección 5.1 · Los ficheros y su procedencia»).R8— código fuente, etiqueta9.4.23— https://r8.googlesource.com/r8/+/refs/tags/9.4.23/ Consultado el 25 de septiembre de 2026. Ficheros leídos:src/resourceshrinker/java/com/android/tools/r8/resourceshrinker/ResourceShrinkerState.java(los elementos del manifiesto que trazaR8y la poda de componentes no exportados) ysrc/main/java/com/android/tools/r8/utils/InternalOptions.java(enableManifestPruning, apagada por defecto). Secciones «5.1» y «5.3».aapt2 link --proguard, ejecutado localmente —aapt22.20-15087165 de build-tools 37.0.0; opción documentada en https://developer.android.com/tools/aapt2 Consultado el 25 de septiembre de 2026. De aquí sale elaapt_rules.txtde un manifiesto mínimo, con una regla y su línea de origen por componente («sección 5.1 · Los ficheros y su procedencia»).androidx.annotation:annotation-jvm:1.11.0yAGP9.4.1 en Google Maven — https://dl.google.com/android/maven2/androidx/annotation/annotation-jvm/1.11.0/ y https://dl.google.com/android/maven2/com/android/tools/build/gradle/9.4.1/ Consultado el 25 de septiembre de 2026; los dosJARcoinciden con el.sha1publicado. Del primero,META-INF/proguard/androidx-annotations.pro; del segundo,com/android/build/gradle/proguard-common.txt(«sección 5.2 · Las reglas que AGP empaqueta de verdad»).