
प्रूफ-ऑफ-कॉन्सेप्ट शोषण CVE-2023-21768 के लिए, एक विंडोज एंसिलरी फंक्शन ड्राइवर (AFD.sys) मनमाना कर्नेल लिखने की कमजोरी जो I/O रिंग के माध्यम से स्थानीय विशेषाधिकार वृद्धि को सक्षम करती है।
CVE-2023-21768 के विस्तृत विवरण के अनुसार, जो Microsoft Security Response Center (MSRC) द्वारा प्रकाशित किया गया है, यह भेद्यता Ancillary Function Driver (AFD) में मौजूद है, जिसका सिस्टम फ़ाइल नाम afd.sys है। AFD मॉड्यूल WinSock API का कर्नेल एंट्री पॉइंट है। इस विश्लेषण में मैं इसका उपयोग विंडोज 11 पर विशेषाधिकार वृद्धि (LPE) के लिए करूंगा।
दो संस्करण afd.sys डाउनलोड करें Winbindex से, एक संस्करण पैच से पहले का नवीनतम, और एक पैच के बाद का संस्करण। फिर इन दो संस्करणों की तुलना करने के लिए Bindiff का उपयोग करें।

दो संस्करणों की सामान्य तुलना से पता चलता है कि केवल एक फ़ंक्शन में अंतर है, वह है AfdNotifyRemoveIoCompletion। इस फ़ंक्शन के दो संस्करणों के बीच अंतर को अधिक विस्तार से देखें।

दोनों संस्करणों के बीच बहुत अधिक अंतर नहीं है। पोस्ट-पैच संस्करण में, पैरामीटर सेट करने और ProbeForWrite फ़ंक्शन को कॉल करने के लिए अतिरिक्त असेंबली निर्देश जोड़े गए हैं। Microsoft के दस्तावेज़ के अनुसार, यह फ़ंक्शन यह जांचने के लिए उपयोग किया जाता है कि कोई पता वास्तव में यूज़र-मोड का है, उसमें लिखने की अनुमति है, और सही ढंग से संरेखित (aligned) है या नहीं। इस कोड का अधिक विस्तृत विश्लेषण:
afd.sys version 10.0.22621.608

-post-patch afd.sys version 10.0.22621.1105

दोनों r15_1 के मान की जाँच करते हैं, यदि यह 0 के बराबर है तो var_304 के मान को struct_1 के एक फ़ील्ड पर निर्दिष्ट पॉइंटर पर लिखते हैं। यदि यह 0 नहीं है, तो ProbeForWrite को कॉल किया जाएगा ताकि यह सुनिश्चित किया जा सके कि पॉइंटर एक वैध पते की ओर इशारा करता है। pre-patch संस्करण में, उसके बाद ही var_304 का मान पॉइंटर पर लिखा जाता है, यह जाँच गायब थी। इस पैच से, हम अनुमान लगा सकते हैं कि हम arg3_1->field_18 के नियंत्रित मान के साथ इस कोड को कॉल कर सकते हैं। यदि हम field_18 पर एक कर्नेल पता सेट कर सकते हैं, तो हम var_304 को कर्नेल मेमोरी क्षेत्र के पते पर लिख सकते हैं।
=> bug type: arbitrary kernel Write-Where
अब बग को ट्रिगर करने का तरीका खोजना होगा। फ़ंक्शन AfdNotifyRemoveIoCompletion को सीधे AfdNotifySock फ़ंक्शन में कॉल किया जाता है।

इसी तरह, 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_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 एक सरणी है जिसमें ड्राइवर के डिस्पैच फ़ंक्शन होते हैं, जो कर्नेल और यूज़रमोड के बीच संचार को संभालते हैं। DeviceIoControl कॉल के अनुरूप डिस्पैच फ़ंक्शन MajorFunction[IRP_MJ_DEVICE_CONTROL] पर संग्रहीत होता है।
#define IRP_MJ_DEVICE_CONTROL 0x0e
afd.sys के DriverEntry फ़ंक्शन से, हम देख सकते हैं कि ड्राइवर ने डिवाइस ऑब्जेक्ट "\Device\Afd" बनाया है:

यह MajorFunction[IRP_MJ_DEVICE_CONTROL] = AfdDispatchDeviceControl असाइन करता है, इसलिए जब कर्नेल के साथ संचार करने के लिए DeviceIoControl कॉल किया जाता है, तो यह इस फ़ंक्शन को कॉल करेगा।

AFD में दो डिस्पैच टेबल हैं: AfdIrpCallDispatch और AfdImmediateCallDispatch।

यह देखना आसान है कि AfdDispatchDeviceIoControl IoControlCode के माध्यम से सबस्क्रिप्ट की गणना करता है और IoControlCode द्वारा सत्यापित करने के लिए AfdIoctlTable से सबस्क्रिप्ट के अनुरूप मान प्राप्त करता है।

AfdImmediateCallDispatch के प्रारंभ पते और AfdNotifySock को संग्रहीत करने वाले पते के बीच की दूरी से, हम इंडेक्स 73 की गणना करते हैं, जिसका नियंत्रण कोड 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;
}
it works!

जैसा कि शुरुआत में बताया गया था, भेद्यता तब होती है जब हम एक struct के माध्यम से एक अमान्य (unvalidated) पॉइंटर पास कर सकते हैं। यह struct सीधे usermode से DeviceIoControl के lpInBuffer के माध्यम से पास किया जाता है। फिर इसे चौथे पैरामीटर के रूप में AfdNotifySock में और तीसरे पैरामीटर के रूप में AfdNotifyRemoveIoCompletion में पास किया जाता है।

