
هروب من الضيف إلى المضيف في VirtualBox E1000
أحب VirtualBox ولا علاقة لذلك بنشري ثغرة يوم-صفر (0day). السبب هو خلافي مع الحالة الراهنة لأمن المعلومات، خاصة البحث الأمني والمكافآت مقابل اكتشاف الثغرات (bug bounty):
لقد سئمت من الأمرين الأولين، لذا قراري هو الإفصاح الكامل. يا أهل أمن المعلومات، تقدمُوا.
البرنامج المعرض للثغرة: VirtualBox 5.2.20 والإصدارات الأقدم.
نظام التشغيل المضيف: أي نظام، فالخطأ في قاعدة أكواد مشتركة.
نظام التشغيل الضيف: أي نظام.
إعدادات الآلة الافتراضية: افتراضية (الشرط الوحيد هو أن تكون بطاقة الشبكة من نوع Intel PRO/1000 MT Desktop (82540EM) والوضع NAT).
حتى صدور إصدار VirtualBox المُصحَّح، يمكنك تغيير بطاقة الشبكة لآلاتك الافتراضية إلى PCnet (أيٍّ من النوعين) أو إلى Paravirtualized Network. إذا لم تستطع، غيّر الوضع من NAT إلى وضع آخر. الطريقة الأولى أكثر أمانًا.
جهاز الشبكة الافتراضي الافتراضي في VirtualBox هو Intel PRO/1000 MT Desktop (82540EM) ووضع الشبكة الافتراضي هو NAT. سنشير إليه باسم E1000.
يحتوي E1000 على ثغرة تسمح لمهاجم يملك صلاحيات root/administrator داخل الضيف بالهروب إلى ring3 على المضيف. بعدها يمكن للمهاجم استخدام تقنيات موجودة لرفع الصلاحيات إلى ring 0 عبر /dev/vboxdrv.
لإرسال حزم الشبكة، يقوم الضيف بما يفعله أي حاسوب عادي: يضبط بطاقة الشبكة ويزوّدها بحزم الشبكة. الحزم هي إطارات طبقة ربط البيانات (data link layer) وترويسات أخرى أعلى مستوى. الحزم المزوَّدة للمهايئ تُغلَّف في واصفات إرسال Tx descriptors (Tx تعني إرسال). واصف الإرسال هو بنية بيانات موصوفة في ورقة بيانات 82540EM (317453006EN.PDF, Revision 4.0). يخزّن معلومات وصفية مثل حجم الحزمة، ووسم VLAN، وعلامات تمكين تجزئة TCP/IP، وما إلى ذلك.
توفر ورقة بيانات 82540EM ثلاثة أنواع من واصفات الإرسال: تراثية (legacy)، سياقية (context)، وبيانات (data). النوع التراثي مهمل على ما أعتقد. النوعان الآخران يُستخدمان معًا. الشيء الوحيد الذي يهمنا هو أن واصفات السياق تضبط أقصى حجم للحزمة وتفعّل تجزئة TCP/IP، وأن واصفات البيانات تحمل العناوين الفيزيائية لحزم الشبكة وأحجامها. يجب أن يكون حجم الحزمة في واصف البيانات أصغر من أقصى حجم للحزمة في واصف السياق. عادةً تُزوَّد واصفات السياق لبطاقة الشبكة قبل واصفات البيانات.
لتزويد بطاقة الشبكة بواصفات الإرسال، يكتب الضيف واصفات الإرسال إلى Tx Ring. هذه حلقة عازلة (ring buffer) تقع في الذاكرة الفيزيائية عند عنوان محدد مسبقًا. عندما تُكتب جميع الواصفات إلى Tx Ring، يحدّث الضيف سجل E1000 MMIO TDT (Transmit Descriptor Tail) ليُعلِم المضيف بوجود واصفات جديدة يجب معالجتها.
اعتبر المصفوفة التالية من واصفات الإرسال:``` [context_1, data_2, data_3, context_4, data_5]
لنقم بتعيين حقول هيكلها كما يلي (أسماء الحقول افتراضية لتكون قابلة للقراءة البشرية لكنها ترتبط مباشرة بمواصفات 82540EM):```
context_1.header_length = 0
context_1.maximum_segment_size = 0x3010
context_1.tcp_segmentation_enabled = true
data_2.data_length = 0x10
data_2.end_of_packet = false
data_2.tcp_segmentation_enabled = true
data_3.data_length = 0
data_3.end_of_packet = true
data_3.tcp_segmentation_enabled = true
context_4.header_length = 0
context_4.maximum_segment_size = 0xF
context_4.tcp_segmentation_enabled = true
data_5.data_length = 0x4188
data_5.end_of_packet = true
data_5.tcp_segmentation_enabled = true
سنتعلم لماذا يجب أن تكون بهذه الطريقة في تحليلنا خطوة بخطوة.
لنفترض أن الواصفات المذكورة أعلاه تُكتب إلى Tx Ring بالترتيب المحدد ويتم تحديث سجل TDT بواسطة الضيف. الآن سينفّذ المضيف دالة e1kXmitPending في الملف src/VBox/Devices/Network/DevE1000.cpp (معظم التعليقات تمت وستتم إزالتها من أجل سهولة القراءة):```c static int e1kXmitPending(PE1KSTATE pThis, bool fOnWorkerThread) { ... while (!pThis->fLocked && e1kTxDLazyLoad(pThis)) { while (e1kLocateTxPacket(pThis)) { fIncomplete = false; rc = e1kXmitAllocBuf(pThis, pThis->fGSO); if (RT_FAILURE(rc)) goto out; rc = e1kXmitPacket(pThis, fOnWorkerThread); if (RT_FAILURE(rc)) goto out; }
ستقوم `e1kTxDLazyLoad` بقراءة جميع واصفات Tx الخمسة من حلقة الإرسال Tx Ring. ثم يتم استدعاء `e1kLocateTxPacket` لأول مرة. تتكرر هذه الدالة عبر جميع الواصفات لإعداد حالة أولية لكنها لا تعالجها فعليًا. في حالتنا، سيتعامل الاستدعاء الأول لـ `e1kLocateTxPacket` مع واصفات `context_1` و`data_2` و`data_3`. أما الواصفان المتبقيان، `context_4` و`data_5`، فسيتم التعامل معهما في التكرار الثاني من حلقة while (سنغطي التكرار الثاني في القسم التالي). هذا التقسيم الثنائي للمصفوفة ضروري لتفعيل الثغرة، لذا دعنا نكتشف السبب.
تبدو `e1kLocateTxPacket` بهذا الشكل:```c
static bool e1kLocateTxPacket(PE1KSTATE pThis)
{
...
for (int i = pThis->iTxDCurrent; i < pThis->nTxDFetched; ++i)
{
E1KTXDESC *pDesc = &pThis->aTxDescriptors[i];
switch (e1kGetDescType(pDesc))
{
case E1K_DTYP_CONTEXT:
e1kUpdateTxContext(pThis, pDesc);
continue;
case E1K_DTYP_LEGACY:
...
break;
case E1K_DTYP_DATA:
if (!pDesc->data.u64BufAddr || !pDesc->data.cmd.u20DTALEN)
break;
...
break;
default:
AssertMsgFailed(("Impossible descriptor type!"));
}
الوصف الأول (context_1) هو من نوع E1K_DTYP_CONTEXT لذا يتم استدعاء الدالة e1kUpdateTxContext. تقوم هذه الدالة بتحديث سياق تجزئة TCP إذا كان تجزئة TCP مفعّلاً للوصف. هذا صحيح بالنسبة إلى context_1 لذا سيتم تحديث سياق تجزئة TCP. (ما هو تحديث سياق تجزئة TCP بالضبط ليس مهمًا، وسنستخدم هذا فقط للإشارة إلى الكود أدناه).
الوصف الثاني (data_2) هو من نوع E1K_DTYP_DATA لذا سيتم تنفيذ عدة إجراءات غير ضرورية للنقاش.
الوصف الثالث (data_3) هو أيضًا من نوع E1K_DTYP_DATA ولكن بما أن data_3.data_length == 0 لا يتم تنفيذ أي إجراء.
في الوقت الحالي تتم معالجة الأوصاف الثلاثة مبدئيًا ويبقى الاثنان. الآن الشيء: بعد عبارة switch يوجد فحص لمعرفة ما إذا كان حقل end_of_packet الخاص بالوصف قد تم تعيينه. هذا صحيح بالنسبة لوصف data_3 (data_3.end_of_packet == true). يقوم الكود ببعض الإجراءات ويعيد من الدالة:```c if (pDesc->legacy.cmd.fEOP) { ... return true; }
إذا كانت `data_3.end_of_packet` خاطئة، فسيتم معالجة الواصفَين المتبقيين `context_4` و`data_5`، وسيتم تجاوز الثغرة. ستجد أدناه سببًا في أن تلك العودة من الدالة تؤدي إلى الخطأ.