Integridad y ofuscación: Play Integrity, root y R8
Todas las defensas que se ejecutan en el dispositivo del usuario comparten un mismo defecto irreparable: las evalúa el mismo adversario del que pretenden defender. Esta lección separa con precisión lo que funciona de lo que solo lo parece: cómo `Play Integrity` emite un veredicto firmado que solo tiene valor si lo verifica tu servidor, por qué la detección de root y de emuladores es una carrera perdida por construcción, qué hace realmente `R8` cuando renombra símbolos y qué sigue quedando perfectamente legible después, y el inventario explícito de amenazas que ninguna de estas tres medidas mitiga por mucho que se combinen.
Existe una petición que llega a todos los equipos móviles tarde o temprano, formulada casi siempre por alguien ajeno a la ingeniería y con una convicción notable: hay que impedir que la aplicación funcione en dispositivos manipulados, hay que proteger el código para que nadie lo entienda y hay que asegurarse de que el cliente que habla con nuestro servidor es realmente el nuestro. Las tres peticiones son razonables como objetivos y ninguna de las tres es alcanzable en el sentido en que se enuncia, porque todas exigen que un programa determine la honestidad del entorno que lo ejecuta, y ese entorno tiene la última palabra sobre todo lo que ese programa observa y sobre todo lo que ese programa concluye. Lo que sí existe es una versión mucho más modesta y genuinamente útil de las tres: un veredicto que no emite tu código sino un tercero con acceso a información que tu código no tiene y que tu servidor puede verificar sin confiar en el dispositivo; un encarecimiento medible del trabajo de ingeniería inversa; y una reducción del ruido de fondo que aparta al atacante oportunista para que puedas concentrarte en el que va en serio. Confundir esta versión modesta con la petición original es el origen de casi todo el gasto inútil en seguridad móvil.
- Explicar el flujo de
Play Integrityy por qué su token carece de todo valor si se evalúa en el dispositivo. - Interpretar los veredictos de integridad de dispositivo, de aplicación y de cuenta, y traducirlos a decisiones de producto proporcionadas.
- Situar la detección de root y de emuladores como heurística de fricción y no como control de seguridad.
- Delimitar con exactitud qué consigue
R8al ofuscar y qué información permanece intacta en el artefacto publicado.
Un veredicto que no emite tu código
Play Integrity sustituyó a la antigua atestación de seguridad, retirada definitivamente a principios de 2025, y su valor procede íntegramente de una propiedad estructural: el juicio sobre el dispositivo no lo formula tu aplicación sino los servicios de Google, que disponen de señales de hardware, de historial del dispositivo y de telemetría agregada a los que tu proceso jamás tendrá acceso. Tu aplicación solo solicita un token y lo transporta.
El flujo correcto tiene cuatro pasos y el orden importa. Tu servidor genera un valor de un solo uso ligado a la operación concreta que se va a autorizar. El cliente pide un token pasando ese valor. El cliente envía el token al servidor sin abrirlo ni interpretarlo. Y el servidor lo descifra y lo verifica contra la interfaz de Google, comprobando además que el valor de un solo uso coincide con el que emitió. Si ese último paso ocurre en el dispositivo, el mecanismo entero no vale nada, porque el atacante controla el código que lee el veredicto y puede sustituirlo por el que le convenga.
El token contiene tres bloques de juicio independientes que conviene no mezclar. La integridad del dispositivo indica si se ejecuta sobre un dispositivo Android genuino con arranque verificado, con un nivel básico más laxo que admite emuladores certificados y un nivel fuerte que exige garantías de hardware recientes. La integridad de la aplicación indica si el binario en ejecución es el que se publicó, sin modificar. Los detalles de cuenta indican si existe una licencia legítima de la tienda.
// En el cliente: pedir y transportar. Nada mas.
class Integridad(private val gestor: StandardIntegrityManager) {
suspend fun tokenPara(retoDelServidor: String): String {
val proveedor = gestor.prepareIntegrityToken(
PrepareIntegrityTokenRequest.builder()
.setCloudProjectNumber(NUMERO_DE_PROYECTO)
.build(),
).await()
val respuesta = proveedor.request(
StandardIntegrityTokenRequest.builder()
.setRequestHash(retoDelServidor) // ata el token a la operacion
.build(),
).await()
return respuesta.token() // opaco: no se abre ni se interpreta aqui
}
}
sequenceDiagram participant C as Cliente participant S as Servidor propio participant G as Servicios de Google S->>C: reto de un solo uso ligado a la operacion C->>G: solicitar token con el reto G-->>C: token cifrado y firmado C->>S: enviar token sin abrirlo S->>G: descifrar y verificar el token G-->>S: veredictos de dispositivo app y cuenta S->>S: comprobar que el reto coincide S-->>C: autorizar o degradar la operacion
La respuesta a un veredicto desfavorable es una decisión de producto y casi nunca debería ser el bloqueo total. Un dispositivo con arranque desbloqueado puede pertenecer perfectamente a un desarrollador honesto, y expulsarlo cuesta usuarios reales a cambio de detener a un atacante que volverá por otra vía. La gradación proporcionada consiste en permitir la lectura pero exigir verificación adicional para operaciones sensibles, elevar el umbral de detección de fraude en el servidor o retrasar retiradas de fondos, reservando el rechazo para lo que de verdad no admite riesgo.
La carrera que no se gana
La detección local de root funciona buscando indicios: la presencia de un binario de superusuario en las rutas habituales, etiquetas de compilación de prueba en las propiedades del sistema, paquetes conocidos de gestores de permisos elevados, particiones montadas con escritura. Todas esas comprobaciones tienen la misma estructura lógica, que es preguntar al entorno por su propio estado, y por tanto todas tienen la misma debilidad: el entorno responde lo que quiere.
Las herramientas modernas de ocultación resuelven esto de forma sistemática interceptando las llamadas que tu código usa para preguntar y devolviendo respuestas limpias, con listas de aplicaciones a las que ocultarse activadas con dos toques. El resultado es que tu detección, escrita en varias tardes, se neutraliza sin escribir una sola línea, y cada nueva comprobación que añadas se neutraliza igual en la siguiente actualización de la herramienta. No es que la carrera sea difícil: es que el otro corredor decide dónde está la meta.
// Esto es una heuristica de friccion, no un control de seguridad.
// Sirve para apartar al curioso; no detiene a nadie con intencion.
fun indiciosDeManipulacion(): Boolean =
listOf("/system/xbin/su", "/system/bin/su", "/sbin/su")
.any { java.io.File(it).exists() } ||
Build.TAGS?.contains("test-keys") == true ||
Build.FINGERPRINT.startsWith("generic")
Lo que sí aporta valor es tratar estas señales como lo que son: entradas de baja fiabilidad para un sistema de detección de fraude que vive en el servidor, donde se combinan con muchas otras y donde ninguna decide por sí sola. Una señal que no es de fiar deja de ser inútil en el momento en que se evalúa junto a otras cincuenta y en un lugar que el atacante no controla. Lo que no aporta valor es una condición en el cliente que decide si la aplicación arranca, porque esa condición es una instrucción de salto que se invierte en un editor hexadecimal.
Qué hace realmente R8
R8 es a la vez el reductor y el ofuscador de la cadena de compilación de Android, y hace tres cosas distintas que se activan juntas y se confunden constantemente. Elimina código y recursos no alcanzables, lo que reduce el tamaño. Optimiza, insertando métodos y simplificando estructuras. Y renombra clases, métodos y campos a identificadores cortos y sin significado, que es la parte que la gente llama ofuscación.
Ese renombrado es real y su efecto es genuino: leer un artefacto ofuscado exige reconstruir mentalmente una semántica que los nombres ya no revelan, y eso multiplica las horas necesarias. Pero conviene ser exacto sobre el alcance. Las cadenas literales permanecen legibles, los nombres de recursos permanecen, las llamadas a las APIs del sistema no se pueden renombrar y por tanto delatan qué hace cada bloque, los nombres exigidos por reflexión o por serialización quedan preservados por las reglas de conservación, y la estructura del programa sigue ahí entera. R8 no cifra nada.
Lo que sí consigue
Retira nombres significativos, elimina código muerto y reordena estructuras. El resultado es un artefacto más pequeño y sensiblemente más caro de leer.
Lo que sigue visible
Cadenas, recursos, llamadas al sistema, símbolos conservados por reglas y el flujo completo del programa. Todo secreto incrustado sigue estando ahí y sigue siendo legible.
android {
buildTypes {
release {
isMinifyEnabled = true // reduce y ofusca
isShrinkResources = true // elimina recursos huerfanos
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
}
Dos consecuencias operativas se derivan de esto y ambas duelen si se descubren tarde. La primera es que el fichero de correspondencia generado en cada compilación es imprescindible para volver a leer los informes de fallo, y hay que archivarlo por versión y subirlo al servicio de incidencias; perderlo convierte los fallos de esa versión en ilegibles para siempre. La segunda es que todo lo que se resuelve por nombre en tiempo de ejecución necesita una regla explícita de conservación, y su ausencia produce fallos que solo ocurren en la compilación de publicación, que es el peor momento posible para descubrirlos.
Qué no protege nada de esto
Ninguna de las tres medidas impide que un atacante lea tu código, y conviene interiorizarlo sin ambigüedad: cualquier secreto presente en el artefacto publicado es un secreto público con un retardo que se mide en horas. Una clave de interfaz de programación incrustada, un token de servicio, una contraseña de firma de peticiones o un algoritmo propietario de cálculo de precios están todos a disposición de quien descargue el paquete y dedique una tarde a ello, ofuscación incluida.
Ninguna impide tampoco la instrumentación en tiempo de ejecución. Los marcos de trabajo de enganche permiten interceptar cualquier método, leer sus argumentos, alterar su valor de retorno y ejecutar código propio dentro de tu proceso, y frente a eso el renombrado de símbolos solo añade el trabajo de localizar el método correcto una vez. Y ninguna impide que alguien reimplemente tu cliente desde cero hablando directamente con tu servidor, que es en la práctica la vía más limpia y la que usa cualquiera que vaya en serio.
Estas medidas no son inútiles: son mal entendidas. Encarecer el trabajo del atacante tiene valor económico real cuando el ataque debe repetirse a escala para ser rentable, y ese es exactamente el caso del fraude publicitario, de la automatización de cuentas y de la manipulación de puntuaciones en un juego. Lo que carece de valor es esperar que detengan a un adversario individual y motivado que solo necesita tener éxito una vez, o que sustituyan una comprobación que debería vivir en el servidor.
El nivel entero converge aquí, y la formulación más precisa que conozco es que el código que se ejecuta en el dispositivo del usuario no puede guardar secretos frente a ese usuario ni tomar decisiones de autorización en las que nadie más pueda confiar, y que ambas imposibilidades son teóricas y no técnicas, de modo que ninguna herramienta futura las va a levantar. Un programa observa su entorno a través de llamadas que ese entorno implementa; cuando el entorno es adversario, todas las respuestas son negociables, y con ellas todas las conclusiones que el programa derive. No se trata de que la ofuscación sea débil o de que la detección de root esté mal escrita: es que la pregunta que ambas intentan responder no admite respuesta desde dentro. Lo notable es que reconocer esto no debilita tu arquitectura sino que la simplifica de forma drástica, porque reordena cada control en el único eje que importa, que es el de quién lo evalúa. Todo lo que un atacante puede reescribir es fricción, y la fricción se dimensiona por coste y beneficio como cualquier otra inversión, sin fingir que es una barrera. Todo lo que evalúa un servidor que él no controla, o un elemento de hardware que ni siquiera el sistema operativo puede leer, es seguridad de verdad, y solo eso. Por eso Play Integrity funciona y una comprobación local no: no porque el algoritmo de Google sea más listo, sino porque el veredicto se emite y se verifica fuera del alcance del adversario y viene atado criptográficamente a una operación concreta. Por eso una clave del Keystore protege y una clave en el binario no. Por eso la biometría vale cuando habilita una firma verificable y no vale cuando enciende un booleano. La regla práctica que resume las cinco lecciones cabe en una frase que conviene aplicar a cada control antes de escribirlo: pregunta quién evalúa esta comprobación y qué pasa si miente, y si la respuesta es que la evalúa el dispositivo y que mentir sale gratis, entonces lo que estás construyendo no es una defensa sino una señal, y las señales se envían al servidor para que él decida, nunca se obedecen en el cliente. El buen diseño de seguridad móvil consiste, casi por completo, en mover cosas al otro lado de esa línea y en ser honesto sobre lo que se ha quedado en este.
- Inventaria todos los controles de seguridad de tu aplicación y clasifica cada uno según quién lo evalúa: cliente, servidor o hardware.
- Integra
Play Integritycon un valor de un solo uso emitido por tu servidor y verifica el token exclusivamente en el servidor. - Define una respuesta gradual a cada combinación de veredictos y justifica por escrito cada caso en el que decidas bloquear.
- Busca en el artefacto compilado cualquier cadena que parezca una credencial y planifica su traslado fuera del cliente.
- Comprueba que el fichero de correspondencia de cada versión publicada está archivado y que los informes de fallo llegan legibles.