
إثبات مفهوم لاستغلال CVE-2023-21768، وهي ثغرة كتابة عشوائية في نواة نظام التشغيل Windows عبر برنامج تشغيل الوظائف المساعدة (AFD.sys)، مما يتيح تصعيد الامتيازات محليًا عبر حلقة الإدخال/الإخراج (I/O ring).
وفقًا للوصف التفصيلي لـ CVE-2023-21768 الذي نشرته Microsoft Security Response Center (MSRC)، توجد الثغرة في Ancillary Function Driver (AFD)، واسم ملفه في النظام هو afd.sys. وحدة AFD هي نقطة دخول النواة (kernel entry point) لـ WinSock API. في هذا التحليل سأستخدمها لاستغلال تصعيد الامتيازات على ويندوز 11.
قم بتحميل نسختين من afd.sys من Winbindex، أحدث نسخة قبل التصحيح، ونسخة بعد التصحيح. ثم استخدم Bindiff لمقارنة هاتين النسختين.

بمقارنة النسختين بشكل عام، نرى أن هناك دالة واحدة فقط مختلفة وهي AfdNotifyRemoveIoCompletion. دعونا نلقي نظرة أكثر تفصيلًا على الاختلافات في هذه الدالة بين النسختين.

لا توجد اختلافات كثيرة بين النسختين. في الإصدار بعد التصحيح (post-patch)، تمت إضافة تعليمات assembly لتعيين المعاملات واستدعاء دالة ProbeForWrite. وفقًا لـ الوثيقة الخاصة بشركة Microsoft، تُستخدم هذه الدالة للتحقق مما إذا كان العنوان ينتمي حقًا إلى وضع المستخدم (user-mode)، ولديه صلاحية الكتابة، ومحاذي بشكل صحيح أم لا. تحليل أكثر تفصيلًا لهذا الكود:
ما قبل التصحيح afd.sys version 10.0.22621.608

ما بعد التصحيح afd.sys version 10.0.22621.1105

كلاهما يتحقق من قيمة r15_1، إذا كانت تساوي 0 فسيتم كتابة قيمة var_304 إلى المؤشر المحدد في أحد حقول struct_1. إذا كانت مختلفة عن 0، فسيتم استدعاء ProbeForWrite للتأكد من أن المؤشر يشير إلى عنوان صالح. في إصدار pre-patch يتم بعد ذلك كتابة القيمة عند var_304 إلى المؤشر، وهذا الفحص مفقود. من هذا التصحيح، يمكننا تخمين أنه يمكننا استدعاء هذا الكود بقيمة arg3_1->field_18 التي نتحكم فيها. إذا تمكنا من تعيين قيمة عنوان kernel في field_18، فيمكننا كتابة var_304 إلى عنوان في ذاكرة kernel.
=> bug type: arbitrary kernel Write-Where
الآن نحتاج إلى إيجاد طريقة لتفعيل (trigger) الثغرة. يتم استدعاء الدالة AfdNotifyRemoveIoCompletion مباشرة داخل الدالة AfdNotifySock.

وبالمثل، عند البحث عن cross reference للدالة AfdNotifySock نجد أنها لا تُستدعى مباشرة من أي دالة أخرى، ولكن عنوان الدالة يُحفظ في عنوان داخل .rdata

هذا العنوان يقع مباشرة قبل AfdIrpCallDispatch.

لتفعيل الثغرة، سأستدعي DeviceIoControl مع IOCTL_AFD_NOTIFY_SOCK وسيتم استدعاء AfdNotifySock.
BOOL DeviceIoControl(
[in] HANDLE hDevice,
[in] DWORD dwIoControlCode,
[in, optional] LPVOID lpInBuffer,
[in] DWORD nInBufferSize,
[out, optional] LPVOID lpOutBuffer,
[in] DWORD nOutBufferSize,
[out, optional] LPDWORD lpBytesReturned,
[in, out, optional] LPOVERLAPPED lpOverlapped
);
لكل برنامج تشغيل (driver)، يتم إنشاء كائن DRIVER_OBJECT في النواة، وهو معرّف على النحو التالي:
typedef struct _DRIVER_OBJECT {
CSHORT Type;
CSHORT Size;
PDEVICE_OBJECT DeviceObject;
ULONG Flags;
PVOID DriverStart;
ULONG DriverSize;
PVOID DriverSection;
PDRIVER_EXTENSION DriverExtension;
UNICODE_STRING DriverName;
PUNICODE_STRING HardwareDatabase;
PFAST_IO_DISPATCH FastIoDispatch;
PDRIVER_INITIALIZE DriverInit;
PDRIVER_STARTIO DriverStartIo;
PDRIVER_UNLOAD DriverUnload;
PDRIVER_DISPATCH MajorFunction[IRP_MJ_MAXIMUM_FUNCTION + 1];
} DRIVER_OBJECT, *PDRIVER_OBJECT;
المكوّن الأخير MajorFunction هو مصفوفة تحتوي على دوال dispatch الخاصة ببرنامج التشغيل لمعالجة الاتصالات بين النواة ووضع المستخدم. دالة dispatch المقابلة لاستدعاء DeviceIoControl تُحفظ في MajorFunction[IRP_MJ_DEVICE_CONTROL].
#define IRP_MJ_DEVICE_CONTROL 0x0e
من خلال دالة DriverEntry الخاصة بـ afd.sys، يمكننا أن نرى أن برنامج التشغيل أنشأ device object "\Device\Afd":