चूंकि struct में क्या है यह ज्ञात नहीं है, इसलिए मैंने IDA को स्वचालित रूप से struct बनाने दिया। अब इस struct में डेटा पास करने और आवश्यक जाँचों को बायपास करने का तरीका खोजना होगा ताकि बग कोड तक पहुंचा जा सके। AfdNotifySock फ़ंक्शन से शुरू करते हैं:

सबसे पहले, struct का आकार 0x30 बाइट्स होना चाहिए।

वे मान जो शून्य नहीं होने चाहिए:

एक और बात, डीबग करते समय मैंने देखा कि यह पिछले UserBuffer चेक पर विफल हो रहा था, इसलिए DeviceIoControl कॉल करते समय इस मान को NULL सेट किया गया। ऊपर दिए गए मान सेट करने के बाद, मैं चेक2 को पार करने में सक्षम हुआ।

अगली जाँच जिसे बायपास करना है:

ObReferenceObjectByHandle को इस चेक को पास करने के लिए STATUS_SUCCESS लौटाना होगा। अर्थात, मुझे एक वैध हैंडल पास करना होगा। मैंने खोजने की कोशिश की लेकिन IoCompletionObjectType बनाने के तरीके के बारे में कोई जानकारी नहीं मिली। इसलिए मैंने इस विश्लेषण का पालन किया: https://securityintelligence.com/posts/patch-tuesday-exploit-wednesday-pwning-windows-ancillary-function-driver-winsock/. NtCreateIoCompletion फ़ंक्शन का उपयोग करके एक IoCompletionObjectType बनाएं और इसका हैंडल ObReferenceObjectByHandle में पास करें।
इस जाँच को बायपास करने के बाद, प्रोग्राम का प्रवाह एक लूप में चला जाता है। इस लूप में ऐसी कोई जगह नहीं है जो विफलता प्रवाह की ओर ले जाए, इसलिए मैंने बस dword20 पर मान को 0x1 सेट कर दिया ताकि लूप से बाहर निकल सकूं।

लूप से बाहर निकलने के बाद, प्रोग्राम AfdNotifyRemoveIoCompletion को कॉल करेगा। AfdNotifyRemoveIoCompletion फ़ंक्शन का विश्लेषण जारी रखें:

सबसे पहले प्रोग्राम struct के एक अन्य फ़ील्ड की जाँच करता है, वह शून्य नहीं होना चाहिए। फिर इसे 0x20 से गुणा किया जाता है, और struct के एक अन्य फ़ील्ड के साथ ProbeForWrite फ़ंक्शन को कॉल करने के लिए पैरामीटर के रूप में उपयोग किया जाता है। यहां केवल एक user-mode मेमोरी पते का उपयोग करने की आवश्यकता है जिसमें लिखने की अनुमति हो और 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 है, जब टाइमआउट मान पहुंच जाता है तो फ़ंक्शन समाप्त हो जाता है। हालांकि, केवल टाइमआउट = 0 सेट करना फ़ंक्शन को वापस लौटने के लिए पर्याप्त नहीं है, बल्कि यह टाइमआउट त्रुटि कोड लौटाएगा। हम NtSetIoCompletion फ़ंक्शन का उपयोग करके IoCompletionObjectType में लंबित IO की गणना को 1 से बढ़ा सकते हैं और टाइमआउट से पहले NtRemoveIoCompletion फ़ंक्शन को समाप्त कर सकते हैं। कई प्रयासों के बाद, मैंने पाया कि लिखा गया मान हमेशा 0x1 होता है।
कर्नेल-मोड पते पर मान 0x1 लिखने की क्षमता के साथ, हम I/O रिंग (Microsoft द्वारा जारी एक नया I/O तंत्र) का लाभ उठाकर इस बग का उपयोग मनमाने पते को पढ़ने/लिखने की पूर्ण क्षमता प्राप्त करने के लिए कर सकते हैं। Yarden Shafir ने इस विधि के बारे में एक बहुत विस्तृत विश्लेषण लिखा है, आप इसे यहाँ पढ़ सकते हैं। एक क्रिया जो एप्लिकेशन कर सकता है, वह है अपने भविष्य के I/O संचालन के लिए सभी बफ़र्स आवंटित करना, फिर उन्हें I/O रिंग के साथ पंजीकृत करना। पूर्व-पंजीकृत बफ़र्स I/O ऑब्जेक्ट के माध्यम से संदर्भित होते हैं:
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 फ़ील्ड को अपडेट/संशोधित करने की अनुमति देती है, तो मानक I/O रिंग API का उपयोग करके कर्नेल मेमोरी को पढ़ा और लिखा जा सकता है। हालांकि, NtQuerySystemInformation फ़ंक्शन का उपयोग करने के लिए Medium IL विशेषाधिकार की आवश्यकता होती है। Low IL से LPE के लिए, कर्नेल पते को लीक करने का कोई तरीका आवश्यक है।
जब IoRing->RegBuffers उपयोगकर्ता-नियंत्रित फ़ेकBuffer की ओर इशारा करता है, तो हम सामान्य I/O रिंग संचालन का उपयोग करके किसी भी पते को पढ़ और लिख सकते हैं, बस फ़ेक में एक इंडेक्स निर्दिष्ट करके जिसे बफ़र के रूप में उपयोग करना है:
अधिक समझने के लिए आप Yarden Shafir का विश्लेषण ऊपर दिए गए लिंक पर पढ़ सकते हैं।
ऊपर दिए गए POC कोड का उपयोग करके IO Ring ऑब्जेक्ट बनाने और लिखने का प्रयास करने के बाद, DeviceIOControl कॉल करने पर विंडोज क्रैश हो गया /_ \ इसलिए मैंने सीधे Ntfunction कॉल करने का तरीका अपनाया (˘・_・˘)
ProbeForWrite को कॉल करने वाला कोड जोड़ा गया