
CVE-2024-38063 - استغلال النواة عن بُعد عبر IPv6
كان هناك تغيير واحد فقط في ملف برنامج التشغيل بأكمله، وقد اتضح أنه بالفعل كان الخطأ.
نظرة عامة من bindiff على tcpip.sys قبل وبعد تثبيت التصحيح.
تم تعديل دالة واحدة فقط في برنامج التشغيل بأكمله. في العادة، قد أقضي يومًا كاملًا في مراجعة أكثر من 20 تغييرًا مختلفًا في الدوال فقط لأعرف أيها يجب أن أركز عليه، لكن ليس هذه المرة.

Ipv6pProcessOptions() قبل التصحيح.
Ipv6pProcessOptions() . بعد التصحيح.
لم يكن مجرد تغيير دالة واحدة، بل تغيير سطر واحد من الكود.
الدالة ذات الاسم الطويل للغاية Feature_2660322619__private_IsEnabledDeviceUsage_3() هي شيء تضيفه Microsoft أحيانًا لتمكين التراجع الجزئي عن التصحيحات. يتحقق الاستدعاء من وجود علامة عامة أو إعداد في السجل، وإذا تم ضبطه، فسيؤدي إلى جعل الدالة تُرجع false، مما يؤدي إلى تنفيذ الكود الأصلي بدلاً من النسخة المصححة.
السبب في قيام Microsoft بذلك هو أن التصحيحات الأمنية قد تكسر أشياءً عن غير قصد في بعض الأحيان، لذا يتيح هذا الإعداد للمسؤول إلغاء تصحيح ثغرة واحدة دون إلغاء تثبيت حزمة التصحيح الشهرية بأكملها وإضعاف أمان نظامه بشكل كبير.
مع أخذ هذا في الاعتبار، من الواضح أن كل ما يفعله هذا التصحيح هو استبدال استدعاء IppSendErrorList() بـ IppSendError()، مما يعطينا دليلًا على أن المشكلة تكمن في نوع من القوائم. أسهل مقارنة تصحيح على الإطلاق (أو هكذا اعتقدت)
هندسة التصحيح عكسيًا للعثور على الكود المعدل ليست سوى نصف التحدي (أو في هذه الحالة أقل من 0.1%). أما باقي العملية فيتمثل في هندسة جزء كافٍ من قاعدة الكود عكسيًا لفهم ما يحدث أصلًا، ومعرفة نوع الثغرة التي تم تصحيحها، وكيفية صياغة طلب للوصول إلى الكود المستهدف، وما الحالة التي تؤدي إلى شرط قابل للاستغلال.
الجزء الأول سهل بما يكفي. التغيير موجود في Ipv6pProcessOptions()، وهو ما يخبرنا بأن الأمر يتعلق بـ IPv6 ويتضمن معالجة الخيارات. لذا، فإن مراجعة سريعة لـ RFC تخبرنا بالضبط ما هو خيار IPv6 وأين يمكننا العثور عليه.
تخطيط ترويسة خيارات الوجهة من ويكيبيديا.
حسنًا، رائع. يبدو أن ما نبحث عنه هو ترويسة خيارات الوجهة، والتي تأتي مباشرة بعد ترويسة IPv6 الرئيسية. دعنا نستخدم مكتبة بايثون 'scapy' لصياغة حزمة IPv6 تجريبية.
ملاحظة: للتخفيف من هجمات حجب الخدمة الموزعة (DDoS) التي تستخدم عناوين IP مزيفة، تقيد Windows القدرة على إنشاء حزم IP خام. لهذا السبب، اخترت استخدام Linux لتطوير إثبات المفهوم الخاص بي. بينما يتيح Linux للمستخدمين إنشاء وإرسال حزم خام من الطبقة الثانية والثالثة، فإنه يتطلب تشغيل سكربت Python بصلاحيات الجذر.
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 على عنصر في القائمة ليس هو الأول.
هنا تحولت الأمور من الواضح إلى الصعب بشكل غير طبيعي، على الرغم من أنني أعتقد أن جزءًا كبيرًا من ذلك كان بسبب انشغال إحدى خليتّي الدماغ المتاحتين بمحاربة عدوى كوفيد سيئة. خسرت بضعة أيام بين محاولة فهم أجزاء من الكود، والنوم، ثم نسيان ما كنت قد اكتشفته. تطلبت العملية بأكملها أكثر من أسبوع من الهندسة العكسية لأجزاء من tcpip.sys لمعرفة ما كان يحدث. لكن مشاركة مدونة Axel كانت مفيدة للغاية.
بالنظر إلى الدوال والبنية التي قام Axel بهندستها عكسيًا، وإلى أي الدوال الأخرى يتم تمريرها، يتضح أن الوسيط الوحيد الذي يُمرَّر إلى Ipv6pProcessOptions() هو نفس بنية packet_t المعرّفة في المقالة. بشكل أساسي، المؤشر الذي يُمرَّر إلى Ipv6pProcessOptions، والذي يتكرر عبره IppSendErrorList، هو قائمة مرتبطة من الحزم.
لذا، قمت بتعيين نقطة توقف على Ipv6pProcessOptions() وفحصت القائمة.

