根据 Microsoft Security Response Center (MSRC) 发布的 CVE-2023-21768 详细描述,该漏洞存在于 Ancillary Function Driver (AFD) 中,其在系统中的文件名为 afd.sys。AFD 模块是 WinSock API 的内核入口点。在本文分析中,我将利用它在 Windows 11 上进行本地权限提升(LPE)。
从 Winbindex 下载两个版本的 afd.sys,一个是最接近修复前的版本,另一个是修复后的版本。然后使用 Bindiff 对这两个版本进行比较。

对两个版本进行总体比较,可以看到只有一个函数存在差异,即 AfdNotifyRemoveIoCompletion。更详细地查看该函数在两个版本之间的差异。

两个版本之间没有太多差异。在修复后版本中,新增了一些汇编指令用于设置参数并调用 ProbeForWrite 函数。根据微软的文档,该函数用于检查某个地址是否真正属于用户模式、是否具有写权限以及是否正确对齐。更详细地分析这段代码:
afd.sys version 10.0.22621.608

-修复后 afd.sys version 10.0.22621.1105

两者都会检查 r15_1 的值,如果为 0,则将 var_304 的值写入 struct_1 中某个字段所指定的指针。如果不为 0,则会调用 ProbeForWrite 以确保指针指向有效地址。在修复前版本中,后续直接将 var_304 的值写入该指针,缺少了这一检查。从该补丁可以推断,我们可以在可控 arg3_1->field_18 值的情况下调用这段代码。如果能在 field_18 中设置一个内核地址值,就可以将 var_304 写入内核内存地址。
=> 漏洞类型:任意内核 Write-Where
现在需要找到触发该漏洞的方法。AfdNotifyRemoveIoCompletion 函数在 AfdNotifySock 函数中被直接调用。

类似地,查找 AfdNotifySock 的交叉引用可以发现,它没有被任何其他函数直接调用,但函数地址被存储在 .rdata 中的某个地址处。

该地址紧邻 AfdIrpCallDispatch 之前。

为了触发该漏洞,我将使用 IOCTL_AFD_NOTIFY_SOCK 调用 DeviceIoControl,这样 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 是一个数组,包含驱动程序用于处理内核与用户模式之间通信的 dispatch 函数。与调用 DeviceIoControl 相对应的 dispatch 函数存储在 MajorFunction[IRP_MJ_DEVICE_CONTROL] 中。
#define IRP_MJ_DEVICE_CONTROL 0x0e
从 afd.sys 的 DriverEntry 函数中,我们可以看到驱动程序创建了设备对象 "\Device\Afd":

将 MajorFunction[IRP_MJ_DEVICE_CONTROL] 设置为 AfdDispatchDeviceControl,因此当调用 DeviceIoControl 与内核通信时,将调用该函数。

在 AFD 中有两个 dispatch 表:AfdIrpCallDispatch 和 AfdImmediateCallDispatch。

可以很容易地看到,AfdDispatchDeviceIoControl 通过 IoControlCode 计算下标,并从 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;
}
成功触发!

正如开头所说,当我们可以通过一个 struct 传入未经校验的指针时,漏洞就会发生。该 struct 通过 DeviceIoControl 的 lpInBuffer 直接从用户模式传入,然后作为第 4 个参数传入 AfdNotifySock,再作为第 3 个参数传入 AfdNotifyRemoveIoCompletion。

由于还不知道 struct 包含什么内容,我让 IDA 自动创建 struct。现在需要找到向该 struct 传入数据并绕过必要检查以到达漏洞代码段的方法。从 AfdNotifySock 函数开始:

首先,struct 的大小必须等于 0x30 字节。

以下值必须非 0:

另外,调试时我发现它会在前面的 UserBuffer 检查处跳转到失败分支,因此在调用 DeviceIoControl 时应将该值设为 NULL。设置上述值之后,我就通过了 check2 后面的检查。

下一个需要绕过的检查:

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 的另一个字段,该字段必须非 0。然后将其乘以 0x20,并作为参数与 struct 的另一个字段一起调用 ProbeForWrite。这里只需要使用一个属于用户模式内存区域、具有写权限的地址,且 dwLen = 1 即可。在能够触发漏洞之前的最后一项检查是:调用 IoRemoveCompletion 函数的返回值必须为 STATUS_SUCCESS。经过一番搜索,我了解到 NtRemoveIoCompletion 被调用后会调用 IoRemoveCompletion 函数。根据这个文档,NtRemoveIoCompletion 是一个“等待调用”,当指定的 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 并不足以让函数返回,而是会返回超时错误码。我们可以使用 NtSetIoCompletion 函数将 IoCompletionObjectType 中待处理 IO 的计数器加 1,从而在超时前结束 NtRemoveIoCompletion 函数。经过多次尝试,我发现写入的值始终为 0x1。
由于能够向内核模式地址写入值 0x1,我们可以利用此漏洞,借助 I/O ring(微软推出的一种新 I/O 机制)获得完整的任意地址读/写能力。Yarden Shafir 撰写了一篇关于此方法的非常详细的分析文章,你可以阅读此处。应用程序可以执行的操作之一是为其未来的 I/O 操作分配所有缓冲区,然后将它们注册到 I/O ring。预注册的缓冲区通过 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 ring API 来读写内核内存。然而,使用 NtQuerySystemInformation 函数需要 Medium IL 权限。若要从 Low IL 进行 LPE,则需要某种方式泄露内核地址。
当 IoRing->RegBuffers 指向由用户控制的 fakeBuffer 后,我们可以使用常规的 I/O ring 操作,通过指定 fake 中的一个 index 作为 buffer,从而在我们想要的任何地址实现读写:
如需进一步了解,可以阅读上方链接中 Yarden Shafir 的分析文章。
尝试创建 IO Ring object 并使用上述 POC 代码进行写入后,Windows 在调用 DeviceIOControl 后崩溃了 /_ \ 因此我改用直接调用 Nt 函数的方法 (˘・_・˘)
ProbeForWrite 的代码段