¿Por qué detectar root?

En un dispositivo rooteado el atacante tiene acceso root al filesystem — puede leer el almacenamiento interno de tu app, extraer las SharedPreferences, interceptar las llamadas de sistema, y desactivar las protecciones de seguridad. El Keystore también puede verse comprometido en algunos dispositivos rooteados.

Para apps financieras, de salud o enterprise, ejecutar en un dispositivo rooteado representa un riesgo que la política de seguridad puede no tolerar.

Técnicas de detección de root

object RootDetector {

    fun estaRooteado(): Boolean {
        return verificarBinarioSU() ||
               verificarAppsRoot() ||
               verificarPropiedadesBuild() ||
               verificarMontajesRW()
    }

    // 1. Buscar el binario 'su' en rutas conocidas
    private fun verificarBinarioSU(): Boolean {
        val rutasSU = listOf(
            "/system/bin/su", "/system/xbin/su",
            "/sbin/su", "/su/bin/su",
            "/data/local/xbin/su", "/data/local/bin/su",
            "/system/sd/xbin/su", "/system/bin/failsafe/su"
        )
        return rutasSU.any { File(it).exists() }
    }

    // 2. Detectar apps de gestión de root (Magisk, SuperSU, etc.)
    private fun verificarAppsRoot(): Boolean {
        val paquetesRoot = listOf(
            "com.topjohnwu.magisk",
            "eu.chainfire.supersu",
            "com.noshufou.android.su",
            "com.koushikdutta.superuser",
            "com.zachspong.temprootremovejb",
            "com.ramdroid.appquarantine"
        )
        return paquetesRoot.any { paquete ->
            try {
                packageManager.getPackageInfo(paquete, 0)
                true
            } catch (e: PackageManager.NameNotFoundException) { false }
        }
    }

    // 3. Propiedades del build que indican root o ROM custom
    private fun verificarPropiedadesBuild(): Boolean {
        val buildTags = Build.TAGS
        return buildTags != null && (
            buildTags.contains("test-keys") ||  // ROM no oficial
            Build.TYPE == "userdebug" ||         // build de debug
            Build.TYPE == "eng"                  // build de engineering
        )
    }

    // 4. Verificar si /system está montado como read-write
    private fun verificarMontajesRW(): Boolean {
        return try {
            val proceso = Runtime.getRuntime().exec("mount")
            val salida = proceso.inputStream.bufferedReader().readText()
            salida.contains("/system rw") || salida.contains("/system ext4 rw")
        } catch (e: Exception) { false }
    }
}

Detección de emulador

object EmulatorDetector {

    fun esEmulador(): Boolean {
        return verificarPropiedades() ||
               verificarHardware() ||
               verificarTelefonia()
    }

    private fun verificarPropiedades(): Boolean {
        return Build.FINGERPRINT.startsWith("generic") ||
               Build.FINGERPRINT.startsWith("unknown") ||
               Build.MODEL.contains("google_sdk") ||
               Build.MODEL.contains("Emulator") ||
               Build.MODEL.contains("Android SDK built for x86") ||
               Build.MANUFACTURER.contains("Genymotion") ||
               Build.BRAND.startsWith("generic") ||
               Build.DEVICE.startsWith("generic") ||
               Build.PRODUCT.contains("sdk") ||
               Build.HARDWARE.contains("goldfish") ||
               Build.HARDWARE.contains("ranchu")
    }

    private fun verificarHardware(): Boolean {
        // Los emuladores generalmente no tienen hardware real de sensor
        return Build.BOARD.isEmpty() || Build.BRAND.isEmpty()
    }

    private fun verificarTelefonia(context: Context): Boolean {
        val tm = context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager
        val imei = tm?.deviceId ?: return false
        // IMEI "000000000000000" es típico de emuladores
        return imei.all { it == '0' }
    }
}

Play Integrity API — la solución oficial

Google ofrece la Play Integrity API (reemplazó a SafetyNet Attestation). Le preguntás a Google si el dispositivo y la app son confiables, y Google responde con un veredicto firmado que verificás en tu servidor:

// build.gradle
implementation("com.google.android.play:integrity:1.3.0")

// En la app — solicitar el veredicto
class IntegrityChecker(private val context: Context) {

    private val integrityManager = IntegrityManagerFactory.create(context)

    suspend fun verificarIntegridad(): IntegrityVerdict {
        // Nonce único por solicitud (generado en tu servidor)
        val nonce = obtenerNonceDelServidor()

        val token = suspendCoroutine { continuation ->
            integrityManager.requestIntegrityToken(
                IntegrityTokenRequest.builder()
                    .setNonce(nonce)
                    .build()
            )
            .addOnSuccessListener { response ->
                continuation.resume(response.token())
            }
            .addOnFailureListener { e ->
                continuation.resumeWithException(e)
            }
        }

        // Enviar el token a TU servidor para verificar
        // Tu servidor llama a la API de Google para decodificar el veredicto
        return apiService.verificarIntegridadToken(token)
    }
}

// El veredicto del servidor incluye:
// - appIntegrity: PLAY_RECOGNIZED, UNRECOGNIZED_VERSION, UNEVALUATED
// - deviceIntegrity: MEETS_DEVICE_INTEGRITY, MEETS_BASIC_INTEGRITY, etc.
// - accountDetails: LICENSED (el usuario tiene la app de Play Store)

La verificación debe hacerse en el servidorNunca verifiques el veredicto de Play Integrity directamente en la app. Un atacante puede interceptar y falsificar la respuesta. El token debe ir a tu backend, que lo verifica con la API de Google usando tu clave de proyecto.

Cómo reaccionar al detectar root o emulador

class SecurityChecker @Inject constructor(
    private val rootDetector: RootDetector,
    private val emulatorDetector: EmulatorDetector
) {
    sealed class SecurityStatus {
        object OK : SecurityStatus()
        data class Comprometido(val razones: List<String>) : SecurityStatus()
    }

    fun verificar(): SecurityStatus {
        val razones = mutableListOf<String>()

        if (rootDetector.estaRooteado()) razones.add("root")
        if (emulatorDetector.esEmulador() && BuildConfig.BUILD_TYPE == "release") {
            razones.add("emulador")
        }

        return if (razones.isEmpty()) SecurityStatus.OK
               else SecurityStatus.Comprometido(razones)
    }
}

// En la Activity principal:
when (val status = securityChecker.verificar()) {
    SecurityStatus.OK -> continuar()
    is SecurityStatus.Comprometido -> {
        // Loggear el evento (sin datos sensibles)
        Firebase.analytics.logEvent("security_check_failed") {
            param("reasons", status.razones.joinToString(","))
        }
        // Mostrar mensaje genérico — no revelar qué fue detectado
        mostrarDialogoSeguridad()
    }
}

Limitaciones reales — la honestidad importa

La detección de root y emulador no es una solución definitiva. Un atacante determinado puede evadir todas estas técnicas:

  • Magisk Hide puede ocultar el root de la mayoría de las detecciones
  • Los emuladores modernos imitan mejor el hardware real
  • Un atacante puede modificar tu APK para deshabilitar las verificaciones

El valor real de estas técnicas es elevar el costo del ataque y filtrar ataques automatizados. Combinalas siempre con otras capas de seguridad (cifrado, pinning, ofuscación) y con controles en el servidor.