
استغلال النواة عن بُعد عبر IPv6
استغلال النواة عن بُعد عبر IPv6
CVE-2024-38063 - استغلال النواة عن بُعد عبر IPv6 ماركوس هوتشنز
منذ أن تم إصدار آخر تصحيح لويندوز في 13 أغسطس، كنت منغمسًا بعمق في tcpip.sys (برنامج تشغيل النواة المسؤول عن معالجة حزم TCP/IP). ثغرة بتقييم CVSS 9.8 في الجزء الأكثر سهولة في الوصول إليه من نواة ويندوز كانت شيئًا لم أستطع ببساطة تفويته. لم أنظر أبدًا إلى IPv6 من قبل (أو برامج التشغيل المسؤولة عن تحليله)، لذلك عرفت أن محاولة هندسة هذه الثغرة عكسيًا ستكون صعبة للغاية، لكنها ستكون تجربة تعليمية جيدة.
في الغالب، tcpip.sys غير موثق إلى حد كبير. تمكنت من العثور على كتابين لاستغلال ثغرات أقدم: هنا، وهنا، وهنا، لكن القليل غير ذلك. عندما تكون أعلى نتيجة بحث في بحثي الإنجليزي على جوجل مكتوبة بالصينية، أعرف فورًا أنني خارج نطاق معرفتي تمامًا وأنني على موعد مع وقت عصيب، لكن يجب أن نتعلم. على الرغم من أن ترجمة جوجل قامت بعمل متوسط، إلا أن المقالة قدمت رؤية مفصلة بشكل لا يصدق حول كيفية عمل تجزئة IPv6، وأعطتني بداية جيدة.
لاحقًا، أثناء البحث في جوجل عن أسماء بعض الدوال، صادفت تحليلًا آخر لنفس الثغرة من عام 2021، كتبه أكسل سوشيه (المعروف بـ 0vercl0k)، والذي تعمق أكثر في التفاصيل الداخلية لـ tcpip.sys وأعطاني معلومات كافية لتعريف العديد من الهياكل غير الموثقة. أسهل تحليل تصحيح على الإطلاق
عادةً، حتى مجرد هندسة التصحيح عكسيًا لمعرفة أي تغيير في الكود يتوافق مع الثغرة يمكن أن يستغرق أيامًا أو حتى أسابيع، لكن في هذه الحالة كان فوريًا. كان الأمر سهلاً للغاية لدرجة أن العديد من الأشخاص على وسائل التواصل الاجتماعي أخبروني أنني مخطئ وأن الثغرة كانت في مكان آخر. هل استمعت إليهم بالفعل ثم أهدرت يومًا كاملًا في عكس برنامج التشغيل الخطأ؟ قد لا نعرف أبدًا.
كان هناك تغيير واحد بالضبط تم إجراؤه في ملف برنامج التشغيل بأكمله، والذي اتضح أنه بالفعل كان الثغرة.
نظرة عامة من Bindiff على tcpip.sys قبل وبعد تثبيت التصحيح.
تم تعديل دالة واحدة فقط في برنامج التشغيل بأكمله. عادةً، يمكنني قضاء يوم كامل في مراجعة أكثر من 20 تغييرًا مختلفًا في الدوال فقط لمعرفة أي واحد يجب أن أركز عليه، لكن ليس هذه المرة.
Ipv6pProcessOptions() قبل التصحيح.
Ipv6pProcessOptions() بعد التصحيح.
لم يكن التغيير مجرد دالة واحدة، بل سطر واحد من الكود.
دالة Feature_2660322619__private_IsEnabledDeviceUsage_3() ذات الاسم الطويل جدًا هي شيء تضيفه مايكروسوفت أحيانًا لتمكين التراجع الجزئي عن التصحيحات. تستدعي الدالة للتحقق من وجود علامة عامة أو إعداد تسجيل، وإذا تم تعيينه، فستعيد الدالة القيمة false، مما يؤدي إلى تنفيذ الكود الأصلي بدلاً من النسخة المصححة.
سبب قيام مايكروسوفت بهذا هو أن التصحيحات الأمنية أحيانًا تكسر أشياءً عن غير قصد، لذا يتيح هذا الإعداد للمسؤول إلغاء تصحيح ثغرة واحدة، دون إلغاء تثبيت حزمة التصحيح الشهرية بأكملها وإضعاف أمان النظام بشكل كبير.
مع أخذ هذا في الاعتبار، من الواضح أن كل ما يفعله هذا التصحيح هو استبدال استدعاء IppSendErrorList() بـ IppSendError()، مما يعطينا فكرة أن المشكلة تتعلق بنوع من القائمة. أسهل مقارنة تصحيح على الإطلاق (أو هكذا اعتقدت). الثغرات اختيارية، الاستغلال إلزامي
هندسة التصحيح عكسيًا للعثور على الكود المعدل ليست سوى نصف التحدي (أو في هذه الحالة أقل من 0.1%). باقي العملية يتكون من هندسة جزء كافٍ من قاعدة الكود عكسيًا لفهم ما يحدث، ومعرفة نوع الثغرة التي تم إصلاحها، وكيفية صياغة طلب للوصول إلى الكود المستهدف، وأي حالة تؤدي إلى حالة قابلة للاستغلال.
الجزء الأول سهل بما يكفي. التغيير موجود في Ipv6pProcessOptions()، مما يخبرنا أنه IPv6 ويتضمن معالجة الخيارات. لذا، استدعاء سريع لـ RFC يخبرنا بالضبط ما هو خيار IPv6 وأين يمكننا العثور عليه.
تخطيط رأس خيارات الوجهة من ويكيبيديا.
حسنًا، رائع. ما نبحث عنه يبدو أنه رأس خيارات الوجهة، والذي يقع مباشرة بعد رأس IPv6 الرئيسي. دعنا نستخدم مكتبة بايثون 'scapy' لصياغة حزمة IPv6 اختبارية.
ملاحظة: للتخفيف من هجمات رفض الخدمة (DDoS) باستخدام عناوين IP مزيفة، يقيد ويندوز القدرة على بناء حزم IP خام. لهذا السبب، اخترت استخدام لينكس لتطوير إثبات المفهوم الخاص بي. بينما يسمح لينكس للمستخدمين ببناء وإرسال حزم الطبقة الثانية والثالثة الخام، فإنه يتطلب تشغيل سكريبت بايثون كجذر.
import sys
import struct
from scapy.all import *
def send_ipv6_option_packet(dest_ip):
ethernet_header = Ether()
ip_header = IPv6(dst=dest_ip)
options_header = IPv6ExtHdrDestOpt()
sendp(ethernet_header / ip_header / options_header)
if len(sys.argv) < 2:
print('Use: python3 script.py <target_ipv6_address>')
exit(-1)
send_ipv6_option_packet(sys.argv[1])
بعد تعيين نقطة توقف على tcpip!Ipv6pProcessOptions، ثم تشغيل السكريبت، كان واضحًا أن كل ما هو مطلوب للوصول إلى الدالة المعرضة للثغرة هو إرسال حزمة IPv6 بهيكل خيارات فارغ. ثم حاولت إضافة بعض الخيارات غير الصالحة إلى الهيكل لمعرفة ما إذا كان بإمكاني الوصول إلى استدعاء IppSendErrorList().
أشارت مراجعة سريعة للكود إلى أن أي تنسيق خيار غير صالح تقريبًا يمكن أن يؤدي إلى استدعاء IppSendErrorList. لذا، قررت استخدام خيار Jumbo Packet بطول غير صالح (أقل من 65535 بايت).
options_header = IPv6ExtHdrDestOpt(options=[Jumbo(jumboplen=0x1337)])
إذن، ما الذي يفعله IppSendErrorList() بالفعل؟ حسنًا، الكود بسيط جدًا.
دالة IppSendErrorList بأكملها.
يقوم الكود بالتكرار عبر قائمة مرتبطة ويستدعي IppSendError() على كل عنصر في القائمة. مرة أخرى، اصطفت النجوم وكانت الأمور سهلة حتى الآن. إذا كان IppSendErrorList يستدعي فقط IppSendError لكل عنصر في قائمة، والتصحيح يستبدل استدعاء IppSendErrorList بـ IppSendError، فإن المشكلة تحدث عندما يتم استدعاء IppSendError على عنصر قائمة غير العنصر الأول.
إذن، ما هي هذه القائمة؟ وكيف نصنع واحدة؟ إنه يقوم بعمل قائمة، إنه يتحقق منها… 52,567 مرة
هنا تحولت الأمور من الواضحة إلى الصعبة بشكل غير طبيعي، على الرغم من أنني أعتقد أن جزءًا كبيرًا من هذا كان بسبب انشغال واحدة من خليتي العقليتين المتاحتين بمحاربة عدوى كوفيد سيئة. خسرت بضعة أيام في فهم أجزاء من الكود، ثم النوم، ثم نسيان ما كنت قد فهمته. تطلبت العملية بأكملها أكثر من أسبوع من هندسة أجزاء من tcpip.sys عكسيًا لمعرفة ما يحدث. لكن مقالة أكسل كانت مفيدة للغاية.
من خلال النظر إلى الدوال والهيكل الذي هندسه أكسل عكسيًا، وأي الدوال الأخرى يتم تمريرها إليها، من الواضح أن الوسيطة الوحيدة التي يتم تمريرها إلى Ipv6pProcessOptions() هي نفس هيكل packet_t المحدد في المقالة. بشكل أساسي، المؤشر الذي يتم تمريره إلى Ipv6pProcessOptions، والذي يتم التكرار عليه بواسطة IppSendErrorList، هو قائمة مرتبطة من الحزم.
لذلك، قمت بتعيين نقطة توقف على Ipv6pProcessOptions() وفحصت القائمة.
الإدخال list->Next هو NULL.
في كل مرة يتم فيها الوصول إلى نقطة التوقف الخاصة بي، كانت القائمة تحتوي على حزمة واحدة فقط. قضيت وقتًا أطول مما أعترف به في محاولة معرفة السبب وكيفية جعل قائمتي قائمة فعلية. كان أول فكرة لي هي تجزئة IPv6: يسمح IPv6 للمرسلين بتقسيم الحزم الكبيرة إلى حزم منفصلة أصغر، والتي سيكون من المنطقي الاحتفاظ بها معًا في قائمة.
بعد هندسة عكسية مكثفة، أكدت افتراضاتي كانت صحيحة، على الرغم من أن قائمة التجزئة لا تتعلق بالقائمة التي نتعامل معها هنا.
في الواقع، انتهى بي الأمر بالعثور على الإجابة بالصدفة تمامًا. في بعض الأحيان، كانت القائمة تملأ، لكن السبب كان غير واضح. بعد الكثير من الدوران في دوائر، أدركت أنه عندما يتم تشغيل نقطة توقف النواة الخاصة بي، فإنها توقف النواة بأكملها، مما يؤدي إلى تراكم الحزم في محول الشبكة. عندما تستأنف النواة، يتم تمرير هذه الحزم إلى أسفل المكدس إلى tcpip.sys في قائمة مرتبة مرتبة. حدث هذا فقط إذا تم إرسال الحزم أثناء توقف النواة، ولكن لم تتم معالجتها قبل الوصول إلى نقطة التوقف التالية.
هذا السلوك على الأرجح هو تحسين للأداء، حيث أنه عند الإنتاجية المنخفضة، تقوم النواة بمعالجة الحزم بشكل فردي، ولكن عند الأحجام الأعلى، يتم تنظيم الحزم في قوائم ومعالجتها على دفعات. على الأرجح يتم فصل القوائم بناءً على عوامل مثل البروتوكول وعنوان المصدر لتسريع المعالجة، لذا يجب أن تحتوي قائمتنا على حزم IPv6 التي أرسلناها فقط. يا صاح، سمعت أنك تحب هجمات DoS
الآن بعد أن علمنا أن الحزم يتم تجميعها في قوائم أثناء الإنتاجية العالية، من الواضح ما هو الخيار الأسهل. المفارقة هي أن إثبات المفهوم الخاص بهجوم رفض الخدمة (DoS) سيضطر إلى استخدام هجوم DoS لتحفيز حالة DoS. إذا قمنا بإغراق النظام بدفعات من حزم IPv6، يجب أن نتمكن من الحصول على قائمة كبيرة وجميلة تمر إلى IppSendErrorList().
في البداية، بغض النظر عن عدد الحزم التي أرسلتها، ما زلت أستطيع فقط الحصول على قائمة بحجم n > 1 إذا أوقفت النواة. لكن… بما أننا نستخدم بايثون (بطيء بشكل مؤلم)، في آلة افتراضية (بطيء بشكل مضاعف)، فربما سنحتاج إلى تعديل بعض الإعدادات. من أجل مواجهة VM-ception التي تحدث على نظام الهجوم الخاص بي، قررت ببساطة إعادة تكوين الـ VM الهدف لاستخدام نواة CPU واحدة فقط.
رائع! قائمة الحزم الآن أصبحت قائمة تحتوي على العديد من الإدخالات!
إذن، اتضح أن VM داخل VM ليس الخيار الأفضل لهجمات DoS، من كان يتوقع؟ لكننا جعلناها تعمل في النهاية. الآن، نحتاج فقط إلى معرفة ما يفعله IppSendError() وأين تكمن المشكلة. المزيد من الهندسة العكسية… مرة أخرى… إلى الأبد…
بعد بعض الهندسة العكسية الموسعة، أصبح من الواضح أكثر ما يفعله IppSendError. في الظروف العادية، يقوم ببساطة بتعطيل الحزمة عن طريق تعيين net_buffer_list->Status إلى 0xC000021B (STATUS_DATA_NOT_ACCEPTED). ثم يقوم بإرسال خطأ ICMP يحتوي على معلومات حول الحزمة الخاطئة مرة أخرى إلى المرسل.
جزءان ذوا صلة من IppSendError.
كان أول ما قمت به هو التحقق مما إذا كانت هناك أي دوال في tcpip.sys تتجاهل قيمة net_buffer_list->Status. سيؤدي ذلك إلى قيام برنامج التشغيل بمعالجة الحزم في حالات غير معرفة أو غير متوقعة، مما قد يؤدي إلى حالة استغلال.
الحلقة الرئيسية المسؤولة عن معالجة الحزم.
نظرًا لأن الحلقة المسؤولة عن استدعاء جميع دوال التحليل مغلفة في فحص خطأ (مما يعني أنه لا يمكننا الذهاب إلى أي مكان بمجرد تعيين رمز الخطأ)، اعتقدت أن هذا هو جحر الأرنب الخطأ. بدلاً من ذلك، قررت العودة إلى IppSendError ومعرفة ما إذا كانت هناك مسارات كود تقوم بتعديل حالة الحزمة قبل تعيين رمز الخطأ، مما قد يؤدي إلى حالة سباق.
بعد المزيد من الهندسة العكسية، وجدت الكود التالي بالقرب من الجزء السفلي جدًا من IppSendError.
مسار كود في IppSendError يقوم بتعيين packet_size إلى صفر.
عندما يتم استدعاء IppSendErrorList، وبالتالي IppSendError، مع تعيين الوسيطة always_send_icmp إلى true، يبدو أنه يحاول إرسال خطأ ICMP إلى كل حزمة في القائمة.
ثم، لأسباب لا يعرفها إلا الله على الأرجح، يصل إلى كتلة كود حيث يتم تعيين حقل packet->packet_size إلى صفر.
من أجل تعيين always_send_icmp إلى true، كل ما نحتاجه هو التسبب في خطأ معين في معالجة رأس الخيارات عن طريق تعيين قيمة 'Option Type' إلى أي رقم أكبر من 0x80.
def build_malicious_option(next_header, header_length, option_type, option_length):
dest_options_header = 60
options_header = struct.pack('BBBB', next_header, header_length, option_type, option_length) + b'1337'
return Ether(dst=mac_addr) / IPv6(dst=ip_addr, nh=dest_options_header) / raw(options_header)
packet = build_malicious_option(next_header=59, header_length=0, option_type=0x81, option_length=0)
sendp(packet)
لكن، أليس من المفترض أن يؤدي تعيين packet_size إلى صفر إلى كسر المحلل اللغوي؟
مقتطف من الحلقة الرئيسية المسؤولة عن معالجة الحزم.
معالج الحزمة يقوم ببساطة باستدعاء دالة VTable بناءً على قيمة packet->next_header، والتي تبقى دون تغيير منذ تعيينها أثناء ما قبل التحليل. هذا يسمح باستمرار معالجة الحزمة ويعطينا أيضًا التحكم في أي معالجة تحدث.
نظرًا لأن قيمة packet->next_header يتم الحصول عليها من حقل 'Next Header' لحزمة IPv6، يمكننا تعيينها إلى أي قيمة رأس IPv6 صالحة، وستقوم الحلقة باستدعاء المحلل اللغوي المقابل. وهذا يعطينا الكثير من سطح الهجوم المحتمل.
تنسيق حزمة IPv6.
كل ما تبقى هو العثور على جزء يمكن الوصول إليه من محلل IPv6 يفعل شيئًا سخيفًا مع حقل packet_size. العودة إلى التجزئة
أول مكان قررت النظر إليه هو محلل تجزئة IPv6، لأن هذه هي مكان الثغرة القديمة CVE-2021-24086، لذا بدا مكانًا جيدًا للعثور على المزيد من الكود الغريب.
إيه… إنه قريب جدًا، لكن أيضًا بعيد جدًا.
لدينا ثغرة هنا، لكنها ليست RCE.
بشكل أساسي، في معظم وحدات المعالجة المركزية (CPUs)، السجلات دائرية. إذا قمت بزيادة سجل يتجاوز قيمته القصوى المحتملة، فإنه يعود إلى الصفر. وبالمثل، إذا قمت بتقليله إلى ما دون أدنى قيمة له، فإنه يعود إلى أعلى قيمة ممكنة. يُشار إلى هذه باسم الفيض الصحيح (integer overflows) والتقليل الصحيح (integer underflows) على التوالي. هذا السلوك يختلف قليلاً للأعداد الصحيحة الموقعة، لكننا لا نتعامل مع تلك هنا.
السطر الأول، fragment_size = LOWORD(packet->packet_size) - 0x30، يتكون من كود ASM التالي:
كود ASM الذي يحسب حجم التجزئة.
AX هو الأجزاء الـ 16 السفلية من سجل EAX. على الرغم من أن سجل EAX هو 32 بت، إلا أن AX يعمل كما لو كان سجلًا خاصًا به بحجم 16 بت، وبالتالي فإن أي فيض أو تقليل محصور في AX، ولن يؤثر على بقية سجل EAX. هذا مريح بشكل لا يصدق لأن التقليل في سجل EAX سيؤدي إلى قيمة 4 مليار، مما قد يؤدي إلى محاولة تخصيص 4 جيجابايت من الذاكرة، والتي من المحتمل أن تفشل.
نظرًا لأن قيمة packet->packet_size هي صفر، فإن هذا الكود يضبط ax على صفر، ثم يطرح 0x30 منه.
تحت الظروف العادية، رأس الحزمة هو 0x30 بايت، لذا packet_size - 0x30 هو حجم بيانات التجزئة.
في حالتنا، packet->packet_size هو 0، لذا فإن طرح حتى 1 منه سيتسبب في دوران السجل حول القيمة القصوى الممكنة لعدد صحيح 16 بت (0xFFFF). بما أننا نطرح 0x30، فإن قيمة AX ستقل وتصبح MAX_VALUE - 0x2F، أو 0xFFD0، وهي 65,488.
لسوء الحظ، بما أن نفس الحساب يُستخدم لكل من تخصيص الذاكرة ونسخ البيانات، فإننا لا نحصل على تجاوز سعة المخزن المؤقت. أعتقد أن RtlCopyMdlToBuffer() تقوم أيضًا بالتحقق من حدود المخزن المؤقت المصدر، لذا لا نحصل حتى على قراءة خارج الحدود. ومع ذلك، لا نخرج خالي الوفاض تمامًا.
نظرًا لأن ExAllocatePoolWithTagPriority() لا تقوم بمسح الذاكرة المخصصة، و RtlCopyMdlToBuffer() تنسخ فقط الكمية الفعلية من البيانات المتاحة، نحصل على حوالي 65 كيلوبايت من ذاكرة النواة غير المهيأة. نظرًا لأن عناوين الذاكرة يتم إعادة استخدامها بعد إلغاء التخصيص، فمن المحتمل أن المخزن المؤقت سيكون مليئًا بأي شيء كان مخزناً سابقاً في العنوان قبل إعادة التخصيص. إذا تمكنا من استخدام التجزئة لبناء حزمة يتم إرسالها إلينا، مثل طلب ICMP Echo، فقد نتمكن من تسريب ذاكرة نواة عشوائية، مما يؤدي إلى تجاوز ASLR.
علاوة على ذلك، يقوم الكود أيضًا بتعيين reassembly->fragment_size إلى العدد الصحيح 16 بت الذي حدث له تقليل (65,488)، لذا لدينا الآن متغيرين منفصلين يمكننا استخدامهما للتسبب في تجاوز سعة المخزن المؤقت. مهزوم، لكن ليس منهزمًا
لسوء الحظ (أو لحسن الحظ، لأن هذا ربما وفر لي الكثير من الوقت)، سبقني شخص ما. قبل أن أتمكن من العثور على مكان لاستخدام أحد الأعداد الصحيحة المقللة لتحفيز تجاوز سعة المخزن المؤقت، وجد @ynwarcs الإجابة ونشر إثبات المفهوم. هذا يحل القطعة الأخيرة من لغزي.
الحل (أو أحد الحلول على الأقل) هو Ipv6pReassemblyTimeout(). بينما لا يمكننا التسبب في تجاوز السعة في معالجة التجزئة الأولية، يمكننا على ما يبدو أثناء التنظيف.
ستبقى أجزاء IPv6 في الذاكرة حتى يتحقق أحد الشروط الثلاثة التالية:
نفسد تجزئتنا بشكل سيء بما يكفي بحيث يخبرنا النظام بالتوقف.
نرسل جزءًا مع تعيين حقل 'More' إلى 0، مما يشير إلى أن هذا هو الجزء الأخير، وسيبدأ النظام في إعادة التجميع.
لا نرسل الجزء الأخير قبل انتهاء فترة المهلة (60 ثانية)، وسيقوم النظام بإسقاط الأجزاء.
يتم استدعاء Ipv6pReassemblyTimeout() في الحالة 3، لذا دعنا نفحص كيف يمكن استغلال هذا.
هذا بالضبط ما نحتاجه!
سابقًا، كانت مشكلتنا هي أن الكود استخدم نفس الحساب تمامًا لكل من تخصيص الذاكرة وعملية النسخ. هذا الكود، من ناحية أخرى، لا يفعل ذلك. دعنا نلقي نظرة أعمق على الـ ASM لنرى كيف يمكن استغلاله.
كود التجميع المسؤول عن حساب حجم التخصيص.
كما ترى هنا، الجزء الأول من الحساب (fragment_list->net_buffer_length + reassembly->packet_length + 8) يتم باستخدام سجل DX 16 بت.
إذا كنت تتذكر من السابق، قمنا بتقليل reassembly->packet_length ليصبح 0xFFD0. لذا فإن سجل DX، بعد إضافة الـ 8 بايت، هو 0xFFD8. إذا كان fragment_list->net_buffer_length أكبر من 0x27 (39 بايت)، فإن DX سيفيض ويعود إلى الصفر.
يجب أن يكون fragment_list->net_buffer_length حوالي 0x38 بايت، لذا سينتج عنه فيض لسجل DX إلى 8. بعد إضافة 0x28 بايت، سنحصل على تخصيص ذاكرة بحجم 48 بايت فقط.
نظرًا لأن استدعاءات memmove() اللاحقة تستخدم ببساطة قيمة reassembly->packet_length غير المعدلة للحجم، فسيؤدي ذلك إلى نسخ 65,488 بايت من reassembly->payload إلى مخزن مؤقت بحجم 30 بايت. ميزة إضافية رائعة هي أن الكثير من البيانات المنسوخة تأتي من حمولة التجزئة، التي نتحكم فيها، ويمكن أن تكون بيانات عشوائية بأي تنسيق، لذا نحصل على تجاوز سعة مخزن مؤقت لطيف يمكن التحكم فيه في تجمع النواة (kernel pool).
من أجل أن تتاح لنا فرصة تحفيز الثغرة، نحتاج إلى وجود حزمة تجزئة واحدة أو أكثر بعد حزمة الخيارات غير الصالحة في القائمة المرتبطة في اللحظة التي يتم فيها استدعاء IppSendErrorList. ومع ذلك، من اختباراتي، لا يبدو أن هذا يضمن الاستغلال. أعتقد أن هناك أيضًا بعض الشروط الأخرى التي يجب استيفاؤها. أتوقع، لكن لم أؤكد، أن كود المزامنة في IppSendError يعني أننا يجب أيضًا الفوز في حالة سباق.