El modelo de seguridad de Android
El aislamiento de una aplicación Android no lo implementa Android: lo implementa el kernel de Linux con un mecanismo de 1970. Esta lección reconstruye la pila completa de defensa desde abajo hacia arriba: el identificador de usuario único que convierte cada aplicación en un inquilino ajeno, el control de acceso obligatorio de SELinux que limita incluso lo que el propio sistema puede hacer, el arranque verificado y el cifrado que protegen los datos en reposo, la firma del paquete como raíz de identidad, y el inventario honesto de lo que este modelo te regala sin pedir nada y de lo que no te protegerá jamás por mucho que lo esperes.
La afirmación de que cada aplicación de Android vive en un sandbox se repite tanto que ha perdido su contenido técnico, y esa pérdida es cara. No hay un sandbox en el sentido de una máquina virtual que interpreta instrucciones y decide cuáles permite: hay un identificador numérico de usuario asignado en el momento de la instalación y jamás reutilizado, hay permisos de sistema de ficheros clásicos de Unix aplicados sobre un directorio privado, y hay un kernel que lleva medio siglo sabiendo que el usuario 10037 no puede leer los ficheros del usuario 10041. Todo lo demás está construido encima de eso. Entenderlo tiene una consecuencia inmediata y liberadora: la mayor parte de la seguridad que tu aplicación disfruta no la escribiste tú, no puedes desactivarla por error en una línea de código, y sigue funcionando aunque tu lógica esté llena de defectos. Y tiene una consecuencia incómoda que es el verdadero motivo de este nivel: exactamente por eso, casi todos los fallos de seguridad reales de una aplicación Android ocurren en las fronteras donde el desarrollador decidió voluntariamente salir del sandbox.
- Explicar el aislamiento por identificador de usuario único y por qué la separación real la aplica el kernel y no el framework.
- Situar
SELinux, el arranque verificado y el cifrado basado en ficheros como capas independientes con propósitos distintos. - Reconocer la firma del paquete como raíz de identidad y comprender qué garantiza y qué no garantiza una actualización.
- Inventariar con precisión qué amenazas neutraliza el modelo por defecto y cuáles quedan enteramente en tus manos.
Un identificador de usuario por aplicación
Cuando el gestor de paquetes instala una aplicación le asigna un identificador de usuario del rango reservado a aplicaciones, que empieza en 10000. Ese número es la identidad operativa de la aplicación durante toda su vida en el dispositivo: es el propietario de su directorio privado, es la credencial con la que el kernel resuelve cada llamada al sistema de ficheros, y es lo que aparece en el otro extremo cuando un proceso hace una llamada por Binder. Desinstalar y volver a instalar produce un identificador distinto, lo cual explica de forma inmediata por qué los datos privados no sobreviven a una desinstalación aunque los ficheros siguieran en disco.
El directorio privado se crea con el propietario puesto a ese identificador y con permisos que excluyen por completo a los demás. Ningún proceso de otra aplicación puede abrir esos ficheros, y no porque el framework lo compruebe, sino porque la llamada open devuelve un error de permiso denegado antes de que ningún código de Android intervenga. Esta es la diferencia entre una política y un mecanismo, y es la razón de que el aislamiento resista incluso cuando la aplicación atacante es nativa y evita todas las APIs de Java.
Los procesos nacen de un fork del proceso zygote, que ya tiene la máquina virtual inicializada y las clases del framework cargadas. Inmediatamente después del fork, el hijo abandona sus privilegios y se convierte en el identificador de la aplicación. Ese descenso es irreversible dentro del proceso: no existe ninguna llamada que permita volver a subir, y por eso una vulnerabilidad de ejecución de código en tu aplicación otorga al atacante exactamente tus privilegios y ni uno más.
Aislamiento de datos
El directorio privado pertenece a un identificador único. La separación la impone el kernel en cada llamada al sistema, no una comprobación del framework que se pueda esquivar.
Aislamiento de procesos
Cada aplicación corre en su propio proceso con su propia máquina virtual. Comprometer un proceso no da acceso a la memoria de otro ni permite escalar privilegios por sí solo.
Durante años existió una puerta trasera legítima a este modelo llamada sharedUserId, que permitía a dos paquetes firmados con la misma clave compartir identificador y por tanto datos y procesos. Está obsoleta desde la API 29 y con motivo: convertía dos superficies de ataque en una sola, y su migración posterior era imposible sin perder los datos, porque cambiar el identificador de un paquete instalado no es una operación que el sistema soporte.
// El directorio privado es la frontera del sandbox.
val interno = context.filesDir // aislado por UID, se borra al desinstalar
val cache = context.cacheDir // aislado, el sistema puede vaciarlo
val externo = context.getExternalFilesDir(null) // propio, pero no es sandbox real
// El modo privado es el unico defendible: MODE_WORLD_READABLE lanza excepcion
// desde la API 24 precisamente porque perforaba el aislamiento.
context.openFileOutput("sesion.bin", Context.MODE_PRIVATE).use { salida ->
salida.write(datos)
}
Las capas que no controlas
Por encima del aislamiento discrecional de Unix, Android aplica desde la versión 5.0 un control de acceso obligatorio con SELinux en modo estricto. La diferencia conceptual importa: los permisos de Unix son discrecionales porque el propietario de un fichero puede relajarlos, mientras que la política obligatoria la define el sistema y no la puede modificar ni siquiera un proceso con identificador cero. Cada aplicación corre bajo un dominio, típicamente untrusted_app, y ese dominio enumera de forma positiva las operaciones permitidas: todo lo que no esté explícitamente autorizado queda denegado. Es la capa que convierte muchas vulnerabilidades de escalada en simples caídas del proceso.
Más abajo todavía, el arranque verificado comprueba criptográficamente cada etapa desde el cargador de arranque hasta la partición del sistema, usando dm-verity para validar bloques bajo demanda durante la ejecución. Su objetivo no es proteger tus datos sino garantizar que el sistema operativo que los custodia es el que el fabricante firmó. Sin esa garantía, ninguna de las capas superiores significaría nada, porque un sistema modificado puede mentir sobre todas ellas.
El cifrado basado en ficheros, obligatorio desde la API 26, introduce dos clases de almacenamiento con ciclos de vida de clave distintos. El almacenamiento protegido por credencial solo es legible después de que el usuario haya desbloqueado el dispositivo por primera vez tras el arranque; el protegido por dispositivo está disponible antes, y por eso es el único lugar donde un receptor de arranque completado puede leer algo. Confundirlos produce el fallo más desconcertante de este terreno: código que funciona siempre en desarrollo y falla exclusivamente en el primer arranque del dispositivo del usuario.
flowchart TD A[Arranque verificado y dm-verity] --> B[Kernel de Linux] B --> C[Aislamiento por identificador de usuario] B --> D[Cifrado basado en ficheros] C --> E[SELinux en modo estricto] E --> F[Sandbox de la aplicacion] F --> G[Permisos de tiempo de ejecucion] F --> H[Binder y componentes exportados]
La firma del paquete cierra el modelo por arriba. Un identificador de paquete no significa nada sin la clave que lo firmó: el sistema solo acepta una actualización si viene firmada por la misma clave que la instalación original, y esa regla es lo que impide que otra aplicación se haga pasar por la tuya. El esquema de firma versión 3 añadió rotación de claves con una cadena de prueba, y la versión 4 habilitó la instalación incremental, pero la invariante permanece intacta desde el primer día.
Binder: dónde viaja la identidad
Si el aislamiento fuera absoluto, Android no funcionaría, porque cada aplicación necesita hablar con los servicios del sistema y a veces entre sí. El canal que atraviesa la frontera es Binder, y su propiedad más importante desde el punto de vista de la seguridad es que la identidad del llamante no la declara el llamante: la adjunta el kernel. Cuando un proceso recibe una llamada, puede preguntar por el identificador de usuario y el identificador de proceso del emisor con la certeza de que nadie los ha falsificado, y esa certeza es lo que hace posible que un servicio decida a quién atiende.
De ahí se deriva la disciplina correcta al exponer un servicio propio: comprobar el permiso del llamante dentro de la implementación y no confiar en que el manifiesto ya lo filtró, porque el atributo de permiso del manifiesto protege la vinculación inicial pero no cada método invocado después. La comprobación tiene además una trampa clásica, y es que las llamadas locales devuelven tu propio identificador, de modo que un servicio invocado desde tu propio proceso siempre pasa el filtro y el defecto solo aparece cuando lo llama otro.
override fun onTransact(codigo: Int, datos: Parcel, respuesta: Parcel?, banderas: Int): Boolean {
// El kernel garantiza estos valores: el llamante no los elige.
val uidLlamante = Binder.getCallingUid()
if (checkCallingPermission(PERMISO_INTERNO) != PackageManager.PERMISSION_GRANTED) {
throw SecurityException("Llamante $uidLlamante sin autorizacion")
}
return super.onTransact(codigo, datos, respuesta, banderas)
}
Un caso particular que merece mención aparte es el de las intenciones pendientes, porque invierten la dirección de la confianza: al crear una, cedes a otro componente la capacidad de actuar con tu identidad y tus permisos. Si además la creas mutable, el receptor puede rellenar los campos vacíos y redirigir la acción a un destino que tú no elegiste. Desde la API 31 el sistema obliga a declarar la mutabilidad de forma explícita, y la respuesta correcta es inmutable salvo justificación concreta y documentada.
Qué te protege por defecto y qué no
Sin escribir una sola línea de código de seguridad, tu aplicación ya está protegida frente a que otra aplicación instalada lea sus ficheros privados, frente a que inspeccione su memoria, frente a que suplante su identidad en una actualización y frente a que acceda a datos del usuario que requieren permisos que nadie le concedió. Ese conjunto es sustancial y conviene reconocerlo, porque el instinto de reimplementar protecciones que el sistema ya ofrece consume esfuerzo y suele introducir defectos.
Lo que el modelo no cubre empieza exactamente donde tú decides publicar algo. Un componente marcado como exportado es alcanzable por cualquier aplicación del dispositivo, y el valor por defecto de ese atributo depende de si el componente declara filtros de intención, un detalle sutil que ha causado incidentes reales durante quince años. Desde la API 31 el atributo es obligatorio y explícito precisamente por eso.
<!-- Componente interno: cerrado y sin ambiguedad posible. -->
<activity
android:name=".DetalleActivity"
android:exported="false" />
<!-- Componente publico: la superficie de ataque empieza aqui. -->
<activity
android:name=".EnlaceActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="ejemplo.com" />
</intent-filter>
</activity>
Tampoco te protege de ti mismo cuando escribes datos sensibles en el almacenamiento compartido, donde el aislamiento por identificador no aplica; ni de registrar un token en el log, que es legible por herramientas de desarrollo y por cualquier proceso con el permiso adecuado; ni de confiar en datos que llegan de otra aplicación sin validarlos. Y no te protege en absoluto en un dispositivo con el arranque verificado desactivado y acceso de administrador, donde el atacante controla las capas inferiores y ninguna comprobación que ejecutes dentro de tu proceso puede ser creída.
Existe una inversión de perspectiva que reordena todo este nivel y que casi nadie hace explícita, y consiste en preguntarse contra quién está diseñado realmente el modelo de seguridad de Android. La lectura ingenua supone que el sistema construye un recinto para defender tu código de los atacantes, y de esa lectura salen conclusiones equivocadas en cadena: que el interior del proceso es un territorio de confianza, que lo que se ejecuta dentro está verificado, que si tu comprobación de integridad devuelve un resultado favorable el entorno es honesto. La lectura correcta es la contraria. El modelo trata a cada aplicación, incluida la tuya, como código potencialmente hostil que hay que contener, y el sujeto protegido es el usuario y el resto del sistema. El identificador único no está ahí para que nadie te lea a ti; está ahí para que tú no leas a nadie. La política obligatoria no enumera lo que puedes hacer para ayudarte; enumera lo que puedes hacer porque todo lo demás se considera inaceptable por defecto. De esta reorientación se deriva la regla operativa más importante de la seguridad en el cliente, y es que la frontera de confianza pasa por fuera de tu proceso y no por dentro. Todo lo que se ejecuta en el dispositivo del usuario está bajo el control del usuario, y por extensión bajo el control de cualquiera que tenga ese dispositivo en la mano y sepa lo suficiente. Un secreto compilado en tu binario es un secreto publicado con un retardo de horas; una comprobación de integridad evaluada dentro de tu propio código es una función que un atacante puede reescribir para que devuelva lo que le convenga; una decisión de autorización tomada en el cliente es una sugerencia. Lo único que un adversario no puede alterar es lo que ocurre en un servidor que él no controla o dentro de un elemento de hardware que ni siquiera el sistema operativo puede leer. Las cuatro lecciones que siguen son, en el fondo, una única lección repetida en cuatro dominios distintos: mover lo que importa fuera del alcance del atacante, y aceptar que todo lo que se quede dentro es, como mucho, un obstáculo con fecha de caducidad.
- Obtén el identificador de usuario de tu aplicación en un dispositivo y verifica que el propietario del directorio privado coincide con él.
- Enumera todos los componentes de tu manifiesto y anota el valor efectivo de su atributo de exportación, justificando cada uno marcado como público.
- Localiza toda escritura fuera del directorio privado y clasifica cada dato como público, tolerable o inaceptable en almacenamiento compartido.
- Añade un receptor de arranque completado que lea una preferencia y comprueba qué ocurre antes del primer desbloqueo del dispositivo.
- Documenta las tres amenazas concretas de tu producto que el modelo por defecto no cubre y decide dónde vive la defensa de cada una.