يتم تعيين MajorFunction[IRP_MJ_DEVICE_CONTROL] = AfdDispatchDeviceControl، لذلك عند استدعاء DeviceIoControl للتواصل مع النواة، سيتم استدعاء هذه الدالة.

يوجد في AFD جدولان للتوزيع (dispatch table) وهما AfdIrpCallDispatch و AfdImmediateCallDispatch.

يمكننا بسهولة ملاحظة أن AfdDispatchDeviceIoControl يحسب subscript من خلال IoControlCode ويأخذ القيمة المقابلة للـ subscript من AfdIoctlTable للتحقق منها باستخدام IoControlCode.

من المسافة بين عنوان بداية AfdImmediateCallDispatch والعنوان الذي يُخزّن AfdNotifySock، نحسب أن الـ index هو 73، ورمز التحكم (control code) هو 0x12127

int main() {
WSADATA WSAData;
SOCKET s;
SOCKADDR_IN sa;
int ierr;
WSAStartup(0x2, &WSAData);
s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
memset(&sa, 0, sizeof(sa));
sa.sin_port = htons(135);
sa.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");
sa.sin_family = AF_INET;
ierr = connect(s, (const struct sockaddr*)&sa, sizeof(sa));
char outBuf[100];
DWORD bytesRet;
DWORD inbuf1[100];
memset(inbuf1, 0, sizeof(inbuf1));
DeviceIoControl((HANDLE)s, 0x12127, (LPVOID)inbuf1, 0x30, outBuf, 0, &bytesRet, NULL);
return 0;
}
لقد نجح الأمر!

كما ذكرنا منذ البداية، تحدث الثغرة عندما نتمكن من تمرير مؤشر غير مُتحقق منه (unvalidated pointer) عبر struct. يتم تمرير هذا الـ struct مباشرة من وضع المستخدم عبر lpInBuffer الخاص بـ DeviceIoControl. ثم يُمرَّر إلى AfdNotifySock كمعامل رابع ويُمرَّر إلى AfdNotifyRemoveIoCompletion كمعامل ثالث.

نظرًا لأننا لا نعرف ما يتضمنه الـ struct، تركت IDA ينشئ الـ struct تلقائيًا. الآن نحتاج إلى إيجاد طريقة لتمرير البيانات إلى هذا الـ struct وتجاوز الفحوصات اللازمة للوصول إلى الكود المعيب. نبدأ من الدالة AfdNotifySock:

أولاً، يجب أن يكون حجم الـ struct مساويًا 0x30 بايت.

القيم التالية يجب أن تكون غير صفرية:

شيء آخر لاحظته أثناء التصحيح هو أنه كان يقفز إلى الفشل عند فحص الـ UserBuffer السابق، لذلك عند استدعاء DeviceIoControl يجب تعيين هذه القيمة إلى NULL. بعد تعيين القيم أعلاه، تمكّنت من تجاوز الفحص check2.

الفحص التالي الذي يجب تجاوزه:

يجب أن تُرجع ObReferenceObjectByHandle القيمة STATUS_SUCCESS حتى يتسنى تجاوز هذا الفحص. أي أنه يجب علينا تمرير handle صالح. بحثت ولم أجد أي مكان يشرح كيفية إنشاء IoCompletionObjectType. لذلك اتبعت مقال التحليل https://securityintelligence.com/posts/patch-tuesday-exploit-wednesday-pwning-windows-ancillary-function-driver-winsock/. استخدام الدالة NtCreateIoCompletion لإنشاء IoCompletionObjectType وتمرير الـ handle الخاص بها إلى ObReferenceObjectByHandle.
بعد تجاوز هذا الفحص، يقفز تدفق البرنامج إلى حلقة، ولا يوجد في هذه الحلقة أي شيء ينقل إلى مسار الفشل، لذلك قمت ببساطة بتعيين القيمة عند dword20 إلى 0x1 للخروج من الحلقة.

بعد الخروج من الحلقة، سيستدعي البرنامج AfdNotifyRemoveIoCompletion. نواصل التحليل مع الدالة AfdNotifyRemoveIoCompletion:

