Le pasé REA al APK de mi app Android con R8: el 94 % de las clases pierde su nombre, el modelo de datos no

Foto de Marco Orta | 7 min de lectura
Terminal con la salida de JADX sobre un APK de release con R8: 5,432 clases y 5,125 sin nombre, llamadas a, i33 o vb0, pero con el toString() y el nombre serializado del modelo legibles; al lado, una tarjeta con 94 %
Tabla de Contenidos

    Pasé REA (Reverse Engineer Anything) por el APK de release de una app Android mía, el cliente de un servicio fiscal, y comparé lo que reconstruyó con el código fuente del mismo build. R8 hizo bien su trabajo con los nombres de las clases: el 94 % quedó como a, i33 o vb0. Pero el modelo de datos sigue legible como texto, por dos vías que casi nadie revisa: el toString() de las data class de Kotlin y los serializadores de kotlinx.serialization. Y una confesión que importa: para llegar a la URL del backend y a las rutas del API, a REA lo guié yo con el mapping.txt de R8, que un atacante no tiene. Abajo van las cifras, lo que REA hizo bien, lo que no, y lo que revisaría antes de subir cualquier app a Play.

    Esta es la segunda parte. En la primera medí REA y lo pasé por mi sitio.

    El experimento

    • La app: un cliente Android mío (Kotlin, Ktor) de un servicio fiscal. Build de release, flavor local (apunta a mi entorno de desarrollo), con R8 activo (minificación, ofuscación y eliminación de código muerto).
    • La herramienta: rea-agents 6.5.0 con sus comandos de Android, que se apoyan en JADX (vía jadx-headless-mcp) y Apktool.
    • La verdad de referencia: el código fuente del commit con el que se compiló ese APK y el mapping.txt que R8 generó en ese build. REA no los lee. Pero yo sí usé el mapping para saber qué clase rastrear, y eso es un atajo que un atacante no tiene. Lo marco en cada punto donde pesó.
    • Todo en mi máquina, sobre software mío.

    Lo que R8 sí escondió

    Medido
    Clases que declara el DEX5,432
    … en el paquete sin nombre, con nombres de 1-3 letras5,125 (94 %)
    Clases mías que sobreviven a R8449
    … que conservan su nombre real33 (7.3 %)

    (JADX, y por tanto REA, cuenta 5,300 clases y 4,995 sin nombre, porque agrupa las internas y sintéticas de otra forma. El porcentaje es el mismo.)

    Las 33 que conservan su nombre son las que no pueden perderlo: la Activity, la Application, el servicio de notificaciones, la actividad de cámara y 29 rutas de navegación que Navigation 3 resuelve por reflexión. Ojo con esas 29: juntas cuentan el mapa de pantallas de la app en claro: el nombre de cada pantalla, tal como está en el código.

    R8 además borró todo el código que nadie llamaba. En este build había funciones que todavía no estaban conectadas a la interfaz, así que R8 las eliminó: en el APK esas clases están vacías. Ninguna herramienta puede recuperar lo que no está.

    Lo que sigue legible

    1. El modelo de datos, por el toString(). Una data class de Kotlin genera un toString() con el nombre de la clase y de cada campo, y R8 no toca esos textos. Aunque la clase se llame i33, el texto NombreReal(campo= sigue ahí, con el resto de sus campos. 64 de mis 238 data class reaparecieron así.
    2. El modelo de datos, otra vez, por la serialización. Las clases marcadas con @Serializable dejan en el binario su nombre completo y los de sus campos, porque el serializador los necesita para leer y escribir JSON. En este APK hay 67 nombres completos de clases mías como texto (con el paquete completo, y cada campo dos veces: en Kotlin y como viaja en el JSON). El contrato JSON con tu servidor es público por definición.
    3. La URL del backend y las rutas del API. Están en el binario como texto. Con REA llegué a ellas, pero guiado: el mapping me dijo que la clase in2 era mi Peticion (la que arma las peticiones) y desde ahí REA rastreó quién la usa y qué hace cada método. Así recuperó las 16 rutas que siguen en el APK (el código fuente llama a 56; R8 borró las demás junto con el código sin usar). La clase con la URL base (vb0) también la elegí yo; con ella, inspect-android-method muestra la URL y la versión en unos 7 s. En este flavor es http://localhost:8000.
    4. Los recursos y el texto de la interfaz. Los nombres de los recursos (alertas_titulo_alertas) no se ofuscan, y los textos en español tampoco.
    5. Los encabezados del contrato con el servidor: Authorization, Bearer y el encabezado propio de la app.

    Sin el mapping, un atacante tardaría más con REA, pero no necesita REA para lo importante: un grep sobre el volcado de JADX encuentra las URLs, las rutas y los nombres de las clases serializables en segundos. La conclusión práctica: ofuscar nombres no protege el contrato con tu backend. Lo protege que el servidor autentique y autorice cada petición como si viniera de un desconocido, porque puede venir de uno.

    Secretos: cero, y cómo lo comprobé

    Busqué llaves, tokens y URLs internas en los APK de tres flavors con un buscador propio, probado antes contra un archivo con diez secretos plantados (los encontró todos): llaves de Google y AWS, una llave con prefijo sk-, un token de GitHub, una llave privada PEM, un JWT, una contraseña en claro (password=), una IP privada, un host de staging y un app id de Firebase.

    • 0 secretos en los tres APK.
    • Los aciertos con token= o password= eran falsos positivos: el toString() de librerías y ruido de los binarios nativos de ML Kit.
    • La configuración de Firebase está vacía en todos los flavors.

    Esto vale en una sola dirección: prueba que el buscador ve esos patrones y que ninguno aparece. No prueba que no exista un secreto con un formato que no busqué.

    Qué hizo bien REA y qué no

    Bien:

    • No inventó nada: todo lo que mostró y comprobé sale del binario.
    • Paquete, versión, permisos (los 9 del manifiesto fusionado) y los 3 componentes propios, exactos.
    • Una vez que sabes qué clase mirar, la cadena de trazas funciona y deja evidencia de cada paso.

    No tan bien:

    • Su análisis genérico (analyze) no sirve para un APK: pide Ghidra, IDA o Hopper. Hay que usar los comandos específicos de Android.
    • No tiene una búsqueda global de cadenas, que es justo lo que más falta hace con código ofuscado. Lo que a REA le costó unos 105 s y decenas de comandos encadenados (con la ayuda del mapping), JADX más grep lo hace en 16 s.
    • No desofusca ni te dice por dónde empezar: saber que in2 era Peticion salió del mapping.txt, no de REA.
    • Los métodos suspend de Kotlin (las corrutinas) a veces no se decompilan y hay que leer smali. Justo ahí viven los repositorios de red.

    Lo que revisaría antes de publicar una app Android

    1. Ninguna llave en el APK. Ni en BuildConfig, ni en recursos, ni en strings.xml. Si una llave tiene que estar en el cliente (Maps, Firebase), restríngela por paquete y firma desde la consola del proveedor, y en Firebase protege los datos con reglas de seguridad y App Check: la restricción por paquete se puede suplantar.
    2. El servidor decide. Precios, permisos, límites y cálculos que valen dinero, del lado del servidor. En mi app los reportes fiscales son peticiones al backend, y el cálculo que hará la app al emitir es solo una vista previa: el importe que vale lo recalcula el servidor al timbrar.
    3. Asume que tu modelo de datos es público. Si un objeto no tiene por qué describirse en un log, sobrescribe su toString() o no lo hagas data class. Pero si es @Serializable, su nombre y sus campos viajan igual en el serializador: no metas en él nada que no aceptarías ver publicado. Y nunca registres objetos con datos personales.
    4. Revisa allowBackup y las reglas de respaldo. En la mía está activo, pero los almacenes de la sesión quedan excluidos del respaldo. Comprueba que no haya otro almacén con datos sensibles fuera de esa lista.
    5. Que el flavor de desarrollo no llegue a la tienda. El de este experimento permite HTTP en claro hacia localhost: está bien para desarrollar y mal para publicar.
    6. Firma de release real. Si tu Gradle cae a la llave de debug cuando falta el keystore, que falle en lugar de firmar.
    7. Pásale tú las herramientas antes que otro. JADX, Apktool o REA sobre tu propio APK son una tarde de trabajo y te dicen qué ve cualquiera. Empieza por el grep: es lo primero que hará quien lo mire.

    Todo esto lo hice sobre una app mía. El propio REA pide usarlo solo en software tuyo o que estés autorizado a examinar.

    ¿Te sirvió? Compártelo

    ¿Te sirvió? Recibe la próxima por correo

    Una vez por semana: qué se rompe al actualizar, IA para desarrolladores y lo que estoy construyendo, con fuentes. Sin spam.

    Al suscribirte aceptas nuestro aviso de privacidad.

    Buscar

    Etiquetas

    IA Migración PHP Laravel JavaScript Tutorial Seguridad Desarrollo Web Upgrade Buenas Prácticas Agentes MCP OpenAI SEO Backend