إدخال list->Next يكون NULL.
في كل مرة يتم فيها الوصول إلى نقطة التوقف الخاصة بي، كانت القائمة تحتوي على حزمة واحدة فقط. قضيت وقتًا أطول مما أود الاعتراف به في محاولة معرفة السبب وكيفية جعل القائمة قائمة فعلًا. كان أول ما فكرت فيه هو تجزئة IPv6: يتيح IPv6 للمرسلين تقسيم الحزم الكبيرة إلى حزم أصغر منفصلة، ومن المنطقي الاحتفاظ بها معًا في قائمة.
بعد هندسة عكسية مكثفة، تأكدت من أن افتراضاتي كانت صحيحة، على الرغم من أن قائمة الشظايا لا علاقة لها بالقائمة التي نتعامل معها هنا.
في الواقع، انتهى بي الأمر بالعثور على الإجابة بالصدفة تمامًا. في بعض الأحيان، كانت القائمة تمتلئ، لكن السبب كان غير واضح. بعد الكثير من الدوران في دوائر، أدركت أنه عندما يتم تشغيل نقطة التوقف الخاصة بي في النواة، فإنها توقف النواة بأكملها، مما يؤدي إلى تراكم الحزم في محول الشبكة. عندما تستأنف النواة عملها، يتم تمرير هذه الحزم إلى أسفل المكدس إلى tcpip.sys في قائمة أنيقة ومرتبة. حدث هذا فقط إذا تم إرسال الحزم بينما كانت النواة متوقفة، ولكن لم تتم معالجتها قبل الوصول إلى نقطة التوقف التالية.
هذا السلوك على الأرجح تحسين للأداء؛ حيث تعالج النواة الحزم بشكل فردي عند معدل نقل منخفض، لكن عند الأحجام الأعلى، يتم تنظيم الحزم في قوائم ومعالجتها على دفعات. على الأرجح تُفصل القوائم بناءً على عوامل مثل البروتوكول وعنوان المصدر لتسريع المعالجة، لذا يجب أن تحتوي قائمتنا على حزم IPv6 التي أرسلناها فقط.
الآن بعد أن عرفنا أن الحزم تتجمع في قوائم أثناء معدل النقل المرتفع، يتضح ما هو الخيار الأسهل. من المفارقات أن إثبات المفهوم لحجب الخدمة (DoS PoC) الخاص بنا سيضطر إلى استخدام DoS لتشغيل حالة DoS. إذا أغرقنا النظام بدفعات من حزم IPv6، يجب أن نتمكن من الحصول على قائمة كبيرة وجميلة تُمرَّر إلى IppSendErrorList().