أولاً، يفحص البرنامج حقلاً آخر في الـ struct، ويجب أن يكون غير صفري. ثم يُضرب في 0x20، ويُستخدم كمعامل لاستدعاء الدالة ProbeForWrite مع حقل آخر في الـ struct. هنا يكفي استخدام عنوان في ذاكرة وضع المستخدم بصلاحية كتابة و dwLen = 1. الفحص الأخير قبل أن نتمكن من تفعيل الثغرة هو أن القيمة المرجعة من استدعاء الدالة IoRemoveCompletion يجب أن تكون STATUS_SUCCESS. بعد البحث، اكتشفت أن الدالة NtRemoveIoCompletion عند استدعائها تستدعي الدالة IoRemoveCompletion. وفقًا لهذه الوثيقة، تعمل الدالة NtRemoveIoCompletion كـ "استدعاء انتظار" (waiting call) وتنتهي عندما يكتمل سجل واحد على الأقل في Io Completion Object محدد. يُضاف السجل عندما تكتمل عملية الإدخال/الإخراج (I/O).
NtRemoveIoCompletion(
IN HANDLE IoCompletionHandle,
OUT PULONG CompletionKey,
OUT PULONG CompletionValue,
OUT PIO_STATUS_BLOCK IoStatusBlock,
IN PLARGE_INTEGER Timeout OPTIONAL );
بالإضافة إلى ذلك، هناك معامل اختياري آخر وهو Timeout، وعند الوصول إلى قيمة المهلة تنتهي الدالة. ومع ذلك، فإن تعيين timeout = 0 فقط لا يكفي لجعل الدالة تعود، بل ستعيد رمز خطأ المهلة (timeout error code). يمكننا استخدام الدالة NtSetIoCompletion لزيادة عداد عمليات الإدخال/الإخراج المعلقة في IoCompletionObjectType بمقدار 1 وإنهاء الدالة NtRemoveIoCompletion قبل المهلة. بعد عدة محاولات، لاحظت أن القيمة المكتوبة تكون دائمًا 0x1.
نظرًا لأننا نستطيع كتابة القيمة 0x1 إلى عنوان في وضع النواة، يمكننا استخدام هذه الثغرة للحصول على قدرة كاملة للقراءة/الكتابة في أي عنوان من خلال الاستفادة من I/O ring (آلية إدخال/إخراج جديدة أطلقتها Microsoft). كتب Yarden Shafir تحليلًا مفصلاً للغاية حول هذه الطريقة، يمكنك قراءته هنا. إحدى العمليات التي يمكن للتطبيق تنفيذها هي تخصيص جميع المخازن المؤقتة (buffers) لعمليات الإدخال/الإخراج المستقبلية، ثم تسجيلها مع I/O ring. تتم الإشارة إلى المخازن المسجلة مسبقًا عبر I/O object:
typedef struct _IORING_OBJECT
{
USHORT Type;
USHORT Size;
NT_IORING_INFO UserInfo;
PVOID Section;
PNT_IORING_SUBMISSION_QUEUE SubmissionQueue;
PMDL CompletionQueueMdl;
PNT_IORING_COMPLETION_QUEUE CompletionQueue;
ULONG64 ViewSize;
ULONG InSubmit;
ULONG64 CompletionLock;
ULONG64 SubmitCount;
ULONG64 CompletionCount;
ULONG64 CompletionWaitUntil;
KEVENT CompletionEvent;
UCHAR SignalCompletionEvent;
PKEVENT CompletionUserEvent;
ULONG RegBuffersCount;
PVOID RegBuffers;
ULONG RegFilesCount;
PVOID* RegFiles;
} IORING_OBJECT, *PIORING_OBJECT;
إذا كانت ثغرة أمنية، مثل الثغرة المذكورة في هذه المقالة، تسمح لك بتحديث/تعديل الحقلين RegBuffersCount و RegBuffers، فيمكن استخدام API I/O ring القياسي لقراءة وكتابة ذاكرة النواة. ومع ذلك، فإن استخدام الدالة NtQuerySystemInformation يتطلب صلاحية Medium IL. من أجل تصعيد الامتيازات (LPE) من Low IL، نحتاج إلى طريقة ما لتسريب (leak) عنوان kernel.
بعد أن يشير IoRing->RegBuffers إلى fakeBuffer، الذي يتحكم فيه المستخدم، يمكننا استخدام عمليات I/O ring العادية للقراءة والكتابة في أي عنوان نريده عن طريق تحديد index يشير إلى الـ fake لاستخدامه كمخزن مؤقت:
لمزيد من الفهم، يمكنك قراءة تحليل Yarden Shafir في الرابط أعلاه.
بعد محاولة إنشاء كائن IO Ring والكتابة باستخدام كود الإثبات (POC) أعلاه، تعطل ويندوز بعد استدعاء DeviceIOControl /_ \ لذلك استخدمت طريقة الاستدعاء المباشر لدوال Ntfunction (˘・_・˘)
ProbeForWrite